checks / unit (pull_request) Successful in 5m54s
A unit standing at a non-zero share and producing nothing is seen by NOTHING. SAFETY-30's per-unit sign gate needs OPPOSITION on both sides of 250 W and a unit at 0 W is not opposing, it is ABSENT. The site sum still agrees with the meter in direction. Circulating power is exactly zero when one unit does nothing. The only trace left is `reason == "saturated-freeze"`, which clamp.py:93-96 documents as the alarm an operator learns to ignore. It is HW-01's shape, which ran 6 h 25 m with all three layers spectators. ⚠️ THE CARD'S OWN DISCRIMINATOR WAS BUILT FIRST AND FALSIFIED, AND THAT IS THE MAIN CONTENT. SAFETY-42 asks for "saturation persisting past N while |grid error| > X, both derived". Run on the two-master rig it fails in BOTH directions, and no (N, X) fixes either: FALSE POSITIVE `plantsim.Plant.limit()` returns 0 W for a discharge command into a pack at its own floor, and a wedged unit produces 0 W too. Load 2000 W, equal weights, 2000 W per-unit cap, 120 cycles: THE TWO ARMS ARE IDENTICAL ON ALL 120 in grid, site target, every share and every measured watt. Both saturated-freeze on 116 of them, both hold |grid error| over 300 W on all 120 and settle at 1200 W, both read circulating 0.0 W. The predicate's inputs do not differ, so no threshold on them can differ. And "a cold pack at its floor must not alarm" is the card's own AC 2. FALSE NEGATIVE the standing grid error is what a HEALTHY SIBLING ERASES. Load 800 W, same config, B wedged: over the last 30 of 200 cycles the loop is frozen on 30 of 30 while the worst |grid error| is 30 W, with B standing at +770 W and delivering 0 W. Any X above 30 W is silent for the whole run on the exact fault the card describes. WHAT REPLACES IT. Per unit, for a discharge share, `share - measured`; readable because of a reduction that only exists at N>=2: a unit is named while it is short by more than `saturation_w` with NO ACCOUNT for it, and the units in that state are a PROPER SUBSET of the units asked to discharge. THE ACCOUNT keeps the cold pack out and is the only thing that can: the two cases are identical in the power domain, so the discriminator has to be a non-power fact, and there is exactly one - the pack's own SoC against its own `bms_soc_floor`, asked through `peakguard.discharge_permitted()` rather than transcribed. ⚠️ `bms_soc_floor` ALONE, not `pack_floor_pct()`'s conjunction with `peak_soc_floor`: that floor bounds a money-saving discharge only and using it would explain away a wedged unit anywhere between the two. THE PROPER SUBSET keeps a whole-site cause out - a derate this add-on cannot see hits every unit; one box that stopped listening does not - and it is what makes this INERT AT N=1, by algebra rather than by a roster guard. IT REPORTS. IT DOES NOT ACT, and the strongest argument is for acting. Dropping the wedged unit from `live` genuinely recovers a site the shipped path does not: at load 1500 W, kept in the split the site settles at 800 W with a 700 W error and NEVER comes inside 100 W in 150 cycles (the freeze pins the target, so the sibling's SHARE never grows); dropped, it delivers 1500 W from cycle 14. What refuses it is that the only re-allocation this add-on can express is all-or-nothing: a unit derated to 50 % is short by more than saturation_w with nothing to explain it, so this detector names it too, and at load 3000 W dropping it leaves the site delivering 2000 W against 2400 W kept - 400 W WORSE. A partial re-allocation is what would make acting safe and the allocator cannot express it. That is a card. control.py is NOT MODIFIED. The freeze shapes the loop; this reports on it. NUMBERS, MEASURED. The watts side is `Tuning.saturation_w`, read at runtime, so there is no fourth CFG-03 copy and it cannot drift from the number the control law itself calls "not following". `delivery_hold_s: 185` comes off 2640 closed-loop two-inverter runs in which nothing is faulty (6 gains x 5 slews over the whole schema range, across load reversals, a flapping load and ASYMMETRIC SPLITS - a family SAFETY-30's campaign did not have and the one that dominates here) plus 1890 runs at a roster of ONE as the control: threshold reversals flapping weighted N=1 control * 500 W 20.0 s 0.0 s 30.0 s 0.0 s The N=1 column is 0.0 s at EVERY threshold in all 1890 runs, and the flapping column - the family that defeated SAFETY-30's site window - is zero too, because a common stimulus makes both units short at once and the subset stops being proper. ⚠️ The curve is NOT monotone (55.0 s at 250 W against 40.0 s at 100 W): lowering the threshold can turn a proper subset into the whole roster and BREAK the run. ⚠️ THE MARGIN IS MEASURED, NOT FELT, BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST. `plantsim.INVERTER_B` is A COPIED, `measured=False`, so every two-inverter figure above is two copies of A. Sweeping a different second unit: the worst legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and scales with its LAG - 45.0 s at twice A's measured 10 s, 90.0 s at four times it. 185 s is 6.2x the measured worst, 2.06x the four-times-slower case, 6.2x LINK_STALE_S and 4.9x inside the 900 s quarter. Deliberately not 180, which is SAFETY-02's site hold. SAFETY-34. `unit/<name>/delivery_tripped` in the store, one notification id per unit, re-raised on the first cycle of every process, cleared by the Start press. No flat mirror: a rollback lands on a version that drives one actuator, where this check is inert. ⚠️ SCHEMA CHANGE - `delivery_hold_s` joins options: and schema:, so this needs a version bump before it ships. Additive and defaulted, so an existing options blob stays valid. STATED GAPS: charge shares are not graded (no configured charge ceiling exists, so a constant-voltage taper cannot be explained and arming it would fire on every maintenance charge); every unit wedged at once is not caught (the subset is not proper and no instrument here separates that from a plant that cannot). 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 - "
|
|
"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")
|