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>
500 lines
24 KiB
Python
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")
|