PRICE-01 round 2: the token on no page, fixed-tariff note, token length, F3 words, test gaps
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:
co-authored by
Claude
parent
4eb88f7fda
commit
0801776645
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user