Skip to content

Commit 0130c73

Browse files
authored
Fix single-acquisition test frame accounting (#1231)
* Document single-acquisition test reliability fix * Plan single-acquisition test reliability fix * Fix single-acquisition test frame accounting
1 parent e34fab3 commit 0130c73

3 files changed

Lines changed: 211 additions & 30 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Single-Acquisition Test Reliability Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Prevent `test_single_acquisition` from mistaking coalesced display notifications for dropped camera frames and guarantee cleanup after failures.
6+
7+
**Architecture:** Keep production acquisition behavior unchanged. Add a test-local pipe reader that returns the newest integer frame identifier observed before the `"stop"` sentinel, use it in the integration test, and move thread/pipe cleanup into `finally`.
8+
9+
**Tech Stack:** Python 3.9, pytest 8, multiprocessing pipes, Navigate's `navigate` Conda environment.
10+
11+
## Global Constraints
12+
13+
- Modify test code only; do not alter camera acquisition or display-pipe behavior.
14+
- Preserve Python 3.9 compatibility.
15+
- Run repository-aware tests with `conda run -n navigate python -m pytest`.
16+
- Format modified Python with Black and lint it with Ruff.
17+
18+
---
19+
20+
### Task 1: Make single-acquisition frame accounting batch-safe
21+
22+
**Files:**
23+
- Modify: `test/model/test_model.py:116-156`
24+
- Test: `test/model/test_model.py`
25+
26+
**Interfaces:**
27+
- Consumes: pipe objects exposing `recv()` and messages containing integer frame identifiers followed by the `"stop"` sentinel.
28+
- Produces: `_receive_last_frame_id(show_img_pipe: Any, max_messages: int = 20) -> Optional[int]`.
29+
30+
- [x] **Step 1: Write the failing split-batch regression**
31+
32+
Add a fake readable pipe and a focused test before defining the helper:
33+
34+
```python
35+
class FakeReadablePipe:
36+
def __init__(self, messages):
37+
self.messages = iter(messages)
38+
39+
def recv(self):
40+
return next(self.messages)
41+
42+
43+
def test_receive_last_frame_id_handles_coalesced_batches():
44+
show_img_pipe = FakeReadablePipe([0, 2, "stop"])
45+
46+
assert _receive_last_frame_id(show_img_pipe) == 2
47+
```
48+
49+
- [x] **Step 2: Run the regression to verify RED**
50+
51+
Run:
52+
53+
```bash
54+
conda run -n navigate python -m pytest test/model/test_model.py::test_receive_last_frame_id_handles_coalesced_batches -q
55+
```
56+
57+
Expected: FAIL with `NameError: name '_receive_last_frame_id' is not defined`.
58+
59+
- [x] **Step 3: Implement the minimal pipe reader**
60+
61+
Add `Any` and `Optional` to the imports and define:
62+
63+
```python
64+
def _receive_last_frame_id(
65+
show_img_pipe: Any, max_messages: int = 20
66+
) -> Optional[int]:
67+
last_frame_id = None
68+
for _ in range(max_messages):
69+
message = show_img_pipe.recv()
70+
if message == "stop":
71+
break
72+
if isinstance(message, int):
73+
last_frame_id = message
74+
return last_frame_id
75+
```
76+
77+
- [x] **Step 4: Verify the focused regression is GREEN**
78+
79+
Run:
80+
81+
```bash
82+
conda run -n navigate python -m pytest test/model/test_model.py::test_receive_last_frame_id_handles_coalesced_batches -q
83+
```
84+
85+
Expected: `1 passed`.
86+
87+
- [x] **Step 5: Replace notification counting and guarantee cleanup**
88+
89+
Update `test_single_acquisition` to compare the final frame identifier and always clean up:
90+
91+
```python
92+
show_img_pipe = model.create_pipe("show_img_pipe")
93+
try:
94+
model.run_command("acquire")
95+
last_frame_id = _receive_last_frame_id(show_img_pipe)
96+
assert last_frame_id == n_frames_expected - 1
97+
finally:
98+
if model.data_thread is not None:
99+
model.data_thread.join()
100+
model.release_pipe("show_img_pipe")
101+
```
102+
103+
- [x] **Step 6: Run focused acquisition verification**
104+
105+
Run:
106+
107+
```bash
108+
conda run -n navigate python -m pytest \
109+
test/model/test_model.py::test_receive_last_frame_id_handles_coalesced_batches \
110+
test/model/test_model.py::test_single_acquisition \
111+
test/model/test_model.py::test_run_data_process_drains_pending_frames_after_signal_completion \
112+
-q
113+
```
114+
115+
Expected: `3 passed`.
116+
117+
- [x] **Step 7: Format, lint, and inspect**
118+
119+
Run:
120+
121+
```bash
122+
conda run -n navigate black --check test/model/test_model.py
123+
conda run -n navigate ruff check test/model/test_model.py
124+
git diff --check
125+
git status --short --branch
126+
```
127+
128+
Expected: Black and Ruff exit successfully, `git diff --check` emits no output, and only the planned files are modified.
129+
130+
- [x] **Step 8: Commit the implementation**
131+
132+
```bash
133+
git add test/model/test_model.py docs/superpowers/plans/2026-07-21-single-acquisition-test-reliability.md
134+
git commit -m "Fix single-acquisition test frame accounting"
135+
```
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Reliable Single-Acquisition Test Design
2+
3+
## Context
4+
5+
The Windows `develop` workflow failed in `test_single_acquisition` after the
6+
synthetic camera acquired three frames but the display pipe emitted only two
7+
notifications. `Model.run_data_process()` deliberately sends the newest frame
8+
identifier from each camera batch rather than every identifier in the batch.
9+
The test currently counts pipe notifications, so a valid batch sequence such
10+
as `[0]` followed by `[1, 2]` is miscounted as two frames.
11+
12+
The test is marked for reruns and uses a module-scoped model. Its thread join
13+
and pipe release occur after the assertion, so a failed attempt skips cleanup
14+
and contaminates subsequent reruns.
15+
16+
## Design
17+
18+
Keep the production acquisition and display-pipe behavior unchanged. Update
19+
`test_single_acquisition` to track the most recent integer frame identifier
20+
received from the pipe and assert that it reaches the expected final frame
21+
identifier. This reflects the pipe's contract: intermediate display frames may
22+
be coalesced, but the newest acquired frame is delivered.
23+
24+
Wrap acquisition, pipe reads, and assertions in `try/finally`. In `finally`,
25+
join the data thread when it exists and release `show_img_pipe`, ensuring that
26+
an assertion failure cannot leave shared module-scoped state behind.
27+
28+
## Regression Coverage
29+
30+
Add a small helper for interpreting display-pipe messages and test it with the
31+
split-batch notification sequence `0, 2, "stop"`. The regression must fail
32+
under notification counting and pass when progress is inferred from the final
33+
frame identifier.
34+
35+
Run the updated single-acquisition test together with the existing batch-drain
36+
test in the project `navigate` Conda environment. Then run formatting and lint
37+
checks on the modified test file.
38+
39+
## Scope
40+
41+
This change modifies test code only. It does not alter camera acquisition,
42+
threading, display-pipe messages, or user-visible behavior.

‎test/model/test_model.py‎

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import pytest
3535
import os
3636
from types import SimpleNamespace
37+
from typing import Any, Iterable, Iterator, Optional
3738
from multiprocessing import Manager
3839
from unittest.mock import MagicMock, patch
3940
import multiprocessing
@@ -45,6 +46,31 @@
4546
IN_GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") == "true"
4647

4748

49+
def _receive_last_frame_id(show_img_pipe: Any, max_messages: int = 20) -> Optional[int]:
50+
last_frame_id = None
51+
for _ in range(max_messages):
52+
message = show_img_pipe.recv()
53+
if message == "stop":
54+
break
55+
if isinstance(message, int):
56+
last_frame_id = message
57+
return last_frame_id
58+
59+
60+
class FakeReadablePipe:
61+
def __init__(self, messages: Iterable[Any]) -> None:
62+
self.messages: Iterator[Any] = iter(messages)
63+
64+
def recv(self) -> Any:
65+
return next(self.messages)
66+
67+
68+
def test_receive_last_frame_id_handles_coalesced_batches() -> None:
69+
show_img_pipe = FakeReadablePipe([0, 2, "stop"])
70+
71+
assert _receive_last_frame_id(show_img_pipe) == 2
72+
73+
4874
@pytest.fixture(scope="module")
4975
def model():
5076
from types import SimpleNamespace
@@ -124,36 +150,14 @@ def test_single_acquisition(model):
124150
)
125151

