checks / unit (pull_request) Successful in 8m6s
OPT-10 measured that this site's pack reaches 90 % SoC on 2 days of 16 and that
20.5 kWh of surplus left the site while the pack had room - 12.6 kWh of it with
the battery not charging at all - and could not say WHY, because PV production
is recorded NOWHERE. Three causes need three different fixes (no irradiance:
nothing to fix; a charge-power limit; the optimiser declining to charge, which
is the only software defect) and guessing between them is how an optimiser gets
tuned to solve a cloud problem. This card adds the channel. It does not do the
attribution.
AN HA ENTITY, NOT A REGISTER, AND THAT IS A PRODUCT DECISION. This add-on's
RS485 bus carries the BATTERY inverter it drives; a household's PV inverter is
normally a different box from a different vendor reporting into Home Assistant
through its own integration. `pv_entity` takes whatever `sensor.*` the site's
own PV integration provides, any brand HA can read.
POSITIVE IS PRODUCTION, AND THERE IS NO `pv_invert`. The convention is derived
rather than assumed: `meter_entity` is positive when IMPORTING and `batt_entity`
is positive when DISCHARGING (DOCS.md, Sources), both of which are "power
arriving at the house's AC bus", and production arrives there too. Those two
keys carry invert flags because they are BIPOLAR and are READ BY THE CONTROL
PATH - `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
circulating-power sensor, so a reversed sensor hands the site a plausible number
it acts on. Production is unipolar and is read by nothing: a reversed sensor
announces itself as -4 kW at noon in the very history this card creates, and a
flag would only let an installer hide it. No negative-value alarm either - a PV
inverter's own standby draw makes a small negative reading at night ordinary, so
any threshold would be a number nobody measured.
ABSENCE IS NEVER ZERO, AND "DROPPED" IS NOT ABSENT. `publish()` SKIPS a None,
which is right for `circulation` and exactly wrong here: a skipped row leaves
Home Assistant holding the last number forever and the recorder draws a FLAT
LINE through the outage, which is indistinguishable from a measurement and
manufactures the "there was no surplus" conclusion this card exists to TEST. So
the absence is published, with HA's own payload: `mqtt/const.py` declares
`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric branch,
so the entity's state becomes `unknown` and the recorder stores a GAP. Read out
of home-assistant/core `dev` on 2026-09-24 and quoted at the constant.
`ABSENT_AS_UNKNOWN` is a one-key set and the suite asserts `circulation: None`
is STILL dropped.
THE UNIT IS READ OFF THE ENTITY. `mW`, `W`, `kW`, `MW`, `GW`, `TW` - HA's own
`UnitOfPower`, every factor an exact power of ten. A sensor with no unit, or a
unit outside that table, is REFUSED by name and the status page says which.
`read_inputs()`'s `abs(raw) < 50` kW-or-W guess is safe for a quarter-hour
demand forecast and is NOT safe here: 30 W of dusk is an ordinary reading and
that heuristic would publish it as 30 kW.
GENUINELY OPTIONAL, AND NOT A SECOND `price_now_entity`. validate.py does not
mention the key - it cannot conflict with anything, because nothing else reads
it. The MQTT row lives in a new `OPTIONAL_SENSORS` list whose `include` argument
DEFAULTS TO EMPTY, so every caller that has never heard of `pv_entity` -
test_mqtt.py's fixtures, tools/rehearse_roster.py, anything written next year -
announces exactly what it announced before. Unset: no state call at all, no
entity, no topic, no status row, no page row. Set and failing: a CAUTION
(`ok=False, warn=True`), never a `bad` row, so a telemetry channel can never put
a battery site into NOT READY.
IT MUST NOT BECOME A CONTROL INPUT, AND THAT IS GRADED TWICE. `test_pv.py`
part 6 drives 40 cycles of the real `cycle()` and asserts the commands are
bit-identical across unset / no reading / 0 W / 8 kW, with a COMPUTED census
(33 modules under app/) showing the material is mentioned in exactly three:
pv.py, main.py, mqtt.py. `tools/tel11_pv_trace.py` is the cross-tree half -
`safety56_behaviour_trace.py`'s pattern with a DIFFERENT control clause, because
this card's claim is that nothing differs rather than that only sentences do: it
REFUSES unless the two `app/` fingerprints differ and unless exactly one side
reacts to the option. Measured on 41 stimulus cells:
control release/1.0 unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control TEL-11 unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control TEL-11 set 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
telemetry release/1.0 unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry TEL-11 unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry TEL-11 set 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
UNSET is byte-identical in BOTH documents, which is stronger than "the control
path is untouched": an install that does not set the option sees no change of
any kind, announcement and status rows included.
⚠️ AND THE CARD'S CENTRAL MUTATION WAS A SURVIVOR ON THE FIRST BATTERY.
`self.grid -= self.pv_w` in `read_inputs()` - +106 B, on disk, purged, genuinely
running - came back GREEN, because the fixture drove a 6 kW export over 12
cycles, which puts the loop AGAINST ITS RAIL: a command moving `slew_w` every
cycle produces the same ramp for -6000 W and for -14 000 W of apparent error.
MUTATION.md habit 23 with a saturated ACTUATOR in place of a coinciding value,
and it is in that file now as the habit's second instance. The fixture now
drives a 600 W import the loop SETTLES on, against a responsive meter, and asks
habit 23's own question as its own check: shift the METER by the same 8 kW and
confirm the commands really would have moved.
Evidence: 31/31 suites green in the CI image, checks.yml's exact command, purged
tree, `:ro`. Ten mutations, NO survivors, each on its own `cp -a` of the tree so
habit 18's revert hazard is closed by construction - table in
`DECISIONS/TEL-11-pv-production.md` §7 with byte deltas and sha256s.
⚠️ THIS IS A RUNTIME CHANGE BEHIND AN UNCHANGED VERSION, AND
`tools/predeploy_check.py` WILL REFUSE IT. `app/`, `config.yaml` and
`translations/` are all in its `RUNTIME` tuple and `config.yaml` still reads
0.10.1, so the gate reports a NO-OP deploy until a version is cut. That is PKG's
call and this card does not take it.
No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## ` heading
that is not config.yaml's `version:`, and opening one would mean taking that
same packaging decision. The operator account is in DOCS.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
398 lines
22 KiB
Python
398 lines
22 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"),
|
|
# TARIFF-18. `price_taxes_eur_kwh` joins the table for `slew_w`'s reason:
|
|
# it stopped being a figure somebody picked and became one SUMMED FROM
|
|
# NAMED CARD ROWS. Three needles, because three different things about it
|
|
# can go wrong on the Configuration tab and only the first is provenance:
|
|
# where the number came from, that the operator's own DSO gives a
|
|
# different one, and that changing it moves no trade - which is the claim
|
|
# an operator is most likely to assume the opposite of when a number
|
|
# doubles.
|
|
"price_taxes_eur_kwh": ("TARIFF-18", "YOUR OWN DSO",
|
|
"MOVES NO TRADING DECISION"),
|
|
}
|
|
_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.
|
|
# ⚠️ THE COUNT IS READ OFF THE TREE, NOT TYPED. It was typed - "the same 72
|
|
# keys" - and was 75 by the time TEL-11 read it, because three cards had added
|
|
# an option each and none of them was looking at this sentence. A number in a
|
|
# check's own message is a claim like any other (MUTATION.md habit 22) and this
|
|
# one had been false for three cards without a single run going red, because
|
|
# nothing asserts it. Derived, it cannot rot.
|
|
check(f"no option was lost, gained or duplicated by the reordering: the two "
|
|
f"halves of config.yaml hold the same {len(ORDER)} keys, once each",
|
|
sorted(_opt_order) == sorted(ORDER) and len(set(ORDER)) == len(ORDER))
|
|
check("...and `inverters: []` is still shipped in the options half, which is "
|
|
"what Supervisor 2026.08.0 refuses every pre-0.6.0 options blob without "
|
|
"(measured; see DECISIONS/CFG-04-inverter-roster.md B)",
|
|
"\n inverters: []\n" in _opt_half)
|
|
check("...and the schema half still declares the NESTED per-entry `max_w:` "
|
|
"BEFORE the flat one, so test_inverter.py part 12's first-match trap "
|
|
"stays baited rather than sorted out by this card's reordering",
|
|
CFG_TEXT[CFG_TEXT.index("\nschema:"):].index(" max_w: int(")
|
|
< CFG_TEXT[CFG_TEXT.index("\nschema:"):].index("\n max_w: int("))
|
|
|
|
print()
|
|
if FAILED:
|
|
print(f"{FAILED} FAILED")
|
|
sys.exit(1)
|
|
print("all checks passed")
|