diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index ef47f8b..ba6e6ff 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -2,6 +2,41 @@ ## Unreleased +**`release/1.0` went red at midnight on a commit nobody had touched (TEST-17).** +`Controller.checks()` read the capacity-tariff peak with `self.peak.peak()` - no +argument - so `tariff.py` resolved the month from `datetime.now()`, while +`test_tariff.py` section 10 banks its quarter at the fixed fixture +`2026-08-30`. From 1 September the page was correctly asked for September, +correctly answered `NO_RECORD`, and the row read *"no completed quarter recorded +yet"* - a string containing no `"peak"`, which the section's filter required. +Four checks failed. The suite had been a latent time bomb since TARIFF-01 +merged on 2026-08-30 and every PR that ran it ran in August. + +Isolated by holding the commit and the image constant and moving only the clock +under `libfaketime`: green at 2026-08-30, four failures at 2026-09-01, identical +at `f4914a1`, `04292c4~1` and `04292c4`. The calendar, not a regression. + +**The product was right and the test was wrong**, so the fix does not change what +the live page says. `checks()` takes `when: datetime | None = None` and threads +it into `peak()` and `warning_frac()` - the convention every other peak query in +`tariff.py` already follows. The single production caller passes nothing and +still means today. Section 10 now grades at its own fixture's month. + +Three new checks make the defect detectable rather than seasonal: the same +controller read at two different reference months must produce two different +rows, which no clock can satisfy; the later month must read `NO_RECORD` and stay +distinguishable from both an observed peak and an unreadable store; and calling +`checks()` with no argument must still mean today. Verified by mutation - a +`checks()` that drops `when` is now red at every clock tried, where the old +section went green in August. Full suite green, 15 files, at the real clock and +at faked clocks in four other months. + +One thing left alone deliberately: only the UNREADABLE branch prefixes its row +with `capacity-tariff peak:`. The `NO_RECORD` row carries neither that phrase +nor the word `peak`, which is what made the original filter brittle and what +broke the first draft of the new checks too. It is product text with other +suites reading it, so it is noted here rather than changed. + **Thirty days of the real meter, and the acceptance criterion three cards deferred is now a number (DATA-01).** TARIFF-01 and SAFETY-07 both merged with AC 3 - *"monthly peak held under the soft limit in >= 95 % of intervals over a diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 6cbc269..509893b 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1270,7 +1270,20 @@ class Controller: self.mqtt.close() # -- UI ------------------------------------------------------------------ - def checks(self) -> list: + def checks(self, when: datetime | None = None) -> list: + """The status rows. `when` is the reference time for anything billed by + the calendar - today's month when it is None, which is what the live + page means; `status_payload()` calls this with no argument. + + ⚠️ IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17). Every other peak + query already takes it - `PeakStore.peak()`, `PeakTracker.peak()`, + `headroom_w()`, `warning_frac()` - and the row built from them was the + one thing that could only be read against `datetime.now()`. A suite + that banks an August quarter then asks this page what the peak is goes + green in August and red in September, which is what happened at 00:00 + on 2026-09-01: TARIFF-01's own section 10, red on a calendar boundary, + on a commit nobody had touched for a day. + """ o = self.o out = [] if self.p1_enabled: @@ -1331,14 +1344,14 @@ class Controller: # a normal state on the 1st; "unreadable" is a broken instrument, and # a status page that spelled them the same way would let a 0 W read # look like a quiet month right up until SAFETY-07 shaved nothing. - _pk = self.peak.peak() + _pk = self.peak.peak(when) if _pk.is_unreadable: out.append({"ok": False, "warn": True, "text": "capacity-tariff peak: " + _pk.explain()}) else: # §9.1's "warning entity at 80 %", as a status row rather than # a second entity - see the constructor on not doubling up. - _frac = self.peak.warning_frac() or 0.0 + _frac = self.peak.warning_frac(when) or 0.0 _ok = _frac < 0.8 out.append({"ok": _ok, "warn": not _ok, "text": _pk.explain() diff --git a/goodwe_controller/test_tariff.py b/goodwe_controller/test_tariff.py index 2060317..636761a 100644 --- a/goodwe_controller/test_tariff.py +++ b/goodwe_controller/test_tariff.py @@ -863,7 +863,12 @@ check("...and syncing the same quarter again banks nothing new, so the count " check("...and the peak is unchanged by the repeat", near(c.peak.peak(B).peak_w, 4998.0, 1e-6)) -rows = [r for r in c.checks() if "capacity-tariff" in r["text"] or "peak" in r["text"]] +# ⚠️ `when=B`, AND THAT ARGUMENT IS THE TICKET (TEST-17). Without it `checks()` +# reads the peak for `datetime.now()`'s month while this fixture banked its +# quarter in August, so every check below passed in August and failed from +# 2026-09-01 - on a commit nobody had touched. The suite must grade the month +# it created, not the month the machine happens to be running in. +rows = [r for r in c.checks(when=B) if "capacity-tariff" in r["text"] or "peak" in r["text"]] peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r["text"]] print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)")) check("the status page carries exactly one capacity-tariff peak row", @@ -879,7 +884,7 @@ check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point " # The half that matters: the same page with a store that cannot answer. c2 = built(TMP / "no-such-dir" / "nope.db") -u_rows = [r for r in c2.checks() if "capacity-tariff peak" in r["text"]] +u_rows = [r for r in c2.checks(when=B) if "capacity-tariff peak" in r["text"]] print(" status row (unreadable store): " + (u_rows[0]["text"] if u_rows else "(none)")) check("an unreadable store gets its OWN row, saying UNREADABLE", len(u_rows) == 1) @@ -891,6 +896,58 @@ check("...and the healthy row and the unreadable row are different text", bool(u_rows) and bool(peak_rows) and u_rows[0]["text"] != peak_rows[0]["text"]) +# --- TEST-17: the page must be graded at a stated time, and prove it ---------- +# Three ways this can regress, so three checks. The first two would both go +# green again if `checks()` quietly went back to reading the clock, which is +# exactly the mutation this section could not previously detect. + +# ⚠️ SELECTED ON "quarter(s) banked", NOT ON "peak". The NO_RECORD row reads +# "2026-10: no completed quarter recorded yet - ..." and contains neither the +# word "peak" nor "capacity-tariff" - only the UNREADABLE branch prefixes the +# family name. Filtering these rows the way the checks above do would drop the +# row and then grade its absence, which is the very mistake this ticket fixes. +# (That asymmetry in the row's own text is worth a look on its own; it is what +# made the original filter brittle. Not changed here - it is product text with +# other suites reading it.) +_peak_rows = lambda rows: [r for r in rows if "quarter(s) banked" in r["text"]] + +_later = B + timedelta(days=45) # same fixture, a later billing month +_other_peak = _peak_rows(c.checks(when=_later)) +print(f" the SAME controller read at {_later.date()}: " + + (_other_peak[0]["text"] if _other_peak else "(no peak row)")) +check("asked for a LATER month, the same controller reports that month's " + "absence of a quarter, not August's 4998 W - the row follows `when`", + bool(_other_peak) and "4998 W" not in _other_peak[0]["text"] + and "no completed quarter recorded yet" in _other_peak[0]["text"]) +check("...and that is NO_RECORD, distinguishable from BOTH an observed peak and " + "an unreadable store - a month with no quarter is not a broken instrument", + bool(_other_peak) and bool(peak_rows) + and "UNREADABLE" not in _other_peak[0]["text"] + and _other_peak[0]["text"] != peak_rows[0]["text"]) +check("...and it is not FLAGGED, because an empty month is not an exceedance", + bool(_other_peak) and _other_peak[0]["ok"] and not _other_peak[0]["warn"]) + +# The mutation guard. `checks()` ignoring `when` is the whole defect, and it is +# invisible to every check above whenever the machine's clock happens to sit in +# the fixture's own month - which is how this shipped. Grading two DIFFERENT +# reference months against each other cannot be satisfied by a clock at all. +check("the peak row differs between two reference months, so a `checks()` that " + "dropped `when` and read datetime.now() would fail HERE regardless of " + "what day the suite is run on", + bool(peak_rows) and bool(_other_peak) + and peak_rows[0]["text"] != _other_peak[0]["text"]) + +# And the production meaning is unchanged: no argument still means today. +_now_peak = _peak_rows(c.checks()) +_today_is_fixture_month = month_key(datetime.now(timezone.utc)) == month_key(B) +check("calling checks() with NO argument still reads the month being billed " + "today - the live status page's meaning is untouched", + bool(_now_peak) and (bool(peak_rows) + and _now_peak[0]["text"] == peak_rows[0]["text"] + if _today_is_fixture_month + else "no completed quarter recorded yet" + in _now_peak[0]["text"])) + # THE MEASUREMENT IS OF DELIVERED POWER (SAFETY-20). Graded on the parsed # SYNTAX, not on the text: the module docstring argues this at length and names # every forbidden word while doing so, so a grep over the source would be