UX-13 P4 review fixes (PR #146, comment 1887)
checks / unit (pull_request) Successful in 10m24s
infra-pin / fresh (pull_request) Successful in 1s

R1: first_start also needs a Store created at this boot (Store.fresh), so
    old never-started sites (both TEST-23 add-ons, one in shadow) stay on Home.
R2: a kettle skip after a fall says on the last step that the reading went down.
R3: the shadow switch goes only on the model the wizard posts, never into V.
R4: Home Assistant not answering is red on the last step.
R5: meter + solar + battery below -300 W flags a reversed battery sensor.
R6: the live checks say what to change and where.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
review-agent
2026-09-28 06:12:09 +02:00
parent be57b87c34
commit 3198f346fd
10 changed files with 195 additions and 75 deletions
+89 -19
View File
@@ -21,6 +21,7 @@ import logging
import os
import re
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
@@ -170,8 +171,9 @@ check("a refusal blocks the step of its field, a hidden or non-wizard field's "
class Store:
"""app/store.py's shape: a dict under .data, and set()."""
def __init__(self, data=None):
def __init__(self, data=None, fresh=False):
self.data = dict(data or {})
self.fresh = fresh
def set(self, key, value):
self.data[key] = value
@@ -294,16 +296,38 @@ check("the page moves on only when the step's count is 0, the kettle test is "
print("\n4. first run: a new install opens the wizard, a working site not")
# --------------------------------------------------------------------------- #
S = uipage.setup_needed
check("a new install: never set up, control never started or stopped",
S(NEW, {}, False) == "first_start"
and S(NEW, {"phase": "idle", "sign_tripped": False}, False)
check("a new install: no Store file at this boot, never set up, control "
"never started or stopped",
S(NEW, {}, False, fresh=True) == "first_start"
and S(NEW, {"phase": "idle", "sign_tripped": False}, False, fresh=True)
== "first_start")
check("a site that runs control, or ever ran or stopped it, is not sent to "
"the wizard", S(NEW, {}, True) is None
and S(NEW, {"auto": True}, False) is None
and S(NEW, {"auto": False}, False) is None)
"the wizard", S(NEW, {}, True, fresh=True) is None
and S(NEW, {"auto": True}, False, fresh=True) is None
and S(NEW, {"auto": False}, False, fresh=True) is None)
check("a site the wizard saved is not sent again",
S(NEW, {uipage.SETUP_DONE: "2026-09-28T10:00:00+00:00"}, False) is None)
S(NEW, {uipage.SETUP_DONE: "2026-09-28T10:00:00+00:00"}, False,
fresh=True) is None)
# Review R1: the Stores the two TEST-23 add-ons really have (2026-09-28): no
# `auto` (Start/Stop never pressed, auto_start off), no setup_done, stopped,
# one of them in shadow for weeks. A Store read from a file is an old site.
_T23_ADDON = dict.fromkeys((
"circ_reason", "circ_tripped", "last_completed", "last_start_attempt",
"maint_schema_version", "phase", "phase_started", "schema_version",
"sign_reason", "sign_tripped", "site_sign_reason", "site_sign_tripped",
"unit/inverter-a/last_completed", "unit/inverter-a/last_start_attempt",
"unit/inverter-a/phase", "unit/inverter-a/phase_started",
"unit/inverter-a/sign_reason", "unit/inverter-a/sign_tripped"))
_T23_ARB = dict.fromkeys((
"circ_reason", "circ_tripped", "last_completed", "last_start_attempt",
"phase", "phase_started", "schema_version", "sign_reason", "sign_tripped",
"site_sign_reason", "site_sign_tripped", "unit/inverter-a/sign_reason",
"unit/inverter-a/sign_tripped"))
check("an old site never started (the real TEST-23 Stores, one in shadow) is "
"not sent to the wizard: its Store came from a file (review R1)",
S(NEW, _T23_ADDON, False) is None and S(NEW, _T23_ARB, False) is None
and S(dict(NEW, shadow_mode="on"), _T23_ARB, False) is None
and S(NEW, {}, False) is None)
check("stored options with no inverter to control always open it, whatever "
"the Store says",
S(DEFAULTS, {uipage.SETUP_DONE: "x", "auto": True}, True)
@@ -345,7 +369,7 @@ async def _first(s, root, sup):
return out
_store = Store({"phase": "idle"})
_store = Store({"phase": "idle"}, fresh=True)
_r, _sup = asyncio.run(session(Controller(NEW, STATES, _store), NEW, _first))
check("over HTTP, /config/model says a new install needs setup, with the spec",
_r["model"]["setup_needed"] == "first_start"
@@ -357,6 +381,22 @@ check("the wizard's save writes the Store marker, and the wizard stops "
"opening by itself",
_r["wizard"][0] == 200 and _store.data.get(uipage.SETUP_DONE)
and _r["after"]["setup_needed"] is None)
_r, _sup = asyncio.run(session(Controller(NEW, STATES, Store(_T23_ARB)), NEW,
_first))
check("over HTTP, an old site never started (a Store read from a file) is not "
"sent to the wizard (review R1)", _r["model"]["setup_needed"] is None)
with tempfile.TemporaryDirectory() as _d:
from app.store import Store as _RealStore
_path = os.path.join(_d, "state.json")
_new = _RealStore(_path)
_new.set("phase", "idle")
_old = _RealStore(_path)
with open(_path, "w") as _fh:
_fh.write("{not json")
_bad = _RealStore(_path)
check("app/store.py: fresh only when this boot found no state file (a new "
"/data), not after a save and not for an unreadable file",
_new.fresh is True and _old.fresh is False and _bad.fresh is False)
_r, _sup = asyncio.run(session(Controller(NEW, STATES, Store({"auto": True}),
auto=True), NEW, _first))
check("a working site's /config/model says no", _r["model"]["setup_needed"]
@@ -399,8 +439,8 @@ check("the battery level reads, and the battery power with its direction",
and ("info", "The battery reads 800 W charging now, so check the "
"inverter shows the same.") in _c)
check("a solar entity below zero, not flipped, looks reversed",
("warn", "sensor.pv reads below zero now, so its solar production "
"sign looks reversed.") in _c
("warn", "sensor.pv reads below zero now, so turn on its “Reads "
"negative” switch in the Solar step.") in _c
and ("ok", "sensor.pv reads 1 200 W of solar production now.")
in tiers(setup_checks(dict(SITE, pv_invert_entities=["sensor.pv"]),
entities=ROWS)))
@@ -430,8 +470,8 @@ check("with two inverters each is named, and a kW meter is shown as the "
"setpoint entity” for b (inverters.setpoint_entity) names.")
in _c2
and ("ok", "The battery level for a reads 62 %.") in _c2
and ("warn", "There is no battery level entity for b, so the add-on "
"holds 0 W.") in _c2)
and ("warn", "There is no battery level entity for b, so pick one in "
"step 2 or the add-on holds 0 W.") in _c2)
_dsmr = dict(SITE, meter_source="ha_dsmr", p1_import_entity="sensor.grid",
p1_export_entity="sensor.inv1_level")
check("import and export are read as their difference",
@@ -447,15 +487,36 @@ check("a meter the add-on reads itself: its live reading when it already "
meter_now_w=250))
if "meter" in x] == ["info"])
check("an unreadable meter is a warning",
("warn", "sensor.grid reads unavailable now, so the meter has no "
"reading yet.") in tiers(setup_checks(SITE, entities=dict(
("warn", "sensor.grid reads unavailable now, so check it in Home "
"Assistant before you start.") in tiers(setup_checks(SITE, entities=dict(
ROWS, **{"sensor.grid": {"state": "unavailable",
"unit": "W"}}))))
_down = tiers(setup_checks(SITE, entities=None))
check("Home Assistant down: one warning, nothing claimed ok about an entity",
_down[0] == ("warn", "Home Assistant is not answering, so no entity "
"was checked yet.")
check("Home Assistant down: red, because the power request entity cannot be "
"checked (review R4), and nothing claimed ok about an entity",
_down[0] == ("bad", "Home Assistant is not answering, so press Check "
"again once it is back.")
and not [x for t, x in _down if t in ("ok", "bad") and "entity" in x])
# Review R5: meter + solar + battery is the house's use, never below zero. A
# battery giving 1 500 W whose sensor reads it the wrong way (-1 500 W).
_rev = dict(ROWS, **{"sensor.pv": {"state": "0", "unit": "W"},
"sensor.inv1_battery": {"state": "-1500", "unit": "W"}})
_bal = ("warn", "The readings add up to -1 098 W of house use, so check each "
"battery's “Battery power sign is reversed” switch in step 2.")
check("a battery sensor reading the wrong way while giving power is flagged "
"(review R5); not once its switch is on, not without solar, not with "
"the battery reading unknown, and not on the normal site",
_bal in tiers(setup_checks(SITE, entities=_rev))
and not [x for t, x in tiers(setup_checks(dict(SITE, batt_invert=True),
entities=_rev))
if "add up" in x]
and not [x for t, x in tiers(setup_checks(dict(SITE, pv_entity=""),
entities=_rev))
if "add up" in x]
and not [x for t, x in tiers(setup_checks(dict(SITE, batt_entity=""),
entities=_rev))
if "add up" in x]
and not [x for x in (t[1] for t in _c) if "add up" in x])
_fixed = U.ui_to_options(dict(U.options_to_ui(SITE), **{
"ui.contract": "fixed", "ui.fallback": "fixed", "ui.fixed_price": 0.3}))
_dyn = dict(SITE, price_provider="generic_dayahead")
@@ -667,7 +728,8 @@ check(f"every wizard string is one sentence of at most 25 words, or a name "
_rendered = [x for notes in (
setup_checks(SITE, entities=ROWS), _c2 and setup_checks(_two, entities=ROWS),
setup_checks(SITE, entities=None), setup_checks(_hw, entities=ROWS),
setup_checks(_fixed, entities=ROWS), setup_checks(dict(
setup_checks(_fixed, entities=ROWS), setup_checks(SITE, entities=_rev),
setup_checks(dict(
SITE, soc_entity="sensor.gone", batt_entity="sensor.grid_x"),
entities=dict(ROWS, **{"sensor.grid": {"state": "x", "unit": "W"}})))
for x in (n.text for n in notes)]
@@ -682,6 +744,14 @@ check("both restart dialogs name the 0 W, and the shadow one says it stays",
in _dialogs[0])
check("the wizard defaults to shadow mode: the switch starts on",
"shadowOn: true" in JS)
check("the shadow switch never writes into V, which the Installer view saves "
"too: it goes only on the model the wizard posts (review R3)",
"V[M.setup.shadow.field]" not in JS and "V[s.field]" not in JS
and JS.count("model: posted()") == 2)
check("a skip after a fall keeps saying the reading went down (review R2)",
"wrong: !!k.wrong }; drawStep();" in JS
and "WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped" in JS
and 'const wrong = o.say === "none" ? !!k.wrong : o.sets == null;' in JS)
_leak = sorted(k for k in TL.config_surface(D.CONFIG)[0] if re.search(
r"[^A-Za-z0-9_]" + re.escape(k) + r"[^A-Za-z0-9_]", JS))
check(f"settings.js, wizard included, names no option key ({_leak})",