Files
goodwe-addon/goodwe_controller/test_batteries_screen.py
T
glenn schrooyenandClaude Opus 5.5 b171ada894
checks / unit (pull_request) Successful in 23m54s
infra-pin / fresh (pull_request) Successful in 1s
Merge release/1.0 (SAFETY-78, #154) into opt11-p4-solar-forecast
Conflicts: modetrade.decide() takes both the day plan (targets) and
SAFETY-78's draining flag; the Why line keeps SAFETY-78's other_cycle
ahead of the plan's word (test added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:37:09 +02:00

500 lines
24 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, 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*<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}}; 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")