The foundation for the new settings page. No visible UI change. - app/uilayout.py: screen -> group -> part -> field for every option in config.yaml (67 fields, 11 derived, 0 hidden), with control, unit, display scale, step, entity kind, show conditions, Simple/wizard placement and "applies after a restart". Ranges stay in the schema, words in translations and the new app/ui_text/en.json. - app/uimodel.py: options_to_ui()/ui_to_options() for the derived keys (one inverter vs the list, solar rows, static_profile from simple prices, a year-stamped region preset for the levies figure, and an OPT-11 hook for arbitrage_enabled that changes nothing yet). Unchanged derivations write the stored values back, so the round trip is exact. - validate.py: validate.Note, a warn/info tier. Each logged warning is also a note when a list is passed; config_notes() adds checks that need live data (missing entity, a kW sensor read as W, a limit below this month's peak). /config/validate returns them as "notes". No refusal changed: 261 recorded validator calls, 0 differ (tools/ux13_validator_calls.py). - GET ha/entities: Home Assistant's entity list for the picker, read through hass.states(), cached 30 s, 10 s timeout. When HA is down it answers 200 with the old list marked old, or an empty list that says why. - DECISIONS/UX-13-one-layout-table.md amends UX-02's "no option key in UI code": keys may appear in uilayout.py and uimodel.py only, both tested. - Tests: test_uilayout, test_uimodel (round trip over the recorded calls, the TEST-23 option files and config.yaml defaults), test_haentities, a warning-tier part in test_validate; test_messages scans notes and ui_text; test_pv and test_selfconfig part 7 widened on purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
203 lines
9.2 KiB
Python
203 lines
9.2 KiB
Python
"""UX-13 P0: the layout table places every option once. `python3 test_uilayout.py`
|
|
|
|
app/uilayout.py is the one place UI code names an option key
|
|
(DECISIONS/UX-13-one-layout-table.md). This file fails CI when config.yaml
|
|
gains an option the table does not place, and checks each row against the
|
|
sources UX-02 keeps authoritative: config.yaml for ranges, translations and
|
|
app/ui_text for words, validate.py for which entities are read as watts.
|
|
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, os.path.join(HERE, "tools"))
|
|
|
|
import translations_lint as TL # noqa: E402
|
|
from app import uilayout as L, uimodel, validate # noqa: E402
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
CONFIG = io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()
|
|
ORDER, NESTED = TL.config_surface(CONFIG)
|
|
TYPES = dict(re.findall(r"^ ([a-z_0-9]+): *(\S.*?) *$",
|
|
CONFIG[CONFIG.index("\nschema:"):], re.M))
|
|
EN = TL.parse_translation_file(os.path.join(HERE, "translations", "en.yaml"))
|
|
UI_TEXT = json.load(io.open(os.path.join(HERE, "app", "ui_text", "en.json"),
|
|
encoding="utf-8"))
|
|
ROWS = list(L.fields())
|
|
KEYS = [f.key for _g, _p, f in ROWS]
|
|
|
|
|
|
def coverage_problems(order, nested):
|
|
"""Every way the table and config.yaml's schema disagree."""
|
|
out = []
|
|
placed = [k for k in KEYS if not k.startswith(("ui.", "inverters."))]
|
|
for k in order:
|
|
n = placed.count(k) + (k in L.DERIVED) + (k in L.HIDDEN)
|
|
if n != 1:
|
|
out.append(f"{k} is placed {n} times")
|
|
out += [f"{k} is not in config.yaml" for k in
|
|
set(placed) | set(L.DERIVED) | set(L.HIDDEN) if k not in order]
|
|
sub = [k.split(".", 1)[1] for k in KEYS if k.startswith("inverters.")]
|
|
out += [f"inverters.{k} has no card field" for k in nested.get("inverters", [])
|
|
if sub.count(k) != 1]
|
|
out += [f"inverters.{k} is not in config.yaml" for k in sub
|
|
if k not in nested.get("inverters", [])]
|
|
return out
|
|
|
|
|
|
print("1. every option has exactly one place")
|
|
_p = coverage_problems(ORDER, NESTED)
|
|
_fields = sum(1 for k in ORDER if k in KEYS)
|
|
print(f" {len(ORDER)} options: {_fields} fields, {len(L.DERIVED)} derived, "
|
|
f"{len(L.HIDDEN)} hidden. Problems: {_p}")
|
|
check(f"config.yaml's {len(ORDER)} options are each a field, derived or hidden, "
|
|
"once", not _p and len(ORDER) >= 78)
|
|
check("a new option without a place fails this check",
|
|
coverage_problems(ORDER + ["new_option_w"], NESTED)
|
|
== ["new_option_w is placed 0 times"])
|
|
check("...and so does a new inverter field",
|
|
coverage_problems(ORDER, {"inverters": NESTED["inverters"] + ["mode"]})
|
|
== ["inverters.mode has no card field"])
|
|
check("no key is both a field and derived",
|
|
not set(KEYS) & (set(L.DERIVED) | set(L.HIDDEN)))
|
|
check("each derived key names the page field that writes it",
|
|
all(any(f.key == how for _g, _p2, f in ROWS)
|
|
for how in L.DERIVED.values()))
|
|
|
|
print("\n2. each row is well formed")
|
|
check("every control, entity kind and show operator is a known one",
|
|
all(f.control in L.CONTROLS and (not f.kind or f.kind in L.ENTITY_KINDS)
|
|
and all(op in L.SHOW_OPS for _n, op, _v in f.show)
|
|
for _g, _p2, f in ROWS))
|
|
_names = set(ORDER) | set(KEYS) | {"inverter_count"}
|
|
check("every show condition reads an option, a page field or inverter_count",
|
|
all(n in _names for _g, _p2, f in ROWS for n, _o, _v in f.show))
|
|
check("every entity control has a kind, and no other control has one",
|
|
all((f.control in (L.ENTITY, L.ENTITY_LIST, L.SOLAR_LIST)) == bool(f.kind)
|
|
for _g, _p2, f in ROWS))
|
|
check("everything applies after a restart: options are read at start, and "
|
|
"OPT-11's runtime switches are not built yet",
|
|
all(f.applies == L.RESTART for _g, _p2, f in ROWS))
|
|
check("no unit states a number, so no range is written here",
|
|
all(not re.search(r"\d", f.unit) for _g, _p2, f in ROWS))
|
|
|
|
|
|
_SCHEMA = CONFIG[CONFIG.index("\nschema:"):]
|
|
_ROSTER = re.search(r"^ inverters:\n((?: {4,}.*\n)+)", _SCHEMA, re.M).group(1)
|
|
ROSTER_TYPES = dict(re.findall(r"^ {4,}(?:- )?([a-z_0-9]+): *(\S+)", _ROSTER,
|
|
re.M))
|
|
|
|
|
|
def _type(key):
|
|
if key.startswith("inverters."):
|
|
return ROSTER_TYPES.get(key.split(".", 1)[1], "")
|
|
return TYPES.get(key, "")
|
|
|
|
|
|
_ranged = [f.key for _g, _p2, f in ROWS
|
|
if f.control in (L.SLIDER, L.NUMBER, L.LOCKED)
|
|
and not f.key.startswith("ui.")]
|
|
_unranged = [k for k in _ranged if not re.match(r"(int|float)\(", _type(k))]
|
|
check(f"every slider, number and locked value has a range in config.yaml "
|
|
f"({len(_ranged)} of them, without: {_unranged})", not _unranged)
|
|
_SUFFIX = (("_eur_kwh", "€/kWh"), ("_eur", "€/kWh"), ("_kwh", "kWh"),
|
|
("_w", "W"), ("_s", "s"), ("_h", "h"), ("_min", "min"),
|
|
("_days", "days"), ("_floor", "%"), ("_ceiling", "%"),
|
|
("_target", "%"))
|
|
|
|
|
|
def _unit_problem(f):
|
|
want = next((u for suf, u in _SUFFIX if f.key.endswith(suf)), None)
|
|
if want is None or f.control not in (L.SLIDER, L.NUMBER, L.LOCKED):
|
|
return False
|
|
return f.unit != want and not (want == "W" and f.unit == "kW"
|
|
and f.scale == 1000)
|
|
|
|
|
|
_mism = [(f.key, f.unit) for _g, _p2, f in ROWS if _unit_problem(f)]
|
|
check(f"each unit matches its key's suffix (mismatched: {_mism})", not _mism)
|
|
check("the peak limit is shown in kW and stored in W",
|
|
L.field("peak_cap_w").unit == "kW" and L.field("peak_cap_w").scale == 1000)
|
|
_watt = sorted({f.key.split(".")[-1] for _g, _p2, f in ROWS
|
|
if f.kind == "power_w"})
|
|
check(f"the pickers that expect watts are exactly the entities validate.py "
|
|
f"reads as watts ({_watt})",
|
|
_watt == sorted(validate.WATT_ENTITY_KEYS))
|
|
check("range_of() reads a Supervisor-shaped schema, nested ones too",
|
|
L.range_of("max_w", [{"name": "max_w", "lengthMin": 100,
|
|
"lengthMax": 5000}]) == (100, 5000)
|
|
and L.range_of("inverters.max_w", [{"name": "inverters", "schema": [
|
|
{"name": "max_w", "lengthMin": 100, "lengthMax": 5000}]}])
|
|
== (100, 5000)
|
|
and L.range_of("p1_host", [{"name": "p1_host"}]) is None)
|
|
|
|
print("\n3. the words come from translations and app/ui_text")
|
|
_conf = EN["configuration"]
|
|
_no_tr = [k for k in KEYS if not k.startswith("ui.") and not (
|
|
k in _conf if "." not in k
|
|
else k.split(".")[1] in _conf["inverters"]["fields"])]
|
|
check(f"every option field has an en.yaml name (missing: {_no_tr})", not _no_tr)
|
|
_no_ui = [k for k in KEYS if k.startswith("ui.")
|
|
and k[3:] not in UI_TEXT["fields"]]
|
|
check(f"every page-only field has words in ui_text (missing: {_no_ui})",
|
|
not _no_ui)
|
|
_titles = [g.id for g in L.GROUPS if g.id not in UI_TEXT["groups"]] + [
|
|
f"{g.id}.{p.id}" for g in L.GROUPS for p in g.parts
|
|
if p.id and f"{g.id}.{p.id}" not in UI_TEXT["parts"]]
|
|
check(f"every group and part has a title (missing: {_titles})", not _titles)
|
|
_contract = {"none", "fixed", "daynight", "dynamic", "custom"}
|
|
check("every contract, fallback and region the model can hold has words",
|
|
set(UI_TEXT["choices"]["contract"]) == _contract
|
|
and set(UI_TEXT["choices"]["fallback"]) == _contract - {"dynamic"}
|
|
and set(UI_TEXT["choices"]["region"])
|
|
== set(uimodel.REGION_PRESETS) | {uimodel.OWN})
|
|
check("both 'applies' labels have words",
|
|
set(UI_TEXT["applies"]) == {L.RESTART, L.NOW})
|
|
|
|
print("\n4. visibility follows the page model")
|
|
_m = uimodel.options_to_ui({"meter_source": "ha_signed", "meter_phases": 3,
|
|
"inverters": [], "maintenance_enabled": False})
|
|
check("a signed meter shows its net entity, and three phases its net list",
|
|
L.visible(L.field("p1_net_entity"), _m)
|
|
and L.visible(L.field("p1_phase_net_entities"), _m)
|
|
and not L.visible(L.field("meter_entity"), _m)
|
|
and not L.visible(L.field("p1_phase_import_entities"), _m))
|
|
check("one inverter shows the one-inverter stops, not the per-inverter ones",
|
|
L.visible(L.field("sign_disagree_w"), _m)
|
|
and not L.visible(L.field("unit_sign_disagree_w"), _m))
|
|
check("maintenance details hide while maintenance is off",
|
|
not L.visible(L.field("maintenance_charge_w"), _m))
|
|
|
|
print("\n5. UI code names option keys in two files only")
|
|
_KEY = re.compile(r"""["']([a-z0-9]+(?:_[a-z0-9]+)+)["']""")
|
|
_src = io.open(os.path.join(HERE, "app", "uimodel.py"), encoding="utf-8").read()
|
|
_owned = set(L.DERIVED) | {"inverters", "price_provider",
|
|
"price_taxes_eur_kwh", "arbitrage_enabled"}
|
|
_extra = sorted({k for k in _KEY.findall(_src) if k in ORDER} - _owned)
|
|
check(f"uimodel.py names only the keys its derivations write (others: "
|
|
f"{_extra})", not _extra)
|
|
_leak = sorted({f"{name}:{k}" for name in ("haentities.py", "web.py")
|
|
for k in _KEY.findall(io.open(os.path.join(HERE, "app", name),
|
|
encoding="utf-8").read())
|
|
if k in ORDER})
|
|
check(f"haentities.py and web.py name no option key ({_leak})", not _leak)
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED")
|
|
sys.exit(1)
|
|
print("all passed")
|