CFG-04 (#45) merged first and moved the base. Two conflicts, both resolved as the union of the two sides: - app/main.py: an import-block collision. CFG-04 added validate_unit_ranges to the .validate import; SAFETY-30 added AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS and SignWatchdog to the .watchdog import. Neither side dropped anything, so the resolution keeps both lists whole. - CHANGELOG.md: two adjacent new entries under ## Unreleased. Both kept. Both sides also independently rewrote large parts of test_peakguard.py (60 and 47 insertions against the merge base) and git merged them textually, so the full suite was run on the MERGE RESULT rather than inferred from the two parents: 19 passed, 0 failed, twice. One caveat recorded rather than hidden: test_peakguard.py failed once during the first sequential full-suite run and then passed 10/10 in isolation and in two further full runs on this same tree. It passes on both parents too, so this is the pre-existing flakiness CFG-04 already disclosed, not a merge defect - it is timing- or load-sensitive. It sits on the required CI context and can therefore block an unrelated merge at random, which wants a card of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
@@ -4,7 +4,9 @@ THE DEFECT, in one sentence: every piece of per-device state in the Controller
|
||||
was a SCALAR, and several of those scalars are safety inputs - so a second
|
||||
inverter would have been graded against the first one's numbers.
|
||||
|
||||
Nine parts, and what each one is deliberately NOT allowed to be:
|
||||
Twelve parts, and what each one is deliberately NOT allowed to be. Parts 1-8
|
||||
and 12 are DEV-02's; parts 9-11 are CFG-04's, which is the card that gave an
|
||||
OPERATOR a way to declare the second inverter DEV-02 made the code ready for.
|
||||
|
||||
1. AC 1. THE GRADER, over `app/main.py` itself. Read, not run: the claim is
|
||||
"no per-device value remains a Controller scalar", which is a statement
|
||||
@@ -36,7 +38,21 @@ Nine parts, and what each one is deliberately NOT allowed to be:
|
||||
evidence for that and they run unmodified; what is here is the part they
|
||||
cannot see - that the compatibility views REFUSE at N>=2 rather than
|
||||
answering for the first inverter.
|
||||
9. MUTATION. Which check bites for each way of defeating this card.
|
||||
9. CFG-04. A TWO-INVERTER CONFIG, through the shipped `build_roster()`, into
|
||||
a roster the shipped `live_units()` answers for - and the other half of it,
|
||||
that an EMPTY `inverters:` is byte-identical to what shipped before.
|
||||
10. CFG-04. RULE 9, and SAFETY-28's two-writers hazard, which is expressible
|
||||
for the first time in this add-on's life. Also the two PERMISSIVE READERS
|
||||
this card found in shipped code - rule 5 grading against the flat `max_w`,
|
||||
and run_control() skipping the whole range re-check when the flat
|
||||
`setpoint_entity` is blank - both run BOTH WAYS, because a refusal that
|
||||
fires on everything passes a check that only looks for the refusal.
|
||||
11. CFG-04. EVERY config.yaml READER IN THE SUITE, FED A TWO-INVERTER CONFIG.
|
||||
Not a lint over their source: the functions are extracted and EXECUTED
|
||||
against a synthetic file whose per-inverter numbers differ from its flat
|
||||
ones, because on the shipped file a first-match reader is wrong and right
|
||||
at the same time.
|
||||
12. MUTATION. Which check bites for each way of defeating this card.
|
||||
|
||||
⚠️ INVERTER B DOES NOT EXIST YET (~2 weeks out; SPIKE-04 measures it). Every
|
||||
second unit below is built from parameters passed IN, and where a number is
|
||||
@@ -83,11 +99,21 @@ def check(name, cond):
|
||||
|
||||
|
||||
def read_schema_max(key):
|
||||
"""The schema's upper bound for `key` out of the SHIPPED config.yaml."""
|
||||
"""The schema's upper bound for `key` out of the SHIPPED config.yaml.
|
||||
|
||||
⚠️ ANCHORED AT EXACTLY TWO SPACES, WHICH IS THE WHOLE OF CFG-04's FIX HERE.
|
||||
This used to match `line.strip()`, and CFG-04's `inverters:` put a NESTED
|
||||
`max_w: int(100,5000)` six spaces in and earlier in the file - so the
|
||||
stripped version answered with the per-inverter bound and stayed green,
|
||||
because the two bounds are the same number today. Part 12 below runs this
|
||||
against a config where they are NOT the same number.
|
||||
"""
|
||||
body = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
||||
for line in body[body.index("\nschema:"):].splitlines():
|
||||
if not line.startswith(f" {key}:") or line[2:3].isspace():
|
||||
continue
|
||||
st = line.strip()
|
||||
if st.startswith(f"{key}:") and "(" in st and ")" in st:
|
||||
if "(" in st and ")" in st:
|
||||
return float(st[st.index("(") + 1:st.rindex(")")].split(",")[1])
|
||||
return None
|
||||
|
||||
@@ -1315,9 +1341,660 @@ check("...and `units` has no setter at all, so a site cannot claim two "
|
||||
and _c8.units == ("inverter-a", "inverter-b"))
|
||||
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("9. mutation: what defeats this card, and which check bites")
|
||||
print("9. CFG-04: a two-inverter CONFIG becomes a two-unit roster")
|
||||
|
||||
# ⚠️ EVERY SECOND UNIT ABOVE WAS CONSTRUCTED BY THIS FILE. That was the whole of
|
||||
# DEV-02's honesty: the code stopped assuming one inverter, and there was still
|
||||
# no way for an OPERATOR to declare a second - `build_roster()` returned a list
|
||||
# of exactly one and said so in its own docstring. This part grades the config
|
||||
# path: an options dict, through the shipped `build_roster()`, into a roster the
|
||||
# shipped `live_units()` answers for.
|
||||
_TWO_OPTS = dict(BASE_OPTS)
|
||||
_TWO_OPTS.pop("setpoint_entity")
|
||||
_TWO_OPTS.pop("soc_entity")
|
||||
_TWO_OPTS.pop("batt_entity")
|
||||
_TWO_OPTS["inverters"] = [
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a",
|
||||
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
|
||||
max_w=A_RATING),
|
||||
# ⚠️ 3000 W IS A HYPOTHESIS AND IS MARKED AS ONE WHEREVER IT APPEARS. It is
|
||||
# here because it DIFFERS from A's 5000 W, which is the only property any
|
||||
# check below uses. SPIKE-04 measures the real device.
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
||||
max_w=B_RATING_HYPOTHESIS),
|
||||
]
|
||||
_c9, _hass9 = built(_TWO_OPTS)
|
||||
print(f" the roster built from `inverters:` -> {[repr(i) for i in _c9.inverters]}")
|
||||
check("a two-inverter config produces a TWO-unit roster, each with its own "
|
||||
"actuator, its own sensors and its own rating - which is the thing this "
|
||||
"card exists to make possible at all",
|
||||
[i.name for i in _c9.inverters] == ["inverter-a", "inverter-b"]
|
||||
and [i.setpoint_entity for i in _c9.inverters] == ["number.sp_a",
|
||||
"number.sp_b"]
|
||||
and [i.max_w for i in _c9.inverters] == [A_RATING, B_RATING_HYPOTHESIS]
|
||||
and [i.soc_entity for i in _c9.inverters] == ["sensor.soc_a",
|
||||
"sensor.soc_b"])
|
||||
check("...and `live_units()` - the method the allocator asks, and the one that "
|
||||
"decides what may be commanded this tick - lists BOTH of them",
|
||||
_c9.live_units() == ("inverter-a", "inverter-b"))
|
||||
check("...and the site bound the loop runs on is the SUM, not one unit's "
|
||||
f"rating: {A_RATING:.0f} + {B_RATING_HYPOTHESIS:.0f} W",
|
||||
_c9.site_max_w == A_RATING + B_RATING_HYPOTHESIS
|
||||
and _c9.tuning.max_w == A_RATING + B_RATING_HYPOTHESIS)
|
||||
check("...and each unit keeps its OWN Store namespace, so a SAFETY-02 trip on "
|
||||
"one cannot latch the other - DEV-02's property, now reachable from a "
|
||||
"config file instead of from a test fixture",
|
||||
_c9.inverters[0].key("sign_tripped") != _c9.inverters[1].key("sign_tripped"))
|
||||
|
||||
# ⚠️ AND THE SHAPE IS ANNOUNCED, because the other shape's keys are still
|
||||
# sitting in the Configuration UI with values in them.
|
||||
check("...and the action log says which shape is in force and what it is NOT "
|
||||
"reading, at every start - an operator whose site runs at half the "
|
||||
"rating they typed has one line that says why",
|
||||
any("roster:" in e and "inverter-b" in e and "NOT" in e
|
||||
for e in _c9.events))
|
||||
|
||||
# --- the other half, and it is the half an upgrade depends on ----------------
|
||||
_c9a, _ = built() # BASE_OPTS: the flat keys, no list
|
||||
_c9b, _ = built({"inverters": []}) # what config.yaml actually ships
|
||||
check("⚠️ AC 3: an EMPTY `inverters:` is not 'unconfigured', it is the "
|
||||
"single-inverter configuration - the flat keys describe the one unit, "
|
||||
"under the same name, with the same entities and the same rating as "
|
||||
"before this card. That is what an install that upgrades reads, with "
|
||||
"nobody touching anything",
|
||||
[(i.name, i.setpoint_entity, i.soc_entity, i.batt_entity, i.max_w)
|
||||
for i in _c9b.inverters]
|
||||
== [(i.name, i.setpoint_entity, i.soc_entity, i.batt_entity, i.max_w)
|
||||
for i in _c9a.inverters]
|
||||
== [(UNIT_A, "number.sp_a", "sensor.soc_a", "sensor.batt_a", A_RATING)])
|
||||
check("...and the key ABSENT and the key EMPTY are the same configuration, not "
|
||||
"two code paths - config.yaml ships `[]` and an options dict written "
|
||||
"before this version has neither",
|
||||
_c9b.units == _c9a.units == (UNIT_A,))
|
||||
# ⚠️ AND THE SHIPPED DEFAULT IS NOT COSMETIC - IT IS WHAT MAKES THE UPGRADE
|
||||
# SURVIVABLE, AND IT WAS MEASURED. Supervisor 2026.08.0's own AppOptions
|
||||
# validator - the version this site runs, run against this very file by
|
||||
# tools/supervisor_shape_probe.py section B - answers `Missing option
|
||||
# 'inverters' in root` for the shipped `options:` block with ONLY this key
|
||||
# removed (one key varied, so the refusal is about this key; section B2's
|
||||
# reconstructed blob is refused for unrelated missing keys and is NOT this
|
||||
# measurement), and `App.test_update_schema()`
|
||||
# turns that into "new schema is not compatible" at UPDATE time. The Supervisor
|
||||
# merges `options:` into the operator's stored blob before validating, so this
|
||||
# one line is the difference between an upgrade nobody notices and an add-on
|
||||
# that cannot start. See DECISIONS/CFG-04-inverter-roster.md; deploy-rollback.md
|
||||
# §4 is the same trap in the other direction.
|
||||
_cfg_txt_9 = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
||||
check("⚠️ config.yaml SHIPS `inverters: []` in its options block. Without it "
|
||||
"Supervisor refuses every options blob written before this version - "
|
||||
"measured on 2026.08.0, not assumed - and the operator is left with an "
|
||||
"add-on that will not start",
|
||||
"\n inverters: []\n" in _cfg_txt_9[:_cfg_txt_9.index("\nschema:")])
|
||||
_sch9 = _cfg_txt_9[_cfg_txt_9.index("\nschema:"):]
|
||||
_entry9 = _sch9[_sch9.index("\n inverters:"):]
|
||||
_entry9 = _entry9[:_entry9.index("\n\n")]
|
||||
_decl9 = dict(ln.strip().lstrip("- ").split(": ", 1)
|
||||
for ln in _entry9.splitlines()[1:] if ": " in ln)
|
||||
print(f" the shipped per-entry schema: {_decl9}")
|
||||
check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, "
|
||||
"`setpoint_entity` and `max_w` are REQUIRED - no trailing `?` - so the "
|
||||
"Supervisor refuses a half-written entry before the add-on sees it, and "
|
||||
"an entry can never fall back on a sibling's rating because there is no "
|
||||
"entry without one",
|
||||
set(_decl9) == {"name", "setpoint_entity", "max_w", "soc_entity",
|
||||
"batt_entity", "batt_invert"}
|
||||
and not any(_decl9[k].endswith("?")
|
||||
for k in ("name", "setpoint_entity", "max_w"))
|
||||
and all(_decl9[k].endswith("?")
|
||||
for k in ("soc_entity", "batt_entity", "batt_invert")))
|
||||
|
||||
# ⚠️ NOTHING IS INHERITED, AND THIS IS DEV-02 RESTATED AS A CONFIG RULE.
|
||||
# ⚠️ THE FLAT KEYS ARE SET TO REAL ENTITIES HERE, AND THAT IS THE WHOLE CHECK.
|
||||
# The first draft left them blank, so "inherited" and "not inherited" produced
|
||||
# the SAME empty string and the check passed against a `build_roster()` that
|
||||
# DID inherit - a mutation this file ran and watched survive. An assertion that
|
||||
# X is not Y has to be run with X and Y actually different (MUTATION.md).
|
||||
_c9c, _ = built({"inverters": [dict(name="inverter-a",
|
||||
setpoint_entity="number.sp_a",
|
||||
max_w=A_RATING)],
|
||||
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a"})
|
||||
check("an entry that omits `soc_entity` has NO SoC sensor and does not borrow "
|
||||
"the flat one - which is set to a real entity right here, so inheriting "
|
||||
"and not inheriting are visibly different answers. A unit silently "
|
||||
"graded against a sibling's measurement is the exact defect DEV-02 spent "
|
||||
"a card removing, and a three-line fallback here would rebuild it in YAML",
|
||||
_c9c.inverters[0].soc_entity == "" and _c9c.inverters[0].batt_entity == ""
|
||||
and _c9c.o["soc_entity"] == "sensor.soc_a")
|
||||
|
||||
_c9d, _ = built({"inverters": [
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a", max_w=A_RATING),
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b")]})
|
||||
check("...and an entry that omits `max_w` does not inherit the flat rating "
|
||||
"either - it gets 0 W and validate.py rule 9 refuses it by name, which "
|
||||
"is a refusal an operator can act on rather than a site quietly running "
|
||||
"at somebody else's number",
|
||||
_c9d.inverters[1].max_w == 0.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("10. CFG-04: rule 9, and SAFETY-28's hazard now expressible for the "
|
||||
"first time")
|
||||
|
||||
from app.validate import ( # noqa: E402
|
||||
actuator_writers, unit_caps, unit_caps_indexed, validate_startup_config,
|
||||
validate_unit_ranges, _unit_label,
|
||||
)
|
||||
|
||||
|
||||
def _why(problems, needle):
|
||||
return any(needle in p for p in problems)
|
||||
|
||||
|
||||
def _opts(**kw):
|
||||
"""A complete, ACCEPTED options dict, so a refusal below is the new one."""
|
||||
o = dict(BASE_OPTS, meter_source="off", sign_disagree_w=300,
|
||||
sign_disagree_s=180, maintenance_charge_w=2000,
|
||||
maintenance_discharge_w=2000, peak_soc_floor=20, bms_soc_floor=10)
|
||||
o.update(kw)
|
||||
return o
|
||||
|
||||
|
||||
check("the fixture this part refuses things out of is itself ACCEPTED, or "
|
||||
"every check below would pass on a config that was already broken",
|
||||
validate_startup_config(_opts(), -A_RATING, A_RATING) == [])
|
||||
|
||||
# --- AC 5: the two-writers hazard --------------------------------------------
|
||||
_same = _opts(setpoint_entity="", inverters=[
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a", max_w=A_RATING),
|
||||
dict(name="inverter-b", setpoint_entity="NUMBER.SP_A",
|
||||
max_w=B_RATING_HYPOTHESIS)])
|
||||
_p_same = validate_startup_config(_same)
|
||||
print(f" two inverters, one entity -> {len(_p_same)} refusal(s)")
|
||||
check("⚠️ AC 5: two inverters naming the SAME setpoint_entity are REFUSED. "
|
||||
"specs §1.5's non-goal has been enforced 'by construction' for the whole "
|
||||
"life of this add-on because the schema had exactly one such key; this "
|
||||
"is the first configuration in which it is expressible, and it is the "
|
||||
"first time that guarantee has had to be a check",
|
||||
_why(_p_same, "one actuator") and _why(_p_same, "number.sp_a"))
|
||||
check("...and case-folded, so the hazard cannot be smuggled past on "
|
||||
"NUMBER.SP_A versus number.sp_a - which is exactly how it would be "
|
||||
"written by someone copying an entity id out of the HA UI",
|
||||
_why(_p_same, "inverters[0].setpoint_entity")
|
||||
and _why(_p_same, "inverters[1].setpoint_entity"))
|
||||
check("...and two inverters on two DIFFERENT entities are accepted, because "
|
||||
"that is the configuration this card exists for and a validator that "
|
||||
"fires on the legitimate case gets switched off",
|
||||
validate_startup_config(_opts(setpoint_entity="", inverters=[
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a", max_w=A_RATING),
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
max_w=B_RATING_HYPOTHESIS)])) == [])
|
||||
|
||||
# --- the rest of rule 9 -------------------------------------------------------
|
||||
_p_flat = validate_startup_config(_opts(inverters=[
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
max_w=B_RATING_HYPOTHESIS)]))
|
||||
check("a flat setpoint_entity left set BESIDE a declared roster is refused. It "
|
||||
"names an actuator the roster does not, so nothing would ever write it - "
|
||||
"a site running quietly smaller than its own configuration, which is the "
|
||||
"'silently a different config' outcome this card ranks below a refusal",
|
||||
_why(_p_flat, "AND an inverters list"))
|
||||
_p_dup = validate_startup_config(_opts(setpoint_entity="", inverters=[
|
||||
dict(name="twin", setpoint_entity="number.sp_a", max_w=A_RATING),
|
||||
dict(name="twin", setpoint_entity="number.sp_b",
|
||||
max_w=B_RATING_HYPOTHESIS)]))
|
||||
check("two entries with the SAME name are refused - the Store namespaces every "
|
||||
"per-unit latch by name, so one name is one SAFETY-02 latch for two "
|
||||
"units and an operator clearing it clears both",
|
||||
_why(_p_dup, "both called") and _why(_p_dup, "twin"))
|
||||
for _what, _bad, _needle in (
|
||||
("with no name at all",
|
||||
dict(setpoint_entity="number.sp_b", max_w=3000), "has no name"),
|
||||
("this controller cannot command",
|
||||
dict(name="inverter-b", max_w=3000), "has no setpoint_entity"),
|
||||
("with no rating",
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b"),
|
||||
"no usable max_w"),
|
||||
("whose rating is not a number",
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b", max_w="lots"),
|
||||
"no usable max_w")):
|
||||
check(f"an entry {_what} is refused, and the refusal says which entry and "
|
||||
f"which key ({_needle!r}) rather than that something is wrong",
|
||||
_why(validate_startup_config(_opts(setpoint_entity="",
|
||||
inverters=[_bad])), _needle))
|
||||
check("`inverters` that is not a list at all is refused with the shape it "
|
||||
"should have been, rather than crashing inside build_roster()",
|
||||
_why(validate_startup_config(_opts(inverters="number.sp_b")),
|
||||
"must be a\nLIST".replace("\n", " ")))
|
||||
|
||||
# --- the permissive readers INSIDE validate.py --------------------------------
|
||||
# ⚠️ RUN BOTH WAYS, WHICH IS MUTATION.md's RULE. A check that only asserts the
|
||||
# refusal happens would also pass if the rule fired on everything; the
|
||||
# counterfactual is the flat reader's own answer on the same config.
|
||||
_weak = _opts(max_w=A_RATING, sign_disagree_w=3000, setpoint_entity="",
|
||||
inverters=[dict(name="inverter-a", setpoint_entity="number.sp_a",
|
||||
max_w=2000),
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
max_w=2000)])
|
||||
print(f" flat max_w {_weak['max_w']:.0f} W, roster caps {unit_caps(_weak)}, "
|
||||
f"sign_disagree_w {_weak['sign_disagree_w']:.0f} W")
|
||||
check("⚠️ A PERMISSIVE READER INSIDE THE VALIDATOR, FIXED AND GRADED. Rule 5 "
|
||||
"compared sign_disagree_w against the FLAT max_w. With 5000 left over "
|
||||
"from a single-inverter install and a roster of two 2000 W units, 3000 W "
|
||||
"passed that comparison while the SAFETY-02 watchdog could fire on "
|
||||
"NEITHER unit - rule 5's own failure mode, reached through rule 5. It "
|
||||
"now reads the roster's caps and refuses",
|
||||
_why(validate_startup_config(_weak), "sign_disagree_w"))
|
||||
check("...and the counterfactual: the number the old reader used, on the same "
|
||||
"config, does not trip it. Without this line the check above would pass "
|
||||
"for a rule that refused everything",
|
||||
3000.0 < float(_weak["max_w"]) and min(unit_caps(_weak)) == 2000.0)
|
||||
check("...and at one inverter `unit_caps()` IS `[max_w]`, so the rule is the "
|
||||
"identical comparison it always was and no existing install changes "
|
||||
"verdict", unit_caps(_opts()) == [A_RATING])
|
||||
|
||||
# --- rules 5 and 6 named the WRONG INVERTER when the two lists desynchronised -
|
||||
# ⚠️ WHAT THE DEFECT WAS. `unit_caps()` SKIPS an entry with no usable `max_w`,
|
||||
# so caps position i stopped being roster position i the moment one entry was
|
||||
# malformed. Rule 5 did `_unit_label(opts, caps.index(weakest))` and rule 6 did
|
||||
# `caps[0]` beside a hard-wired `_unit_label(opts, 0)` - both feeding a CAPS
|
||||
# index to a function that indexes the ROSTER. The number in the message was
|
||||
# right and the NAME beside it was another inverter's: an operator sent to the
|
||||
# wrong device by the message that exists to stop exactly that.
|
||||
#
|
||||
# It is latent, not live - rule 9(d) refuses a junk `max_w` in the same pass, so
|
||||
# the operator sees both refusals. But "unreachable" was a property of today's
|
||||
# rule ordering rather than of the code, and `validate_startup_config()` is also
|
||||
# `sim/inject.py`'s entry point. The index travels with the cap now.
|
||||
_skew = _opts(setpoint_entity="", sign_disagree_w=1500, maintenance_charge_w=3000,
|
||||
maintenance_discharge_w=1000, inverters=[
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a",
|
||||
max_w="junk"), # SKIPPED by unit_caps()
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b", max_w=2000),
|
||||
dict(name="inverter-c", setpoint_entity="number.sp_c", max_w=1000)])
|
||||
_p_skew = validate_startup_config(_skew)
|
||||
print(f" caps {unit_caps(_skew)} but pairs {unit_caps_indexed(_skew)} - "
|
||||
f"roster is {len(_skew['inverters'])} long")
|
||||
check("the fixture actually desynchronises the two lists, or the two checks "
|
||||
"below prove nothing: one entry is skipped, so caps is SHORTER than the "
|
||||
"roster and every caps index is off by one",
|
||||
unit_caps(_skew) == [2000.0, 1000.0]
|
||||
and unit_caps_indexed(_skew) == [(1, 2000.0), (2, 1000.0)])
|
||||
check("⚠️ RULE 5 NAMES THE UNIT THE NUMBER CAME OFF. The weakest cap is "
|
||||
"inverter-c's 1000 W; it sits at caps index 1, and `_unit_label(opts, 1)` "
|
||||
"is inverter-B. The refusal says inverter-c",
|
||||
_why(_p_skew, "max_w (inverter-c) is 1000 W")
|
||||
and not _why(_p_skew, "max_w (inverter-b) is 1000 W"))
|
||||
check("⚠️ RULE 6 TOO. `caps[0]` is the first entry WITH a usable cap - "
|
||||
"inverter-b - while `_unit_label(opts, 0)` was hard-wired to inverter-a, "
|
||||
"so the maintenance refusal quoted B's rating under A's name",
|
||||
_why(_p_skew, "max_w (inverter-b) is 2000 W")
|
||||
and not _why(_p_skew, "max_w (inverter-a) is 2000 W"))
|
||||
check("...and the counterfactual, so this is not a check that would pass on a "
|
||||
"validator that named nothing: the OLD expressions still evaluate, on "
|
||||
"this same config, to the wrong names",
|
||||
_unit_label(_skew, unit_caps(_skew).index(1000.0)) == " (inverter-b)"
|
||||
and _unit_label(_skew, 0) == " (inverter-a)")
|
||||
check("...and rule 9(d) refuses this config anyway - which is WHY the defect "
|
||||
"was latent, and is recorded rather than used as a reason not to fix it",
|
||||
_why(_p_skew, "inverter-a"))
|
||||
|
||||
# --- validate_unit_ranges: rule 2, per unit, against ITS OWN device -----------
|
||||
_c10, _ = built(_TWO_OPTS)
|
||||
_c10.inverters[0].dev_min, _c10.inverters[0].dev_max = -A_RATING, A_RATING
|
||||
# B's ESPHome reports a 2000 W device while the config claims 3000 W for it.
|
||||
_c10.inverters[1].dev_min, _c10.inverters[1].dev_max = -2000.0, 2000.0
|
||||
_p_rng = validate_unit_ranges(_c10.inverters)
|
||||
print(f" per-unit range check -> {_p_rng}")
|
||||
check("⚠️ A READER THAT SILENTLY PINNED INVERTER #1, IN main.py. run_control() "
|
||||
"re-ran rule 2 behind `if self.o.get('setpoint_entity')` - the FLAT key, "
|
||||
"which a roster config leaves blank - so on a two-inverter site NO "
|
||||
"unit's max_w was graded against the device it is written to, and the "
|
||||
"skip was silent. The per-unit form catches B against B's own ESPHome",
|
||||
len(_p_rng) == 1 and _why(_p_rng, "inverter-b")
|
||||
and _why(_p_rng, "THE DEVICE WINS"))
|
||||
check("...and it says INVERTER-B, not 'the setpoint device' - a site with "
|
||||
"three inverters and one bad rating needs to be told which one",
|
||||
_why(_p_rng, "number.sp_b") and not _why(_p_rng, "inverter-a"))
|
||||
check("...and a unit whose ESPHome has not answered is SKIPPED rather than "
|
||||
"guessed at, which is the same (None, None) rule the flat check has "
|
||||
"always had - refusing there would refuse every cold boot",
|
||||
validate_unit_ranges([_c9.inverters[1]]) == [])
|
||||
check("...and at N=1 the unit the flat call already graded is skipped, so one "
|
||||
"conflict is reported once and not twice under two wordings",
|
||||
validate_unit_ranges(_c9a.inverters, skip_entity="number.sp_a") == [])
|
||||
# ⚠️ AND THE ADD-ON IS DRIVEN, NOT GREPPED. The first draft asserted the call's
|
||||
# SOURCE LINE was in main.py, and the mutation that puts it back inside `if
|
||||
# entity:` - which is one level of indentation - left that string intact and the
|
||||
# check green. So `run_control()` is run, on a two-inverter config with no flat
|
||||
# `setpoint_entity` at all, against an ESPHome that reports a device narrower
|
||||
# than what B's entry claims.
|
||||
_hass_rc = Hass({"sensor.grid": 0.0, "sensor.soc_a": 50.0, "sensor.batt_a": 0.0,
|
||||
"sensor.soc_b": 50.0, "sensor.batt_b": 0.0},
|
||||
{"number.sp_a": (-A_RATING, A_RATING, 1.0),
|
||||
# B's real device is 2000 W; its roster entry claims 3000 W.
|
||||
"number.sp_b": (-2000.0, 2000.0, 1.0)})
|
||||
_rc_opts = {k: v for k, v in _TWO_OPTS.items() if k != "setpoint_entity"}
|
||||
_c_rc = Controller(dict({k: v for k, v in BASE_OPTS.items()
|
||||
if k not in ("setpoint_entity", "soc_entity",
|
||||
"batt_entity")},
|
||||
**_rc_opts),
|
||||
_hass_rc, MemStore(), Pub(), stop=asyncio.Event())
|
||||
check("the fixture really does have NO flat setpoint_entity, which is the "
|
||||
"condition the old re-check silently skipped on",
|
||||
not _c_rc.o.get("setpoint_entity") and len(_c_rc.inverters) == 2)
|
||||
# ⚠️ WITH A DEADLINE, BECAUSE THE FAILURE MODE IS NOT AN EXCEPTION. A
|
||||
# `run_control()` that does NOT refuse enters the control loop and never
|
||||
# returns, so the mutation this check exists to catch turns a red into a HANG -
|
||||
# and a check that hangs is worse than one that passes wrongly, because CI
|
||||
# times out with no name attached. The timeout makes "did not refuse" a
|
||||
# failure with a sentence on it.
|
||||
async def _drive_rc():
|
||||
try:
|
||||
await asyncio.wait_for(_c_rc.run_control(), timeout=10.0)
|
||||
return ""
|
||||
except asyncio.TimeoutError:
|
||||
return ("run_control() entered the control loop instead of refusing - "
|
||||
"the per-unit range check did not run")
|
||||
|
||||
|
||||
_rc_why = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
_drive_rc())
|
||||
print(f" run_control() on a two-inverter config -> refused={_c_rc.refused}"
|
||||
+ (f" [{_rc_why}]" if _rc_why else ""))
|
||||
check("⚠️ RUN, NOT GREPPED: the shipped `run_control()` REFUSES this config and "
|
||||
"never enters the loop. Before this card the whole re-check hung off the "
|
||||
"flat `setpoint_entity`, so a roster config skipped it and B's 3000 W "
|
||||
"against a 2000 W device was never noticed by anything",
|
||||
not _rc_why and _c_rc.refused
|
||||
and any("inverter-b" in p for p in _c_rc.refused)
|
||||
and _c_rc.stop_event.is_set())
|
||||
check("...and it wrote nothing on the way out - a refusal is a config that "
|
||||
"never ran, not one that ran once",
|
||||
not _hass_rc.writes)
|
||||
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("11. CFG-04: every config.yaml reader in the suite, FED a two-inverter "
|
||||
"config")
|
||||
|
||||
# ⚠️ THE HAZARD THIS PART EXISTS FOR, AND IT IS THE ONE THE PM PROOFREAD NAMED.
|
||||
# CFG-03 made config.yaml the single source of truth for defaults, so nine
|
||||
# checks across this suite now READ it rather than transcribing it. Every one of
|
||||
# those readers was written when a key appeared at most twice - once under
|
||||
# `options:`, once under `schema:` - and several disambiguated by taking the
|
||||
# FIRST match on a `line.strip()`, which throws the indentation away.
|
||||
#
|
||||
# CFG-04's `inverters:` is a list of objects and its entries carry a `max_w:` of
|
||||
# their own. So `max_w` is now declared at TWO nesting levels, and a stripped
|
||||
# first-match reader answers with the PER-INVERTER one. On the shipped file that
|
||||
# is harmless by luck - the two bounds are the same `int(100,5000)` - which is
|
||||
# precisely what makes it dangerous: the reader silently returns inverter #1's
|
||||
# number and every check that consumes it stays green. This project's rule is
|
||||
# that a check which stays green after you break the thing it watches was never
|
||||
# watching, and a reader that is right by coincidence is the same defect one
|
||||
# layer down.
|
||||
#
|
||||
# So: a SYNTHETIC config.yaml is built from the shipped one with the nested
|
||||
# per-inverter numbers made DIFFERENT from the flat ones (MUTATION.md's rule -
|
||||
# an assertion that "X is not Y" must be run once with X and Y actually made
|
||||
# different), and every reader in the suite is executed against it.
|
||||
import re as _re # noqa: E402
|
||||
import tempfile as _tempfile # noqa: E402
|
||||
|
||||
_SHIPPED_CFG = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
||||
|
||||
# The per-inverter numbers, chosen only because they differ from BOTH flat
|
||||
# answers (options `max_w: 2000`, schema `int(100,5000)`). Nothing is measured
|
||||
# here and inverter B still does not exist.
|
||||
_NESTED_W = "3141"
|
||||
|
||||
_two_cfg = _SHIPPED_CFG.replace(
|
||||
" inverters: []\n",
|
||||
" inverters:\n"
|
||||
" - name: inverter-a\n"
|
||||
" setpoint_entity: number.sp_a\n"
|
||||
f" max_w: {_NESTED_W}\n"
|
||||
" - name: inverter-b\n"
|
||||
" setpoint_entity: number.sp_b\n"
|
||||
f" max_w: {_NESTED_W}\n", 1)
|
||||
_two_cfg = _two_cfg.replace(" max_w: int(100,5000)\n",
|
||||
f" max_w: int(100,{_NESTED_W})\n", 1)
|
||||
check("the synthetic two-inverter config really did get built - it differs "
|
||||
"from the shipped file, and the per-inverter numbers really are "
|
||||
"different from the flat ones, or every reader below would 'pass' by "
|
||||
"answering the same number either way",
|
||||
_two_cfg != _SHIPPED_CFG
|
||||
and _two_cfg.count(f"max_w: {_NESTED_W}") == 2
|
||||
and f"max_w: int(100,{_NESTED_W})" in _two_cfg
|
||||
and _NESTED_W not in _SHIPPED_CFG)
|
||||
|
||||
# ⚠️ AND THE SHIPPED FILE ITSELF STILL BAITS THE TRAP. If somebody moves
|
||||
# `inverters:` below `max_w:` in the schema block, the stripped-first-match bug
|
||||
# stops being reachable on the shipped file and this whole part quietly becomes
|
||||
# a check about a synthetic fixture. Assert the ordering rather than trusting a
|
||||
# comment to preserve it.
|
||||
_schema_half = _SHIPPED_CFG[_SHIPPED_CFG.index("\nschema:"):]
|
||||
check("⚠️ the shipped config.yaml still declares the NESTED per-inverter "
|
||||
"`max_w:` BEFORE the flat one under `schema:`, so a stripped-line reader "
|
||||
"would genuinely take the wrong one. Reorder that and the readers below "
|
||||
"become right by accident and this part stops watching anything",
|
||||
_schema_half.index(" max_w: int(") < _schema_half.index("\n max_w: int("))
|
||||
|
||||
_TMPCFG = Path(_tempfile.mkdtemp(prefix="cfg04-"))
|
||||
io.open(_TMPCFG / "config.yaml", "w", encoding="utf-8").write(_two_cfg)
|
||||
|
||||
# --- who the readers ARE, found rather than listed ---------------------------
|
||||
# ⚠️ THE TABLE IS AUDITED, THE DISCOVERY IS MECHANICAL, AND BOTH HALVES MATTER.
|
||||
# A hand-written list of readers is a list that goes stale the moment somebody
|
||||
# adds the tenth one - and the tenth one is exactly the one that gets a
|
||||
# stripped-line idiom typed for it. So every function in every test file whose
|
||||
# body touches config.yaml is DISCOVERED by AST, and a discovered function that
|
||||
# is not in the table below fails this part rather than being skipped.
|
||||
_AUDITED = {
|
||||
("test_peakguard.py", "read_option"),
|
||||
("test_peakguard.py", "read_schema_max"),
|
||||
("test_peakguard.py", "read_schema_min"),
|
||||
("test_peakguard.py", "_schema_bound"),
|
||||
("test_inverter.py", "read_schema_max"),
|
||||
("test_maintenance.py", "shipped_default"),
|
||||
("test_maintenance.py", "schema_range"),
|
||||
("test_freeze.py", "_cfg_default"),
|
||||
("test_price.py", "shipped"),
|
||||
("test_tariff.py", "shipped_option"),
|
||||
}
|
||||
# Helpers the audited readers call. Not called with a key themselves, so there
|
||||
# is nothing to feed them; they are exec'd so their callers work.
|
||||
_HELPERS = {("test_peakguard.py", "_cfg_halves"), ("test_peakguard.py", "_top_level")}
|
||||
_CFG_GLOBALS = ("_CFG_TXT", "_CFG_TEXT", "_cfg_lines", "_cfg_txt", "_cfg_src",
|
||||
"_SCHEMA_AT", "_txt")
|
||||
# ⚠️ AND THE INDIRECT ONES, WHICH IS WHERE THE FIRST DRAFT OF THIS AUDIT WENT
|
||||
# WRONG. `read_schema_max()` and `_schema_bound()` in test_peakguard.py do not
|
||||
# contain the string "config.yaml" at all - they go through `_cfg_halves()` -
|
||||
# so a predicate that only looks for the filename finds nine readers and misses
|
||||
# the two that CFG-04 actually had to change. Naming a helper counts as reading.
|
||||
_HELPER_NAMES = tuple(n for _f, n in _HELPERS)
|
||||
|
||||
|
||||
def _reads_cfg(src):
|
||||
return ("config.yaml" in src
|
||||
or any(g in src for g in _CFG_GLOBALS)
|
||||
or any(f"{h}(" in src for h in _HELPER_NAMES))
|
||||
|
||||
|
||||
_discovered, _sources, _all_fns = set(), {}, {}
|
||||
for _path in sorted(HERE.glob("test_*.py")):
|
||||
_body = io.open(_path, encoding="utf-8").read()
|
||||
if "config.yaml" not in _body:
|
||||
continue
|
||||
for _node in ast.walk(ast.parse(_body)):
|
||||
if not isinstance(_node, ast.FunctionDef):
|
||||
continue
|
||||
_seg = ast.get_source_segment(_body, _node) or ""
|
||||
_all_fns[(_path.name, _node.name)] = _seg
|
||||
if _reads_cfg(_seg):
|
||||
_discovered.add((_path.name, _node.name))
|
||||
_sources.update({k: _all_fns[k] for k in _discovered})
|
||||
# ⚠️ TO A FIXPOINT, because reading is TRANSITIVE and the first draft of this
|
||||
# audit stopped at one hop. `read_schema_max()` calls `_schema_bound()` which
|
||||
# calls `_cfg_halves()`; only the last of those three names the file, and the
|
||||
# first two are among the readers CFG-04 had to change. A one-hop predicate
|
||||
# found nine readers, missed those two, and would have reported "all clear" for
|
||||
# the exact functions this part was written about.
|
||||
while True:
|
||||
_grew = False
|
||||
for (_f, _n), _seg in _all_fns.items():
|
||||
if (_f, _n) in _discovered:
|
||||
continue
|
||||
if any(_df == _f and f"{_dn}(" in _seg for _df, _dn in _discovered):
|
||||
_discovered.add((_f, _n))
|
||||
_sources[(_f, _n)] = _seg
|
||||
_grew = True
|
||||
if not _grew:
|
||||
break
|
||||
|
||||
# ⚠️ WHAT MAKES A DISCOVERED FUNCTION AUDITABLE IS ITS SIGNATURE, NOT A LIST
|
||||
# SOMEBODY MAINTAINS. A reader is a thing you hand a key and get a value from,
|
||||
# so `def f(key)` / `def f(key, which)` is the shape, and EVERY function of that
|
||||
# shape in the closure above has to be in the table - which makes the table
|
||||
# self-maintaining: write the eleventh reader and this check names it on the
|
||||
# first run. The rest of the closure are CONSUMERS (a `replay()` that takes a
|
||||
# number `_cfg_default()` produced), and there is no key to feed them; what they
|
||||
# are held to instead is that they do not do their own key matching, one check
|
||||
# further down.
|
||||
def _key_shaped(src):
|
||||
_sig = src.split("(", 1)[1].split(")", 1)[0] if "(" in src else ""
|
||||
_args = [a.split("=")[0].split(":")[0].strip()
|
||||
for a in _sig.split(",") if a.strip()]
|
||||
return _args[:1] == ["key"]
|
||||
|
||||
|
||||
_key_takers = {k for k in _discovered if _key_shaped(_sources[k])}
|
||||
_consumers = _discovered - _key_takers - _HELPERS
|
||||
print(f" {len(_discovered)} functions in the config.yaml read-closure; "
|
||||
f"{len(_key_takers)} take a key and are fed the two-inverter config, "
|
||||
f"{len(_HELPERS)} are helpers, {len(_consumers)} are consumers")
|
||||
check("every key-taking reader in the suite is in the audited table - the "
|
||||
"table is not maintained by hand, the SIGNATURE decides, so the eleventh "
|
||||
f"reader cannot arrive unwatched. Unaudited: {sorted(_key_takers - _AUDITED)}",
|
||||
_key_takers == _AUDITED)
|
||||
check("...and the closure is transitive, so a reader that never names the file "
|
||||
"because it goes through a helper is still in it - which is where the "
|
||||
"first draft of this audit failed, on the two functions CFG-04 changed",
|
||||
("test_peakguard.py", "read_schema_max") in _discovered
|
||||
and "config.yaml" not in _sources[("test_peakguard.py", "read_schema_max")])
|
||||
|
||||
# --- and now RUN each of them against the two-inverter config -----------------
|
||||
_answers, _broken = {}, []
|
||||
for _key in sorted(_AUDITED):
|
||||
_file, _fn = _key
|
||||
_ns = {"io": io, "re": _re, "ast": ast, "Path": Path, "HERE": _TMPCFG,
|
||||
"_CFG_TXT": _two_cfg, "_CFG_TEXT": _two_cfg, "_txt": _two_cfg,
|
||||
"_cfg_txt": _two_cfg, "_cfg_src": _two_cfg,
|
||||
"_cfg_lines": _two_cfg.splitlines(),
|
||||
"_SCHEMA_AT": _two_cfg.index("\nschema:")}
|
||||
# Every function of that file that is in the read-closure, so a reader
|
||||
# that delegates (read_schema_max -> _schema_bound -> _cfg_halves) resolves.
|
||||
for _dep in sorted(_HELPERS | _key_takers):
|
||||
if _dep[0] == _file and _dep != _key:
|
||||
exec(_sources[_dep], _ns) # noqa: S102
|
||||
exec(_sources[_key], _ns) # noqa: S102
|
||||
_f = _ns[_fn]
|
||||
try:
|
||||
_answers[_key] = _f("max_w", 1) if _fn == "_schema_bound" else _f("max_w")
|
||||
except Exception as _err: # noqa: BLE001
|
||||
# ⚠️ AN EXCEPTION IS A PASS HERE. "Handle the per-inverter shape or FAIL
|
||||
# LOUDLY on it" - a reader that refuses to answer an ambiguous question
|
||||
# is doing the second, and it is the answer that quietly names inverter
|
||||
# #1 that this part is hunting.
|
||||
_answers[_key] = f"raised {type(_err).__name__}"
|
||||
for _key, _got in sorted(_answers.items()):
|
||||
print(f" {_key[0]}:{_key[1]}('max_w') -> {_got!r}")
|
||||
if _NESTED_W in str(_got):
|
||||
_broken.append(f"{_key[0]}:{_key[1]}")
|
||||
elif _got is None:
|
||||
_broken.append(f"{_key[0]}:{_key[1]} (None - answered nothing at all)")
|
||||
|
||||
check("⚠️ AC 4/AC 6: NOT ONE reader answers with the PER-INVERTER number when "
|
||||
"fed a two-inverter config. A reader that silently returns inverter #1's "
|
||||
f"value fails right here. Offenders: {_broken}", not _broken)
|
||||
check("...and every one of them still answers SOMETHING - a reader that went "
|
||||
"None on the new shape would take its whole check with it, which is "
|
||||
"fail-safe but is not the same as handling it",
|
||||
all(v is not None for v in _answers.values()))
|
||||
check("...and the answers are the SITE numbers: the options half says the "
|
||||
"shipped default 2000, the schema half says the shipped ceiling 5000",
|
||||
_answers[("test_peakguard.py", "read_option")] == 2000.0
|
||||
and _answers[("test_peakguard.py", "read_schema_max")] == 5000.0
|
||||
and _answers[("test_inverter.py", "read_schema_max")] == 5000.0
|
||||
and _answers[("test_tariff.py", "shipped_option")] == 2000.0
|
||||
and _answers[("test_freeze.py", "_cfg_default")] == 2000.0
|
||||
and _answers[("test_price.py", "shipped")] == 2000
|
||||
and _answers[("test_maintenance.py", "shipped_default")] == "2000"
|
||||
and _answers[("test_maintenance.py", "schema_range")] == (100.0, 5000.0))
|
||||
|
||||
# --- the reads that are NOT functions ----------------------------------------
|
||||
# ⚠️ SAID PLAINLY: THIS ONE IS A TEXT BACKSTOP, NOT A BEHAVIOURAL CHECK, and it
|
||||
# is here because six files read config.yaml at MODULE level (test_validate's
|
||||
# `_shipped` regex, test_watchdog's `circulation_*` scrape, test_arbitrage's and
|
||||
# test_price_forecast's `\n key:` counts) where there is no function to call.
|
||||
# Every one of those is already anchored at exactly two spaces and none of them
|
||||
# changed verdict on the two-inverter file - but the way to keep that true is to
|
||||
# ban the idiom that was not: a stripped line, matched by prefix, first hit
|
||||
# wins. If a module-level read ever needs to become auditable, make it a
|
||||
# function and it lands in the table above automatically.
|
||||
# ⚠️ THE NEEDLE IS ASSEMBLED AT RUNTIME so that this check's own source does
|
||||
# not match it, and it is scoped to lines within ten of a config.yaml
|
||||
# reference: the same three tokens over a plugin DOCSTRING, looking for a
|
||||
# SOURCES: line, is an entirely different thing (test_price.py part 4) and a
|
||||
# blanket grep fails on it.
|
||||
_NEEDLE = ".strip()" + ".startswith("
|
||||
_NEAR = ("config.yaml", "_CFG", "_cfg", "_txt")
|
||||
_permissive = []
|
||||
for _path in sorted(HERE.glob("test_*.py")):
|
||||
_body = io.open(_path, encoding="utf-8").read()
|
||||
if "config.yaml" not in _body:
|
||||
continue
|
||||
_lines = _body.splitlines()
|
||||
for _n, _line in enumerate(_lines, 1):
|
||||
if _NEEDLE not in _line:
|
||||
continue
|
||||
_window = "\n".join(_lines[max(0, _n - 11):_n])
|
||||
if any(_w in _window for _w in _NEAR):
|
||||
_permissive.append(f"{_path.name}:{_n}")
|
||||
check("no check in this suite matches a config.yaml key on a STRIPPED line any "
|
||||
"more - the idiom that made indentation invisible, and with it the "
|
||||
f"difference between the site's max_w and one inverter's. Found: {_permissive}",
|
||||
not _permissive)
|
||||
|
||||
# ⚠️ AND THE ADD-ON ITSELF READS NONE OF THIS. The Dockerfile does not copy
|
||||
# config.yaml into the image, so every reader above is a CHECK reading the
|
||||
# shipped file, and app/ code cannot have this class of bug at all. Asserted,
|
||||
# because "the running add-on cannot read it" is load-bearing for CFG-03's whole
|
||||
# single-source argument and is exactly the kind of fact that stops being true.
|
||||
# ⚠️ A STRING CONSTANT, NOT A MENTION. Half of app/ discusses config.yaml in
|
||||
# its comments - that is CFG-03 working - and grepping the text would fail on
|
||||
# the documentation rather than on a read.
|
||||
_app_opens = []
|
||||
for _f in sorted((HERE / "app").glob("*.py")):
|
||||
for _node in ast.walk(ast.parse(io.open(_f, encoding="utf-8").read())):
|
||||
if isinstance(_node, ast.Constant) and _node.value == "config.yaml":
|
||||
_app_opens.append(_f.name)
|
||||
check("no module under app/ so much as names config.yaml as a PATH, so the "
|
||||
"first-match hazard is confined to the checks - CFG-03's in-code mirrors "
|
||||
f"exist precisely because the running add-on cannot open this file. "
|
||||
f"Found: {sorted(set(_app_opens))}", not _app_opens)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("12. mutation: what defeats this card, and which check bites")
|
||||
|
||||
# Each row is a one-line defeat of something above, with the check that catches
|
||||
# it. ⚠️ THESE WERE RUN, NOT REASONED: every one was applied to the shipped
|
||||
@@ -1407,9 +2084,61 @@ MUTATIONS = [
|
||||
"test_inverter.py part 6: 'the next heartbeat does NOT put the old shares "
|
||||
"back' - a hazard THIS CARD created, because the heartbeat re-emits a "
|
||||
"share now and not the site number"),
|
||||
# --- CFG-04's ten, run the same way (tools/mutation_run.sh) -------------
|
||||
("test_inverter.py's `read_schema_max()` back to matching a STRIPPED line",
|
||||
"part 11: 'NOT ONE reader answers with the PER-INVERTER number' names it "
|
||||
"as an offender. ⚠️ THE SHIPPED FILE CANNOT CATCH THIS - the two `max_w` "
|
||||
"bounds are the same int(100,5000) - which is why part 11 executes the "
|
||||
"readers against a synthetic config where they differ"),
|
||||
("test_peakguard.py's `read_option()` back to whole-file first match",
|
||||
"part 11, same check, plus the text backstop naming test_peakguard.py:234"),
|
||||
("test_peakguard.py's `_schema_bound()` back to a stripped line",
|
||||
"part 11, which names BOTH it and `read_schema_max()` - the caller "
|
||||
"delegates, so one edit breaks two readers and the transitive closure "
|
||||
"finds both"),
|
||||
("`build_roster()` inheriting the flat `soc_entity` for an entry that "
|
||||
"omits it",
|
||||
"part 9: 'an entry that omits soc_entity has NO SoC sensor'. ⚠️ THIS ONE "
|
||||
"SURVIVED THE FIRST RUN and the FIXTURE was changed, not the rule: the "
|
||||
"flat keys were blank, so inherited and not-inherited were the same empty "
|
||||
"string. MUTATION.md's own failure mode, found in this file"),
|
||||
("`build_roster()` inheriting the flat `max_w` for an entry that omits it",
|
||||
"part 9: '...and an entry that omits max_w does not inherit the flat "
|
||||
"rating either'"),
|
||||
("validate.py rule 9(e) dropped - a flat `setpoint_entity` beside a "
|
||||
"declared roster silently ignored",
|
||||
"part 10: 'a flat setpoint_entity left set BESIDE a declared roster is "
|
||||
"refused'"),
|
||||
("validate.py rule 5 back to grading `sign_disagree_w` against the FLAT "
|
||||
"`max_w`",
|
||||
"part 10: 'A PERMISSIVE READER INSIDE THE VALIDATOR', with the "
|
||||
"counterfactual beside it so a rule that refused everything would not pass"),
|
||||
("main.py's per-unit range check put back inside `if entity:`",
|
||||
"part 10: 'RUN, NOT GREPPED'. ⚠️ THIS ONE SURVIVED THE FIRST RUN TOO - the "
|
||||
"check asserted the call's source line was present, and re-indenting it "
|
||||
"leaves the line present. It drives `run_control()` now, under a deadline, "
|
||||
"because the mutation's real symptom is a HANG and not a red"),
|
||||
("config.yaml no longer shipping the `inverters: []` default",
|
||||
"part 9: 'config.yaml SHIPS inverters: []'. The consequence is not in "
|
||||
"this suite at all - Supervisor answers `Missing option 'inverters' in "
|
||||
"root` and the add-on will not start after the upgrade - which is why the "
|
||||
"check is on the file and the measurement is in "
|
||||
"DECISIONS/CFG-04-inverter-roster.md §B. ⚠️ THE PROBE ITSELF WAS "
|
||||
"REGRESSION-BLIND TO THIS MUTATION until the review: its section B "
|
||||
"asserted `not ok` on a DIFFERENT blob, so deleting the default left it "
|
||||
"printing PASS. Section B now varies one key and asserts on the KEY NAME "
|
||||
"in the refusal, and the mutation was re-run against the real image: "
|
||||
"`2 CASE(S) FAILED`, exit 1"),
|
||||
("validate.py allowing two roster entries with the SAME name",
|
||||
"part 10: 'two entries with the SAME name are refused' - one Store "
|
||||
"namespace for two units is DEV-02's defect rebuilt in YAML"),
|
||||
]
|
||||
# ⚠️ TWO OF THESE SURVIVED THE FIRST TIME AND THE CHECKS WERE CHANGED, NOT THE
|
||||
# TABLE. The serialised dispatch and the shared `last_beat` both passed while
|
||||
# ⚠️ FOUR OF THESE SURVIVED THE FIRST TIME AND THE CHECKS WERE CHANGED, NOT THE
|
||||
# TABLE - two in DEV-02, two more in CFG-04, and the CFG-04 pair are recorded in
|
||||
# their own rows above because both are the same mistake in different clothes: a
|
||||
# check that observed the TEST FIXTURE (blank flat keys) or the SOURCE TEXT (a
|
||||
# line that survives re-indentation) rather than the behaviour it was named
|
||||
# after. The serialised dispatch and the shared `last_beat` both passed while
|
||||
# part 6 gathered its OWN `write_setpoint()` calls and computed its OWN `due`
|
||||
# list - a check on this file rather than on the add-on. `send_heartbeats()` was
|
||||
# extracted from `run_control()` so that the shipped dispatch is reachable by a
|
||||
@@ -1421,7 +2150,7 @@ for what, caught in MUTATIONS:
|
||||
check(f"all {len(MUTATIONS)} defeats of this card are caught by a named check "
|
||||
f"above. A guard whose checks survive its own defeat is not a guard, and "
|
||||
f"this repository has three separate records of exactly that",
|
||||
len(MUTATIONS) == 24)
|
||||
len(MUTATIONS) == 34)
|
||||
|
||||
|
||||
print()
|
||||
|
||||
Reference in New Issue
Block a user