TEST-26: a missing value in a check is a named FAIL, not a traceback #122

Merged
admin merged 1 commits from TEST-26 into release/1.0 2026-09-25 00:57:21 +02:00
Owner

TEST-26 (absorbs TEST-25). Test files only; no app/ change.

What

Checks and prints that raised on a missing value (empty list indexed, next() exhausted, :.0f on None, a dict key the code under test omitted) killed the rest of their file, so a regression showed up as one unnamed traceback and every check below it went ungraded (MUTATION.md, FCST-06).

  • goodwe_controller/named_absence.py (new, shared): nth(), first(), field(), given() return the value or an Absent stand-in that never raises (formats as <ABSENT ...>, compares False both ways, is falsy). The checks that consume one add present(x) / len(x) == 1, because not x, not in and all() are true of an absence.
  • Every site in the brief, plus the ones the new scan and a sweep found in 17 files (dict keys on published frames, attribute lists like .frames[-1]/.writes[-1], source-pin next() chains, config values that may be None, two / slew_limit_w() divisions).
  • test_named_absence.py (new, suite 31 → 32 files): scans every test_*.py for unguarded filtered/accumulator-list indexing (including lists returned by a function in the same file), next() without a default and numeric formats on maybe-None names. It checks that it flags a planted site of each kind and ignores the guarded spellings. It prints what it cannot see, counted per form (dict keys 1041, lists it cannot size 481, ...).
  • test_price.py 16a: the _T17_CAPS is _T17_CAPS tautology now reads the file's two assemble() calls and grades that they share caps/feed/markup by name and differ only in taxes.
  • test_control.py: the coverage table is re-measured, with the exact form of each mutation, on runs graded to the end. Rows marked * were short before because the file died. The stale-bytecode note now names both ways a figure under-reports.

Mutations: before (a45ed06) → after

Each mutation was run on a fresh cp -a, with __pycache__ purged, in goodwe-addon-checks:1 with the tree mounted :ro. "Graded" = ok + FAIL lines.