126152
show_img_pipe = model.create_pipe("show_img_pipe")
127-
128-
model.run_command("acquire")
129-
130-
# If three channel acquisitions happen quickly, the synthetic camera may return
131-
# something like [0, 1, 2] in a single call, and only one pipe message is sent
132-
# for that batch.
133-
image_id = show_img_pipe.recv()
134-
if isinstance(image_id, list):
135-
n_framed_received = len(image_id)
136-
else:
137-
n_framed_received = image_id + 1
138-
139-
# maximum of 20 iterations to avoid infinite loop in case of failure
140-
max_iters = 20
141-
while image_id != "stop" and max_iters > 0:
142-
143-
image_id = show_img_pipe.recv()
144-
if image_id == "stop":
145-
break
146-
if isinstance(image_id, list):
147-
n_framed_received += len(image_id)
148-
else:
149-
n_framed_received += 1
150-
151-
# decrement iteration counter
152-
max_iters -= 1
153-
154-
assert n_framed_received == n_frames_expected
155-
model.data_thread.join()
156-
model.release_pipe("show_img_pipe")
153+
try:
154+
model.run_command("acquire")
155+
last_frame_id = _receive_last_frame_id(show_img_pipe)
156+
assert last_frame_id == n_frames_expected - 1
157+
finally:
158+
if model.data_thread is not None:
159+
model.data_thread.join()
160+
model.release_pipe("show_img_pipe")
157161

158162

159163
def test_run_acquisition_marks_finite_signal_completion_for_data_thread():

0 commit comments

Comments
 (0)