"""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")