1. Discard reloads /config/model: after a stale save the page kept the
new fingerprint with the old values, so Discard + Save overwrote the
other surface's change without a 409.
2. Removing cards back to one restores the stored inverter shape, so a
flat site stays flat after add + remove.
3. A choice the page can only keep ("Other time windows") stays offered
while it is the stored value.
4. A load locks every locked value again.
5. at_zero() needs an accepted 0 W write; before the first one Home says
"Going to 0 W" (the heartbeat writes every heartbeat_s, so it clears).
6. A field with a unit drops the translation's "(W)" from its label: the
peak limit read "Peak limit (W)" beside a kW value.
7. A mirrored choice lists the mirrored field's keys, so a search for
static_profile finds the contract on a fixed-price site.
8. test_settings: a save refused by ui_to_options writes nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
644 lines
30 KiB
Python
644 lines
30 KiB
Python
"""UX-13 P1: the Home screen's `/status` and the page shell. `python3 -B test_home.py`
|
|
|
|
1. `/status` keeps every old field (`banner`, `level`, `rows`, `checks`,
|
|
`hint`, `events`) AND carries the structured half the Home screen reads:
|
|
`state`, `now`, `batteries`, `plan`, `attention`, `month_peak`, `control`.
|
|
Both are pinned here, by name and shape.
|
|
2. The plan shows only what the add-on does today. Trading on price is the
|
|
verdict the control cycle would reach (the same `plan.arbitrage()` call),
|
|
drawn where the cheapest and dearest prices are, with kWh and € per block.
|
|
The monthly cycle, the peak limit and "follows the house" are the rest.
|
|
3. Shadow mode says "would", a stopped site plans nothing, and a red row is an
|
|
alarm under "Needs attention".
|
|
4. A fault in the Home view never costs the page its old rows.
|
|
5. The page: served at `/` from app/static, no external file, relative URLs
|
|
only, every label resolves in app/ui_text, and the region year reaches
|
|
`config/model` for the P2 Settings screens.
|
|
|
|
The STYLE scan of every Home sentence is test_messages.py part 8.
|
|
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
|
"""
|
|
|
|
import asyncio
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from aiohttp import ClientSession, web as aioweb
|
|
|
|
from app import homeview, web
|
|
from app.main import Controller
|
|
from app.peakguard import PeakDecision
|
|
from app.price import PricePoint, PriceProduct, PriceRead
|
|
from app.static_tariff import TariffPlan
|
|
from app.uimodel import REGION_PRESETS
|
|
from named_absence import field, nth, present
|
|
|
|
FLANDERS = REGION_PRESETS["flanders"]["taxes_eur_kwh"]
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Fixtures: a real Controller, with its readings set directly.
|
|
# --------------------------------------------------------------------------- #
|
|
class _Store:
|
|
def __init__(self):
|
|
self.data = {}
|
|
|
|
def set(self, k, v):
|
|
self.data[k] = v
|
|
|
|
def get(self, k, d=None):
|
|
return self.data.get(k, d)
|
|
|
|
def set_time(self, k, v):
|
|
self.data[k] = v.isoformat()
|
|
|
|
def get_time(self, k):
|
|
v = self.data.get(k)
|
|
return None if v is None else datetime.fromisoformat(v)
|
|
|
|
|
|
class _Pub:
|
|
enabled = False
|
|
|
|
def publish(self, _v):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class _Hass:
|
|
session = None
|
|
token = ""
|
|
|
|
async def limits(self, _e):
|
|
return (-5000.0, 5000.0, 1.0)
|
|
|
|
async def number(self, _e, invert=False):
|
|
return None
|
|
|
|
async def state(self, _e):
|
|
return None
|
|
|
|
async def set_number(self, _e, _v):
|
|
return True
|
|
|
|
async def call(self, *_a):
|
|
return True
|
|
|
|
async def states(self):
|
|
return []
|
|
|
|
|
|
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
|
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
|
"maintenance_enabled": False}
|
|
TWO = dict(ONE, inverters=[
|
|
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
|
|
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
|
|
"capacity_kwh": 9.6},
|
|
{"name": "Battery B", "setpoint_entity": "number.b", "soc_entity":
|
|
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
|
|
"capacity_kwh": 5.4}])
|
|
|
|
# Sunday 27 September 2026, 11:40 in Brussels (09:40 UTC): the mockup's day.
|
|
NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc)
|
|
DAY0 = datetime(2026, 9, 26, 22, 0, tzinfo=timezone.utc)
|
|
MOCK = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, .14,
|
|
.16, .19, .23, .28, .33, .36, .34, .29, .26, .24]
|
|
|
|
|
|
def make(opts, *, soc=(62.0, 35.0), batt=(-2500.0, -300.0), grid=10.0,
|
|
auto=True, wrote=0.0):
|
|
"""`wrote` is each inverter's last accepted power request (None: none yet)."""
|
|
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
|
c.auto = auto
|
|
for i, inv in enumerate(c.inverters):
|
|
inv.soc, inv.batt = soc[i], batt[i]
|
|
inv.last_write = wrote
|
|
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
|
c.grid = grid
|
|
return c
|
|
|
|
|
|
def provider_plan(prices=MOCK, day0=DAY0, days=2, provisional_from=None):
|
|
pts = []
|
|
for d in range(days):
|
|
for k, p in enumerate(prices):
|
|
start = day0 + timedelta(days=d, hours=k)
|
|
pts.append(PricePoint(start, p, provisional_from is not None
|
|
and start >= provisional_from))
|
|
return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD,
|
|
"generic_dayahead", NOW), None,
|
|
"provider")
|
|
|
|
|
|
def quiet_guard(projected_w=1240.0):
|
|
return PeakDecision(engaged=False, floor_w=None, ceiling_w=3500.0,
|
|
ceiling_from="cap", projected_w=projected_w)
|
|
|
|
|
|
def status(c, now=NOW):
|
|
s = c.status_payload()
|
|
# The status route builds at wall-clock time; rebuild the structured half
|
|
# at the fixture's instant so the day and the hours are the mockup's.
|
|
s.update(homeview.build(c, level=s["level"], checks=s["checks"], now=now))
|
|
return s
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("1. /status: the old fields stay, the structured half is added")
|
|
# --------------------------------------------------------------------------- #
|
|
c1 = make(ONE, soc=(62.0,), batt=(960.0,), grid=800.0)
|
|
s1 = c1.status_payload()
|
|
OLD = {"banner", "level", "rows", "checks", "hint", "events"}
|
|
NEW = {"state", "now", "batteries", "plan", "attention", "month_peak",
|
|
"control"}
|
|
check(f"every old field is still there, with its old type ({sorted(OLD)})",
|
|
OLD <= set(s1) and isinstance(s1["rows"], list)
|
|
and all(len(r) == 2 for r in s1["rows"])
|
|
and all({"ok", "warn", "text"} <= set(r) for r in s1["checks"])
|
|
and isinstance(s1["banner"], str) and s1["level"] in ("ok", "warn", "bad")
|
|
and isinstance(s1["events"], list))
|
|
check(f"...and the structured half is added beside them ({sorted(NEW)})",
|
|
NEW <= set(s1) and "home_error" not in s1)
|
|
check("...and it is JSON, as the route serves it", json.loads(json.dumps(s1))
|
|
== json.loads(json.dumps(s1)))
|
|
check("`state` names the kind, the pill word, the control row and a sub-line",
|
|
set(s1["state"]) == {"kind", "pill", "ran", "sub"}
|
|
and s1["state"]["kind"] == "running")
|
|
check("`now` carries the four tiles in W and a plain line for each",
|
|
{"solar_w", "house_w", "grid_w", "batt_w", "has_solar", "lines"}
|
|
<= set(s1["now"])
|
|
and set(s1["now"]["lines"]) == {"solar", "house", "grid", "batt"}
|
|
and s1["now"]["grid_w"] == 800.0 and s1["now"]["batt_w"] == 960.0)
|
|
_nh = homeview.now_block(grid_w=-3000.0, pv_w=1000.0, batt_w=1500.0,
|
|
has_solar=True, shadow=False)
|
|
_nz = homeview.now_block(grid_w=-1030.0, pv_w=1000.0, batt_w=0.0,
|
|
has_solar=True, shadow=False)
|
|
_np = homeview.now_block(grid_w=800.0, pv_w=1000.0, batt_w=-300.0,
|
|
has_solar=True, shadow=False)
|
|
check("House = grid (+ import) + solar + battery (+ giving): 800 + 1000 - 300 "
|
|
"= 1500 W",
|
|
_np["house_w"] == 1500.0 and _np["lines"]["house"] == "Using")
|
|
check("...a sum below zero is not shown as a positive figure: 0 W inside "
|
|
"the idle band, and no figure beyond it",
|
|
_nz["house_w"] == 0.0 and _nh["house_w"] is None
|
|
and _nh["lines"]["house"] == homeview.tile_house(None, True, True))
|
|
check("...and with no solar reading the house figure is NOT invented: it is "
|
|
"None and the tile says why",
|
|
s1["now"]["house_w"] is None and s1["now"]["has_solar"] is False
|
|
and s1["now"]["lines"]["house"] == homeview.tile_house(None, False))
|
|
_b = s1["batteries"][0]
|
|
check("`batteries[]` has level, power, state, mode, the now line and why",
|
|
{"name", "level_pct", "power_w", "state", "mode", "now", "why",
|
|
"capacity_kwh", "stored_kwh", "maintenance"} <= set(_b)
|
|
and _b["level_pct"] == 62.0 and _b["state"] == "giving"
|
|
and _b["now"] == "Giving 1.0 kW now.")
|
|
_p = s1["plan"]
|
|
check("`plan` carries the verdict, the prices, the runs, the lines and "
|
|
"the region",
|
|
{"verdict", "head", "prices", "runs", "lines", "now_h", "hours",
|
|
"price_line", "tomorrow_line", "region", "region_line"} <= set(_p)
|
|
and len(_p["prices"]) in (23, 24, 25))
|
|
check("`attention[]` has severity, title, body and docs; `month_peak` and "
|
|
"`control` are there",
|
|
all({"severity", "title", "body", "docs"} <= set(a)
|
|
for a in s1["attention"])
|
|
and {"month", "limit_w", "peak_w", "pct", "period_w", "state", "line"}
|
|
<= set(s1["month_peak"])
|
|
and {"running", "shadow", "text", "maintenance", "docs_url"}
|
|
<= set(s1["control"]))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n2. the plan is what the add-on actually does")
|
|
# --------------------------------------------------------------------------- #
|
|
s_house = status(make(TWO))
|
|
check("with trading off and no cycle due, each battery follows the house "
|
|
"from now to the end of the day, and nothing else is drawn",
|
|
[r["kind"] for r in s_house["plan"]["runs"]] == ["house", "house"]
|
|
and s_house["plan"]["verdict_key"] == "house"
|
|
and all(r["end_h"] == 24.0 and abs(r["start_h"] - 11 - 40 / 60) < 0.01
|
|
for r in s_house["plan"]["runs"])
|
|
and s_house["plan"]["runs"][0]["to"] == "24:00")
|
|
|
|
ARB = dict(TWO, arbitrage_enabled=True, cycle_cost_threshold_eur=0.05)
|
|
c_arb = make(ARB)
|
|
c_arb.tariff_plan, c_arb.tariff_at = provider_plan(), NOW
|
|
c_arb.peak_guard = quiet_guard()
|
|
s_arb = status(c_arb)
|
|
_runs = s_arb["plan"]["runs"]
|
|
_grid = [r for r in _runs if r["kind"] == "grid"]
|
|
_give = [r for r in _runs if r["kind"] == "give"]
|
|
check("trading on price: a charge block at the cheapest hour (13:00) and a "
|
|
"give block at the dearest (19:00), on each battery",
|
|
[(r["battery"], r["from"], r["to"]) for r in _grid]
|
|
== [(0, "13:00", "14:00"), (1, "13:00", "14:00")]
|
|
and [(r["battery"], r["from"], r["to"]) for r in _give]
|
|
== [(0, "19:00", "20:00"), (1, "19:00", "20:00")]
|
|
and s_arb["plan"]["verdict_key"] == "trade")
|
|
check("...placed where the control cycle's own verdict puts them: the same "
|
|
"plan.arbitrage() call on the same plan",
|
|
c_arb.fresh_tariff_plan(NOW).arbitrage(
|
|
peak_guard_active=False, min_spread_eur_kwh=0.05,
|
|
uncertainty=frozenset({__import__("app.price", fromlist=["x"])
|
|
.Uncertainty.NO_WEATHER}))
|
|
.match(allowed=lambda v: (v.cheapest.start, v.dearest.start),
|
|
refused=lambda v: None)
|
|
== (DAY0 + timedelta(hours=13), DAY0 + timedelta(hours=19)))
|
|
# Battery A: 2.5 kW for 1 h = 2.5 kWh; room to the 95 % ceiling is 33 % of
|
|
# 9.6 kWh = 3.17 kWh, so 2.5. Battery B: 60 % of 5.4 = 3.24, so 2.5.
|
|
check("each block carries kWh and €: charging is capped by the battery's own "
|
|
"rating and the room its level leaves, € = kWh x that hour's price",
|
|
[r["kwh"] for r in _grid] == [2.5, 2.5]
|
|
and [r["eur"] for r in _grid] == [0.35, 0.35]
|
|
and [r["eur_kwh"] for r in _grid] == [0.14, 0.14]
|
|
and "2.5 kWh" in _grid[0]["what"] and "€0.14" in _grid[0]["why"]
|
|
and "€0.35" in _grid[0]["amount"])
|
|
# Giving at 19:00 comes after the 13:00 charge, so each battery may give what
|
|
# it will hold then: B is at 35 % + 2.5 kWh of 5.4 = 81 %, and 81 - 20 = 61 %
|
|
# of 5.4 = 3.3 kWh, so 2.5 (an upper bound, like every amount here).
|
|
check("...and giving is capped by what each battery holds above arbitrage's "
|
|
"floor after the charge before it",
|
|
[r["kwh"] for r in _give] == [2.5, 2.5]
|
|
and [r["eur"] for r in _give] == [0.9, 0.9])
|
|
|
|
|
|
def trade(soc, prices=MOCK, provisional_from=None, now=NOW, opts=ARB):
|
|
c = make(opts, soc=soc)
|
|
c.tariff_plan = provider_plan(prices, provisional_from=provisional_from)
|
|
c.tariff_at, c.peak_guard = now, quiet_guard()
|
|
s = status(c, now=now)
|
|
return ([(r["kind"], r["battery"], r["kwh"]) for r in s["plan"]["runs"]
|
|
if r["kind"] in ("grid", "give")], s["plan"]["lines"])
|
|
|
|
|
|
# Review of #144: only what arbitrage.decide() would still do is drawn.
|
|
_t, _l = trade((96.0, 35.0))
|
|
check("a battery at arbitrage's ceiling gets no charge block, not "
|
|
"'Charges up to 0.0 kWh'", ("grid", 0, 0.0) not in _t
|
|
and [k for k in _t if k[0] == "grid"] == [("grid", 1, 2.5)])
|
|
# 20:00 local: the dearest (19:00) is over; tomorrow's charge is a line. Put
|
|
# the dearest later today instead, with the emptiest pack at the floor.
|
|
_late = MOCK[:22] + [.40, .24]
|
|
_t, _l = trade((62.0, 20.0), prices=_late, now=NOW + timedelta(hours=8))
|
|
_t2, _l2 = trade((62.0, 35.0), prices=_late, now=NOW + timedelta(hours=8))
|
|
check("no give block while the emptiest battery is at arbitrage's floor: "
|
|
"the cycle's sale is graded on the emptiest pack (at 35 % both give)",
|
|
not [k for k in _t if k[0] == "give"]
|
|
and [k[:2] for k in _t2] == [("give", 0), ("give", 1)])
|
|
_t, _l = trade((62.0, 35.0), prices=[x - 0.5 for x in MOCK])
|
|
check("no give block at a negative price (the cycle refuses that sale)",
|
|
not [k for k in _t if k[0] == "give"]
|
|
and [k for k in _t if k[0] == "grid"])
|
|
# Running, a missing level is a red row and the site is held. In shadow mode
|
|
# the plan is still drawn, so this is where the level gate shows.
|
|
_t, _l = trade((None, 35.0), opts=dict(ARB, shadow_mode="on"))
|
|
check("with a battery level unknown the cycle does not trade, so nothing is "
|
|
"drawn and a line says why (shadow mode, which still plans)",
|
|
_t == [] and homeview.line_no_trade("no_level") in _l)
|
|
_t, _l = trade((62.0, 35.0), provisional_from=DAY0)
|
|
check("a plan with no published prices yet says so, not 'the price gap is "
|
|
"too small'",
|
|
_t == [] and homeview.line_no_trade("not_firm") in _l
|
|
and homeview.line_no_trade("not_worth") not in _l)
|
|
check("the price bars are today's 24 hours, from the real series, with the "
|
|
"cheap and expensive thirds marked",
|
|
[b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK
|
|
and s_arb["plan"]["prices"][13]["kind"] == "cheap"
|
|
and s_arb["plan"]["prices"][19]["kind"] == "dear"
|
|
and s_arb["plan"]["prices"][0]["kind"] == "mid"
|
|
and s_arb["plan"]["prices"][13]["label"] == "13:00")
|
|
check("the price line names the price now, today's lowest and highest, and "
|
|
"that tomorrow's prices are in",
|
|
s_arb["plan"]["price_line"] == ("Price now €0.16 per kWh, all in. "
|
|
"Today: lowest €0.14 at 13:00, "
|
|
"highest €0.36 at 19:00.")
|
|
and s_arb["plan"]["tomorrow_line"] == "Tomorrow's prices are in.")
|
|
|
|
c_late = make(ARB)
|
|
c_late.tariff_plan, c_late.tariff_at = provider_plan(), NOW
|
|
c_late.peak_guard = quiet_guard()
|
|
s_late = status(c_late, now=NOW + timedelta(hours=9)) # 20:40 local
|
|
check("a block whose hour is over is not drawn: at 20:40 no trade is left "
|
|
"today",
|
|
not [r for r in s_late["plan"]["runs"] if r["kind"] in ("grid", "give")])
|
|
|
|
c_peak = make(ARB)
|
|
c_peak.tariff_plan, c_peak.tariff_at = provider_plan(), NOW
|
|
c_peak.peak_guard = PeakDecision(engaged=True, floor_w=800.0,
|
|
ceiling_w=3500.0, ceiling_from="cap",
|
|
projected_w=3900.0)
|
|
s_peak = status(c_peak)
|
|
check("while the peak limit holds, the verdict refuses as the cycle does: no "
|
|
"trade block, a plain line, and a peak block for this 15-minute period",
|
|
not [r for r in s_peak["plan"]["runs"] if r["kind"] in ("grid", "give")]
|
|
and homeview.line_no_trade("peak") in s_peak["plan"]["lines"]
|
|
and [(r["from"], r["to"]) for r in s_peak["plan"]["runs"]
|
|
if r["kind"] == "peak"] == [("11:30", "11:45"), ("11:30", "11:45")])
|
|
|
|
c_pk = make(ARB)
|
|
c_pk.p1_enabled = True # the peak line is shown where P1 runs
|
|
c_pk.tariff_plan, c_pk.tariff_at = provider_plan(), NOW
|
|
c_pk.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=4200.0,
|
|
ceiling_from="month_peak", projected_w=1240.0)
|
|
s_pk = status(c_pk)
|
|
check("the plan's peak line names the guard's ceiling, which is this month's "
|
|
"peak once it is above the limit",
|
|
homeview.line_peak_limit(4200.0, False) in s_pk["plan"]["lines"]
|
|
and s_pk["month_peak"]["line"].endswith("above 4.2 kW."))
|
|
|
|
c_blind = make(ARB)
|
|
c_blind.tariff_plan, c_blind.tariff_at = provider_plan(), NOW
|
|
s_blind = status(c_blind) # meter_source off: the guard never runs
|
|
check("with nothing measuring the 15-minute peak, trading is NOT drawn, as "
|
|
"the cycle refuses it (guard state None)",
|
|
not [r for r in s_blind["plan"]["runs"] if r["kind"] in ("grid", "give")]
|
|
and homeview.line_no_trade("not_covered") in s_blind["plan"]["lines"])
|
|
|
|
c_flat = make(ARB)
|
|
c_flat.tariff_plan, c_flat.tariff_at = provider_plan(prices=[.2] * 24), NOW
|
|
c_flat.peak_guard = quiet_guard()
|
|
s_flat = status(c_flat)
|
|
check("a flat day: no trade, no cheap or expensive hour marked",
|
|
not [r for r in s_flat["plan"]["runs"] if r["kind"] in ("grid", "give")]
|
|
and {b["kind"] for b in s_flat["plan"]["prices"]} == {"mid"}
|
|
and homeview.line_no_trade("not_worth") in s_flat["plan"]["lines"])
|
|
|
|
c_old = make(ARB)
|
|
c_old.tariff_plan = provider_plan()
|
|
c_old.tariff_at = NOW - timedelta(hours=3) # past its max age
|
|
c_old.peak_guard = quiet_guard()
|
|
s_old = status(c_old)
|
|
check("a stale plan is not traded on by the cycle, so it is not drawn",
|
|
not [r for r in s_old["plan"]["runs"] if r["kind"] in ("grid", "give")])
|
|
|
|
MAINT = dict(TWO, maintenance_enabled=True, maintenance_start_hour=12)
|
|
c_m = make(MAINT)
|
|
s_m = status(c_m)
|
|
_cyc = [r for r in s_m["plan"]["runs"] if r["kind"] == "cycle"]
|
|
check("a monthly cycle due today is drawn on ONE battery, at the start hour "
|
|
"the cycle uses (12 UTC is 14:00 here), and the other one waits",
|
|
len(_cyc) == 1 and field(nth(_cyc), "from") == "14:00"
|
|
and s_m["plan"]["verdict_key"] == "cycle"
|
|
and any("waits its turn" in x for x in s_m["plan"]["lines"]))
|
|
check("...with its duration from the battery's capacity and the cycle's rates "
|
|
"and hold time",
|
|
present(nth(_cyc)) and _cyc[0]["amount"].startswith("Takes about ")
|
|
and _cyc[0]["end_h"] > _cyc[0]["start_h"] + 2)
|
|
c_m.maints[0].store.set("phase", "drain")
|
|
s_md = status(c_m)
|
|
check("a cycle under way is drawn from now, and the battery's now line says "
|
|
"so",
|
|
[r["from"] for r in s_md["plan"]["runs"] if r["kind"] == "cycle"]
|
|
== ["11:40"]
|
|
and s_md["batteries"][0]["now"] == "Monthly cycle: emptying."
|
|
and any("Battery B waits its turn" in x for x in s_md["plan"]["lines"]))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n3. shadow mode, a stopped site, an alarm")
|
|
# --------------------------------------------------------------------------- #
|
|
c_sh = make(dict(ARB, shadow_mode="on"))
|
|
c_sh.tariff_plan, c_sh.tariff_at = provider_plan(), NOW
|
|
c_sh.peak_guard = quiet_guard()
|
|
s_sh = status(c_sh)
|
|
_texts = [r["what"] for r in s_sh["plan"]["runs"]] + [s_sh["plan"]["head"]]
|
|
check("shadow: the state is shadow, the battery tile is held at 0 W, and "
|
|
"every block and the head say 'would'",
|
|
s_sh["state"]["kind"] == "shadow"
|
|
and s_sh["now"]["lines"]["batt"] == "Held at 0 W"
|
|
and all("ould" in x for x in _texts)
|
|
and s_sh["plan"]["head"].startswith("Shadow mode:"))
|
|
check("...and 'Shadow mode is on' is the first item under Needs attention",
|
|
s_sh["attention"][0]["title"] == homeview.ATTN_SHADOW
|
|
and s_sh["attention"][0]["severity"] == "info")
|
|
check("...and each battery's now line says it is held at 0 W",
|
|
all(b["now"].startswith("Held at 0 W.") for b in s_sh["batteries"]))
|
|
|
|
s_stop = status(make(TWO, auto=False))
|
|
check("stopped: nothing is planned and the head says so",
|
|
s_stop["plan"]["runs"] == [] and s_stop["state"]["kind"] == "stopped"
|
|
and s_stop["plan"]["verdict_key"] == "stopped"
|
|
and s_stop["control"]["running"] is False
|
|
and all(b["now"] == "Held at 0 W while control is stopped."
|
|
for b in s_stop["batteries"]))
|
|
|
|
c_al = make(ONE, soc=(62.0,), batt=(0.0,))
|
|
c_al.inverters[0].sign_wd.tripped = True
|
|
s_al = c_al.status_payload()
|
|
_red = [a for a in s_al["attention"] if a["severity"] == "alarm"]
|
|
check("an alarm: level bad, state alarm, and the red row is the first item "
|
|
"under Needs attention",
|
|
s_al["level"] == "bad" and s_al["state"]["kind"] == "alarm"
|
|
and _red and s_al["attention"][0] is _red[0]
|
|
and _red[0]["title"] == homeview.ATTN_TITLES["direction"])
|
|
check("...and nothing is planned while every inverter is held at 0 W",
|
|
s_al["plan"]["runs"] == [] and s_al["plan"]["verdict_key"] == "held")
|
|
_rows = [r for r in s_al["checks"] if not r["ok"] and not r["warn"]]
|
|
check("...and every red, amber and settling row reaches Needs attention with "
|
|
"its own sentence as the body, and no green row does",
|
|
[a["body"] for a in homeview.attention(s_al["checks"])]
|
|
== [r["text"] for r in s_al["checks"] if not r["ok"] and not r["warn"]]
|
|
+ [r["text"] for r in s_al["checks"] if not r["ok"] and r["warn"]]
|
|
+ [r["text"] for r in s_al["checks"] if r["ok"] and r["warn"]])
|
|
|
|
# UX-13 P1 test finding (a): the page said "Held at 0 W" about 15 s before
|
|
# the inverter's power request reached 0 W. Until the last accepted request
|
|
# is 0 W it says "Going to 0 W".
|
|
c_al.inverters[0].last_write = 20.0
|
|
s_go = homeview.build(c_al, level="bad", checks=s_al["checks"], now=NOW)
|
|
c_al.inverters[0].last_write = 0.0
|
|
s_at = homeview.build(c_al, level="bad", checks=s_al["checks"], now=NOW)
|
|
check("an alarm while the last power request is still 20 W says 'Going to "
|
|
"0 W', and 'Held at 0 W' once 0 W was accepted",
|
|
s_go["plan"]["verdict"] == "Going to 0 W"
|
|
and s_go["batteries"][0]["now"] == "Going to 0 W until the alarm clears."
|
|
and s_go["plan"]["head"].startswith("Every inverter goes to 0 W")
|
|
and s_at["plan"]["verdict"] == "Held at 0 W"
|
|
and s_at["batteries"][0]["now"] == "Held at 0 W until the alarm clears.")
|
|
c_sp = make(TWO, auto=False)
|
|
c_sp.inverters[1].last_write = -1500.0
|
|
s_sp = status(c_sp)
|
|
s_new = status(make(TWO, auto=False, wrote=None))
|
|
check("...and before any power request was accepted (just started, or every "
|
|
"write refused) it says 'Going to 0 W', not 'Held'",
|
|
[b["now"] for b in s_new["batteries"]]
|
|
== ["Going to 0 W while control is stopped."] * 2)
|
|
check("...and a stop does the same, per battery",
|
|
[b["now"] for b in s_sp["batteries"]]
|
|
== ["Held at 0 W while control is stopped.",
|
|
"Going to 0 W while control is stopped."])
|
|
|
|
# Finding (b): a quarter-hourly feed. The block's price and the price line
|
|
# name the same 15-minute point, and nothing says "the list".
|
|
_q = []
|
|
for _k, _p in enumerate(MOCK):
|
|
for _m in range(4):
|
|
_q.append(PricePoint(DAY0 + timedelta(hours=_k, minutes=15 * _m),
|
|
round(_p + (0.0 if _m != 3 else -0.01), 4), False))
|
|
_q += [PricePoint(p.start + timedelta(days=1), p.eur_per_kwh, False)
|
|
for p in list(_q)]
|
|
c_q = make(ARB)
|
|
c_q.tariff_plan = TariffPlan(PriceRead.series(tuple(_q), PriceProduct.DAY_AHEAD,
|
|
"generic_dayahead", NOW), None,
|
|
"provider")
|
|
c_q.tariff_at = NOW
|
|
c_q.peak_guard = quiet_guard()
|
|
s_q = status(c_q)
|
|
_grid = [r for r in s_q["plan"]["runs"] if r["kind"] == "grid"]
|
|
_lo = min(_q[:96], key=lambda p: p.eur_per_kwh)
|
|
check("quarter-hour prices: the charge block, its price and the price line's "
|
|
"lowest are the same 15-minute period",
|
|
_grid and _grid[0]["from"] == homeview.hhmm(_lo.start)
|
|
and _grid[0]["why"] == f"Lowest published price, €{_lo.eur_per_kwh:.2f} "
|
|
"per kWh."
|
|
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
|
|
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
|
|
and "the list" not in json.dumps(s_q["plan"]))
|
|
check("...and the hour bar says it is an hour average",
|
|
s_q["plan"]["prices"][13]["points"] == 4
|
|
and '"plan.average_tip"' in io.open(os.path.join(
|
|
HERE, "app", "static", "app.js"), encoding="utf-8").read())
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n4. a fault in the Home view never costs the page its rows")
|
|
# --------------------------------------------------------------------------- #
|
|
_real = homeview.build
|
|
try:
|
|
homeview.build = lambda *a, **k: 1 / 0
|
|
s_err = make(ONE, soc=(50.0,), batt=(0.0,)).status_payload()
|
|
finally:
|
|
homeview.build = _real
|
|
check("the old fields are served and `home_error` says the rest is missing",
|
|
OLD <= set(s_err) and s_err.get("home_error") is True
|
|
and not (NEW & set(s_err)))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n5. the page")
|
|
# --------------------------------------------------------------------------- #
|
|
PAGE = web.page()
|
|
JS = io.open(os.path.join(HERE, "app", "static", "app.js"),
|
|
encoding="utf-8").read()
|
|
UI = json.load(io.open(os.path.join(HERE, "app", "ui_text", "en.json"),
|
|
encoding="utf-8"))["home"]
|
|
|
|
|
|
def resolve(path):
|
|
node = UI
|
|
for k in path.split("."):
|
|
if not isinstance(node, dict) or k not in node:
|
|
return None
|
|
node = node[k]
|
|
return node
|
|
|
|
|
|
check("the page is assembled: no placeholder is left and the words parse",
|
|
"/*APP_CSS*/" not in PAGE and "/*APP_JS*/" not in PAGE
|
|
and "/*UI_TEXT*/" not in PAGE
|
|
and json.loads(PAGE.split('id="ui-text">', 1)[1]
|
|
.split("</script>", 1)[0].replace("<\\/", "</"))["home"]
|
|
== UI)
|
|
check("four tabs with the four hash routes, in a keyboard tablist",
|
|
re.findall(r'data-hash="(\w+)"', PAGE) == ["home", "batteries",
|
|
"settings", "setup"]
|
|
and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 4
|
|
and "ArrowRight" in JS and "ArrowLeft" in JS)
|
|
_ext = re.findall(r"""(?:https?:)?//[A-Za-z0-9.-]+\.[a-z]{2,}""", PAGE)
|
|
check(f"no external file and no web font: no URL leaves the add-on "
|
|
f"({_ext})", not _ext and "@import" not in PAGE
|
|
and "<link" not in PAGE and "src=" not in PAGE)
|
|
_abs = re.findall(r"""(?:fetch\(|href=)["'](/[^"']*)["']""", PAGE)
|
|
check(f"every fetch and link is relative, for ingress ({_abs})",
|
|
not _abs and 'fetch("status"' in JS and 'fetch("action"' in JS)
|
|
_keys = set(re.findall(r'data-t(?:-label)?="([\w.]+)"', PAGE)) | set(
|
|
re.findall(r'''\bt\("([\w.]+)"\)''', JS))
|
|
_missing = sorted(k for k in _keys if not isinstance(resolve(k), str))
|
|
check(f"every label the page asks for is in app/ui_text ({len(_keys)} "
|
|
f"keys; missing: {_missing})", len(_keys) > 30 and not _missing)
|
|
check("data is set as text, never as HTML, so no status sentence can inject "
|
|
"markup", "innerHTML" not in JS and "insertAdjacentHTML" not in JS)
|
|
check("every function of the old page is still on it: start/stop, the "
|
|
"maintenance start and abort, the status rows, the checks and the "
|
|
"event line",
|
|
"auto_toggle" in JS and '"maint_start"' in JS and '"maint_abort"' in JS
|
|
and 'id="rows"' in PAGE and 'id="checks"' in PAGE
|
|
and 'id="hint"' in PAGE and 'href="config"' in PAGE)
|
|
check("stopping, starting and both maintenance actions ask first",
|
|
all(f'ask("{k}"' in JS for k in ("stop", "start", "maint_start",
|
|
"maint_abort")))
|
|
check("tooltips open on hover, on a tap and on keyboard focus",
|
|
"pointermove" in JS and "pointerdown" in JS and "focusin" in JS)
|
|
check("the chart is not colour alone: expensive hours and blocks are "
|
|
"patterned, and every item has a text label",
|
|
"repeating-linear-gradient" in PAGE and '"aria-label": tipText' in JS)
|
|
check("the phone layout puts the tabs at the bottom under 640 px, and dark "
|
|
"mode follows the system",
|
|
"@media (max-width:640px)" in PAGE and "position:fixed;bottom:0" in PAGE
|
|
and "prefers-color-scheme:dark" in PAGE)
|
|
|
|
|
|
async def _serve():
|
|
class _Sup:
|
|
async def info(self):
|
|
return {"options": {"price_taxes_eur_kwh": FLANDERS,
|
|
"price_taxes_profile": ""},
|
|
"schema": [], "translations": {}}
|
|
c = make(ONE, soc=(50.0,), batt=(0.0,))
|
|
runner = aioweb.AppRunner(web.build_app(c, _Sup()))
|
|
await runner.setup()
|
|
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
|
await site.start()
|
|
port = site._server.sockets[0].getsockname()[1]
|
|
out = {}
|
|
try:
|
|
async with ClientSession() as s:
|
|
async with s.get(f"http://127.0.0.1:{port}/") as r:
|
|
out["page"] = (r.status, r.content_type, await r.text())
|
|
async with s.get(f"http://127.0.0.1:{port}/status") as r:
|
|
out["status"] = await r.json()
|
|
async with s.get(f"http://127.0.0.1:{port}/config/model") as r:
|
|
out["model"] = await r.json()
|
|
finally:
|
|
await runner.cleanup()
|
|
return out
|
|
|
|
|
|
E = asyncio.run(_serve())
|
|
check("GET / serves the assembled page as HTML", E["page"][0] == 200
|
|
and E["page"][1] == "text/html" and E["page"][2] == PAGE)
|
|
check("GET status over HTTP carries both halves",
|
|
OLD <= set(E["status"]) and NEW <= set(E["status"]))
|
|
check("the levies preset and its year reach config/model for the P2 "
|
|
"Settings screens (P0 review follow-up)",
|
|
E["model"].get("region") == {"id": "flanders", "year": 2026})
|
|
c_reg = make(dict(ARB, price_taxes_eur_kwh=FLANDERS))
|
|
c_reg.tariff_plan, c_reg.tariff_at = provider_plan(), NOW
|
|
s_reg = status(c_reg)
|
|
check("...and Home names the preset with its year wherever prices come from "
|
|
"a feed, and says 'your own figure' otherwise",
|
|
s_reg["plan"]["region"] == {"id": "flanders", "year": 2026}
|
|
and s_reg["plan"]["region_line"]
|
|
== "Levies and VAT: Flanders, Fluvius Antwerpen, 2026 figures."
|
|
and s_arb["plan"]["region_line"] == "Levies and VAT: your own figure."
|
|
and s_house["plan"]["region_line"] == "")
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED")
|
|
sys.exit(1)
|
|
print("all passed")
|