Files
goodwe-addon/goodwe_controller/test_batteries_screen.py
goodwe-review-agent a9f7d78527
checks / unit (pull_request) Successful in 11m10s
infra-pin / fresh (pull_request) Successful in 2s
UX-13 P3 review: truthful peak lines, a clock-proof test, Simple stays default
- The peak limit's Why and plan block need a real power request (floor above
  0 W), not a blind or floor-limited guard. An Off battery gets the peak
  block only while it gives power. Why no longer says every battery gives.
- test_batteries_screen pins the fixture clock: unpinned it failed every
  morning. New checks cover the trade and cycle filters, the blind guard and
  an Off battery that A covers alone.
- Settings keeps only a picked view; a link to Installer is not remembered.
- Browser check: badges, unsaved change across views, locked value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:20:33 +02:00

435 lines
20 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
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*<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")