site mutation before after
test_pv.py:351 main.py if "pv" in optional_keys(self.o): → … and self.pv_w is not None: (+26 B) 10/69, 0 named, traceback (whole 31-file suite: 0 named) 69/69, 5 named
test_control.py:555 control.py freeze EXIT if → if False (−96 B) 58/99, 4 named, traceback 98/99¹, 11 named
test_control.py:523 control.py if frozen: → if False: (−1 B) 55/99, 5 named, traceback 98/99¹, 14 named
test_watchdog.py:1105 main.py circ latch restore → tripped=False, (−38 B) 128/231, 4 named, traceback 231/231, 6 named
test_watchdog.py:2056 main.py N=1 arms unit_sign_cfg (−26 B) 193/231, 23 named, traceback 231/231, 30 named
test_clamp.py:325 clamp.py escalation + re-assert → if False (−37 B) 36/166, 1 named, traceback 166/166, 3 named
test_clamp.py:526 main.py clamp log row if → if False: (−24 B) 52/166, 1 named, traceback 166/166, 3 named
test_freshness.py:452 (+1244-6, 1558-9, 1599) freshness.py self.stale = True → False (+1 B) 19/113, 7 named, traceback 113/113, 52 named
test_tariff.py:263 (+318, 335-6) p1.py if stop < end: → <= (+1 B) 8/121, 5 named, traceback 121/121, 44 named
test_peakguard.py:1459-61 main.py peakguard.decide( → getattr(peakguard, "decide")( (+12 B) 93/315, 0 named, traceback 315/315, 6 named
test_peakguard.py:4816 main.py arbitrage.decide( → getattr(…) (+12 B) 277/315, 0 named, traceback 315/315, 1 named
test_peakguard.py:2495/2510 floor-limited status row dropped (+45 B) 174/315, 2 named, traceback 315/315, 4 named
test_peakguard.py:5956 peak-guard idle/engaged row dropped (+6 B) 295/315, 2 named, traceback 315/315, 3 named
test_p1.py:1323-4 mqtt.py "p1_age" → "p1_agE" (+0 B, fresh copy) 229/242, 1 named, traceback 242/242, 4 named
test_inverter.py:997 main.py decision = compute( → compute.__call__( (+9 B) 62/147, 1 named, traceback 147/147, 2 named
test_control.py:994 slew_limit_w() → return abs(float(tuning.slew_w)) (−9 B) 91/99, 5 named, ZeroDivisionError 99/99, 9 named

¹ 98/99 because the if _bad: check is skipped when _bad is empty. That is by design and the run still reaches the end of the file.

The scan bites: putting back {started[0]} at test_control.py:521 turns test_named_absence.py red with test_control.py:521 index started[0].

What this does not cover

  • The scan only reads syntax within one file. It does not see dict keys, lists returned by other modules or methods, formats on values from another module's function (e.g. short_by()), divisions, or attribute access on None. Part 3 prints these with their counts on every run.
  • int(), range() and list indexing still raise on an Absent, so those sites are guarded locally.
  • A missing config.yaml key now gives a named precondition FAIL first. The Absent it produces can still raise later, once it is inside app/ code.
  • MUTATION.md habit 20 still says test_price_history.py's FCST-04 section 1 read is unguarded. This PR does not edit MUTATION.md.

Clean suite: 32/32 PASS with checks.yml's exact command, purged, :ro.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

TEST-26 (absorbs TEST-25). Test files only; no `app/` change. ## What Checks and prints that **raised** on a missing value (empty list indexed, `next()` exhausted, `:.0f` on None, a dict key the code under test omitted) killed the rest of their file, so a regression showed up as one unnamed traceback and every check below it went ungraded (MUTATION.md, FCST-06). - **`goodwe_controller/named_absence.py`** (new, shared): `nth()`, `first()`, `field()`, `given()` return the value or an `Absent` stand-in that never raises (formats as `<ABSENT ...>`, compares False both ways, is falsy). The checks that consume one add `present(x)` / `len(x) == 1`, because `not x`, `not in` and `all()` are true of an absence. - **Every site in the brief**, plus the ones the new scan and a sweep found in 17 files (dict keys on published frames, attribute lists like `.frames[-1]`/`.writes[-1]`, source-pin `next()` chains, config values that may be None, two `/ slew_limit_w()` divisions). - **`test_named_absence.py`** (new, **suite 31 → 32 files**): scans every `test_*.py` for unguarded filtered/accumulator-list indexing (including lists returned by a function in the same file), `next()` without a default and numeric formats on maybe-None names. It checks that it flags a planted site of each kind and ignores the guarded spellings. It prints what it cannot see, counted per form (dict keys 1041, lists it cannot size 481, ...). - **`test_price.py` 16a**: the `_T17_CAPS is _T17_CAPS` tautology now reads the file's two `assemble()` calls and grades that they share caps/feed/markup by name and differ only in `taxes`. - **`test_control.py`**: the coverage table is re-measured, with the exact form of each mutation, on runs graded to the end. Rows marked `*` were short before because the file died. The stale-bytecode note now names both ways a figure under-reports. ## Mutations: before (a45ed06) → after Each mutation was run on a fresh `cp -a`, with `__pycache__` purged, in `goodwe-addon-checks:1` with the tree mounted `:ro`. "Graded" = ok + FAIL lines. | site | mutation | before | after | |---|---|---|---| | test_pv.py:351 | main.py `if "pv" in optional_keys(self.o):` → `… and self.pv_w is not None:` (+26 B) | 10/69, **0 named**, traceback (whole 31-file suite: 0 named) | 69/69, **5 named** | | test_control.py:555 | control.py freeze EXIT `if` → `if False` (−96 B) | 58/99, 4 named, traceback | 98/99¹, 11 named | | test_control.py:523 | control.py `if frozen:` → `if False:` (−1 B) | 55/99, 5 named, traceback | 98/99¹, 14 named | | test_watchdog.py:1105 | main.py circ latch restore → `tripped=False,` (−38 B) | 128/231, 4 named, traceback | 231/231, 6 named | | test_watchdog.py:2056 | main.py N=1 arms `unit_sign_cfg` (−26 B) | 193/231, 23 named, traceback | 231/231, 30 named | | test_clamp.py:325 | clamp.py escalation + re-assert → `if False` (−37 B) | 36/166, 1 named, traceback | 166/166, 3 named | | test_clamp.py:526 | main.py clamp log row `if` → `if False:` (−24 B) | 52/166, 1 named, traceback | 166/166, 3 named | | test_freshness.py:452 (+1244-6, 1558-9, 1599) | freshness.py `self.stale = True` → `False` (+1 B) | 19/113, 7 named, traceback | 113/113, 52 named | | test_tariff.py:263 (+318, 335-6) | p1.py `if stop < end:` → `<=` (+1 B) | 8/121, 5 named, traceback | 121/121, 44 named | | test_peakguard.py:1459-61 | main.py `peakguard.decide(` → `getattr(peakguard, "decide")(` (+12 B) | 93/315, 0 named, traceback | 315/315, 6 named | | test_peakguard.py:4816 | main.py `arbitrage.decide(` → `getattr(…)` (+12 B) | 277/315, 0 named, traceback | 315/315, 1 named | | test_peakguard.py:2495/2510 | floor-limited status row dropped (+45 B) | 174/315, 2 named, traceback | 315/315, 4 named | | test_peakguard.py:5956 | peak-guard idle/engaged row dropped (+6 B) | 295/315, 2 named, traceback | 315/315, 3 named | | test_p1.py:1323-4 | mqtt.py `"p1_age"` → `"p1_agE"` (**+0 B**, fresh copy) | 229/242, 1 named, traceback | 242/242, 4 named | | test_inverter.py:997 | main.py `decision = compute(` → `compute.__call__(` (+9 B) | 62/147, 1 named, traceback | 147/147, 2 named | | test_control.py:994 | `slew_limit_w()` → `return abs(float(tuning.slew_w))` (−9 B) | 91/99, 5 named, ZeroDivisionError | 99/99, 9 named | ¹ 98/99 because the `if _bad:` check is skipped when `_bad` is empty. That is by design and the run still reaches the end of the file. The scan bites: putting back `{started[0]}` at test_control.py:521 turns `test_named_absence.py` red with `test_control.py:521 index started[0]`. ## What this does not cover - The scan only reads syntax within one file. It does not see dict keys, lists returned by other modules or methods, formats on values from another module's function (e.g. `short_by()`), divisions, or attribute access on None. Part 3 prints these with their counts on every run. - `int()`, `range()` and list indexing still raise on an `Absent`, so those sites are guarded locally. - A missing config.yaml key now gives a named precondition FAIL first. The `Absent` it produces can still raise later, once it is inside app/ code. - MUTATION.md habit 20 still says `test_price_history.py`'s FCST-04 section 1 read is unguarded. This PR does not edit MUTATION.md. Clean suite: 32/32 PASS with checks.yml's exact command, purged, `:ro`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-25 00:07:30 +02:00
Checks and prints that indexed an empty list, exhausted next(), or formatted
None killed the rest of their file (FCST-06). Read them through the new
named_absence.py (nth/first/field/given/present) and conjoin a presence term.

- every site in the brief, plus the family the new scan and a sweep found
- test_named_absence.py: scans every test_*.py for unguarded filtered-list
  indexing, next() without a default and numeric formats on maybe-None
  names; proven to bite; prints what it cannot see, counted
- test_price.py 16a: the `_T17_CAPS is _T17_CAPS` tautology now grades the
  two assemble() calls' arguments
- test_control.py: coverage table re-measured on runs graded to the end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Author
Owner

Review (TEST-26) - no blocking findings. Evidence below; four non-blocking notes need an answer here, as a reply or a follow-up card.

Combined trees (goodwe-addon-checks:1, --network none, source :ro, __pycache__ purged):

  • (a) this PR merged onto release/1.0 @ 6e65272: 34/34 PASS. That includes test_control_task.py (#121), and the guard scans all 34 files with 0 found.
  • (b) (a) plus #120 @ f4f7221: 35/35 PASS. The guard also scans test_tool_inputs.py and flags nothing.
  • (c) #120 has since merged at b06f1fa. This PR merged onto release/1.0 @ 3e38baf: 35/35 PASS.

CI-10: run 835 (attempt 1, PRNUM 122, BASEREF release/1.0, head 770988c) is green, but it graded against base fa3d237, not the current 3e38baf, and ran 33 files. I'm reopening the PR to get a run graded against the current base before merging.

Absent never turns a FAIL into a PASS: I AST-scanned every check() whose condition reads an accessor result and uses not / not in / != / is not / all(). I hand-read 47 sites across 10 files. Every reachable one either has a presence term or starts with a term that is False on an Absent. I set an indexed list to [] in 6 places. Each gave a named FAIL, and each file was graded to the end with the same total count as the baseline:

  • watchdog _pre38: 4 FAIL / 231
  • clamp notes_x: 2 / 166, including the not in + present() check
  • pv _pv_cfg: 2 / 69
  • freshness _stale57: 2 / 113, including the not in + present() check
  • peakguard rows25: 3 / 315
  • control pinned: 1 / 99

The guard bites: I put back _pv_cfg[0] (test_pv:477) and a bare next(...) (test_packshare:351). Both were flagged, and the file went red. The part 3 skip table prints its counts (1046 / 492 / 476 / 435 / 258 / 216 / 103).

Coverage table: I re-ran saturated_now = False. It gives 27 FAIL of 98 graded, matching the row.

No weakened assertions: every changed condition is equal or stricter. The added len(x)==1 terms make checks stricter, and the T17 identity check (_T17_CAPS is _T17_CAPS, which was always true) is replaced by a real check.

Non-blocking:

  1. Guard scope. The PR says the guard flags "any unguarded constant index". It only flags indexes on lists it can see are filtered, or were started as []. Everything else goes into the census (492 name-lists). That is honestly printed, but the PR text / docstring should say "filtered list".
  2. test_pv.py:897. pv_msg(_m_full) != pv_msg(...) has no presence term. It is unreachable in practice, because the same line's .frames[-1] would raise first. Add present(_m_full), or leave it and note why.
  3. test_peakguard: config constants now flow into the code under test as Absent. Two checks can then pass: not near(...) at :422 and not any(near(...)) at :6055. The named precondition (present(CAP_W, ...)) still turns the file red. This matches the old None behaviour, so it is a follow-up at most.
  4. test_price _AMAIN. next(..., None) inside derived() swaps derived's own _Raised naming for a None that fails later. It is cosmetic, but the scan could exempt derived(lambda: ...) bodies instead.

Length: fine. The helper docstring is 11 lines, and the coverage table got shorter. No bloat to flag.

**Review (TEST-26) - no blocking findings.** Evidence below; four non-blocking notes need an answer here, as a reply or a follow-up card. **Combined trees** (goodwe-addon-checks:1, `--network none`, source `:ro`, `__pycache__` purged): - (a) this PR merged onto release/1.0 @ 6e65272: **34/34 PASS**. That includes `test_control_task.py` (#121), and the guard scans all 34 files with 0 found. - (b) (a) plus #120 @ f4f7221: **35/35 PASS**. The guard also scans `test_tool_inputs.py` and flags nothing. - (c) #120 has since merged at b06f1fa. This PR merged onto release/1.0 @ 3e38baf: **35/35 PASS**. **CI-10:** run 835 (attempt 1, PRNUM 122, BASEREF release/1.0, head 770988c) is green, but it graded against base **fa3d237**, not the current 3e38baf, and ran 33 files. I'm reopening the PR to get a run graded against the current base before merging. **Absent never turns a FAIL into a PASS:** I AST-scanned every `check()` whose condition reads an accessor result and uses `not` / `not in` / `!=` / `is not` / `all()`. I hand-read 47 sites across 10 files. Every reachable one either has a presence term or starts with a term that is False on an Absent. I set an indexed list to `[]` in 6 places. Each gave a named FAIL, and each file was graded to the end with the same total count as the baseline: - watchdog `_pre38`: 4 FAIL / 231 - clamp `notes_x`: 2 / 166, including the `not in` + `present()` check - pv `_pv_cfg`: 2 / 69 - freshness `_stale57`: 2 / 113, including the `not in` + `present()` check - peakguard `rows25`: 3 / 315 - control `pinned`: 1 / 99 **The guard bites:** I put back `_pv_cfg[0]` (test_pv:477) and a bare `next(...)` (test_packshare:351). Both were flagged, and the file went red. The part 3 skip table prints its counts (1046 / 492 / 476 / 435 / 258 / 216 / 103). **Coverage table:** I re-ran `saturated_now = False`. It gives 27 FAIL of 98 graded, matching the row. **No weakened assertions:** every changed condition is equal or stricter. The added `len(x)==1` terms make checks stricter, and the T17 identity check (`_T17_CAPS is _T17_CAPS`, which was always true) is replaced by a real check. Non-blocking: 1. **Guard scope.** The PR says the guard flags "any unguarded constant index". It only flags indexes on lists it can see are filtered, or were started as `[]`. Everything else goes into the census (492 name-lists). That is honestly printed, but the PR text / docstring should say "filtered list". 2. **test_pv.py:897.** `pv_msg(_m_full) != pv_msg(...)` has no presence term. It is unreachable in practice, because the same line's `.frames[-1]` would raise first. Add `present(_m_full)`, or leave it and note why. 3. **test_peakguard: config constants now flow into the code under test as Absent.** Two checks can then pass: `not near(...)` at :422 and `not any(near(...))` at :6055. The named precondition (`present(CAP_W, ...)`) still turns the file red. This matches the old None behaviour, so it is a follow-up at most. 4. **test_price `_AMAIN`.** `next(..., None)` inside `derived()` swaps derived's own `_Raised` naming for a None that fails later. It is cosmetic, but the scan could exempt `derived(lambda: ...)` bodies instead. Length: fine. The helper docstring is 11 lines, and the coverage table got shorter. No bloat to flag.
admin closed this pull request 2026-09-25 00:38:38 +02:00
admin reopened this pull request 2026-09-25 00:38:41 +02:00
Author
Owner

CI-10: run 839 is green. Attempt 1, PRNUM 122, BASEREF release/1.0, base 3e38baf (the current tip, which includes #120 and #121), head 770988c, GRADED: refs/pull/122/head merged into release/1.0, 35/35 files. Merging with head_commit_id pinned. The four non-blocking notes above move to a follow-up card, TEST-27, unless answered here.

CI-10: run 839 is green. Attempt 1, PRNUM 122, BASEREF release/1.0, base 3e38baf (the current tip, which includes #120 and #121), head 770988c, `GRADED: refs/pull/122/head merged into release/1.0`, 35/35 files. Merging with head_commit_id pinned. The four non-blocking notes above move to a follow-up card, TEST-27, unless answered here.
admin merged commit 43b274035f into release/1.0 2026-09-25 00:57:21 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#122