checks / unit (pull_request) Successful in 6m17s
The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.
WHAT IT ADDS
app/configui.py the field model, built from `GET /addons/self/info`'s own
`schema` + `translations`. No option key, no label, no
rule of its own. UX-01's `Group · ` label prefix becomes a
real section heading, in whichever language resolved.
app/selfconfig.py the Supervisor conversation and the write gate.
app/config_page.py the page. Its longest function decides which HTML input a
type deserves; it contains no rule.
app/web.py five routes. Still the only file a reviewer has to read to
answer "does anything here reach the inverter".
THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
1. A blob the SHIPPED validator refuses is never written - not warned about,
not written. An invalid blob plus a restart is exit 78, and the panel is
gone with the add-on that serves it.
2. A blob that moved underneath is refused, not clobbered. Read hands out a
sha256 of the canonical blob, save hands it back, and the stored blob is
re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
applied to configuration - "a blob reaches the Supervisor only if it was
derived from the blob stored right now" - with the honest weakening stated
in the decision record: a second writer is DETECTED, not made
inexpressible.
3. Every message is the validator's own, byte for byte. test_selfconfig.py
part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
under app/ to show validate.py is the only one that differs, and serves the
real /config/validate route out of that copy in a subprocess. The new
sentence arrives, filed under the key it names.
4. Nothing on the surface commands an inverter or clears a latch. Saving does
not restart: applying costs a control interruption - the wind-down commands
0 W on every inverter - so that is a separate, confirmed button that says
so first.
MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
- the three self-paths are permitted, and `/addons/self/info` is admitted by
the `default` role too while `options` and `restart` rest on the bypass
ALONE. The probe's first draft claimed the role admitted none of the three
and went red.
- `/addons/self/options/validate` is NOT reachable from inside an add-on, so
the only validator this panel can call is ours - which is the one with the
cross-field rules.
- a POST that omits a key does not preserve it: the key reverts to
config.yaml's default. Hence whole-blob read-modify-write.
- the Configuration tab CAN be removed, and doing so drops 0-of-71 options
through and takes the add-on down. The card said it could not be; it stays
by decision, which is a better reason.
DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.
Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
---
⚠️ REBASED ONTO release/1.0 @ 3368231, AND HISTORY DELIBERATELY FLATTENED.
The original branch carried `653874d` - an ORPHANED first attempt at PKG-07
whose commit message justified the version bump with a claim that was FALSE
(that `slew_w: 0` used to mean "no limit"; the schema was `int(50,5000)` from
the repo's first commit, so 0 was never legal). That commit's CONTENT was
superseded when release/1.0 was merged in, and the merge resolved correctly -
one 0.8.0 section, no fabricated text. But the retracted claim would have
entered release/1.0's history as a commit message with no retraction attached
to it, where PKG-07's own merged commit is the thing that retracts it.
So the branch is replayed as a single commit on top of the corrected PKG-07
rather than merged with the bad one trailing behind. The tree is byte-identical
to the reviewed branch tip; only the ancestry changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
375 lines
21 KiB
Python
375 lines
21 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"),
|
|
# SAFETY-42. Two needles rather than one: the campaign it was derived from,
|
|
# and the word that says what it does NOT do. An operator who reads
|
|
# "not-delivering report" and assumes the add-on has handled it is the
|
|
# failure this alarm's whole wording is written against.
|
|
"delivery_hold_s": ("SAFETY-42", "DERIVED FROM", "REPORTS ONLY"),
|
|
"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"),
|
|
# SAFETY-39. `slew_w` joins the table because it stopped being an
|
|
# operator's convenience and became a DERIVED value the moment it was
|
|
# tied to the rating: 0 resolves to about a ninth of `max_w`, from an
|
|
# ~18 s measured step response. It is the one entry here whose value is
|
|
# a RATIO, so the description has to say what it follows as well as
|
|
# where the ratio came from - a citation alone would leave an operator
|
|
# reading "0" on the tab with no idea it is not "off".
|
|
"slew_w": ("SAFETY-39", "DERIVED", "0 MEANS FOLLOW"),
|
|
}
|
|
_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 - "
|
|
"71 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 71 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")
|