Files
goodwe-addon/goodwe_controller/test_batteries_screen.py
T
Claude 2d2b6afb07
checks / unit (pull_request) Successful in 11m9s
infra-pin / fresh (pull_request) Successful in 2s
UX-13 P3: Simple view and Batteries screen
- Batteries: one card per battery with On/Off, the mode list (cheap hours
  and selling greyed out until OPT-11 P3), level, and Now / Today / Why
  lines built in homeview.py. Replaces the OPT-11 P1 minimal cards and uses
  the existing pack_use / pack_mode actions and store keys.
- An Off battery the peak limit still needs says so with its figure; no line
  shows 0 W beside moving power (OPT-11 P1 test F2). Today says "Not planned
  yet" until OPT-11 P3/P4.
- Home's plan leaves Off batteries out, except for the peak limit.
- Settings opens on Simple: the layout's simple fields with an applies chip
  and a note, plus the battery switches and modes. Installer is one tap.
- English and Dutch words in app/ui_text; test_batteries_screen.py; browser
  check tools/ux13_browser_check.py (CHECK=1 ux13_screenshots.sh); new and
  changed screenshots only.

No control, allocator, arbiter or command module changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
2026-09-28 22:39:43 +02:00

368 lines
17 KiB
Python

"""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*<button[^>]*'
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")