Files
Claude c9ab48d6fc UX-13 P2: the Installer settings view
- Settings tab: the Installer view (app/static/settings.js), drawn from
  app/uilayout.py through the new app/uipage.py: search, six collapsible
  groups with "n to fix / n to check / n changed" badges and an "applies
  after a restart" chip, radio cards, segmented choices, sliders with units
  (the peak limit in kW), entity pickers with search and the live reading,
  inverter cards, solar rows, locked measured values behind "Change", and
  the add-on's refusals, warnings and info beside the field they name.
- Save path: the page posts its model to the existing config/validate and
  config/save; uimodel turns it into options before the shipped validator,
  so nothing it refuses is saved. No route added. A value equal to the
  stored one is written as stored, so a save that changed nothing writes an
  identical blob. Save bar, then the restart dialog that names 0 W.
- The region choice shows its preset year; Wallonia and Brussels are listed
  as having no preset yet (TARIFF-22). "Classic settings" keeps /config.
- P1 test findings: (a) "Going to 0 W" until the last accepted power request
  is 0 W; (b) the trade blocks and the price line name the feed's own points
  ("Lowest published price"), the hour bars say "hour average"; (c) the entity
  list's WARNING gives a reason on a timeout.
- test_settings.py; test_home, test_haentities, test_uilayout extended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 02:49:58 +02:00

206 lines
9.4 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) | {uimodel.COUNT} | set(uimodel.GROWN)
check("every show condition reads an option, a page field, or the inverter "
"count or shape",
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",
"uipage.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, web.py and uipage.py name no option key ({_leak})",
not _leak)
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")