"""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 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") LANGS = {"en": EN, "nl": NL} def build(state, lang="en", c=None): c = c or D.controller(state) s = c.status_payload() return homeview.build(c, level=s["level"], checks=s["checks"], lang=lang) # 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_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"}) # ============================================================================ # 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, moving=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, moving=True), "off_peak"), (dict(off=True, peak=True, moving=False), "off"), (dict(off=True, moving=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")