"""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, messages, packmode, uilayout as L, uipage, web # noqa 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"), # SAFETY-78: the other battery says why it does not charge. "maint": ("cycle_mode", "other_cycle"), } # ============================================================================ # 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; Today says Solar " "first follows the house and, before any cycle has made the price " "runs (OPT-11 P3), that Leftover solar is not planned yet", _modes["modes_on"] is True and [(b["use"], b["mode_key"]) for b in _modes["batteries"]] == [(True, "solar_first"), (True, "leftover_solar")] and [b["today_line"] for b in _modes["batteries"]] == [EN["home_words"]["today"]["house"], EN["home_words"]["today"]["not_planned"]]) _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) # OPT-11 P3: with modes on the plan shows each battery's price runs, which a # control cycle makes; make them as the cycle does (`_mode_roles()`). _mc = D.controller("modes") _mc._mode_roles(MORNING, None) _m = build("modes", c=_mc, 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: Solar first A follows " "the house and has its cycle but no price block (OPT-11 P3: Solar first " "never trades), Off B has no block and waits for no cycle; with B on " "(Leftover solar) B has its expensive run but no grid block (it takes " "solar only until the forecast, owner decision 2026-09-29) and waits", {"house", "cycle"} == _kinds[0] and not _kinds[1] and not any(B_NAME in x for x in _p["plan"]["lines"]) and "give" in {r["kind"] for r in _m["plan"]["runs"] if r["battery"] == 1} and "grid" not in {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"), # OPT-11 P3: the battery's role in the price runs, and no plan. (dict(mode=packmode.CHEAP_HOURS, role="charge"), "grid_charge"), (dict(role="rest"), "rests_charge"), (dict(mode=packmode.CHEAP_HOURS, role="sell", giving=True), "selling"), (dict(mode=packmode.LEFTOVER_SOLAR, role="give", giving=True), "expensive"), (dict(mode=packmode.LEFTOVER_SOLAR, role="hold", has_at=True), "saves"), (dict(mode=packmode.CHEAP_HOURS, role="hold"), "saves_later"), (dict(mode=packmode.CHEAP_HOURS, role="house", plan="no_prices"), "no_prices"), (dict(mode=packmode.LEFTOVER_SOLAR, role="house", plan="no_spread", giving=True), "no_spread"), # ...and the urgent reasons still come first. (dict(role="charge", refused=-1), "refuses_charge"), (dict(role="sell", peak=True), "peak"), (dict(role="hold", off=True), "off"), # OPT-11 P4: the day plan's word, while holding only. (dict(mode=packmode.LEFTOVER_SOLAR, role="hold", has_at=True, day="sun_fills"), "sun_fills"), (dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="tops_up"), "tops_up"), (dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="no_forecast"), "no_forecast"), (dict(mode=packmode.LEFTOVER_SOLAR, role="charge", day="tops_up"), "grid_charge"), # SAFETY-78: another battery drains for its monthly cycle. (dict(other_cycle="A"), "other_cycle"), (dict(other_cycle="A", modes_on=False, giving=True), "other_cycle"), (dict(other_cycle="A", mode=packmode.CHEAP_HOURS, role="charge"), "other_cycle"), (dict(other_cycle="A", peak=True), "peak"), (dict(other_cycle="A", refused=-1), "refuses_charge"), (dict(other_cycle="A", off=True), "off"), (dict(other_cycle="A", mode=packmode.LEFTOVER_SOLAR, role="hold", day="tops_up"), "other_cycle"), ] _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"), ({"action": "pack_sell", "unit": A, "value": True}, "en"), ({"action": "pack_sell", "unit": A, "value": "yes"}, "en"), ({"action": "pack_mode", "unit": A, "value": "sell_only"}, "en"), ])) (_s1, _j1), (_, _st1), (_s2, _j2), (_, _st2), (_s3, _), (_, _st3), \ (_s4, _j4), (_, _st4), (_s5, _j5), (_, _st5), (_s6, _j6), (_, _st6), \ (_s7, _j7), (_, _st7) = _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("OPT-11 P3: Cheap hours only is a mode now: 200, and /status shows it", _s4 == 200 and _st4["batteries"][0]["mode_choice"] == packmode.CHEAP_HOURS) check("owner decision 2026-09-29 (option A): selling is off, so a posted " "pack_sell is refused with the plain reason 'Selling is off for now.', " "and /status says no battery sells or can", _s5 == 400 and _j5["error"] == messages.pack_refused("sell_off") and _s6 == 400 and all(b["sell"] is False and b["can_sell"] is False for b in _st5["batteries"] + _st6["batteries"])) check("...a mode that does not exist is refused with a plain 400, and " "nothing changes", _s7 == 400 and _j7["error"] and _st7["batteries"][0]["mode_choice"] == packmode.CHEAP_HOURS) _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}}; selling has no control while it is off", _acts == set(web.PACK_ACTIONS) - {"pack_sell"} and packmode.SELLING_ENABLED is False and "action: what, unit: b.name, value: value" in JS) check("the switch is a real switch and the list offers the three modes, " "cheap hours included, with nothing greyed out", 'role: "switch"' in JS and '"aria-checked"' in JS and 'const MODES = ["solar_first", "leftover_solar", "cheap_hours"];' in JS and "batteries.later" not in JS) check("no selling switch anywhere on the page or in Settings, and no words " "for one", "pack_sell" not in JS and "sell" not in SJS and "batteries.sell" not in JS and all("sell" not in W["home"]["batteries"] for W in LANGS.values())) _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")