From bcee2356e0dc4849263f10f695dd5a6393c2d846 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 6 Sep 2026 22:38:54 +0200 Subject: [PATCH] UX-02: pin both halves of the max_w fix, because neither was graded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the fix commit found that BOTH halves reverted with the whole 24-file suite still green - including the half that turns a crash into a SILENT ACCEPTANCE, which was my own wrong first attempt at this fix. A fix nothing grades is a fix that comes back. MUTATION.md habit 4. ⚠️ AND THE OBVIOUS PIN HAD THE SAME DEFECT IT WAS WRITTEN TO CATCH. "problems_ and_ranges does not raise" is ALSO satisfied by the backstop swallowing the exception and returning [] - the silent acceptance half two exists to prevent. MEASURED: with both halves reverted, every max_w check reported `ok` and only the forced-boom pins went red. So half one is now pinned on `build_roster` ITSELF, where the backstop cannot answer for it. That is the same defect as the one being fixed - a check whose data cannot reach the failure it names - written into the check for it, and it is the twelfth recorded instance. New section 2b of test_selfconfig.py: - `_flat_max_w` agrees with `_number()` across ten shapes, so composition tolerates exactly what rule 9 tolerates. - ⭐ `build_roster(max_w="")` does not raise - half one, standing alone. - a cleared box answers with the validator's own verdict; an unparseable one answers "max_w 'abc' is not a number". - ⭐ clearing max_w on the LIVE shape (5000 W rail, explicit slew_w 400) is REFUSED by rule 10 "against a site rail of 2000 W". `unit_caps_indexed` reads the flat rail through the same `_number`, so the rail collapses in lockstep and rule 10 catches the collapse. This is what makes the fix safe rather than merely non-crashing: the box cannot be emptied into a quietly halved rail. - ⭐ the backstop never returns an empty list, forced by monkeypatching build_roster to raise; and the patch is shown undone. DRIVEN, all four arms through the real harness: clean rc=0 fails=0 revert half one rc=1 fails=2 (_flat_max_w -> bare float()) revert half two rc=1 fails=2 (backstop returns [] again) revert BOTH rc=1 fails=3 Each half is caught on its own, which is the property the earlier draft lacked. Suite: 24 files, all pass. Not addressed here, filed instead: N3 (`/config/` 404s), N4 (undeclared keys on a hand-crafted POST, which the page cannot produce and Supervisor drops), N5 (saving pins every default as an explicit option), the page reporting "would start" for a blob Supervisor's own schema refuses, and 2000.0 hardcoded in three places that agree today with nothing pinning them together. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/test_selfconfig.py | 97 ++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/goodwe_controller/test_selfconfig.py b/goodwe_controller/test_selfconfig.py index 74f55f1..336ea71 100644 --- a/goodwe_controller/test_selfconfig.py +++ b/goodwe_controller/test_selfconfig.py @@ -247,6 +247,103 @@ check("...and the caller gets the new token back, so the next save can " res["fingerprint"] == selfconfig.fingerprint(_good)) +# --------------------------------------------------------------------------- # +print() +print("2b. ⚠️ THE TWO HALVES OF THE max_w FIX, EACH PINNED SO IT CANNOT BE UNDONE") +# Review found that BOTH halves reverted with the whole 24-file suite still +# green - including the half that turns a crash into a SILENT ACCEPTANCE, which +# is the more dangerous of the two and was a wrong first attempt at this very +# fix. A fix nothing grades is a fix that comes back. MUTATION.md habit 4. +from app.inverter import _flat_max_w, build_roster # noqa: E402 +from app.validate import _number # noqa: E402 + +# ⚠️ THIS CHECK EXISTS BECAUSE THE OBVIOUS ONE HAS A HOLE. "problems_and_ranges +# does not raise" is ALSO satisfied by the backstop swallowing the exception and +# returning [] - which is the silent-acceptance bug half two exists to prevent. +# Measured: with BOTH halves reverted, every max_w check below reported ok and +# only the forced-boom pins went red. So half one is pinned HERE, on the +# composition itself, where the backstop cannot answer for it. +_raised = None +try: + build_roster(base(max_w=""), None) +except Exception as exc: # noqa: BLE001 + _raised = exc +check("⭐ THE COMPOSITION ITSELF TOLERATES A CLEARED max_w - not merely the " + "backstop above it. `validate.py`'s `_number()` reads '' as the default, " + "so rule 9 sees 2000 and is content; a bare `float('')` in the flat path " + "raised instead, inverting inverter.py's own rule that a shape rule 9 " + "tolerates must be composable", + _raised is None) + +# HALF ONE: composition tolerates exactly what rule 9 tolerates. +for raw in ("", None, 0, False, True, -1, "2000", " 2000 ", "2e3", 1e18): + o = base(max_w=raw) + expect = _number(o, "max_w", 2000.0) + if expect is not None: + check(f"_flat_max_w agrees with _number() for max_w={raw!r}", + _flat_max_w(o) == expect) + +_raised = None +try: + _probs, _ = run(selfconfig.problems_and_ranges(base(max_w=""), None)) +except Exception as exc: # noqa: BLE001 + _raised, _probs = exc, None +check("⚠️ A CLEARED max_w BOX ANSWERS WITH A VERDICT, NOT AN EXCEPTION. It " + "used to raise ValueError from inside Controller.__init__, which the " + "panel served as HTTP 500 on a non-JSON body and the page rendered as " + "'cannot reach the add-on' - untrue, for the most prominent limit " + "field's most likely transient state", + _raised is None) +check("...and it agrees with the validator, which reads '' as the default", + _probs == list(validate_startup_config(base(max_w=""), None, None))) + +_raised = None +try: + _probs, _ = run(selfconfig.problems_and_ranges(base(max_w="abc"), None)) +except Exception as exc: # noqa: BLE001 + _raised, _probs = exc, None +check("an UNPARSEABLE max_w answers with the validator's own sentence", + _raised is None and any("is not a number" in p for p in (_probs or []))) + +# HALF ONE, THE PART THAT MAKES IT SAFE RATHER THAN MERELY NOT-CRASHING: +# clearing the box on the LIVE shape must FAIL CLOSED, not silently halve the +# rail. `unit_caps_indexed` reads the flat rail through the same `_number`, so +# the rail collapses to 2000 in lockstep and rule 10 catches the collapse. +_live = base(max_w=5000, slew_w=400) +_probs, _ = run(selfconfig.problems_and_ranges(dict(_live), None)) +check("the live shape (5000 W rail, slew_w 400) starts", _probs == []) +_probs, _ = run(selfconfig.problems_and_ranges(dict(_live, max_w=""), None)) +check("⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the " + "rail collapsing to the 2000 default under an explicit 400 W slew, so " + "the box cannot be emptied into a quietly halved rail", + any("site rail of 2000 W" in p for p in _probs)) + +# HALF TWO: the backstop cannot report "no problems" for a config it could not +# compose. Returning [] there enables Save - a crash became a green. +_orig_roster = selfconfig.build_roster + + +def _boom(*_a, **_kw): + raise ValueError("forced boom") + + +selfconfig.build_roster = _boom +try: + _probs, _ = run(selfconfig.problems_and_ranges(base(), None)) +finally: + selfconfig.build_roster = _orig_roster +check("⚠️ THE BACKSTOP NEVER RETURNS AN EMPTY LIST. A roster that cannot be " + "composed while the validator names no reason is itself the finding - " + "returning [] would turn a crash into a silent acceptance and enable " + "Save, which is worse than the 500 it replaced", + _probs != [] and len(_probs) == 1) +check("...and it says so in words, naming the exception", + any("could not be composed" in p and "forced boom" in p + for p in _probs)) +check("...and the patch was undone, so nothing below inherits it", + selfconfig.build_roster is _orig_roster) + + # --------------------------------------------------------------------------- # print() print("3. THE OTHER SURFACE WRITES WHILE THIS PAGE IS OPEN")