"""UX-01 - the Configuration tab's labels, and the check that stops them rotting. RUN: python3 -B test_translations.py (no third-party imports; the add-on CI image is Alpine + aiohttp + paho) WHAT THIS FILE IS FOR. Before UX-01 there was no `translations/` directory at all, so Home Assistant rendered the raw option KEYS - `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w` - for all seventy options, in `schema:` order, with no description. The two files this grades are what turn that into sentences, and the highest-value half of them is not the label: it is the sentence that says WHETHER TO TOUCH THE VALUE, because the tab presents a constant derived from 1890 closed-loop test runs and the operator's own inverter rating as the same kind of field. ⚠️ WHAT THIS FILE CANNOT SEE, SAID FIRST. It grades COVERAGE and PROVENANCE - that every option has a name and a description in both languages, that no translation names an option that does not exist, that each derived constant's English description says to leave it, and that DOCS.md's "For installers" entry for it names the record it came from (moved there by UX-08). It does NOT grade whether a sentence is true, whether the Dutch is good Dutch, or whether the result looks right on screen. The last of those is measured, not asserted, by `tools/supervisor_shape_probe.py` section F, which loads these files with Supervisor 2026.08.0's OWN loader out of the release image, runs its OWN `UiOptions`, replays the frontend's label resolution as the shipped bundle writes it, and prints every rendered row. That transcript is the evidence; this file is the gate that keeps it true. ⚠️ AND THE PARSER IS THE OTHER HALF OF THAT PAIR. There is no PyYAML in the CI image and a check that skips on ImportError is not a check, so `tools/translations_lint.py` parses these files by hand. A hand parser that quietly disagrees with the real loader is exactly the defect this repository keeps finding, so the probe imports THAT SAME MODULE and asserts, key by key and string by string, that it returns what `supervisor.store.data. _read_app_translations()` returns. Part 5 below re-states the arrangement so a reader of this file is told where the licence for the hand parser comes from. MUTATION.md is the method for the ledger in part 6; `tools/mutation_run.sh` is the harness, and every row there was RUN rather than reasoned about. """ import io import sys from pathlib import Path HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE / "tools")) import translations_lint as TL # noqa: E402 FAILED = 0 def check(what, ok): global FAILED print((" ok " if ok else " FAIL ") + what) if not ok: FAILED += 1 CFG_TEXT = io.open(HERE / "config.yaml", encoding="utf-8").read() ORDER, NESTED = TL.config_surface(CFG_TEXT) LANGS = ("en", "nl") TR = {lg: TL.parse_translation_file(HERE / "translations" / f"{lg}.yaml") for lg in LANGS} print("UX-01 - add-on option translations\n") print("1. the surface being described") print(f" config.yaml declares {len(ORDER)} top-level options under `schema:` " f"and {sum(len(v) for v in NESTED.values())} per-entry key(s) under " f"{sorted(NESTED)}") # ⚠️ A FLOOR, NOT AN EQUALITY. This is the "did the reader read anything at all" # line: `config_surface()` returning an empty list would make every coverage # check below vacuously true, which is the third failure mode in MUTATION.md - # the assertion runs, the sentence is true, and the data cannot reach the # failure it names. check(f"the option surface was actually read - {len(ORDER)} options, not zero", len(ORDER) >= 60 and "inverters" in ORDER and "max_w" in ORDER) check("...and the repeatable `inverters:` section's per-entry keys were read " "too, which is the half a top-level-only reader would miss", NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"] and len(NESTED["inverters"]) == 8) print("\n2. AC: every option and every per-entry key is translated, in BOTH " "languages") for lg in LANGS: problems = TL.audit(CFG_TEXT, TR[lg], lg) print(f" {lg}: {len(TR[lg]['configuration'])} options translated, " f"{len(problems)} problem(s)") for p in problems[:10]: print(" - " + p) check(f"`{lg}` covers config.yaml exactly: no option without a translation, " f"no translation without an option, every one with a name AND a " f"description. Problems: {problems[:3]}", not problems) # ⚠️ THE `nl` HALF IS NOT OPTIONAL EVEN THOUGH THE FALLBACK IS. The frontend # resolves `translations[lang]?.name || translations["en"]?.name || key`, PER # KEY - measured off the shipped bundle, see the probe - so a MISSING nl key # degrades to English and a WRONG one does not degrade at all. The card asks for # both languages complete, and complete is a thing a check can hold. check("...and the two files describe the SAME option set, so nothing on the " "owner's Dutch screen quietly falls back to English", set(TR["en"]["configuration"]) == set(TR["nl"]["configuration"])) check("...including inside the repeatable section", set(TR["en"]["configuration"]["inverters"]["fields"]) == set(TR["nl"]["configuration"]["inverters"]["fields"])) check("...and no Dutch string was left as a copy of the English one, which is " "what an untranslated key looks like when it is present rather than " "absent", not [k for k in ORDER if TR["en"]["configuration"][k]["description"] == TR["nl"]["configuration"][k]["description"]]) print("\n3. ⭐ the description says WHETHER TO TOUCH IT") # The value of this surface is the sentence that says whether a number is a # knob. Since UX-08 phase 2c the tab keeps that sentence short ("Measured value, # leave it unchanged.") and the record it came from lives in DOCS.md under # "For installers: where the numbers come from", where UX-01's citations moved. # So each derived key is pinned twice: # its English description tells the reader what to do, and its DOCS entry names # the record. A text check, weak on purpose: it cannot know a citation is TRUE, # only that deleting it, or the instruction, goes red. DOCS = (HERE / "DOCS.md").read_text(encoding="utf-8") _inst = DOCS.split("\n### For installers: where the numbers come from\n", 1) INSTALLERS = _inst[-1].split("\n#", 1)[0] if len(_inst) == 2 else "" check("DOCS.md has the 'For installers: where the numbers come from' section", bool(INSTALLERS.strip())) def docs_entry(key): """The DOCS bullet that names `key` in backticks, lower-cased.""" hits = [b for b in INSTALLERS.split("\n- ") if f"`{key}`" in b] return hits[0].lower() if hits else "" LEAVE_M, LEAVE_D = "Measured value, leave it unchanged", \ "Derived value, leave it unchanged" # key: (what the English description must say, what its DOCS entry must name) DERIVED = { "sign_disagree_w": (LEAVE_M, ("SAFETY-02", "measured")), "sign_disagree_s": (LEAVE_M, ("SAFETY-02", "measured")), "unit_sign_disagree_w": (LEAVE_D, ("SAFETY-30", "1890")), "unit_sign_disagree_s": (LEAVE_D, ("SAFETY-30", "1890")), "site_sign_disagree_w": (LEAVE_M, ("SAFETY-30", "carried")), "site_sign_disagree_s": (LEAVE_D, ("SAFETY-30", "1890")), "meter_freeze_s": (LEAVE_M, ("SAFETY-18", "measured")), "circulation_w": (LEAVE_M, ("SAFETY-31", "924")), "circulation_hold_s": (LEAVE_D, ("SAFETY-31", "derived")), # SAFETY-42: the alarm changes nothing, and both surfaces say so. "delivery_hold_s": (LEAVE_D, ("SAFETY-42", "derived from", "reports only")), "peak_soc_floor": (LEAVE_D, ("SAFETY-24", "derived")), "bms_soc_floor": ("Read it off your battery", ("0x0560", "measured")), "integrator_max_w": ("Do not raise it above", ("measured",)), "gain": ("do not raise it", ("measured on",)), # SAFETY-39: 0 is not "off", it follows the rating. "slew_w": ("0 follows", ("SAFETY-39", "derived", "0 means follow")), # TARIFF-18: where the figure came from, that your DSO differs, and that # changing it moves no trade. "price_taxes_eur_kwh": ("Check it against your own grid operator", ("TARIFF-18", "your own dso", "moves no trading decision")), } _uncited = [] for key, (says, needles) in sorted(DERIVED.items()): if says not in TR["en"]["configuration"][key]["description"]: _uncited.append(f"{key} description does not say {says!r}") for needle in needles: if needle.lower() not in docs_entry(key): _uncited.append(f"{key}: DOCS does not name {needle!r}") print(f" {len(DERIVED)} derived constants; {len(_uncited)} problem(s)") check(f"every derived constant's description says what to do with it, and its " f"DOCS entry names where the number came from. Problems: {_uncited}", not _uncited) _theirs = ["max_w", "peak_cap_w"] check("...and the numbers that really are the OPERATOR'S say so instead - " "`max_w` and `peak_cap_w` start 'Your own number', here and in DOCS", all(TR["en"]["configuration"][k]["description"].startswith( "Your own number") and "your own number" in docs_entry(k) for k in _theirs)) check("...and the one threshold that is NOT derived from a measurement admits " "it - `cycle_cost_threshold_eur` is a product choice, and DOCS names the " "open question (OPT-08)", "product choice, not a measured one" in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"] and "opt-08" in docs_entry("cycle_cost_threshold_eur") and "not derived from a measurement" in docs_entry("cycle_cost_threshold_eur")) # DOCS.md's "Settings at a glance" shows the tab's text. It must not drift. _glance = DOCS.split("\n### Settings at a glance\n", 1)[-1].split("\n#", 1)[0] _rows = {} for line in _glance.splitlines(): cells = [c.strip() for c in line.strip().strip("|").split("|")] if len(cells) == 4 and cells[2].startswith("`"): _rows[cells[2].strip("`")] = (cells[1], cells[3]) _drift = [] for key in ORDER: conf = TR["en"]["configuration"][key] want = (conf["name"].split(" · ")[-1], conf["description"]) if _rows.get(key) != want: _drift.append(key) check(f"DOCS.md 'Settings at a glance' shows every option's English label and " f"description exactly as the tab does. Drifted: {_drift}", not _drift) print("\n4. task ordering, and the labels that carry it") # ⚠️ ADD-ON OPTIONS ARE A FLAT LIST - no section headers, no per-option advanced # flag, no units on the input. Grouping is approximated by ORDER plus label text # and by nothing else, so both halves are graded: the label prefix, and that the # prefixes appear in the file as CONTIGUOUS RUNS in the task order. A group # whose members are scattered renders as a jumble however good each label is. GROUPS = ["Hardware", "Meter", "Limits", "Tariff", "Maintenance", "Safety", "Advanced"] NL_GROUPS = {"Hardware": "Hardware", "Meter": "Meter", "Limits": "Grenzen", "Tariff": "Tarief", "Maintenance": "Onderhoud", "Safety": "Veiligheid", "Advanced": "Geavanceerd"} _prefixes = [] for key in ORDER: name = TR["en"]["configuration"][key]["name"] _prefixes.append(name.split(" \u00b7 ")[0] if " \u00b7 " in name else f"") _runs = [p for i, p in enumerate(_prefixes) if i == 0 or p != _prefixes[i - 1]] print(f" the rendered order groups into: {_runs}") check(f"every label carries a group prefix, so the flat list reads as groups. " f"Missing: {[p for p in _prefixes if p.startswith(' 71 while OPT-09 independently added an eighth per-entry key # (`capacity_kwh`), and the two edits collided here on the rebase - each # restating a number the other had moved. The counts now come from the # same parse the assertion uses, so the sentence cannot drift from it # again. PROC-01. f"{len(ORDER)} options and {sum(len(v) for v in NESTED.values())} " f"per-entry keys came back out of each, which is what says the " f"strictness above is not just refusing everything", all(len(TR[lg]["configuration"]) == len(ORDER) and len(TR[lg]["configuration"]["inverters"]["fields"]) == 8 for lg in LANGS)) print(" ⚠️ the licence for this hand parser is NOT this file. " "tools/supervisor_shape_probe.py section F loads the same two files with " "`supervisor.store.data._read_app_translations()` out of the 2026.08.0 " "release image and asserts, key by key and string by string, that this " "module returns the same thing. Re-run it when either changes.") print("\n6. MUTATION: the check is run against broken inputs, both directions") # ⚠️ THIS IS THE PART THE CARD ASKS FOR IN SO MANY WORDS, AND IT IS RUN RATHER # THAN DESCRIBED. A coverage check is worth exactly what it catches, so each # mutation below is APPLIED to a copy of the real parsed translations and the # audit is required to NAME the key it broke - not merely to be non-empty. A # check that goes red for the wrong reason is a check that will go green for the # wrong reason later. import copy # noqa: E402 def _mutate(what, fn, must_name): tr = copy.deepcopy(TR["en"]) fn(tr) problems = TL.audit(CFG_TEXT, tr, "en") named = [p for p in problems if must_name in p] print(f" {what}\n -> {len(problems)} problem(s); " f"{(named[0][:120] + '...') if named else 'NONE NAMED ' + must_name}") check(f"MUTATION: {what} -> the audit REFUSES and names `{must_name}`", bool(named)) _mutate("delete one option's whole translation (`saturation_cycles`)", lambda tr: tr["configuration"].pop("saturation_cycles"), "saturation_cycles") _mutate("delete one option's description but keep its name (`meter_phases`)", lambda tr: tr["configuration"]["meter_phases"].pop("description"), "meter_phases") _mutate("blank one option's description (`gain`) - present but empty is not " "covered either", lambda tr: tr["configuration"]["gain"].update(description=" "), "gain") _mutate("translate an option that does not exist (`saturation_cyles`, one " "letter out - the rename this check exists for)", lambda tr: tr["configuration"].update( saturation_cyles={"name": "x", "description": "y"}), "saturation_cyles") _mutate("delete one PER-ENTRY key of the repeatable section (`bms_soc_floor`)", lambda tr: tr["configuration"]["inverters"]["fields"].pop("bms_soc_floor"), "bms_soc_floor") _mutate("invent a per-entry key the schema does not declare (`max_kw`)", lambda tr: tr["configuration"]["inverters"]["fields"].update( max_kw={"name": "x", "description": "y"}), "max_kw") _mutate("drop the whole `fields:` block from the repeatable section", lambda tr: tr["configuration"]["inverters"].pop("fields"), "inverters") _mutate("hang a `fields:` block off an option that is not a repeatable section", lambda tr: tr["configuration"]["max_w"].update( fields={"x": {"name": "y"}}), "max_w") # ⚠️ AND THE TWO THAT COST THE WHOLE FILE RATHER THAN ONE LABEL. Supervisor # validates each translation file through SCHEMA_APP_TRANSLATIONS and, on # refusal, LOGS A WARNING AND DROPS ALL OF IT - so a shape it rejects does not # lose one description, it silently returns the entire tab to raw keys, and the # only trace is one line in a log nobody is reading. Neither of these is # reachable through the `configuration:` coverage above, which is exactly why # they are here. _mutate("a top-level block Supervisor does not read (`netwrok:`) - the file is " "DROPPED WHOLE, not partly", lambda tr: tr.update(netwrok={"x": "y"}), "netwrok") _mutate("a `network:` block whose value is a mapping rather than a string - " "same consequence, every label in the file gone", lambda tr: tr.update(network={"8099/tcp": {"x": "y"}}), "network") # ⚠️ AND THE MUTATION THAT MATTERS MOST: A NEW OPTION ARRIVES. This is the rot # the acceptance criterion is written about - somebody adds a key to config.yaml # in six months and the tab renders it raw. The mutation is applied to the # CONFIG side rather than the translation side, because that is the direction it # happens in. _grown = CFG_TEXT.replace("\n log_level: list(", "\n new_option_s: int(1,9)\n log_level: list(") check("the config-side mutation really did land - a new option in the schema " "half that no translation mentions", _grown != CFG_TEXT and "new_option_s" in TL.config_surface(_grown)[0]) _grow_problems = TL.audit(_grown, TR["en"], "en") print(f" a new option added to config.yaml -> {_grow_problems[:1]}") check("MUTATION: adding an option to config.yaml and forgetting the " "translation goes RED, naming the key - which is the whole of the " "acceptance criterion about this rotting", any("new_option_s" in p for p in _grow_problems)) # ⚠️ THE NEGATIVE CONTROL, WITHOUT WHICH THE EIGHT ROWS ABOVE PROVE NOTHING. An # audit that returned a problem for every input would have "passed" all of them. check("...and the UNMUTATED input is still clean, so the rows above are the " "mutations speaking and not a check that always complains", not TL.audit(CFG_TEXT, TR["en"], "en") and not TL.audit(CFG_TEXT, TR["nl"], "nl")) print("\n7. no behaviour and no value moved") # ⚠️ UX-01 IS A PRESENTATION CARD. It reorders config.yaml and adds two files; # it must not move a single default. The whole options half is compared against # the schema half key by key here, and every other suite in this directory pins # the actual values by reading this same file - test_maintenance.py's eight, # test_watchdog.py's six, test_price.py's seven, test_freeze.py's, and # test_peakguard.py's. This line is the cheap one that says the reorder did not # lose or duplicate a key on the way. # ⚠️ THE COUNT IS READ OFF THE TREE, NOT TYPED. It was typed - "the same 72 # keys" - and was 75 by the time TEL-11 read it, because three cards had added # an option each and none of them was looking at this sentence. A number in a # check's own message is a claim like any other (MUTATION.md habit 22) and this # one had been false for three cards without a single run going red, because # nothing asserts it. Derived, it cannot rot. check(f"no option was lost, gained or duplicated by the reordering: the two " f"halves of config.yaml hold the same {len(ORDER)} keys, once each", sorted(_opt_order) == sorted(ORDER) and len(set(ORDER)) == len(ORDER)) check("...and `inverters: []` is still shipped in the options half, which is " "what Supervisor 2026.08.0 refuses every pre-0.6.0 options blob without " "(measured; see DECISIONS/CFG-04-inverter-roster.md B)", "\n inverters: []\n" in _opt_half) check("...and the schema half still declares the NESTED per-entry `max_w:` " "BEFORE the flat one, so test_inverter.py part 12's first-match trap " "stays baited rather than sorted out by this card's reordering", CFG_TEXT[CFG_TEXT.index("\nschema:"):].index(" max_w: int(") < CFG_TEXT[CFG_TEXT.index("\nschema:"):].index("\n max_w: int(")) print() if FAILED: print(f"{FAILED} FAILED") sys.exit(1) print("all checks passed")