diff --git a/goodwe_controller/test_rehearse.py b/goodwe_controller/test_rehearse.py index e919e57..1367519 100644 --- a/goodwe_controller/test_rehearse.py +++ b/goodwe_controller/test_rehearse.py @@ -142,9 +142,18 @@ print("2. THE CLEAN CONTROLS: two transitions on which the shipped code is " # CONTROL-B a store that has already run flat under 0.7.0 (schema stamped, # no latch), onto a roster named `garage`/`shed`. This is the # MAINT-05 half, and the names are deliberately not `inverter-a`. +# CONTROL-C a FRESH INSTALL - an empty state file - onto `garage`/`shed`. +# ⚠️ THIS ONE FOUND A DEFECT IN THE HARNESS. A fresh store's +# migration writes `sign_tripped = False` and `sign_reason = ""` +# under `inverter-a` before any roster exists, and the first draft +# reported both as STRANDED - red on every fresh install. Losing a +# DEFAULT loses nothing; that is `MigrationReport.lost`'s own +# distinction, and an alarm that fires on the harmless case is one +# an operator learns to dismiss before the real one arrives. CONTROLS = { "CONTROL-A": ("state-legacy.json", "roster-inverter-a.yaml"), "CONTROL-B": ("state-flat-070.json", "roster.yaml"), + "CONTROL-C": ("state-fresh.json", "roster.yaml"), } @@ -379,6 +388,22 @@ print("6. THE REVERSE DIRECTION: the retreat, and the return from it") # --------------------------------------------------------------------------- # code_r, out_r = run(HERE, "state-flat-070.json", "roster.yaml", "--reverse", "--round-trip") +# ⚠️ AND THE SOFTENING IS NOT A HOLE. The SAME key stranded with a NON-default +# value is still a finding - that is the whole of part 5 - so the two cases are +# separated by the VALUE and not by the key name. +_fresh_code, _fresh_out = run(HERE, "state-fresh.json", "roster.yaml") +check("a fresh install DOES strand `unit/inverter-a/sign_tripped` - the key is " + "written and nothing on the roster reads it - and the report SAYS SO", + _fresh_code == RR.EXIT_OK + and "unit/inverter-a/sign_tripped = False" in _fresh_out) +check("...and does NOT call it a finding, because the value is the default; " + "the same key at True is the finding part 5 turns on, so the two cases " + "are separated by the VALUE and not by the key name", + "STRANDED STORE KEY: unit/inverter-a/sign_tripped" not in _fresh_out + and "STRANDED STORE KEY: unit/inverter-a/sign_tripped = True" + in "\n".join(l.strip() for l in + run(HERE, "state-legacy.json", "roster.yaml")[1].splitlines())) + _orphaned = set(re.findall(r"ORPHANED ENTITY: (sensor\.\S+)", out_r)) print(f" the retreat orphans: {sorted(_orphaned)}") check("the RETREAT is rehearsed at all, and it reports the entities it " diff --git a/goodwe_controller/tools/rehearsal/state-fresh.json b/goodwe_controller/tools/rehearsal/state-fresh.json new file mode 100644 index 0000000..b6884d1 --- /dev/null +++ b/goodwe_controller/tools/rehearsal/state-fresh.json @@ -0,0 +1,3 @@ +{ + "_comment": "ARCH-02 SYNTHETIC FIXTURE - a FRESH INSTALL. No maintenance history, no latch, no schema markers: what /data/state.json looks like before the add-on has ever completed a cycle. It is a clean control, and it earned its place: the first draft of the harness reported this transition RED, because the DEV-02 migration writes both SAFETY-02 defaults under `inverter-a` before a roster exists and the tool called them stranded. Losing a default loses nothing." +} diff --git a/goodwe_controller/tools/rehearse_roster.py b/goodwe_controller/tools/rehearse_roster.py index 5ff9f20..a86fdf2 100644 --- a/goodwe_controller/tools/rehearse_roster.py +++ b/goodwe_controller/tools/rehearse_roster.py @@ -371,6 +371,9 @@ class Snapshot: self.store_migration = controller.store_migration self.maint_migration = controller.maint_migration self.machine_read = machine_reads(controller, store) + # Keys no machine reads whose value is the default `Store.load()` would + # have supplied. Reported, never a finding: see `diff()`. + self.stranded_at_default = [] @property def unit_keys(self): @@ -451,14 +454,27 @@ def diff(before: Snapshot, after: Snapshot) -> list: # ever look under. `MigrationReport.misdirected` says the same thing about # the keys the migration itself wrote; this says it about the WHOLE FILE, # including keys written by a version that is no longer installed. + # + # ⚠️ ONLY WHERE THE VALUE IS NOT THE DEFAULT, WHICH IS `MigrationReport`'s + # OWN DISTINCTION AND NOT A SOFTENING OF THIS ONE. A stranded + # `sign_tripped = False` has lost the DEFAULT - the same statement "absent" + # already makes - so nothing is gone; a stranded `sign_tripped = True` has + # lost A LATCH. Reporting the first would make this tool red on a FRESH + # INSTALL, whose migration writes both defaults under `inverter-a` before + # any roster exists, and an alarm that fires on the harmless case is an + # alarm an operator learns to dismiss before the real one arrives - which is + # the argument `MigrationReport.lost` already carries in store.py. for key in sorted(after.data): - if not key.startswith("unit/"): + if not key.startswith("unit/") or key in after.machine_read: continue - if key in after.machine_read: + value = after.data[key] + bare = key.rsplit("/", 1)[-1] + if value in (None, DEFAULTS.get(bare)): + after.stranded_at_default.append(key) continue findings.append(Finding( "STRANDED STORE KEY", - f"{key} = {after.data[key]!r} is in the store and NO machine on " + f"{key} = {value!r} is in the store and NO machine on " f"the roster {after.units!r} read it during a full start. Its " f"value is not information.")) @@ -511,7 +527,8 @@ def _latch_finding(key, before, after): "LATCH KEY NOT UNDER A ROSTER NAME", f"the flat `{key}` = {was!r} did not reach {target}, which is the " f"key `Inverter({unit!r})` restores from")) - elif target in after.data and target not in after.machine_read: + elif (target in after.data and target not in after.machine_read + and after.data[target] not in (None, DEFAULTS.get(key))): out.append(Finding( "LATCH KEY NOT READ BY ANY MACHINE", f"{target} = {after.data[target]!r} exists but no machine on the " @@ -660,6 +677,15 @@ def report_transition(name, before, after, findings) -> None: mark = " " if key in after.data else " (absent, defaulted) " print(f" {key}{mark}") + if after.stranded_at_default: + print() + print(" keys no machine reads whose value IS the default, so " + "nothing is lost by") + print(" stranding them - reported rather than hidden, not counted " + "as findings:") + for key in after.stranded_at_default: + print(f" {key} = {after.data[key]!r}") + print() print(" -- MAINTENANCE HISTORY (MAINT-05: a second move must not drop it) -") for label, hist in (("before", before.history), ("after", after.history)):