- TariffPlan.arbitrage(now=...) is required (keyword-only, no default); so are homeview.trade_verdict/refusal_key's now. Existing test calls pass one. - test_arbitrage.py: no module-level decide wrapper. Fixtures that probed a one-day plan after its extremes now hold two days (as a site does); the flap day gets a unique cheapest and dearest hour; the replay breaks its ties without direction. The flap bound is <= 4 and the replay check is len(_trades) < 365 again. 228 checks pass on the old and the new code. - test_price02.py part 6: ties (equal quarters, a real 15-minute day, alternating hourly prices) and the required now. - Identity pins: the masked digest is only for the older-tree comparison; this tree's own digests are pinned with nothing hidden. - DECISIONS: the tie analysis with numbers. Co-Authored-By: Claude <noreply@anthropic.com>
933 lines
44 KiB
Python
933 lines
44 KiB
Python
"""UX-13 P1: the Home screen's `/status` and the page shell. `python3 -B test_home.py`
|
|
|
|
1. `/status` keeps every old field (`banner`, `level`, `rows`, `checks`,
|
|
`hint`, `events`) AND carries the structured half the Home screen reads:
|
|
`state`, `now`, `batteries`, `plan`, `attention`, `month_peak`, `control`.
|
|
Both are pinned here, by name and shape.
|
|
2. The plan shows only what the add-on does today. Trading on price is the
|
|
verdict the control cycle would reach (the same `plan.arbitrage()` call),
|
|
drawn where the cheapest and dearest prices are, with kWh and € per block.
|
|
The monthly cycle, the peak limit and "follows the house" are the rest.
|
|
3. Shadow mode says "would", a stopped site plans nothing, and a red row is an
|
|
alarm under "Needs attention".
|
|
4. A fault in the Home view never costs the page its old rows.
|
|
5. The page: served at `/` from app/static, no external file, relative URLs
|
|
only, every label resolves in app/ui_text, and the region year reaches
|
|
`config/model` for the P2 Settings screens.
|
|
|
|
The STYLE scan of every Home sentence is test_messages.py part 8.
|
|
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
|
"""
|
|
|
|
import asyncio
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from aiohttp import ClientSession, web as aioweb
|
|
|
|
from app import homeview, web
|
|
from app.main import Controller
|
|
from app.peakguard import PeakDecision
|
|
from app.price import PricePoint, PriceProduct, PriceRead
|
|
from app.static_tariff import TariffPlan, static_plan
|
|
from app import packmode
|
|
from app.store import unit_key
|
|
from app.uimodel import REGION_PRESETS
|
|
from named_absence import field, nth, present
|
|
|
|
FLANDERS = REGION_PRESETS["flanders"]["taxes_eur_kwh"]
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Fixtures: a real Controller, with its readings set directly.
|
|
# --------------------------------------------------------------------------- #
|
|
class _Store:
|
|
def __init__(self):
|
|
self.data = {}
|
|
|
|
def set(self, k, v):
|
|
self.data[k] = v
|
|
|
|
def get(self, k, d=None):
|
|
return self.data.get(k, d)
|
|
|
|
def set_time(self, k, v):
|
|
self.data[k] = v.isoformat()
|
|
|
|
def get_time(self, k):
|
|
v = self.data.get(k)
|
|
return None if v is None else datetime.fromisoformat(v)
|
|
|
|
|
|
class _Pub:
|
|
enabled = False
|
|
|
|
def publish(self, _v):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class _Hass:
|
|
session = None
|
|
token = ""
|
|
|
|
async def limits(self, _e):
|
|
return (-5000.0, 5000.0, 1.0)
|
|
|
|
async def number(self, _e, invert=False):
|
|
return None
|
|
|
|
async def state(self, _e):
|
|
return None
|
|
|
|
async def set_number(self, _e, _v):
|
|
return True
|
|
|
|
async def call(self, *_a):
|
|
return True
|
|
|
|
async def states(self):
|
|
return []
|
|
|
|
|
|
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
|
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
|
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
|
"maintenance_enabled": False}
|
|
TWO = dict(ONE, inverters=[
|
|
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
|
|
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
|
|
"capacity_kwh": 9.6},
|
|
{"name": "Battery B", "setpoint_entity": "number.b", "soc_entity":
|
|
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
|
|
"capacity_kwh": 5.4}])
|
|
|
|
# Sunday 27 September 2026, 11:40 in Brussels (09:40 UTC): the mockup's day.
|
|
NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc)
|
|
DAY0 = datetime(2026, 9, 26, 22, 0, tzinfo=timezone.utc)
|
|
MOCK = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, .14,
|
|
.16, .19, .23, .28, .33, .36, .34, .29, .26, .24]
|
|
|
|
|
|
def make(opts, *, soc=(62.0, 35.0), batt=(-2500.0, -300.0), grid=10.0,
|
|
auto=True, wrote=0.0):
|
|
"""`wrote` is each inverter's last accepted power request (None: none yet)."""
|
|
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
|
c.auto = auto
|
|
for i, inv in enumerate(c.inverters):
|
|
inv.soc, inv.batt = soc[i], batt[i]
|
|
inv.last_write = wrote
|
|
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
|
c.grid = grid
|
|
return c
|
|
|
|
|
|
def provider_plan(prices=MOCK, day0=DAY0, days=2, provisional_from=None):
|
|
pts = []
|
|
for d in range(days):
|
|
for k, p in enumerate(prices):
|
|
start = day0 + timedelta(days=d, hours=k)
|
|
pts.append(PricePoint(start, p, provisional_from is not None
|
|
and start >= provisional_from))
|
|
return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD,
|
|
"generic_dayahead", NOW), None,
|
|
"provider")
|
|
|
|
|
|
def quiet_guard(projected_w=1240.0):
|
|
return PeakDecision(engaged=False, floor_w=None, ceiling_w=3500.0,
|
|
ceiling_from="cap", projected_w=projected_w)
|
|
|
|
|
|
def set_modes(c, *modes):
|
|
"""File each battery's mode in the store (None leaves the default, which
|
|
follows the house)."""
|
|
for inv, m in zip(c.inverters, modes):
|
|
if m is not None:
|
|
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = m
|
|
|
|
|
|
def status(c, now=NOW):
|
|
s = c.status_payload()
|
|
# The status route builds at wall-clock time; rebuild the structured half
|
|
# at the fixture's instant so the day and the hours are the mockup's.
|
|
s.update(homeview.build(c, level=s["level"], checks=s["checks"], now=now))
|
|
return s
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("1. /status: the old fields stay, the structured half is added")
|
|
# --------------------------------------------------------------------------- #
|
|
c1 = make(ONE, soc=(62.0,), batt=(960.0,), grid=800.0)
|
|
s1 = c1.status_payload()
|
|
OLD = {"banner", "level", "rows", "checks", "hint", "events"}
|
|
NEW = {"state", "now", "batteries", "plan", "attention", "month_peak",
|
|
"control"}
|
|
check(f"every old field is still there, with its old type ({sorted(OLD)})",
|
|
OLD <= set(s1) and isinstance(s1["rows"], list)
|
|
and all(len(r) == 2 for r in s1["rows"])
|
|
and all({"ok", "warn", "text"} <= set(r) for r in s1["checks"])
|
|
and isinstance(s1["banner"], str) and s1["level"] in ("ok", "warn", "bad")
|
|
and isinstance(s1["events"], list))
|
|
check(f"...and the structured half is added beside them ({sorted(NEW)})",
|
|
NEW <= set(s1) and "home_error" not in s1)
|
|
check("...and it is JSON, as the route serves it", json.loads(json.dumps(s1))
|
|
== json.loads(json.dumps(s1)))
|
|
check("`state` names the kind, the pill word, the control row and a sub-line",
|
|
set(s1["state"]) == {"kind", "pill", "ran", "sub"}
|
|
and s1["state"]["kind"] == "running")
|
|
check("`now` carries the four tiles in W and a plain line for each",
|
|
{"solar_w", "house_w", "grid_w", "batt_w", "has_solar", "lines"}
|
|
<= set(s1["now"])
|
|
and set(s1["now"]["lines"]) == {"solar", "house", "grid", "batt"}
|
|
and s1["now"]["grid_w"] == 800.0 and s1["now"]["batt_w"] == 960.0)
|
|
_nh = homeview.now_block(grid_w=-3000.0, pv_w=1000.0, batt_w=1500.0,
|
|
has_solar=True, shadow=False)
|
|
_nz = homeview.now_block(grid_w=-1030.0, pv_w=1000.0, batt_w=0.0,
|
|
has_solar=True, shadow=False)
|
|
_np = homeview.now_block(grid_w=800.0, pv_w=1000.0, batt_w=-300.0,
|
|
has_solar=True, shadow=False)
|
|
check("House = grid (+ import) + solar + battery (+ giving): 800 + 1000 - 300 "
|
|
"= 1500 W",
|
|
_np["house_w"] == 1500.0 and _np["lines"]["house"] == "Using")
|
|
check("...a sum below zero is not shown as a positive figure: 0 W inside "
|
|
"the idle band, and no figure beyond it",
|
|
_nz["house_w"] == 0.0 and _nh["house_w"] is None
|
|
and _nh["lines"]["house"] == homeview.tile_house(None, True, True))
|
|
check("...and with no solar reading the house figure is NOT invented: it is "
|
|
"None and the tile says why",
|
|
s1["now"]["house_w"] is None and s1["now"]["has_solar"] is False
|
|
and s1["now"]["lines"]["house"] == homeview.tile_house(None, False))
|
|
_b = s1["batteries"][0]
|
|
check("`batteries[]` has level, power, state, mode, the now line and why",
|
|
{"name", "level_pct", "power_w", "state", "mode", "now", "why",
|
|
"capacity_kwh", "stored_kwh", "maintenance"} <= set(_b)
|
|
and _b["level_pct"] == 62.0 and _b["state"] == "giving"
|
|
and _b["now"] == "Giving 1.0 kW now.")
|
|
_p = s1["plan"]
|
|
check("`plan` carries the verdict, the prices, the runs, the lines and "
|
|
"the region",
|
|
{"verdict", "head", "prices", "runs", "lines", "now_h", "hours",
|
|
"price_line", "tomorrow_line", "region", "region_line"} <= set(_p)
|
|
and len(_p["prices"]) in (23, 24, 25))
|
|
check("`attention[]` has severity, title, body and docs; `month_peak` and "
|
|
"`control` are there",
|
|
all({"severity", "title", "body", "docs"} <= set(a)
|
|
for a in s1["attention"])
|
|
and {"month", "limit_w", "peak_w", "pct", "period_w", "state", "line"}
|
|
<= set(s1["month_peak"])
|
|
and {"running", "shadow", "text", "maintenance", "docs_url"}
|
|
<= set(s1["control"]))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n2. the plan is what the add-on actually does")
|
|
# --------------------------------------------------------------------------- #
|
|
s_house = status(make(TWO))
|
|
check("with trading off and no cycle due, each battery follows the house "
|
|
"from now to the end of the day, and nothing else is drawn",
|
|
[r["kind"] for r in s_house["plan"]["runs"]] == ["house", "house"]
|
|
and s_house["plan"]["verdict_key"] == "house"
|
|
and all(r["end_h"] == 24.0 and abs(r["start_h"] - 11 - 40 / 60) < 0.01
|
|
for r in s_house["plan"]["runs"])
|
|
and s_house["plan"]["runs"][0]["to"] == "24:00")
|
|
|
|
ARB = dict(TWO, arbitrage_enabled=True, cycle_cost_threshold_eur=0.05)
|
|
c_arb = make(ARB)
|
|
c_arb.tariff_plan, c_arb.tariff_at = provider_plan(), NOW
|
|
c_arb.peak_guard = quiet_guard()
|
|
s_arb = status(c_arb)
|
|
_runs = s_arb["plan"]["runs"]
|
|
_grid = [r for r in _runs if r["kind"] == "grid"]
|
|
_give = [r for r in _runs if r["kind"] == "give"]
|
|
check("trading on price: a charge block at the cheapest hour (13:00) and a "
|
|
"give block at the dearest (19:00), on each battery",
|
|
[(r["battery"], r["from"], r["to"]) for r in _grid]
|
|
== [(0, "13:00", "14:00"), (1, "13:00", "14:00")]
|
|
and [(r["battery"], r["from"], r["to"]) for r in _give]
|
|
== [(0, "19:00", "20:00"), (1, "19:00", "20:00")]
|
|
and s_arb["plan"]["verdict_key"] == "trade")
|
|
check("...placed where the control cycle's own verdict puts them: the same "
|
|
"plan.arbitrage() call on the same plan",
|
|
c_arb.fresh_tariff_plan(NOW).arbitrage(
|
|
now=NOW, peak_guard_active=False, min_spread_eur_kwh=0.05,
|
|
uncertainty=frozenset({__import__("app.price", fromlist=["x"])
|
|
.Uncertainty.NO_WEATHER}))
|
|
.match(allowed=lambda v: (v.cheapest.start, v.dearest.start),
|
|
refused=lambda v: None)
|
|
== (DAY0 + timedelta(hours=13), DAY0 + timedelta(hours=19)))
|
|
# Battery A: 2.5 kW for 1 h = 2.5 kWh; room to the 95 % ceiling is 33 % of
|
|
# 9.6 kWh = 3.17 kWh, so 2.5. Battery B: 60 % of 5.4 = 3.24, so 2.5.
|
|
check("each block carries kWh and €: charging is capped by the battery's own "
|
|
"rating and the room its level leaves, € = kWh x that hour's price",
|
|
[r["kwh"] for r in _grid] == [2.5, 2.5]
|
|
and [r["eur"] for r in _grid] == [0.35, 0.35]
|
|
and [r["eur_kwh"] for r in _grid] == [0.14, 0.14]
|
|
and "2.5 kWh" in _grid[0]["what"] and "€0.14" in _grid[0]["why"]
|
|
and "€0.35" in _grid[0]["amount"])
|
|
# Giving at 19:00 comes after the 13:00 charge, so each battery may give what
|
|
# it will hold then: B is at 35 % + 2.5 kWh of 5.4 = 81 %, and 81 - 20 = 61 %
|
|
# of 5.4 = 3.3 kWh, so 2.5 (an upper bound, like every amount here).
|
|
check("...and giving is capped by what each battery holds above arbitrage's "
|
|
"floor after the charge before it",
|
|
[r["kwh"] for r in _give] == [2.5, 2.5]
|
|
and [r["eur"] for r in _give] == [0.9, 0.9])
|
|
|
|
|
|
def trade(soc, prices=MOCK, provisional_from=None, now=NOW, opts=ARB):
|
|
c = make(opts, soc=soc)
|
|
c.tariff_plan = provider_plan(prices, provisional_from=provisional_from)
|
|
c.tariff_at, c.peak_guard = now, quiet_guard()
|
|
s = status(c, now=now)
|
|
return ([(r["kind"], r["battery"], r["kwh"]) for r in s["plan"]["runs"]
|
|
if r["kind"] in ("grid", "give")], s["plan"]["lines"])
|
|
|
|
|
|
# Review of #144: only what arbitrage.decide() would still do is drawn.
|
|
_t, _l = trade((96.0, 35.0))
|
|
check("a battery at arbitrage's ceiling gets no charge block, not "
|
|
"'Charges up to 0.0 kWh'", ("grid", 0, 0.0) not in _t
|
|
and [k for k in _t if k[0] == "grid"] == [("grid", 1, 2.5)])
|
|
# 20:00 local: the dearest (19:00) is over; tomorrow's charge is a line. Put
|
|
# the dearest later today instead, with the emptiest pack at the floor.
|
|
_late = MOCK[:22] + [.40, .24]
|
|
_t, _l = trade((62.0, 20.0), prices=_late, now=NOW + timedelta(hours=8))
|
|
_t2, _l2 = trade((62.0, 35.0), prices=_late, now=NOW + timedelta(hours=8))
|
|
check("no give block while the emptiest battery is at arbitrage's floor: "
|
|
"the cycle's sale is graded on the emptiest pack (at 35 % both give)",
|
|
not [k for k in _t if k[0] == "give"]
|
|
and [k[:2] for k in _t2] == [("give", 0), ("give", 1)])
|
|
_t, _l = trade((62.0, 35.0), prices=[x - 0.5 for x in MOCK])
|
|
check("no give block at a negative price (the cycle refuses that sale)",
|
|
not [k for k in _t if k[0] == "give"]
|
|
and [k for k in _t if k[0] == "grid"])
|
|
# Running, a missing level is a red row and the site is held. In shadow mode
|
|
# the plan is still drawn, so this is where the level gate shows.
|
|
_t, _l = trade((None, 35.0), opts=dict(ARB, shadow_mode="on"))
|
|
check("with a battery level unknown the cycle does not trade, so nothing is "
|
|
"drawn and a line says why (shadow mode, which still plans)",
|
|
_t == [] and homeview.line_no_trade("no_level") in _l)
|
|
_t, _l = trade((62.0, 35.0), provisional_from=DAY0)
|
|
check("a plan with no published prices yet says so, not 'the price gap is "
|
|
"too small'",
|
|
_t == [] and homeview.line_no_trade("not_firm") in _l
|
|
and homeview.line_no_trade("not_worth") not in _l)
|
|
check("the price bars are today's 24 hours, from the real series, with the "
|
|
"cheap and dear hour marked",
|
|
[b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK
|
|
and s_arb["plan"]["prices"][13]["kind"] == "cheap"
|
|
and s_arb["plan"]["prices"][19]["kind"] == "dear"
|
|
and s_arb["plan"]["prices"][0]["kind"] == "mid"
|
|
and s_arb["plan"]["prices"][13]["label"] == "13:00")
|
|
check("the price line names the price now, today's lowest and highest, and "
|
|
"that tomorrow's prices are in",
|
|
s_arb["plan"]["price_line"] == ("Price now €0.16 per kWh, all in. "
|
|
"Today: lowest €0.14 at 13:00, "
|
|
"highest €0.36 at 19:00.")
|
|
and s_arb["plan"]["tomorrow_line"] == "Tomorrow's prices are in.")
|
|
|
|
c_late = make(ARB)
|
|
c_late.tariff_plan, c_late.tariff_at = provider_plan(), NOW
|
|
c_late.peak_guard = quiet_guard()
|
|
s_late = status(c_late, now=NOW + timedelta(hours=9)) # 20:40 local
|
|
check("a block whose hour is over is not drawn: at 20:40 no trade is left "
|
|
"today",
|
|
not [r for r in s_late["plan"]["runs"] if r["kind"] in ("grid", "give")])
|
|
|
|
c_peak = make(ARB)
|
|
c_peak.tariff_plan, c_peak.tariff_at = provider_plan(), NOW
|
|
c_peak.peak_guard = PeakDecision(engaged=True, floor_w=800.0,
|
|
ceiling_w=3500.0, ceiling_from="cap",
|
|
projected_w=3900.0)
|
|
s_peak = status(c_peak)
|
|
check("while the peak limit holds, the verdict refuses as the cycle does: no "
|
|
"trade block, a plain line, and a peak block for this 15-minute period",
|
|
not [r for r in s_peak["plan"]["runs"] if r["kind"] in ("grid", "give")]
|
|
and homeview.line_no_trade("peak") in s_peak["plan"]["lines"]
|
|
and [(r["from"], r["to"]) for r in s_peak["plan"]["runs"]
|
|
if r["kind"] == "peak"] == [("11:30", "11:45"), ("11:30", "11:45")])
|
|
|
|
c_pk = make(ARB)
|
|
c_pk.p1_enabled = True # the peak line is shown where P1 runs
|
|
c_pk.tariff_plan, c_pk.tariff_at = provider_plan(), NOW
|
|
c_pk.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=4200.0,
|
|
ceiling_from="month_peak", projected_w=1240.0)
|
|
s_pk = status(c_pk)
|
|
check("the plan's peak line names the guard's ceiling, which is this month's "
|
|
"peak once it is above the limit",
|
|
homeview.line_peak_limit(4200.0, False) in s_pk["plan"]["lines"]
|
|
and s_pk["month_peak"]["line"].endswith("above 4.2 kW."))
|
|
|
|
c_blind = make(ARB)
|
|
c_blind.tariff_plan, c_blind.tariff_at = provider_plan(), NOW
|
|
s_blind = status(c_blind) # meter_source off: the guard never runs
|
|
check("with nothing measuring the 15-minute peak, trading is NOT drawn, as "
|
|
"the cycle refuses it (guard state None)",
|
|
not [r for r in s_blind["plan"]["runs"] if r["kind"] in ("grid", "give")]
|
|
and homeview.line_no_trade("not_covered") in s_blind["plan"]["lines"])
|
|
|
|
c_flat = make(ARB)
|
|
c_flat.tariff_plan, c_flat.tariff_at = provider_plan(prices=[.2] * 24), NOW
|
|
c_flat.peak_guard = quiet_guard()
|
|
s_flat = status(c_flat)
|
|
check("a flat day: no trade, no cheap or expensive hour marked",
|
|
not [r for r in s_flat["plan"]["runs"] if r["kind"] in ("grid", "give")]
|
|
and {b["kind"] for b in s_flat["plan"]["prices"]} == {"mid"}
|
|
and homeview.line_no_trade("not_worth") in s_flat["plan"]["lines"])
|
|
|
|
c_old = make(ARB)
|
|
c_old.tariff_plan = provider_plan()
|
|
c_old.tariff_at = NOW - timedelta(hours=3) # past its max age
|
|
c_old.peak_guard = quiet_guard()
|
|
s_old = status(c_old)
|
|
check("a stale plan is not traded on by the cycle, so it is not drawn",
|
|
not [r for r in s_old["plan"]["runs"] if r["kind"] in ("grid", "give")])
|
|
|
|
MAINT = dict(TWO, maintenance_enabled=True, maintenance_start_hour=12)
|
|
c_m = make(MAINT)
|
|
s_m = status(c_m)
|
|
_cyc = [r for r in s_m["plan"]["runs"] if r["kind"] == "cycle"]
|
|
check("a monthly cycle due today is drawn on ONE battery, at the start hour "
|
|
"the cycle uses (12 UTC is 14:00 here), and the other one waits",
|
|
len(_cyc) == 1 and field(nth(_cyc), "from") == "14:00"
|
|
and s_m["plan"]["verdict_key"] == "cycle"
|
|
and any("waits its turn" in x for x in s_m["plan"]["lines"]))
|
|
check("...with its duration from the battery's capacity and the cycle's rates "
|
|
"and hold time",
|
|
present(nth(_cyc)) and _cyc[0]["amount"].startswith("Takes about ")
|
|
and _cyc[0]["end_h"] > _cyc[0]["start_h"] + 2)
|
|
# MAINT-10: with the site's zone set, the hour is read on the local clock.
|
|
from app.tariff import _EUTimezone # noqa: E402
|
|
c_ml = make(dict(MAINT, maintenance_start_hour=14))
|
|
for _mm in c_ml.maints:
|
|
_mm.tz = _EUTimezone()
|
|
_cyc_l = [r for r in status(c_ml)["plan"]["runs"] if r["kind"] == "cycle"]
|
|
check("MAINT-10: start hour 14 in the local zone is drawn at 14:00, the "
|
|
"instant the cycle starts", len(_cyc_l) == 1
|
|
and field(nth(_cyc_l), "from") == "14:00")
|
|
c_m.maints[0].store.set("phase", "drain")
|
|
s_md = status(c_m)
|
|
check("a cycle under way is drawn from now, and the battery's now line says "
|
|
"so",
|
|
[r["from"] for r in s_md["plan"]["runs"] if r["kind"] == "cycle"]
|
|
== ["11:40"]
|
|
and s_md["batteries"][0]["now"] == "Monthly cycle: emptying."
|
|
and any("Battery B waits its turn" in x for x in s_md["plan"]["lines"]))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n3. shadow mode, a stopped site, an alarm")
|
|
# --------------------------------------------------------------------------- #
|
|
c_sh = make(dict(ARB, shadow_mode="on"))
|
|
c_sh.tariff_plan, c_sh.tariff_at = provider_plan(), NOW
|
|
c_sh.peak_guard = quiet_guard()
|
|
s_sh = status(c_sh)
|
|
_texts = [r["what"] for r in s_sh["plan"]["runs"]] + [s_sh["plan"]["head"]]
|
|
check("shadow: the state is shadow, the battery tile is held at 0 W, and "
|
|
"every block and the head say 'would'",
|
|
s_sh["state"]["kind"] == "shadow"
|
|
and s_sh["now"]["lines"]["batt"] == "Held at 0 W"
|
|
and all("ould" in x for x in _texts)
|
|
and s_sh["plan"]["head"].startswith("Shadow mode:"))
|
|
check("...and 'Shadow mode is on' is the first item under Needs attention",
|
|
s_sh["attention"][0]["title"] == homeview.ATTN_SHADOW
|
|
and s_sh["attention"][0]["severity"] == "info")
|
|
check("...and each battery's now line says it is held at 0 W",
|
|
all(b["now"].startswith("Held at 0 W.") for b in s_sh["batteries"]))
|
|
|
|
s_stop = status(make(TWO, auto=False))
|
|
check("stopped: nothing is planned and the head says so",
|
|
s_stop["plan"]["runs"] == [] and s_stop["state"]["kind"] == "stopped"
|
|
and s_stop["plan"]["verdict_key"] == "stopped"
|
|
and s_stop["control"]["running"] is False
|
|
and all(b["now"] == "Held at 0 W while control is stopped."
|
|
for b in s_stop["batteries"]))
|
|
|
|
c_al = make(ONE, soc=(62.0,), batt=(0.0,))
|
|
c_al.inverters[0].sign_wd.tripped = True
|
|
s_al = c_al.status_payload()
|
|
_red = [a for a in s_al["attention"] if a["severity"] == "alarm"]
|
|
check("an alarm: level bad, state alarm, and the red row is the first item "
|
|
"under Needs attention",
|
|
s_al["level"] == "bad" and s_al["state"]["kind"] == "alarm"
|
|
and _red and s_al["attention"][0] is _red[0]
|
|
and _red[0]["title"] == homeview.ATTN_TITLES["direction"])
|
|
check("...and nothing is planned while every inverter is held at 0 W",
|
|
s_al["plan"]["runs"] == [] and s_al["plan"]["verdict_key"] == "held")
|
|
_rows = [r for r in s_al["checks"] if not r["ok"] and not r["warn"]]
|
|
check("...and every red, amber and settling row reaches Needs attention with "
|
|
"its own sentence as the body, and no green row does",
|
|
[a["body"] for a in homeview.attention(s_al["checks"])]
|
|
== [r["text"] for r in s_al["checks"] if not r["ok"] and not r["warn"]]
|
|
+ [r["text"] for r in s_al["checks"] if not r["ok"] and r["warn"]]
|
|
+ [r["text"] for r in s_al["checks"] if r["ok"] and r["warn"]])
|
|
|
|
# UX-13 P1 test finding (a): the page said "Held at 0 W" about 15 s before
|
|
# the inverter's power request reached 0 W. Until the last accepted request
|
|
# is 0 W it says "Going to 0 W".
|
|
c_al.inverters[0].last_write = 20.0
|
|
s_go = homeview.build(c_al, level="bad", checks=s_al["checks"], now=NOW)
|
|
c_al.inverters[0].last_write = 0.0
|
|
s_at = homeview.build(c_al, level="bad", checks=s_al["checks"], now=NOW)
|
|
check("an alarm while the last power request is still 20 W says 'Going to "
|
|
"0 W', and 'Held at 0 W' once 0 W was accepted",
|
|
s_go["plan"]["verdict"] == "Going to 0 W"
|
|
and s_go["batteries"][0]["now"] == "Going to 0 W until the alarm clears."
|
|
and s_go["plan"]["head"].startswith("Every inverter goes to 0 W")
|
|
and s_at["plan"]["verdict"] == "Held at 0 W"
|
|
and s_at["batteries"][0]["now"] == "Held at 0 W until the alarm clears.")
|
|
c_sp = make(TWO, auto=False)
|
|
c_sp.inverters[1].last_write = -1500.0
|
|
s_sp = status(c_sp)
|
|
s_new = status(make(TWO, auto=False, wrote=None))
|
|
check("...and before any power request was accepted (just started, or every "
|
|
"write refused) it says 'Going to 0 W', not 'Held'",
|
|
[b["now"] for b in s_new["batteries"]]
|
|
== ["Going to 0 W while control is stopped."] * 2)
|
|
check("...and a stop does the same, per battery",
|
|
[b["now"] for b in s_sp["batteries"]]
|
|
== ["Held at 0 W while control is stopped.",
|
|
"Going to 0 W while control is stopped."])
|
|
|
|
# Finding (b): a quarter-hourly feed. The block's price and the price line
|
|
# name the same 15-minute point, and nothing says "the list".
|
|
_q = []
|
|
for _k, _p in enumerate(MOCK):
|
|
for _m in range(4):
|
|
_q.append(PricePoint(DAY0 + timedelta(hours=_k, minutes=15 * _m),
|
|
round(_p + (0.0 if _m != 3 else -0.01), 4), False))
|
|
_q += [PricePoint(p.start + timedelta(days=1), p.eur_per_kwh, False)
|
|
for p in list(_q)]
|
|
c_q = make(ARB)
|
|
c_q.tariff_plan = TariffPlan(PriceRead.series(tuple(_q), PriceProduct.DAY_AHEAD,
|
|
"generic_dayahead", NOW), None,
|
|
"provider")
|
|
c_q.tariff_at = NOW
|
|
c_q.peak_guard = quiet_guard()
|
|
s_q = status(c_q)
|
|
_grid = [r for r in s_q["plan"]["runs"] if r["kind"] == "grid"]
|
|
_lo = min(_q[:96], key=lambda p: p.eur_per_kwh)
|
|
check("quarter-hour prices: the charge block, its price and the price line's "
|
|
"lowest are the same 15-minute period",
|
|
_grid and _grid[0]["from"] == homeview.hhmm(_lo.start)
|
|
and _grid[0]["why"] == f"Lowest published price, €{_lo.eur_per_kwh:.2f} "
|
|
"per kWh."
|
|
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
|
|
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
|
|
and "the list" not in json.dumps(s_q["plan"]))
|
|
|
|
# UX-45: the chart draws the steps the add-on decides on.
|
|
_pq = s_q["plan"]["prices"]
|
|
check("UX-45: 96 quarter prices -> 96 bars, each one quarter, labelled "
|
|
"from its own start, with its own price and no average",
|
|
len(_pq) == 96 and _pq[0]["label"] == "00:00"
|
|
and _pq[0]["end_label"] == "00:15" and _pq[57]["label"] == "14:15"
|
|
and _pq[57]["end_label"] == "14:30"
|
|
and [b["eur_kwh"] for b in _pq] == [round(p.eur_per_kwh, 4)
|
|
for p in _q[:96]]
|
|
and all("points" not in b for b in _pq)
|
|
and s_q["plan"]["hours"] == 24)
|
|
check("...the quarters of one hour differ (the chart shows the steps)",
|
|
len({b["eur_kwh"] for b in _pq[52:56]}) > 1)
|
|
check("...and the page has no 'hour average' tip any more",
|
|
"average_tip" not in io.open(os.path.join(
|
|
HERE, "app", "static", "app.js"), encoding="utf-8").read()
|
|
and "average_tip" not in io.open(os.path.join(
|
|
HERE, "app", "ui_text", "en.json"), encoding="utf-8").read()
|
|
and "average_tip" not in io.open(os.path.join(
|
|
HERE, "app", "ui_text", "nl.json"), encoding="utf-8").read())
|
|
_cheap_q = [b["start"] for b in _pq if b["kind"] == "cheap"]
|
|
_dear_q = [b["start"] for b in _pq if b["kind"] == "dear"]
|
|
_hi = max(_q[:96], key=lambda p: p.eur_per_kwh)
|
|
check("...without battery modes the marks are the two quarters the "
|
|
"arbitrage verdict trades in, and no others",
|
|
_cheap_q == [_lo.start.isoformat()] and _dear_q == [_hi.start.isoformat()]
|
|
and sum(1 for b in _pq if b["kind"] == "mid") == 94)
|
|
|
|
# Battery modes on: the marks ARE the kept priceruns result.
|
|
from app import priceruns
|
|
_rp = priceruns.plan(tuple(_q), NOW, required_eur_kwh=0.05, charge_kwh=2.0,
|
|
charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
|
|
c_m = make(ARB)
|
|
c_m.tariff_plan, c_m.tariff_at = c_q.tariff_plan, NOW
|
|
c_m.peak_guard = quiet_guard()
|
|
c_m.modes_on, c_m.mode_runs = True, _rp
|
|
set_modes(c_m, packmode.SOLAR_FIRST, packmode.CHEAP_HOURS)
|
|
_pm = status(c_m)["plan"]["prices"]
|
|
_today = [(a, z) for a, z, _p in priceruns.firm_slots(_q, NOW)
|
|
if a < DAY0 + timedelta(hours=24)]
|
|
_want_c = {a.isoformat() for a, z in _today
|
|
if _rp.cheap.start <= a < _rp.cheap.end}
|
|
_want_d = {a.isoformat() for g in _rp.give for a, z in _today
|
|
if g.start <= a < g.end}
|
|
check("UX-45: with battery modes on, the cheap and dear quarters are the "
|
|
"priceruns cheap run and give runs for the same input",
|
|
_rp.on and _rp.charge_useful() and _want_c and _want_d
|
|
and {b["start"] for b in _pm if b["kind"] == "cheap"} == _want_c
|
|
and {b["start"] for b in _pm if b["kind"] == "dear"} == _want_d
|
|
and len(_pm) == 96)
|
|
c_m2 = make(ARB)
|
|
c_m2.tariff_plan, c_m2.tariff_at = c_q.tariff_plan, NOW
|
|
c_m2.peak_guard = quiet_guard()
|
|
c_m2.modes_on = True
|
|
set_modes(c_m2, packmode.CHEAP_HOURS, None)
|
|
c_m2.mode_runs = priceruns.plan(tuple(_q), NOW, required_eur_kwh=5.0,
|
|
charge_kwh=2.0, charge_kw=4.0, sell_kwh=0.0,
|
|
sell_kw=0.0)
|
|
check("...a run plan that says no trade marks nothing",
|
|
not c_m2.mode_runs.on
|
|
and {b["kind"] for b in status(c_m2)["plan"]["prices"]} == {"mid"})
|
|
|
|
# A quarter that is over keeps the mark it had while it was ahead.
|
|
# (`status()` also builds at wall-clock time, which starts the day's memory
|
|
# over, so the two builds below go straight to `homeview.build`.)
|
|
_later = NOW + timedelta(hours=4)
|
|
_s0 = c_q.status_payload()
|
|
homeview.build(c_q, level=_s0["level"], checks=_s0["checks"], now=NOW)
|
|
c_q.tariff_at = _later
|
|
_pl = homeview.build(c_q, level=_s0["level"], checks=_s0["checks"],
|
|
now=_later)["plan"]["prices"]
|
|
check("...and a quarter already over keeps the mark it had while ahead",
|
|
[b["kind"] for b in _pl if b["start"] == _lo.start.isoformat()]
|
|
== ["cheap"]
|
|
and [b["kind"] for b in _pl if b["label"] == "19:00"] == ["dear"])
|
|
c_q.tariff_at = NOW
|
|
|
|
# UX-45 round 2 (F2): a hole is covered by the add-on's own rule,
|
|
# `price._cover_table()`: the point before it runs on, never over an hour.
|
|
def _hole_bars(missing):
|
|
pts = [PricePoint(DAY0 + timedelta(minutes=15 * i), .10 + .001 * i, False)
|
|
for i in range(96)]
|
|
gone = {DAY0 + timedelta(hours=10, minutes=15 * (1 + j))
|
|
for j in range(missing)}
|
|
pts = [p for p in pts if p.start not in gone]
|
|
return homeview.price_bars(pts, DAY0, DAY0 + timedelta(hours=24))
|
|
|
|
|
|
_gap = {}
|
|
for _n in (1, 2, 3, 4, 5):
|
|
_bb = _hole_bars(_n)
|
|
_gap[_n] = ([b["eur_kwh"] is None for b in _bb[41:41 + _n]],
|
|
len(_bb))
|
|
_b1 = _hole_bars(1)
|
|
check("UX-45: 1, 2 or 3 missing quarters show the price before them (the "
|
|
"add-on's own cover rule), and nothing shifts",
|
|
all(len(_hole_bars(n)) == 96 for n in (1, 2, 3))
|
|
and _gap[1][0] == [False] and _gap[2][0] == [False, False]
|
|
and _gap[3][0] == [False, False, False]
|
|
and [b["eur_kwh"] for b in _hole_bars(3)[40:44]]
|
|
== [_hole_bars(3)[40]["eur_kwh"]] * 4
|
|
and _b1[42]["label"] == "10:30" and _b1[43]["label"] == "10:45"
|
|
and _b1[43]["eur_kwh"] == round(.10 + .001 * 43, 4))
|
|
check("...4 missing quarters leave 1 bar empty, 5 leave 2: a hole over an "
|
|
"hour is not filled past the hour",
|
|
_gap[4][0] == [False, False, False, True]
|
|
and _gap[5][0] == [False, False, False, True, True]
|
|
and sum(b["eur_kwh"] is None for b in _hole_bars(4)) == 1
|
|
and sum(b["eur_kwh"] is None for b in _hole_bars(5)) == 2)
|
|
|
|
# A cheap run that is not worth charging in is not marked; an estimate is.
|
|
import types
|
|
_pts = [PricePoint(DAY0 + timedelta(minutes=15 * i),
|
|
.30 if i < 4 else .10, False) for i in range(8)]
|
|
_rp2 = priceruns.plan(tuple(_pts), DAY0, required_eur_kwh=0.05,
|
|
charge_kwh=0.5, charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
|
|
c_w2 = make(ARB)
|
|
c_w2.modes_on, c_w2.mode_runs = True, _rp2
|
|
set_modes(c_w2, packmode.LEFTOVER_SOLAR, None)
|
|
_w2 = homeview.price_windows(c_w2, None, None)
|
|
check("UX-45: a cheap run that comes after every dear slot is not worth a "
|
|
"charge, so it is not marked (the dear run is)",
|
|
_rp2.on and _rp2.cheap is not None and not _rp2.charge_useful()
|
|
and [w[0] for w in _w2] == ["dear"])
|
|
_est = [PricePoint(DAY0 + timedelta(minutes=15 * i), .2, False)
|
|
for i in range(80)] + [PricePoint(DAY0 + timedelta(hours=20), .25,
|
|
True)]
|
|
_be = homeview.price_bars(_est, DAY0, DAY0 + timedelta(hours=24))
|
|
check("UX-45: a carried (estimate) price after quarter prices is the last "
|
|
"point, so it covers one quarter (the add-on's tail rule), as an "
|
|
"estimate bar; the rest stays empty",
|
|
[b["estimate"] for b in _be[78:83]] == [False, False, True, False, False]
|
|
and _be[80]["eur_kwh"] == .25 and _be[81]["eur_kwh"] is None)
|
|
_est2 = _est[:80] + [PricePoint(DAY0 + timedelta(hours=20 + k), .25, True)
|
|
for k in range(3)]
|
|
_be2 = homeview.price_bars(_est2, DAY0, DAY0 + timedelta(hours=24))
|
|
check("...carried hourly points one after another cover an hour each",
|
|
[b["eur_kwh"] for b in _be2[80:92]] == [.25] * 12
|
|
and _be2[92]["eur_kwh"] is None)
|
|
|
|
# Hourly prices -> 24 bars (above); a day/night tariff -> 24 bars.
|
|
_ph1 = s_arb["plan"]["prices"]
|
|
check("UX-45: hourly prices -> 24 hour bars with the hour's own price",
|
|
len(_ph1) == 24 and _ph1[13]["end_label"] == "14:00"
|
|
and "points" not in _ph1[13])
|
|
c_dn = make(dict(ARB, static_profile=(
|
|
'{"periods": [{"name":"day","from":"07:00","to":"22:00","days":"all",'
|
|
'"eur_per_kwh":0.34},{"name":"night","from":"22:00","to":"07:00",'
|
|
'"days":"all","eur_per_kwh":0.26}]}')))
|
|
c_dn.peak_guard = quiet_guard()
|
|
_pd = status(c_dn)["plan"]["prices"]
|
|
check("UX-45: a day/night tariff -> 24 bars, 0.26 at night and 0.34 by day",
|
|
len(_pd) == 24
|
|
and [b["eur_kwh"] for b in _pd]
|
|
== [.26] * 7 + [.34] * 15 + [.26] * 2
|
|
and all(b["kind"] == "mid" for b in _pd))
|
|
|
|
# Clock-change days: 92 and 100 quarters.
|
|
_d0 = datetime(2026, 3, 28, 23, 0, tzinfo=timezone.utc)
|
|
_d1 = datetime(2026, 3, 29, 22, 0, tzinfo=timezone.utc)
|
|
_sp = [PricePoint(_d0 + timedelta(minutes=15 * i), .1, False)
|
|
for i in range(92)]
|
|
check("UX-45: a 23-hour day has 92 quarter bars, a 25-hour day 100",
|
|
len(homeview.price_bars(_sp, _d0, _d1)) == 92
|
|
and len(homeview.price_bars(
|
|
_sp, _d0, _d0 + timedelta(hours=25))) == 100)
|
|
|
|
_f0 = datetime(2026, 10, 24, 22, 0, tzinfo=timezone.utc) # 25-hour day
|
|
_fb = [PricePoint(_f0 + timedelta(minutes=15 * i), .1, False)
|
|
for i in range(100)]
|
|
|
|
# F5: the day/night chart with the stored static plan, as in production
|
|
# (the cycle stores `static_plan()` when the provider is off).
|
|
_PROF = ('{"periods": [{"name":"day","from":"07:00","to":"22:00",'
|
|
'"days":"all","eur_per_kwh":0.34},{"name":"night","from":"22:00",'
|
|
'"to":"07:00","days":"all","eur_per_kwh":0.26}]}')
|
|
|
|
|
|
def _dn(arb):
|
|
c = make(dict(ARB if arb else TWO, static_profile=_PROF))
|
|
c.peak_guard = quiet_guard()
|
|
c.tariff_plan = static_plan(c.o, NOW, NOW + timedelta(hours=36), now=NOW)
|
|
c.tariff_at = NOW
|
|
return c, status(c)["plan"]["prices"]
|
|
|
|
|
|
_c_off, _p_off = _dn(False)
|
|
_c_on, _p_on = _dn(True)
|
|
_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW), NOW)
|
|
check("UX-45: day/night tariff with the stored plan and price trading off: "
|
|
"24 bars, no cheap or dear mark",
|
|
len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"})
|
|
check("...and with price trading on, the marks are the verdict's cheapest "
|
|
"and dearest hour (one each)",
|
|
_v_on is not None and len(_p_on) == 24
|
|
and [datetime.fromisoformat(b["start"]) for b in _p_on
|
|
if b["kind"] == "cheap"] == [_v_on.cheapest.start]
|
|
and [datetime.fromisoformat(b["start"]) for b in _p_on
|
|
if b["kind"] == "dear"] == [_v_on.dearest.start]
|
|
and sum(b["kind"] == "mid" for b in _p_on) == 22)
|
|
|
|
# F3 / owner decision 2a: with battery modes on, marks only when a battery
|
|
# uses a price mode.
|
|
def _modes(*modes, use=(True, True)):
|
|
c = make(ARB)
|
|
c.tariff_plan, c.tariff_at = c_q.tariff_plan, NOW
|
|
c.peak_guard = quiet_guard()
|
|
c.modes_on, c.mode_runs = True, _rp
|
|
set_modes(c, *modes)
|
|
for inv, u in zip(c.inverters, use):
|
|
c.store.data[unit_key(inv.name, packmode.USE_KEY)] = u
|
|
return {b["kind"] for b in status(c)["plan"]["prices"]}
|
|
|
|
|
|
_F, _L, _C = (packmode.SOLAR_FIRST, packmode.LEFTOVER_SOLAR,
|
|
packmode.CHEAP_HOURS)
|
|
check("UX-45: battery modes on, every battery follows the house: no marks",
|
|
_modes(_F, _F) == {"mid"} and _modes(None, None) == {"mid"})
|
|
check("...one battery on Cheap hours or Leftover solar: the marks show",
|
|
{"cheap", "dear"} <= _modes(_F, _C) and {"cheap", "dear"} <= _modes(_L, _F))
|
|
check("...a price mode on a battery that is switched off does not count",
|
|
_modes(_F, _C, use=(True, False)) == {"mid"}
|
|
and {"cheap", "dear"} <= _modes(_F, _C, use=(False, True)))
|
|
|
|
# F4: the day's memory of past marks starts over on a new day.
|
|
_s4 = c_q.status_payload()
|
|
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=NOW)
|
|
_k0 = dict(c_q.home_prices.kinds)
|
|
_tom = NOW + timedelta(days=1)
|
|
c_q.tariff_at = _tom
|
|
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=_tom)
|
|
c_q.tariff_at = NOW
|
|
check("UX-45: past marks start over on a new day (nothing of yesterday is "
|
|
"kept)",
|
|
len(_k0) > 0 and c_q.home_prices.day != DAY0
|
|
and c_q.home_prices.kinds
|
|
and all(k >= DAY0 + timedelta(days=1) for k in c_q.home_prices.kinds)
|
|
and len(c_q.home_prices.kinds) <= 96)
|
|
|
|
# F1: the setup wizard counts hours, not bars.
|
|
from app.validate import setup_checks
|
|
_o = dict(ARB, price_provider="entsoe_be")
|
|
_say = lambda n: [x for x in map(repr, setup_checks(
|
|
_o, entities={}, running=_o, prices_known=n)) if "Prices for today" in x]
|
|
check("UX-45: 96 quarter prices count as 24 hours in the setup wizard, "
|
|
"not 96",
|
|
homeview.priced_hours(_pq) == 24
|
|
and homeview.priced_hours(s_arb["plan"]["prices"]) == 24
|
|
and "for 24 of 24 hours" in "".join(_say(homeview.priced_hours(_pq)))
|
|
and "96 of 24" not in "".join(_say(homeview.priced_hours(_pq))))
|
|
check("...a half-priced day counts its hours; a hole of one quarter loses "
|
|
"no hour",
|
|
homeview.priced_hours(_pq[:48]) == 12
|
|
and homeview.priced_hours(_hole_bars(5)) == 24
|
|
and homeview.priced_hours([dict(b, eur_kwh=None) for b in _pq]) == 0)
|
|
check("...a 23-hour day gives 23 and a 25-hour day 24 (clock hours)",
|
|
homeview.priced_hours(homeview.price_bars(_sp, _d0, _d1)) == 23
|
|
and homeview.priced_hours(homeview.price_bars(
|
|
_fb, _f0, _f0 + timedelta(hours=25))) == 24)
|
|
check("...and the wizard takes that count",
|
|
"known = priced_hours(prices)" in io.open(os.path.join(
|
|
HERE, "app", "web.py"), encoding="utf-8").read())
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n4. a fault in the Home view never costs the page its rows")
|
|
# --------------------------------------------------------------------------- #
|
|
_real = homeview.build
|
|
try:
|
|
homeview.build = lambda *a, **k: 1 / 0
|
|
s_err = make(ONE, soc=(50.0,), batt=(0.0,)).status_payload()
|
|
finally:
|
|
homeview.build = _real
|
|
check("the old fields are served and `home_error` says the rest is missing",
|
|
OLD <= set(s_err) and s_err.get("home_error") is True
|
|
and not (NEW & set(s_err)))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n5. the page")
|
|
# --------------------------------------------------------------------------- #
|
|
PAGE = web.page()
|
|
JS = io.open(os.path.join(HERE, "app", "static", "app.js"),
|
|
encoding="utf-8").read()
|
|
UI = json.load(io.open(os.path.join(HERE, "app", "ui_text", "en.json"),
|
|
encoding="utf-8"))["home"]
|
|
|
|
|
|
def resolve(path):
|
|
node = UI
|
|
for k in path.split("."):
|
|
if not isinstance(node, dict) or k not in node:
|
|
return None
|
|
node = node[k]
|
|
return node
|
|
|
|
|
|
_WORDS = json.loads(PAGE.split('id="ui-text">', 1)[1]
|
|
.split("</script>", 1)[0].replace("<\\/", "</"))
|
|
check("the page is assembled: no placeholder is left and the words parse, "
|
|
"English and Dutch (UX-13 P5)",
|
|
"/*APP_CSS*/" not in PAGE and "/*APP_JS*/" not in PAGE
|
|
and "/*UI_TEXT*/" not in PAGE
|
|
and _WORDS["en"]["home"] == UI and set(_WORDS) == {"en", "nl"})
|
|
check("four tabs with the four hash routes, in a keyboard tablist",
|
|
re.findall(r'data-hash="(\w+)"', PAGE) == ["home", "batteries",
|
|
"settings", "setup"]
|
|
and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 4
|
|
and "ArrowRight" in JS and "ArrowLeft" in JS)
|
|
_ext = re.findall(r"""(?:https?:)?//[A-Za-z0-9.-]+\.[a-z]{2,}""", PAGE)
|
|
check(f"no external file and no web font: no URL leaves the add-on "
|
|
f"({_ext})", not _ext and "@import" not in PAGE
|
|
and "<link" not in PAGE and "src=" not in PAGE)
|
|
_abs = re.findall(r"""(?:fetch\(|href=)["'](/[^"']*)["']""", PAGE)
|
|
check(f"every fetch and link is relative, for ingress ({_abs})",
|
|
not _abs and 'fetch("status?lang=' in JS and 'fetch("action"' in JS)
|
|
_keys = set(re.findall(r'data-t(?:-label)?="([\w.]+)"', PAGE)) | set(
|
|
re.findall(r'''\bt\("([\w.]+)"\)''', JS))
|
|
_missing = sorted(k for k in _keys if not isinstance(resolve(k), str))
|
|
check(f"every label the page asks for is in app/ui_text ({len(_keys)} "
|
|
f"keys; missing: {_missing})", len(_keys) > 30 and not _missing)
|
|
check("data is set as text, never as HTML, so no status sentence can inject "
|
|
"markup", "innerHTML" not in JS and "insertAdjacentHTML" not in JS)
|
|
check("every function of the old page is still on it: start/stop, the "
|
|
"maintenance start and abort, the status rows, the checks and the "
|
|
"event line",
|
|
"auto_toggle" in JS and '"maint_start"' in JS and '"maint_abort"' in JS
|
|
and 'id="rows"' in PAGE and 'id="checks"' in PAGE
|
|
and 'id="hint"' in PAGE and 'href="config"' in PAGE)
|
|
check("stopping, starting and both maintenance actions ask first",
|
|
all(f'ask("{k}"' in JS for k in ("stop", "start", "maint_start",
|
|
"maint_abort")))
|
|
check("tooltips open on hover, on a tap and on keyboard focus",
|
|
"pointermove" in JS and "pointerdown" in JS and "focusin" in JS)
|
|
check("the chart is not colour alone: expensive hours and blocks are "
|
|
"patterned, and every item has a text label",
|
|
"repeating-linear-gradient" in PAGE and '"aria-label": tipText' in JS)
|
|
check("the phone layout puts the tabs at the bottom under 640 px, and dark "
|
|
"mode follows the system",
|
|
"@media (max-width:640px)" in PAGE and "position:fixed;bottom:0" in PAGE
|
|
and "prefers-color-scheme:dark" in PAGE)
|
|
|
|
|
|
async def _serve():
|
|
class _Sup:
|
|
async def info(self):
|
|
return {"options": {"price_taxes_eur_kwh": FLANDERS,
|
|
"price_taxes_profile": ""},
|
|
"schema": [], "translations": {}}
|
|
c = make(ONE, soc=(50.0,), batt=(0.0,))
|
|
runner = aioweb.AppRunner(web.build_app(c, _Sup()))
|
|
await runner.setup()
|
|
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
|
await site.start()
|
|
port = site._server.sockets[0].getsockname()[1]
|
|
out = {}
|
|
try:
|
|
async with ClientSession() as s:
|
|
async with s.get(f"http://127.0.0.1:{port}/") as r:
|
|
out["page"] = (r.status, r.content_type, await r.text())
|
|
async with s.get(f"http://127.0.0.1:{port}/status") as r:
|
|
out["status"] = await r.json()
|
|
async with s.get(f"http://127.0.0.1:{port}/status?lang=nl-BE") as r:
|
|
out["status_nl"] = await r.json()
|
|
async with s.get(f"http://127.0.0.1:{port}/config/model") as r:
|
|
out["model"] = await r.json()
|
|
finally:
|
|
await runner.cleanup()
|
|
return out
|
|
|
|
|
|
E = asyncio.run(_serve())
|
|
_nl_words = json.load(io.open(os.path.join(HERE, "app", "ui_text", "nl.json"),
|
|
encoding="utf-8"))["home_words"]
|
|
check("UX-13 P5: /status?lang=nl-BE builds Home in Dutch, and plain /status "
|
|
"stays English",
|
|
E["status_nl"]["state"]["pill"] in _nl_words["pill"].values()
|
|
and E["status_nl"]["control"]["text"] in _nl_words["control"].values()
|
|
and E["status"]["state"]["pill"] not in _nl_words["pill"].values()
|
|
and set(E["status_nl"]) == set(E["status"]))
|
|
check("GET / serves the assembled page as HTML", E["page"][0] == 200
|
|
and E["page"][1] == "text/html" and E["page"][2] == PAGE)
|
|
check("GET status over HTTP carries both halves",
|
|
OLD <= set(E["status"]) and NEW <= set(E["status"]))
|
|
check("the levies preset and its year reach config/model for the P2 "
|
|
"Settings screens (P0 review follow-up)",
|
|
E["model"].get("region") == {"id": "flanders", "year": 2026})
|
|
c_reg = make(dict(ARB, price_taxes_eur_kwh=FLANDERS))
|
|
c_reg.tariff_plan, c_reg.tariff_at = provider_plan(), NOW
|
|
s_reg = status(c_reg)
|
|
check("...and Home names the preset with its year wherever prices come from "
|
|
"a feed, and says 'your own figure' otherwise",
|
|
s_reg["plan"]["region"] == {"id": "flanders", "year": 2026}
|
|
and s_reg["plan"]["region_line"]
|
|
== "Levies and VAT: Flanders, Fluvius Antwerpen, 2026 figures."
|
|
and s_arb["plan"]["region_line"] == "Levies and VAT: your own figure."
|
|
and s_house["plan"]["region_line"] == "")
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED")
|
|
sys.exit(1)
|
|
print("all passed")
|