"""UX-13 P3: the Batteries screen and Settings, Simple. `python3 test_batteries_screen.py` 1. Each battery's Now, Today and Why lines, in English and Dutch, for the six states the brief names: modes off, modes on, a battery Off, an Off battery giving way to the peak limit, a refusal hold, and the monthly cycle. 2. The OPT-11 P1 test's F2 contradictions stay gone: a battery that moves power never reads "0 W" beside it, the Off help names no watts, and no battery line says the peak limit "holds back" a battery it asks for power. 3. The plan leaves an Off battery out, except for the peak limit. 4. `battery_why()` is pure and picks the most urgent reason first. 5. The screen's `/action` round trips over the real web app: the switch and the mode list post `pack_use` and `pack_mode`, `/status` shows the result in both languages, and a refused value comes back as a plain 400. 6. The page: the cards, the Simple view and its fields come from the layout table and ui_text, and the scripts post only the existing actions. 7. The screen changes no control path: homeview.py reads the controller and calls nothing that writes, and the page adds no /action value. The headless-browser check is in the PR, not in CI (the CI image has no browser). Mutating a module this file grades? Read MUTATION.md first. """ import ast import asyncio import io import json import os import re import socket import sys from datetime import datetime, timedelta, timezone HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "tools")) import aiohttp # noqa: E402 from aiohttp import web as aioweb # noqa: E402 import ux13_demo as D # noqa: E402 from app import homeview, packmode, uilayout as L, uipage, web # noqa: E402 from app.maintenance import IDLE, DRAIN # noqa: E402 fails = [] def check(name, cond): print(f" {'ok ' if cond else 'FAIL'} {name}") if not cond: fails.append(name) def words(lang): with io.open(os.path.join(HERE, "app", "ui_text", f"{lang}.json"), encoding="utf-8") as f: return json.load(f) EN, NL = words("en"), words("nl") B_NAME = D.OPTS["inverters"][1]["name"] LANGS = {"en": EN, "nl": NL} # The fixture's prices are for the day it is built, so the plan depends on # the hour. Pin the clock (review of #150: unpinned, this file failed every # morning): the fixture is built at 00:15 on 2026-09-28, Brussels time. Part 1 # looks at 23:30, when the day's trades and cycle start are past, and part 3 # at 00:30, with all of them ahead. _BUILT = datetime(2026, 9, 27, 22, 15, tzinfo=timezone.utc) class _Clock(datetime): @classmethod def now(cls, tz=None): return _BUILT.astimezone(tz) if tz else _BUILT.replace(tzinfo=None) D.datetime = _Clock _DAY0 = homeview.local_day(_BUILT)[0] EVENING = _DAY0 + timedelta(hours=23, minutes=30) MORNING = _DAY0 + timedelta(minutes=30) def build(state, lang="en", c=None, now=None): c = c or D.controller(state) s = c.status_payload() return homeview.build(c, level=s["level"], checks=s["checks"], lang=lang, now=now or EVENING) # The six states and what each battery's Why line must be. WANT = { "running": ("modes_off", "modes_off"), "modes": ("solar_first", "leftover"), "off": ("solar_first", "off"), "off_peak": ("peak", "off_peak"), "refusal": ("refuses_give", "other_refuses"), "maint": ("cycle_mode", "leftover"), } # ============================================================================ # print("1. Now, Today and Why per battery, English and Dutch") ROWS = {(st, lang): build(st, lang) for st in WANT for lang in LANGS} for st, want in WANT.items(): got = tuple(b["why_key"] for b in ROWS[(st, "en")]["batteries"]) en = ROWS[(st, "en")]["batteries"] nl = ROWS[(st, "nl")]["batteries"] print(f" {st}: " + " / ".join(f"{b['now']} | {b['why']} | " f"{b['today_line'] or b['today']}" for b in en)) check(f"{st}: the Why lines are {want}, in each language's own words", got == want and all(b["why"] == homeview.uitext.say( W["home_words"]["why"][b["why_key"]], name=D.OPTS["inverters"][0]["name"]) for W, rows in ((EN, en), (NL, nl)) for b in rows)) check(f"{st}: every battery has a Now, a Why and a Today, and the Dutch " f"lines differ from the English", all(b["now"] and b["why"] and (b["today"] or b["today_line"]) for b in en + nl) and all(e["why"] != n["why"] and e["now"] != n["now"] for e, n in zip(en, nl))) _modes = ROWS[("modes", "en")] check("modes on: each row carries its switch and mode, and Today says the " "mode's plan is not made yet (OPT-11 P3/P4), without inventing one", _modes["modes_on"] is True and [(b["use"], b["mode_key"]) for b in _modes["batteries"]] == [(True, "solar_first"), (True, "leftover_solar")] and all(b["today_line"] == EN["home_words"]["today"]["not_planned"] for b in _modes["batteries"])) _off = ROWS[("running", "en")] check("modes off: no switch, no mode, and Today and Why say it follows the " "house", _off["modes_on"] is False and all("mode_key" not in b and "use" not in b and b["today_line"] == EN["home_words"]["today"]["house"] for b in _off["batteries"])) _b = ROWS[("off", "en")]["batteries"][1] check("a battery Off: 'Off. Resting now.', 'You switched it off.', and no " "monthly cycle today", (_b["mode_key"], _b["now"], _b["why"], _b["today_line"]) == ("off", EN["home_words"]["now"]["off_resting"], EN["home_words"]["why"]["off"], EN["home_words"]["today"]["off"])) _b = ROWS[("off_peak", "en")]["batteries"][1] _bn = ROWS[("off_peak", "nl")]["batteries"][1] check("Off giving way to the peak limit says so plainly, with the figure: " "'Off, but gives 0.5 kW for the peak limit.'", _b["now"] == "Off, but gives 0.5 kW for the peak limit." and _bn["now"] == "Uit, maar levert 0,5 kW voor de piekgrens." and _b["why"] == EN["home_words"]["why"]["off_peak"]) _a, _b = ROWS[("refusal", "en")]["batteries"] check("a refusal hold: the refusing battery says it refuses and when it " "tries again, the other says it takes over", _a["why"] == EN["home_words"]["why"]["refuses_give"] and _b["why"] == "Battery A refuses to give power, so this battery " "takes over.") _a = ROWS[("maint", "en")]["batteries"][0] check("maintenance: the cycle comes first, the mode resumes after, and Today " "shows the cycle block", _a["maintenance"] == DRAIN and _a["why"] == EN["home_words"]["why"]["cycle_mode"] and [r["kind"] for r in _a["today"]] == ["cycle"]) _sh = build("shadow")["batteries"] _al = build("alarm")["batteries"] check("shadow mode and an alarm: Why says which, and Today plans nothing " "the add-on does not do", all(b["why_key"] == "shadow" and b["today_line"] == EN["home_words"]["today"]["house_would"] for b in _sh) and all(b["why_key"] == "held" and b["today_line"] == EN["home_words"]["today"]["held"] for b in _al)) # ============================================================================ # print("\n2. the OPT-11 P1 test's F2: no line contradicts the power shown") ZERO = re.compile(r"\b0 W\b") GOING = (EN["home_words"]["zero_going"], NL["home_words"]["zero_going"], "going to 0 W", "gaat naar 0 W") _bad = [] for (st, lang), payload in sorted(ROWS.items()): for b in payload["batteries"]: moving = b["power_w"] is not None and abs(b["power_w"]) > homeview.IDLE_W if moving and b.get("maintenance", IDLE) == IDLE: kw = homeview._dec(f"{abs(b['power_w']) / 1000:.1f}") kw = kw.replace(".", ",") if lang == "nl" else kw if kw not in b["now"]: _bad.append(f"{st}/{lang}/{b['name']}: no figure in {b['now']!r}") for line in (b["now"], b["why"]): if ZERO.search(line) and not any(g in line for g in GOING): _bad.append(f"{st}/{lang}/{b['name']}: 0 W beside power: " f"{line!r}") if "held back" in b["why"] or "tegengehouden" in b["why"]: _bad.append(f"{st}/{lang}/{b['name']}: {b['why']!r}") print(f" {_bad}") check("a battery that moves power shows the figure, and no line beside it " "says 0 W", not _bad) check("the Off help names no watts, in either language, and says the peak " "limit may use the battery", all(not ZERO.search(W["home"]["batteries"]["choice"]["off"] ["description"]) for W in LANGS.values()) and "peak limit" in EN["home"]["batteries"]["choice"]["off"] ["description"]) _going = homeview.battery_now(62, 900, phase=IDLE, shadow=False, running=True, off=True, peak=False) check("an Off battery still moving without the peak limit reads 'going to " "0 W' with its figure, never a bare 0 W", _going == "Off. Still at 0.9 kW, going to 0 W.") # ============================================================================ # print("\n3. the plan leaves an Off battery out, except for the peak limit") _c = D.controller("off") _c.maints[1].cfg = _c.maints[0].cfg # both due, so both would be drawn _p = build("off", c=_c) _kinds = {i: {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == i} for i in (0, 1)} print(f" {_kinds}") check("Off: no 'follows the house', no trade, no monthly cycle for B", not _kinds[1] and "house" in _kinds[0]) _p = build("off", now=MORNING) _m = build("modes", now=MORNING) _kinds = {i: {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == i} for i in (0, 1)} print(f" morning: {_kinds}, lines {_p['plan']['lines']}") check("in the morning, with trades and a cycle ahead: A has them, Off B has " "no block and waits for no cycle (with B on, it does)", {"house", "grid", "give", "cycle"} <= _kinds[0] and not _kinds[1] and not any(B_NAME in x for x in _p["plan"]["lines"]) and {"grid", "give"} <= {r["kind"] for r in _m["plan"]["runs"] if r["battery"] == 1} and any(B_NAME in x for x in _m["plan"]["lines"])) _p = build("off_peak") check("...but the peak block stays on it while the peak limit needs it", {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == 1} == {"peak"}) # Review: an Off battery that A covers alone rests, so it gets no peak block, # and a card never says "every battery gives" beside it. _c = D.controller("off_peak") _c.inverters[1].batt = 0.0 _p = build("off_peak", c=_c) _a, _b = _p["batteries"] check("peak limit covered by A alone: Off B rests, has no peak block, and " "A's Why does not claim every battery gives", _b["why_key"] == "off" and _b["now"] == EN["home_words"]["now"] ["off_resting"] and not _b["today"] and {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == 0} >= {"peak"} and _a["why_key"] == "peak" and "every" not in _a["why"] and "elke" not in build("off_peak", "nl", c=_c)["batteries"][0]["why"]) # Review: a blind or floor-limited guard is engaged with a 0 W floor. It # forbids charging only, so no battery gives power for it. _bad = [] for _kw in (dict(blind="meter gap"), dict(floor_limited=True)): _c = D.controller("off_peak") _c.peak_guard = homeview.peakguard.PeakDecision( engaged=True, floor_w=0.0, ceiling_w=3500.0, ceiling_from="cap", projected_w=None, **_kw) _c.inverters[1].batt = 300.0 # B still going to 0 W _p = build("off_peak", c=_c) _a, _b = _p["batteries"] if (_a["why_key"] == "peak" or _b["why_key"] == "off_peak" or "peak limit" in _b["now"] or any(r["kind"] == "peak" for r in _p["plan"]["runs"])): _bad.append((_kw, _a["why_key"], _b["why_key"], _b["now"])) print(f" {_bad}") check("a blind or floor-limited peak guard (floor 0 W) draws no peak block " "and no line says the peak limit takes power", not _bad) # ============================================================================ # print("\n4. battery_why(): pure, most urgent first") BASE = dict(modes_on=True, mode=packmode.SOLAR_FIRST, off=False, reading=True, phase=IDLE, shadow=False, running=True, held=False, refused=0, peak=False, giving=False, sf_ahead=False) def why(**kw): return homeview.battery_why(**dict(BASE, **kw)) _cases = [ (dict(reading=False, held=True), "no_reading"), (dict(held=True, shadow=True), "held"), (dict(running=False, off=True), "stopped"), (dict(shadow=True, phase=DRAIN), "shadow"), (dict(phase=DRAIN, off=True), "cycle_mode"), (dict(phase=DRAIN, modes_on=False), "cycle"), (dict(off=True, peak=True, giving=True), "off_peak"), (dict(off=True, peak=True, giving=False), "off"), # charging or resting (dict(off=True, giving=True), "off"), (dict(refused=1, peak=True), "refuses_give"), (dict(refused=-1), "refuses_charge"), (dict(peak=True, giving=True, other_refuses="B"), "peak"), (dict(giving=True, other_refuses="B", modes_on=False), "other_refuses"), (dict(modes_on=False, giving=True), "modes_off"), (dict(giving=True, mode=packmode.LEFTOVER_SOLAR), "covers_house"), (dict(mode=packmode.LEFTOVER_SOLAR, sf_ahead=True), "leftover"), (dict(mode=packmode.LEFTOVER_SOLAR), "leftover_alone"), ({}, "solar_first"), ] _wrong = [(kw, want, why(**kw)) for kw, want in _cases if why(**kw) != want] print(f" {_wrong}") check(f"{len(_cases)} cases, each the expected reason", not _wrong) check("every reason it can return has words in both languages", {w for _k, w in _cases} == set(EN["home_words"]["why"]) == set(NL["home_words"]["why"])) # ============================================================================ # print("\n5. /action round trips from the screen, over the real web app") async def _serve(state, calls): c = D.controller(state) stored = dict(D.defaults(D.CONFIG), **D.OPTS) runner = aioweb.AppRunner(web.build_app(c, D.Supervisor(stored))) await runner.setup() sock = socket.socket() sock.bind(("127.0.0.1", 0)) port = sock.getsockname()[1] sock.close() await aioweb.TCPSite(runner, "127.0.0.1", port).start() out = [] try: async with aiohttp.ClientSession() as s: root = f"http://127.0.0.1:{port}/" for body, lang in calls: if body is not None: async with s.post(root + "action", json=body) as r: out.append((r.status, await r.json(content_type=None))) async with s.get(root + "status?lang=" + lang) as r: out.append((r.status, await r.json(content_type=None))) finally: await runner.cleanup() return out A, B = (i["name"] for i in D.OPTS["inverters"]) # Exactly what app.js posts: {action, unit, value}. _r = asyncio.run(_serve("modes", [ ({"action": "pack_use", "unit": B, "value": False}, "en"), ({"action": "pack_mode", "unit": A, "value": packmode.LEFTOVER_SOLAR}, "nl"), ({"action": "pack_use", "unit": B, "value": True}, "en"), ({"action": "pack_mode", "unit": A, "value": "cheap_hours"}, "en"), ])) (_s1, _j1), (_, _st1), (_s2, _j2), (_, _st2), (_s3, _), (_, _st3), \ (_s4, _j4), (_, _st4) = _r check("switch B off: 200, and /status shows B Off with 'You switched it off.'", _s1 == 200 and _j1["result"]["use"] is False and _st1["batteries"][1]["mode_key"] == "off" and _st1["batteries"][1]["why"] == EN["home_words"]["why"]["off"]) check("set A to Leftover solar: 200, and /status?lang=nl shows it in Dutch", _s2 == 200 and _st2["batteries"][0]["mode_choice"] == packmode.LEFTOVER_SOLAR and _st2["batteries"][0]["mode"] == NL["home_words"]["modes"]["leftover_solar"]) check("switch B back on: its stored mode returns", _s3 == 200 and _st3["batteries"][1]["mode_key"] == packmode.LEFTOVER_SOLAR) check("a mode the list only shows greyed out is refused with a plain 400, and " "nothing changes", _s4 == 400 and _j4["ok"] is False and _j4["error"] and _st4["batteries"][0]["mode_choice"] == packmode.LEFTOVER_SOLAR) _r = asyncio.run(_serve("running", [ ({"action": "pack_use", "unit": B, "value": False}, "en")])) check("with battery modes off the switch is refused with the plain reason, " "and the batteries stay on", _r[0][0] == 400 and _r[0][1]["error"] and "mode_key" not in _r[1][1]["batteries"][1]) # ============================================================================ # print("\n6. the page") PAGE = web.page() JS = io.open(os.path.join(HERE, "app", "static", "app.js"), encoding="utf-8").read() SJS = io.open(os.path.join(HERE, "app", "static", "settings.js"), encoding="utf-8").read() check("the Batteries screen has its cards, the modes-off card and 'How the " "modes work'", all(f'id="{i}"' in PAGE for i in ("bcards", "batt-off", "batt-how", "batt-banner"))) check("Settings has a Simple / Installer choice as a radio group, and Simple " "comes first", re.search(r'id="set-view"[^>]*>\s*]*' r'data-v="simple"', PAGE) is not None and PAGE.count('role="radio"') >= 2 and 'id="simple"' in PAGE) _acts = set(re.findall(r'packAct\("(\w+)"', JS)) check(f"the cards post only the existing actions ({sorted(_acts)}), as " f"{{action, unit, value}}", _acts == set(web.PACK_ACTIONS) and "action: what, unit: b.name, value: value" in JS) check("the switch is a real switch and the list names cheap hours as coming " "later, not choosable", 'role: "switch"' in JS and '"aria-checked"' in JS and 'h("option", { value: "", disabled: true }, t("batteries.later"))' in JS) check("the selling switch is greyed out until OPT-11 P3 (no pack_sell is " "posted)", "pack_sell" not in JS and "disabled: true })" in JS) _T = uipage.ui_text("en") _lay = uipage.layout(D.schema_ui(D.CONFIG), {"en": {}}) _simple = [f for g in _lay["groups"] for p in g["parts"] for f in p["fields"] if f.get("simple")] check("Settings, Simple draws the fields the layout marks, and each note " "has words in both languages", sorted(f["key"] for f in _simple) == sorted(f.key for g in L.GROUPS for p in g.parts for f in p.fields if f.simple) and all(f["note"] in EN["settings"]["simple"]["note"] and f["note"] in NL["settings"]["simple"]["note"] for f in _simple if f["note"])) check("settings.js picks them by the `simple` flag and names no option key", "f.simple" in SJS and not any(f'"{f.key}"' in SJS for g in L.GROUPS for p in g.parts for f in p.fields)) # ============================================================================ # print("\n7. no control path changes: the screen reads, it does not write") SRC = io.open(os.path.join(HERE, "app", "homeview.py"), encoding="utf-8").read() WRITES = {"set_number", "store_set", "set", "set_time", "handle_action", "log_event", "abort", "tick", "publish", "call", "write", "_pack_action", "_maint_stop_off"} _calls = sorted({n.func.attr for n in ast.walk(ast.parse(SRC)) if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr in WRITES}) check(f"homeview.py calls nothing that writes state or commands an inverter " f"({_calls})", not _calls) _imports = sorted({(n.module or "") for n in ast.walk(ast.parse(SRC)) if isinstance(n, ast.ImportFrom)}) check(f"...and imports no module that sends power requests ({_imports})", not {"main", "inverter", "hass", "control", "allocator", "arbiter"} & {m.lstrip(".") for m in _imports}) _c = D.controller("off_peak") _before = (json.dumps(_c.store.data, sort_keys=True, default=str), [(i.last_write, i.target) for i in _c.inverters]) for lang in LANGS: build("off_peak", lang, c=_c) check("building the screen twice leaves the store and every power request " "as it was", _before == (json.dumps(_c.store.data, sort_keys=True, default=str), [(i.last_write, i.target) for i in _c.inverters])) print() if fails: print(f"{len(fails)} FAILED") sys.exit(1) print("all passed")