Files
goodwe-addon/goodwe_controller/test_translations.py
T
Claude Opus 5 ac9854886b
checks / unit (pull_request) Successful in 7m14s
TARIFF-15: your own tariff card, as two coefficients the card itself grades
A Belgian dynamic contract prices energy as `B x index + A`, and the two
coefficients exist on exactly one artefact: the customer's own tariff card.
There is no machine-readable source for them anywhere - CREG's consumer
tariff-card pages 404, CREG Scan is a JS wizard, the annual report has the
supplier list without per-product coefficients, VREG was unreachable, and
every coefficient found in research came off a supplier's own PDF, one
image-only. So nothing can be scraped and a per-supplier plugin would have no
source for its numbers, which is why TARIFF-07 withdrew three of them. The
customer is the only party who can see their own coefficients; they type them.

New provider `custom_formula`, one new option `price_formula` (a JSON string,
the `static_profile` precedent exactly), and a new module
`app/custom_formula.py`. The index path is `generic_dayahead`'s, not a second
one: the URL/override decision is now one shared `dayahead_feed_url()` both
plugins call, same feed, same Brussels-calendar range, same columnar parser,
same declared egress hosts.

It declares {ENERGY, MARKUP}, NOT ALL_IN. A card's Tariefformule line is the
energy component alone - Eneco tabulates network tariffs and levies
separately, ENGIE states the formula excludes network, distribution,
transport, levies and excise - so ALL_IN would lie for every operator who
typed what their card says and would drop the taxes from every hour
(0.2125 -> 0.1555 EUR/kWh, measured).

THE CARD IS THE GRADER, and that is why this is safe to ship. The framework
already mechanises that a PLUGIN cannot double-count. This introduces a risk
none of those guards can see because it is config, not code: the OPERATOR
double-counting, by folding network charges into `offset` while
price_taxes_eur_kwh carries them too. So validate.py recomputes the card's own
printed price from the operator's coefficients at startup and refuses to start
unless the two agree to within 0.01, naming the discrepancy. An inflated
offset cannot pass: 15.55 becomes 28.27. The same check catches VAT omitted,
VAT doubled, a transposed digit and a unit mismatch between the two sides.

And the limit, in the same sentence as the claim: it grades the coefficients
against the card, NOT the card against reality. A consistent-but-wrong pair
passes. A second refusal covers the one unit error the first cannot see - the
one applied to both sides at once - by bounding the card's own point to
0.02-1.00 EUR/kWh. Its FLOOR is applied to the energy figure rather than the
all-in one because the shipped 0.06 of taxes is three times that floor and
would carry a hundredfold error over it alone; found by writing the mutation,
not by review (M10, +0 B).

The market's own arithmetic, reproduced through the shipped code:

  Eneco (0.102 x 133.9905 + 1) x 1.06 = 15.5471   card prints 15,55
  ENGIE (0.1   x  61.96   + 0.9) x 1.06 = 7.5218  card prints 7,522

NO EXPRESSION EVALUATION OF ANY KIND. The string is settable over HTTP through
the add-on's own ingress page, so eval on it is RCE in a root container
holding SUPERVISOR_TOKEN and driving an inverter over RS485 - but the deciding
argument is that an expression is UNVERIFIABLE: two coefficients can be graded
against the card, an arbitrary expression cannot be graded against anything,
so it would make the invisible-wrong-coefficient problem strictly worse. An
unknown field is REFUSED rather than ignored, so `"expr"` is told it does not
exist. Future shapes become named fields, never a grammar. test_price.py 15f
walks the module's AST for eval/exec/compile/__import__/literal_eval and
exercises the scanner's red direction on every run.

Refused at startup: card absent or out by >0.01; `units` absent (no default);
factor == 0 (every spread 0, arbitrage silently never fires - the message
points at static_profile); factor < 0 (inverts the market); vat_pct outside
0-30 (0 stays legitimate); non-finite or non-numeric; unknown field, index or
resolution; the plausibility band. A card older than ~6 months WARNS and does
not refuse - ENGIE freezes coefficients for a contract's duration. Negative
output prices are not refused anywhere.

A startup preview logs the next 24 h as `index EUR/MWh -> all-in EUR/kWh` with
min/median/max and the day's spread, through a new default-silent
`PriceProvider.startup_preview()` hook - only the plugin knows what went in to
its own arithmetic. Emitted once per process, and a preview that raises cannot
cost the site its prices.

`custom_formula` joins the config.yaml enum through the DERIVED set
PROVIDERS | RETIRED_PROVIDERS | {"off"}, not a hardcoded list. Both
translations describe the option and the new id. DOCS.md replaces the
"planned" sentence with the shape, a worked example from a real card, a real
startup transcript and the limit of the check stated beside the claim.

22 mutations, one per check added, no survivors - MUTATION.md carries the
ledger. M13 (the preview's index column) was a -13 B survivor of the whole
suite on the first attempt because the check graded the HEADING and not the
row; it is graded on a rendered row now. All 29 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 03:49:08 +02:00

382 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"]) == 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 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 - "
# ⚠️ COUNTED, NOT TYPED. UX-02 corrected this sentence's option count
# 70 -> 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.
check("no option was lost, gained or duplicated by the reordering: the two "
"halves of config.yaml hold the same 72 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")