Skip to content

test(swing_speed): cover the capture loop's swing detection state machine - #3

Open
noahjobse wants to merge 1 commit into
mainfrom
test/swing-speed-capture-loop
Open

test(swing_speed): cover the capture loop's swing detection state machine#3
noahjobse wants to merge 1 commit into
mainfrom
test/swing-speed-capture-loop

Conversation

@noahjobse

@noahjobse noahjobse commented Aug 22, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

Adds nine tests covering SwingSpeedMonitor._capture_loop, which had no test coverage. Test-only: no source changes.

Why was this required?

_capture_loop is the decision core of swing speed training. It decides when a swing starts, when silence means it ended, whether motion was too brief to count, and how long to suppress readings so a follow-through does not register as a second rep.

None of that was tested. swing_speed.py sat at 55%, with lines 165-240 (the entire loop body) uncovered, so any of those decisions could be broken by a refactor with the suite still green. The existing tests in tests/test_swing_speed.py cover _build_event, _select_swing_reading, connect and disconnect, but nothing drives the loop itself.

CONTRIBUTING lists test coverage improvements as a high-priority contribution.

Automated tests

Nine tests, one per distinct loop behaviour:

Behaviour Test
The first qualifying reading becomes the trigger speed, not the peak starts_swing_on_first_qualifying_reading
Consecutive readings accumulate into one swing accumulates_readings_into_a_single_swing
A swing is only finalised after end_quiet_ms of silence waits_for_end_quiet_ms_before_emitting
Brief motion below single_reading_peak_mph is rejected as noise rejects_motion_with_too_few_readings
One reading above that threshold counts anyway accepts_a_single_reading_above_the_peak_threshold
cooldown_ms blocks a second swing opening suppresses_readings_during_cooldown
Selected readings reach the live callback forwards_selected_readings_to_live_callback
Inbound, too-slow and implausibly-fast readings never start a swing ignores_non_qualifying_readings
An empty candidate batch falls back to read_speed_nonblocking falls_back_to_single_reading_when_batch_is_empty

On the approach. The loop's behaviour is defined by elapsed time, so the tests replace the module's time reference with a controlled clock rather than shrinking the constructor timings. Two reasons: the shipped defaults (end_quiet_ms=1000, cooldown_ms=750) stay the values actually under test, and the tests cannot flake, since time only advances when the test says so. The whole file runs in 0.21s.

The harness derives its poll interval from monitor.poll_interval_ms rather than a hardcoded copy, so _idle(ms) keeps spanning real simulated time if that default ever changes. It also caps its own iterations and asserts the loop logged no errors, so a future drift between _ScriptedRadar and the real OPS243Radar fails loudly instead of spinning forever on a clock that only advances in memory.

Coverage of swing_speed.py, measured with the new tests deselected and then selected:

BEFORE   143 stmts   65 miss   55%    missing 165-240 (the whole loop)
AFTER    143 stmts   22 miss   85%    missing 227, 233-240

The only lines still uncovered inside the loop are 227 (the _event_callback dispatch branch, since the tests read get_events() instead of registering a callback) and 233-240 (the broad except Exception handler). Everything else still missing is in connect/disconnect/get_radar_info, outside this PR's scope.

Manual (human) testing

1. Ran the feature for real. Started openflight-server --web-port 8082 --mock --mock-swing-speed and fired simulated swings over the websocket. Got well-formed events: peak_speed_mph 93.2 / 98.3 / 90.9, reading_count: 4, trigger_speed_mph: 76.0, mode: "swing-speed", with session stats aggregating correctly.

To be precise about what that proves: that path runs MockSwingSpeedMonitor, which bypasses _capture_loop entirely. So it confirms the SwingSpeedEvent shape my tests construct matches what the app really emits, but it does not exercise the loop. I do not have an OPS243, so the loop itself is covered by the tests only.

2. Mutation tested the tests. Broke one loop behaviour at a time in the source and confirmed the intended test failed each time, with a control run to confirm the harness was not failing for unrelated reasons:

M1  trigger speed becomes the peak, not the first qualifying reading   CAUGHT
M2  each reading replaces the swing instead of appending to it         CAUGHT
M3  swing end no longer waits for end_quiet_ms of silence              CAUGHT
M4  min_readings filter disabled (brief noise accepted as a swing)     CAUGHT
M5  single-reading peak escape hatch removed                           CAUGHT
M6  post-swing cooldown never armed                                    CAUGHT
M7  selected readings no longer forwarded to the live callback         CAUGHT
M8  outbound-only filter dropped (inbound motion starts a swing)       CAUGHT
M9  empty-batch fallback to read_speed_nonblocking removed             CAUGHT
M10 swing duration measured from start to start (always zero)          CAUGHT
M0  CONTROL: no mutation applied                                       all 16 pass

Source restored and re-verified clean afterwards.

3. Verified the harness guard actually works. Renamed read_speed_candidates_nonblocking in the source so the loop can never reach the scripted radar that stops it. Because _capture_loop swallows every exception and the fake clock only advances in memory, an unguarded harness would hang the suite here. Result: 1 failed in 0.3s with AssertionError: _capture_loop logged 605 error(s): ... object has no attribute 'read_speed_candidates_renamed'. Fails fast and loudly rather than hanging.

4. Full suite and linters: 1350 passed, 8 skipped. pylint src/openflight/ --fail-under=9 scores 9.72. ruff check src/openflight/ clean. UI builds and lints clean (untouched by this change).

One observation, not part of this PR

While writing these I noticed the three conditions after reading is not None in the qualifies expression (swing_speed.py:184-188) are already guaranteed by _select_swing_reading, which filters on direction, trigger_threshold_mph and _within_max_speed before returning. Deleting them from qualifies breaks no test. Not touching it here to keep this scoped to one thing; happy to follow up separately if that is useful.

No CHANGELOG entry, matching the two most recent merged test-only commits (907b125, a716b8d), neither of which added one.

Checklist

  • Single feature/fix - one thing: coverage for _capture_loop
  • Automated tests included - nine new tests, mutation verified
  • Manual testing described - above
  • Python tests pass (uv run pytest tests/ -v) - 1350 passed, 8 skipped
  • Pylint passes (uv run pylint src/openflight/ --fail-under=9) - 9.72/10
  • Ruff passes (uv run ruff check src/openflight/)
  • UI builds (cd ui && npm run build)
  • UI lint passes (cd ui && npm run lint)
  • Updated docs or CHANGELOG if needed - not needed, see above
  • No unrelated changes mixed in - one file, no source changes

…hine

`SwingSpeedMonitor._capture_loop` is the decision core of swing speed
training: it decides when a swing starts, when silence means it ended,
whether motion was too brief to count, and how long to suppress readings
so a follow-through does not register as a second rep.

None of it was covered. swing_speed.py sat at 55%, with lines 165-240
entirely untested, so any of those decisions could break in a refactor
with the suite still green.

Adds nine tests over the loop's distinct behaviours. They replace the
module's `time` reference with a controlled clock, so the shipped
defaults (end_quiet_ms=1000, cooldown_ms=750) are the values under test,
the suite never waits on real elapsed time, and it cannot flake. The
harness derives its poll interval from monitor.poll_interval_ms and caps
its own iterations, so it fails loudly rather than hanging if the
scripted radar ever stops terminating the loop.

Coverage of swing_speed.py: 55% -> 85%. The remainder is thread plumbing
and trivial accessors. No source changes.
@noahjobse
noahjobse force-pushed the test/swing-speed-capture-loop branch from acbcb6f to 41f47fb Compare August 22, 2026 09:08
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