app/drivers.py holds one Driver per roster entry (configured, actuator_id, limits, read, write, release, probe, link_stale_s, batt_bound, settle_s, brand, words). GoodWeEsphomeDriver makes the calls the code it replaced made, in the same order. Inverter gets driver; has_writer, is_live and link_note read it. The SoC/power read, the write, the device range (run_control, amain, the settings panel), rule 2 per unit and the SAFETY-28 census go through it. setpoint_entity is optional per entry beside driver, host, port, unit_id and maintenance; only goodwe_esphome is selectable, the rest are checked and unused. Co-Authored-By: Claude <noreply@anthropic.com>
208 lines
9.5 KiB
Python
208 lines
9.5 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.")]
|
|
# SMA-2: a per-entry key may instead be listed in L.ROSTER_HIDDEN (the
|
|
# driver keys the page does not draw), and then it must not also be a field.
|
|
out += [f"inverters.{k} has no card field" for k in nested.get("inverters", [])
|
|
if sub.count(k) + (k in L.ROSTER_HIDDEN) != 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")
|