Skip to content

test(tools): collect test_whoop_spot_hrv too — 8 more tests that never ran - #815

Merged
ryanbr merged 1 commit into
mainfrom
tools/spot-hrv-test-collection
Jul 26, 2026
Merged

test(tools): collect test_whoop_spot_hrv too — 8 more tests that never ran#815
ryanbr merged 1 commit into
mainfrom
tools/spot-hrv-test-collection

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Follow-up to #814, which fixed unittest collection for test_whoop_activity.py and missed the identical instance in the same directory — in a file that PR had already moved.

test_whoop_spot_hrv.py is also pytest-style: 8 bare def test_*, no unittest.TestCase. So python3 -m unittest collected nothing from it and exited 0, which looks exactly like passing. Its own docstring told readers to run it with pytest — which isn't in requirements.txt and isn't what the README documents — so in practice these eight tests ran for nobody.

All eight take no fixtures, so the same load_tests hook is enough. All pass. Docstring corrected to name both runners.

python3 -m unittest discover -s . -p 'test_*.py'
# 159 -> 167 tests, OK (skipped=1)

A repo-wide sweep now finds no Python test file that unittest silently skips.

I found this by asking whether #814's bug class existed anywhere else instead of assuming one instance was the whole problem. It wasn't — and the second instance was in a file #814 had touched, which is the part I should have caught before merging.

Docs and tests only. No app code, no schema, no scores, no strings.

#814 fixed unittest collection for test_whoop_activity.py and missed the identical
instance sitting in the same directory, in a file that PR moved:
test_whoop_spot_hrv.py is also pytest-style — 8 bare `def test_*`, no
unittest.TestCase — so `python3 -m unittest` collected nothing from it and exited 0.

The file's own docstring told people to run it with pytest, which is not in
requirements.txt and is not what the README documents, so in practice these eight
tests ran for nobody. All eight take no fixtures, so the same load_tests hook is
enough; all pass. Docstring corrected to name both runners.

Suite: 159 -> 167 tests, OK (1 skipped). A repo-wide sweep now finds no Python test
file that unittest silently skips.

Found by asking whether #814's bug class existed anywhere else rather than assuming
one instance was the whole problem — it was not, and the second instance was in a
file that PR had already touched.
@ryanbr
ryanbr merged commit 5bb0e97 into main Jul 26, 2026
1 check passed
ryanbr added a commit that referenced this pull request Jul 26, 2026
…hat described two wrongly (#816)

Closes the coverage gap #815 recorded rather than fixed, and corrects a docstring
found while doing it.

MUTATION MATRIX (__pycache__ cleared between every run)

  drop min_dist spacing filter     survived -> killed
  drop min_prom prominence gate    survived -> killed
  min_prom > becomes >=            killed (new test)
  neighbours symmetric >=          killed (new test)
  neighbours symmetric >           killed (new test)
  invert local-maximum test        killed (2 tests) -> killed (4)

5/5, up from 1/3. Suite 167 -> 170, OK (1 skipped).

WHY THE EXISTING TEST COULD NOT COVER THESE

test_find_peaks_counts_beats asserts a beat count on synthetic PPG whose beats are
already well separated and well above the noise floor. Both filters are no-ops on
that input, so it passes whether they exist or not. The new tests use minimal
hand-built arrays and each asserts BOTH directions, so none can pass vacuously:

  min_dist  [0,5,0,9,0] maxima two apart: min_dist=3 keeps only the taller [3],
            min_dist=2 keeps both [1,3] — pinning the documented >= contract
  min_prom  [0,2,0,9,0]: min_prom=5 rejects the 2.0 maximum, min_prom=1 keeps it
  boundary  [0,5,0] at min_prom=5.0 -> [] (strict >), at 4.9 -> [1]
  plateau   [0,5,5,0] and [0,5,5,5,0] -> [1] regardless of plateau length

THE DOCSTRING WAS WRONG ON TWO COUNTS

  said:  "Local maxima >= neighbours and >= min_prom"
  code:  v[i] > v[i-1] and v[i] >= v[i+1] and v[i] > min_prom

min_prom is a strict >, so a maximum exactly on the threshold is rejected. And the
neighbour test is asymmetric, not ">= neighbours".

The code is right on both and the docstring is what changed. The asymmetry is the
standard way to take one index from a plateau: symmetric >= emits a duplicate per
plateau sample and inflates the beat count, symmetric > drops plateaus entirely.
Verified empirically rather than asserted — [0,5,5,0] gives [1,2], [] and [1]
under the three variants respectively.

That docstring had been wrong for as long as it existed and no test could have
caught it, because nothing exercised either boundary. Same shape as the rest of
today's tooling work: the defect was not a failing test, it was the absence of one.

Verified under both runner shapes — discover (170) and the explicit module list the
README documents (53) — from a cleared bytecode cache, after a stale .pyc earlier
reported failures from a mutant whose source had already been restored.

Tests and one docstring. No behaviour change, no schema, no scores, no strings.
ryanbr pushed a commit that referenced this pull request Jul 26, 2026
…#815) (#818)

DataRange.pagesBehind read the write/read/ring-capacity triplet two bytes early —
cmdOff+10/14/22 instead of +12/16/24 — on both platforms. The word grid starts at
payload offset 3, not 1.

Verified independently against the four real captures in the fixtures, decoded both
ways:

  capture          new +12/16/24                old +10/14/22
                   W        U        T          W            T
  #791  4.0        67491    67408    131072     128122881    0
  #815  5/MG      100790   100788    131072    2310406145    0
  #815  5/MG      100837   100829    131072    2313486337    0
  #815  5/MG      112968   112474    131072    3108503553    0

Ring capacity is 2^17 on every frame across both families, the pointers are
plausible page counters, and pagesBehind comes out 83 / 2 / 8 / 494 exactly. Under
the old offsets capacity reads 0 and the pointers are in the billions.

So this was worse than "RE'd but unconfirmed": because T always landed on padding,
the `t > 0` plausibility guard rejected 100% of real frames and the feature never
fired once on hardware. The doc comment said "not yet confirmed against real
captures" and that read as "probably fine" to everyone who passed it.

Renaming the synthetic-only whoop5CmdOff10 test is the same lesson — a fixture
named as though it were hardware-validated is how the wrong offsets survived.

Kotlin twin is byte-for-byte: same payloadOffset + 11/15/23, same four fixtures,
same expected values. It cannot run in CI (android.yml is disabled, so no Kotlin
test executes there) — the PR's "should pass under android.yml" will not happen
automatically. All 9 Swift checks green including test (WhoopProtocol).

Class-of-bug sweep came back clean: BodyLocationProbe and ExtendedBatteryProbe also
take cmdOff + 1 as the payload start, which is correct, and both dump raw payload
hex rather than indexing a word grid — so the i*4+1 assumption had no sibling.

Follow-up to come: the nil path is silent. `if let pages = … { log }` means a
rejected decode logs nothing, which is exactly why this went unnoticed — the strap
log looked identical to a strap that never answered. The raw-frame dump one line
above is logged unconditionally for precisely that reason; pagesBehind should match
it.

Diagnostic only: never gates sync or backfill. Wraparound branch still unverified
(no capture has crossed it), left as-is.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant