checks / unit (pull_request) Successful in 5m1s
The add-on had NO translations/ directory, so Home Assistant fell back to rendering the raw option KEYS. An operator opening Configuration read `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w` - seventy of them, in file order, with no description and no way to tell a constant derived from 1890 closed-loop test runs from their own inverter's rating. ⚠️ PRESENTATION ONLY. Not one default, not one schema range and not one line of behaviour moved. The 175 non-comment lines of config.yaml are byte-identical to release/1.0's, reordered; the only comment lines that changed are the group banners and five sentences whose "above"/"below" the reorder made false. * translations/en.yaml and translations/nl.yaml: a name and a description for all 70 options and all 7 per-entry keys of the `inverters:` repeatable section. The Dutch is written for a Flemish homeowner - afname/injectie, kwartierpiek, capaciteitstarief - not transliterated from the English. * ⭐ Every derived constant says WHERE IT CAME FROM and whether to touch it. `unit_sign_disagree_s` cites its 1890 closed-loop runs (SAFETY-30); `circulation_w` its 924 runs and the measured 400 W knee (SAFETY-31); `meter_freeze_s` its 2399 s capture (SAFETY-18); `bms_soc_floor` register 0x0560. The two numbers that are the operator's - max_w and peak_cap_w - say "THIS IS YOUR NUMBER". `cycle_cost_threshold_eur`, which is neither, says it is an open product question (OPT-08). * config.yaml reordered into task order in BOTH halves, comments carried with their keys: hardware -> meter -> ratings and limits -> tariff -> maintenance -> safety -> advanced. ⚠️ test_inverter.py:1788's trap is PRESERVED: the nested per-entry `max_w:` still precedes the flat one under `schema:`, and `inverters: []` still ships in the `options:` half. Moving the nested block below the flat one is a 1-byte edit; it was run and goes red in test_inverter.py and test_translations.py. * tools/supervisor_shape_probe.py section F MEASURES the result against ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 - Supervisor's own _read_app_translations(), its own UiOptions, and the frontend's label resolution transcribed from the shipped bundle - and prints every rendered row in both languages. `all cases passed`, exit 0. * ⚠️ ONE MEASURED NEGATIVE RESULT, NOT GLOSSED: the per-entry keys inside the `inverters:` section still render RAW on frontend 20250925.1, because a list-of-objects goes to ha-selector-object and the converter sets no `label` and no `translation_key`. The `fields:` translations are shipped and validated - that is Supervisor's own shape for this - but this card does not claim they reach the screen. The `inverters` label and description do. * test_translations.py: a translation naming a non-existent option, or an option with no translation, fails CI - both directions, both languages, top level and per entry. Mutation-tested BOTH WAYS: eleven in-process mutations each required to NAME the key they broke, plus a negative control; six more run on disk through the purge-and-re-read protocol. Two of the eleven are the ones that cost the WHOLE FILE rather than one label - an unrecognised top-level block, and a `network:` value that is not a string. Supervisor drops a file that fails SCHEMA_APP_TRANSLATIONS with only a log warning, so those silently return the entire tab to raw keys; neither is reachable through the `configuration:` coverage, which is why they were hunted for and closed. * tools/translations_lint.py is the hand parser CI needs (no PyYAML in the add-on image) and the probe grades it, key by key and string by string, against Supervisor's own loader. It is strict and RAISES rather than skipping; nine malformed shapes prove it. Full 20-file suite green (19 existing + test_translations.py). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
362 lines
20 KiB
Python
362 lines
20 KiB
Python
"""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
|
|
CITATIONS - that every option has a name and a description in both languages,
|
|
that no translation names an option that does not exist, and that each derived
|
|
constant's English description cites the record it was derived from. 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"]) == 7)
|
|
|
|
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 HIGHEST-VALUE ITEM IN THE CARD, AND THE ONE A COVERAGE COUNT CANNOT
|
|
# REACH. Sixty-odd descriptions that all say "the threshold, in watts" would
|
|
# pass part 2 completely. What makes this surface safe is that a DERIVED
|
|
# constant names the campaign it came from and says the value is not a knob,
|
|
# while a number that really is the operator's says so too.
|
|
#
|
|
# ⚠️ AND THIS IS A TEXT CHECK, WHICH IS WEAK ON PURPOSE AND IS SAID TO BE. It
|
|
# cannot know whether a citation is TRUE; it can only know that the citation is
|
|
# there and names the right record, so that deleting the provenance - or
|
|
# renaming the card it points at - goes red instead of shipping. The table is
|
|
# written out rather than derived because there is nothing in config.yaml that
|
|
# distinguishes a derived constant from an operator's number; that distinction
|
|
# is the whole of what this card had to add.
|
|
DERIVED = {
|
|
"sign_disagree_w": ("SAFETY-02", "MEASURED"),
|
|
"sign_disagree_s": ("SAFETY-02", "MEASURED"),
|
|
"unit_sign_disagree_w": ("SAFETY-30", "1890"),
|
|
"unit_sign_disagree_s": ("SAFETY-30", "1890"),
|
|
"site_sign_disagree_w": ("SAFETY-30", "CARRIED"),
|
|
"site_sign_disagree_s": ("SAFETY-30", "1890"),
|
|
"meter_freeze_s": ("SAFETY-18", "MEASURED"),
|
|
"circulation_w": ("SAFETY-31", "924"),
|
|
"circulation_hold_s": ("SAFETY-31", "DERIVED"),
|
|
"peak_soc_floor": ("SAFETY-24", "DERIVED"),
|
|
"bms_soc_floor": ("0x0560", "MEASURED"),
|
|
"integrator_max_w": ("MEASURED", "Do not raise it"),
|
|
"gain": ("MEASURED ON", "do not raise it"),
|
|
}
|
|
_uncited = []
|
|
for key, needles in sorted(DERIVED.items()):
|
|
desc = TR["en"]["configuration"][key]["description"]
|
|
for needle in needles:
|
|
if needle not in desc:
|
|
_uncited.append(f"{key} does not cite {needle!r}")
|
|
print(f" {len(DERIVED)} derived constants must cite their campaign; "
|
|
f"{len(_uncited)} do not")
|
|
check(f"every derived constant's English description names WHERE THE NUMBER "
|
|
f"CAME FROM, so an operator reading the tab can tell it apart from their "
|
|
f"own inverter rating. Uncited: {_uncited}", not _uncited)
|
|
# The other direction, and it matters as much: a number that IS the operator's
|
|
# must not be dressed up as derived, or the labels stop meaning anything.
|
|
_theirs = ["max_w", "peak_cap_w"]
|
|
check("...and the numbers that really are the OPERATOR'S say so instead of "
|
|
"carrying a citation - `max_w` and `peak_cap_w` are the two the card "
|
|
"names, and both read 'THIS IS YOUR NUMBER'",
|
|
all("THIS IS YOUR NUMBER" in TR["en"]["configuration"][k]["description"]
|
|
for k in _theirs))
|
|
check("...and the one threshold that is NOT derived from a measurement admits "
|
|
"it rather than borrowing the others' authority - "
|
|
"`cycle_cost_threshold_eur` is an open product question (OPT-08)",
|
|
"NOT DERIVED FROM A MEASUREMENT"
|
|
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"]
|
|
and "OPT-08"
|
|
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"])
|
|
|
|
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"<none:{key}>")
|
|
_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('<none:')]}",
|
|
not [p for p in _prefixes if p.startswith("<none:")])
|
|
check(f"...and each group is ONE CONTIGUOUS RUN in the rendered order, in the "
|
|
f"task order the card asks for - hardware, meter, ratings and limits, "
|
|
f"tariff, maintenance, safety, advanced. Got: {_runs}", _runs == GROUPS)
|
|
_nl_bad = [k for k in ORDER
|
|
if NL_GROUPS.get(TR["en"]["configuration"][k]["name"].split(" \u00b7 ")[0])
|
|
!= TR["nl"]["configuration"][k]["name"].split(" \u00b7 ")[0]]
|
|
check(f"...and the Dutch labels carry the SAME grouping under Dutch names, so "
|
|
f"the two languages do not read as two different layouts. Off: {_nl_bad}",
|
|
not _nl_bad)
|
|
# ⚠️ THE ORDER IS THE `schema:` BLOCK'S, NOT THE `options:` BLOCK'S, AND THAT IS
|
|
# A MEASUREMENT. `App.schema_ui` is `UiOptions(coresys)(data["schema"])`, so the
|
|
# tab renders the schema half in its own order. Asserted here because the whole
|
|
# of part 4 is about an ordering that would otherwise be graded on the wrong
|
|
# half of the file - and because the two halves of config.yaml were already in
|
|
# different orders before this card.
|
|
# ⚠️ ANCHORED ON THE INDENTATION AND SCOPED TO THE `options:` BLOCK. The file
|
|
# starts with the add-on manifest, whose `services:` list carries ` - mqtt:want`
|
|
# at two spaces with a colon in it - a looser reader picks that up as an option
|
|
# called `- mqtt` and the comparison below then fails for a reason that has
|
|
# nothing to do with what it is watching. Measured, on the first run.
|
|
import re as _re # noqa: E402
|
|
_opt_half = CFG_TEXT[CFG_TEXT.index("\noptions:\n"):CFG_TEXT.index("\nschema:")]
|
|
_opt_order = _re.findall(r"^ ([a-z_0-9]+):", _opt_half, _re.M)
|
|
check("config.yaml's two halves now declare the same options in the same "
|
|
"order, so the file reads the way the tab renders even though only the "
|
|
"schema half decides it", _opt_order == ORDER)
|
|
|
|
print("\n5. the parser is strict, so a file it half-read cannot pass")
|
|
# ⚠️ THE FAILURE THIS PART EXISTS FOR. A permissive parser answers "no problems"
|
|
# for a file it silently skipped half of - the same green-and-not-watching
|
|
# defect one layer down. So every shape outside the subset UX-01 writes must
|
|
# RAISE, and that is graded with real malformed input rather than asserted.
|
|
BAD = [
|
|
("a tab-indented line", "configuration:\n\tmeter_entity:\n"),
|
|
("three-space indent", "configuration:\n meter_entity:\n"),
|
|
("a bare unquoted scalar", 'configuration:\n a:\n name: hello\n'),
|
|
("a single-quoted scalar", "configuration:\n a:\n name: 'hello'\n"),
|
|
("a list item", 'configuration:\n a:\n - "x"\n'),
|
|
("a duplicate key", 'configuration:\n a:\n name: "x"\n name: "y"\n'),
|
|
("an over-indented child", 'configuration:\n a:\n'),
|
|
("a line that is not a key at all", "configuration:\n just some prose\n"),
|
|
("an unsupported escape", 'configuration:\n a:\n name: "x\\ty"\n'),
|
|
]
|
|
_accepted = []
|
|
for what, text in BAD:
|
|
try:
|
|
TL.parse_translation_text(text, what)
|
|
_accepted.append(what)
|
|
except TL.TranslationSyntaxError:
|
|
pass
|
|
check(f"every shape outside the accepted subset RAISES rather than being "
|
|
f"skipped. Silently accepted: {_accepted}", not _accepted)
|
|
check("...and the accepted subset really does round-trip the shipped files - "
|
|
"70 options and 7 per-entry keys came back out of each, which is what "
|
|
"says the strictness above is not just refusing everything",
|
|
all(len(TR[lg]["configuration"]) == len(ORDER)
|
|
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 7
|
|
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.
|
|
check("no option was lost, gained or duplicated by the reordering: the two "
|
|
"halves of config.yaml hold the same 70 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")
|