PRICE-01 round 2: the token on no page, fixed-tariff note, token length, F3 words, test gaps
checks / unit (pull_request) Successful in 39m0s
infra-pin / fresh (pull_request) Successful in 2s

F1: every password option is shown as ******** (set) or empty in /config/model
(options, running, ui) and in a stale refusal; the classic page draws it as a
password field; a save or validate that sends ******** or nothing keeps the
stored value. F2: a static_profile beside price_service is said once at start
(notification and log), not refused. F3 (owner, 2026-10-14): the no-price row,
Home line and notification only while maintenance_enabled is on. F4: tests for
the reviewer's R01 and R10. F5: price_service_token must be 16+ characters.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-14 18:56:51 +02:00
co-authored by Claude
parent 4eb88f7fda
commit 0801776645
12 changed files with 767 additions and 17 deletions
+229
View File
@@ -622,6 +622,7 @@ print("\n6. no price for now: one status line, one notification, cleared")
OPTS_C = {"setpoint_entity": "number.sp", "max_w": 5000,
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "meter_entity": "sensor.grid",
"maintenance_enabled": True,
"maintenance_charge_w": 2000, "maintenance_discharge_w": 2000}
@@ -1366,6 +1367,234 @@ check("the chain logs under goodwe.price.chain",
if "price source" in r.getMessage() or "price service"
in r.getMessage()))
# =========================================================================== #
print("\n13. round 2: the no-price words only with the maintenance charge on (F3)")
async def _scenario_maint(on):
"""Every source down, six ticks, then the prices come back. `on` is the
maintenance switch."""
from app import homeview
with Wired(NOW, maintenance_enabled=on) as W:
c = W.ctl
W.net.down()
await W.refresh()
for k in range(6):
await W.tick(NOW + timedelta(seconds=30 * k))
mid = {"gap": c.price_gap, "notes": list(W.notes()),
"row": dict(c.status_payload()["rows"]).get("Prices"),
"home": [x for x in homeview.build_plan(
c, W.clock.now, False, True)["lines"]
if "No prices" in x],
"gate": c._charge_gate_open(c.inverters[0]),
"cheap": c.cheap}
W.net.up()
await W.refresh(NOW + timedelta(minutes=30))
await W.tick(NOW + timedelta(minutes=30))
return mid, list(W.notes())
_on_mid, _on_all = run(_scenario_maint(True))
_off_mid, _off_all = run(_scenario_maint(False))
check("maintenance on: the row, the Home line and ONE notification appear",
_on_mid["gap"] is True and len(_on_mid["notes"]) == 1
and _on_mid["row"] == "No prices yet, maintenance waits."
and _on_mid["home"] == ["No prices yet, maintenance waits."])
check("...and the notification is taken down once when prices return",
[n[0] for n in _on_all] == ["create", "dismiss"])
check("maintenance off: no row, no Home line, no notification, at any tick",
_off_mid["gap"] is False and _off_mid["notes"] == []
and _off_mid["row"] is None and _off_mid["home"] == []
and _off_all == [])
check("...and the gate itself does not change with the switch: still shut "
"with no price",
_off_mid["gate"] is False and _on_mid["gate"] is False
and _off_mid["cheap"] is False)
async def _scenario_switched_off():
"""A notification raised with the cycle on is cleared when it is off."""
with Wired(NOW) as W:
c = W.ctl
W.net.down()
await W.refresh()
await W.tick(NOW)
raised = list(W.notes())
c.o["maintenance_enabled"] = False
await W.tick(NOW + timedelta(seconds=30))
await W.tick(NOW + timedelta(seconds=60))
return raised, list(W.notes()), c.price_gap
_r, _n, _g = run(_scenario_switched_off())
check("a notification raised while the cycle was on is cleared, once, when "
"it is off",
len(_r) == 1 and [x[0] for x in _n] == ["create", "dismiss"]
and _g is False)
async def _modes_no_price():
"""Battery modes on, maintenance off, no price anywhere: what happens."""
from app import packmode, homeview
with Wired(NOW, battery_modes_enabled=True, capacity_kwh=10,
maintenance_enabled=False) as W:
c = W.ctl
W.net.down()
await W.refresh()
c.store = FakeStore()
c.store.data = {packmode.unit_key("inverter-a", packmode.MODE_KEY):
packmode.CHEAP_HOURS}
c.inverters[0].soc, c.inverters[0].capacity_kwh = 40.0, 10.0
await W.tick(NOW)
runs = c._price_runs(NOW, c._mode_packs(), 20, 95)
plan = homeview.build_plan(c, NOW, False, True)["lines"]
return runs, c.price_gap, list(W.notes()), plan
_mr, _mg, _mn, _ml = run(_modes_no_price())
check("battery modes with no price and maintenance off: no price run is "
"made (so no battery holds or trades on price), and nothing about "
"'maintenance waits' is shown or notified",
_mr is None and _mg is False and _mn == []
and not [x for x in _ml if "maintenance waits" in x])
print(" Home plan lines in that state:", _ml)
# =========================================================================== #
print("\n14. round 2: a fixed tariff beside price_service is said once (F2)")
_PROFILE = json.dumps({"periods": [
{"name": "all", "days": "all", "from": "00:00", "to": "24:00",
"eur_per_kwh": 0.25}]})
async def _scenario_fixed(**over):
with Wired(NOW, **over) as W:
await W.refresh()
for k in range(4):
await W.tick(NOW + timedelta(seconds=30 * k))
return [x for x in W.notes() if x[1].get("notification_id")
== "goodwe_fixed_tariff_unused"], W.ctl
_before = mark()
_f, _fc = run(_scenario_fixed(static_profile=_PROFILE))
_logged = [m for m in since_mark(_before, logging.WARNING)
if "fixed tariff" in m]
check("price_service with a static_profile: ONE notification over four "
"ticks, and the setting is not refused",
len(_f) == 1 and _f[0][0] == "create"
and problems(static_profile=_PROFILE) == [])
check("...in English and Dutch, plain and short",
_f[0][1]["title"] == "Fixed tariff not used"
and "The fixed tariff is not used, because the price service gives the "
"prices." in _f[0][1]["message"]
and "Vast tarief niet gebruikt. Het vaste tarief wordt niet gebruikt, "
"want de prijsdienst levert de prijzen." in _f[0][1]["message"])
check("...and one WARNING line in the log",
_logged == ["The fixed tariff is not used, because the price service "
"gives the prices."])
check("no static_profile: nothing is said",
run(_scenario_fixed())[0] == [])
check("another provider with a static_profile: nothing is said (there it is "
"the fallback)",
run(_scenario_fixed(price_provider="generic_dayahead",
static_profile=_PROFILE))[0] == [])
# =========================================================================== #
print("\n15. round 2: a token shorter than 16 characters is refused (F5)")
_short = problems(price_service_token="a" * 15)
check(f"15 characters: refused with one short plain sentence ({_short})",
_short == ["“Price service token” (price_service_token) is too short. "
"Paste the whole token."])
check("...16 characters start", problems(price_service_token="a" * 16) == [])
check("...the refusal does not echo the value",
"a" * 15 not in "".join(_short))
# =========================================================================== #
print("\n16. round 2: the guards the reviewer's mutations R01 and R10 got past")
_t0 = local_midnight(date(2026, 10, 14))
_now0 = _t0 + timedelta(minutes=31) # inside period 2
_pts = [P.PricePoint(_t0 + timedelta(minutes=15 * i), v, i == 2)
for i, v in enumerate((0.10, 0.20, 0.05, 0.40))]
_prov = P.PriceRead.series(_pts, PriceProduct.DAY_AHEAD, "price_service", NOW)
_g = C.gate_for(_prov, _now0)
check("R01: if the quarter holding now is an estimate it is not priced as "
"firm, even though it is cheap: the gate is shut and says 'no price'",
_g.cheap is False and _g.covered is False
and _g.words == "No price for now")
_firm = P.PriceRead.series(
[P.PricePoint(p.start, p.eur_per_kwh, False) for p in _pts],
PriceProduct.DAY_AHEAD, "price_service", NOW)
check("...the same series with every quarter firm opens (it is cheap), so "
"only the estimate flag shut it",
C.gate_for(_firm, _now0).cheap is True
and C.gate_for(_firm, _now0).covered is True)
_prov2 = P.PriceRead.series(
[P.PricePoint(_t0 + timedelta(minutes=15 * i), v, i == 3)
for i, v in enumerate((0.10, 0.15, 0.05, 0.40))],
PriceProduct.DAY_AHEAD, "price_service", NOW)
check("an estimated quarter elsewhere in the day stays out of today's "
"average: 0.15 against 0.10, 0.15, 0.05 is above their average (0.10), "
"where with the estimated 0.40 counted it would be cheap (0.175)",
C.gate_for(_prov2, _t0 + timedelta(minutes=16)).cheap is False)
async def _plain_wire():
from aiohttp import web, ClientResponseError
seen, elsewhere = [], []
async def ok(request):
seen.append(dict(request.headers))
return web.Response(body=b'{"ok": 1}')
async def moved(request):
raise web.HTTPFound("/elsewhere")
async def other(request):
elsewhere.append(1)
return web.Response(body=b'{"ok": 2}')
async def missing(request):
return web.Response(status=404, text="no")
async def broken(request):
return web.Response(status=503, text="no")
app = web.Application()
for path, fn in (("/ok", ok), ("/moved", moved), ("/elsewhere", other),
("/missing", missing), ("/broken", broken)):
app.router.add_get(path, fn)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
base = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}"
res = {}
try:
res["ok"] = await P.https_get(base + "/ok", 3)
res["moved"] = await P.https_get(base + "/moved", 3)
for name in ("missing", "broken"):
try:
await P.https_get(base + "/" + name, 3)
res[name] = "no error"
except ClientResponseError as e:
res[name] = e.status
except Exception as e: # noqa: BLE001
res[name] = "other " + type(e).__name__
finally:
await runner.cleanup()
res["headers"] = seen[0] if seen else {}
res["followed"] = len(elsewhere)
return res
_pw = run(_plain_wire())
check("R10: the plain transport the other providers use returns the body "
"of a 200", _pw["ok"] == b'{"ok": 1}')
check("...follows a redirect, as it always did (the price service's own "
"path does not)",
_pw["moved"] == b'{"ok": 2}' and _pw["followed"] == 1)
check("...raises the library's own error for a 4xx and a 5xx, not the price "
"service's words",
_pw["missing"] == 404 and _pw["broken"] == 503)
check("...and sends no Authorization header of its own",
"Authorization" not in _pw["headers"])
for d in _TMP:
shutil.rmtree(d, ignore_errors=True)