feat(3-2): add recovery tests and story file for automatic recovery on fresh decode
Two-cycle stale→recovery tests confirm that a non-empty fetch after timeout or empty-list correctly resets is_stale=False; no changes to main.py or aircraft.py required as recovery logic was already correct from story 3-1. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
# Story 3.2: Automatic Recovery on Fresh Decode
|
||||||
|
|
||||||
|
Status: review
|
||||||
|
|
||||||
|
## Story
|
||||||
|
|
||||||
|
As a user whose RTL-SDR has recovered,
|
||||||
|
I want the display to automatically return to normal filled aircraft rendering on the next successful fetch,
|
||||||
|
So that recovery requires no intervention.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
AC1: **Given** the display is in stale state **When** `HttpFetcher.fetch()` returns a non-empty aircraft list **Then** all fetched aircraft have `is_stale=False` and are drawn with normal filled icons
|
||||||
|
|
||||||
|
AC2: **Given** the display has recovered **When** the next render cycle runs **Then** no stale outline rendering occurs for the recovered aircraft
|
||||||
|
|
||||||
|
AC3: **Given** a stale-then-recovery sequence **When** `FileFixtureFetcher` first returns `[]` (simulating stale) then returns a populated list **Then** the first cycle produces stale aircraft and the second produces normal fresh aircraft
|
||||||
|
|
||||||
|
## Tasks / Subtasks
|
||||||
|
|
||||||
|
- [ ] Task 1: Add recovery tests to `tests/test_stale.py` (AC: #1, #2, #3)
|
||||||
|
- [ ] 1.1 Add `test_recovery_after_timeout_returns_fresh`
|
||||||
|
- [ ] 1.2 Add `test_recovery_after_empty_returns_fresh`
|
||||||
|
- [ ] 1.3 Verify no code changes needed in `main.py` or `aircraft.py` — recovery is already correct
|
||||||
|
|
||||||
|
- [ ] Task 2: Run quality gates
|
||||||
|
- [ ] 2.1 `python -m pytest tests/` — all tests pass
|
||||||
|
- [ ] 2.2 `python -m ruff check .` — zero violations
|
||||||
|
- [ ] 2.3 `python -m ruff format --check .` — no formatting issues
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Recovery logic already correct from story 3-1
|
||||||
|
|
||||||
|
The `_run_one_cycle()` stale detection logic in `main.py` is:
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
fresh = fetcher.fetch()
|
||||||
|
except requests.Timeout:
|
||||||
|
log.warning("fetch timeout — using stale data")
|
||||||
|
fresh = []
|
||||||
|
stale_needed = True
|
||||||
|
else:
|
||||||
|
stale_needed = (len(fresh) == 0 and len(last_aircraft) > 0)
|
||||||
|
|
||||||
|
if stale_needed:
|
||||||
|
aircraft_list = [dataclasses.replace(a, is_stale=True) for a in last_aircraft]
|
||||||
|
else:
|
||||||
|
aircraft_list = fresh
|
||||||
|
```
|
||||||
|
|
||||||
|
When `len(fresh) > 0`, `stale_needed` is `False` and `aircraft_list = fresh`. All `Aircraft` instances from a fresh fetch default `is_stale=False`, so recovery is automatic — no code changes required.
|
||||||
|
|
||||||
|
### No changes to `main.py` or `aircraft.py`
|
||||||
|
|
||||||
|
Recovery is already handled correctly. The only deliverable is two additional tests in `tests/test_stale.py` confirming the recovery path for both the timeout-then-fresh and empty-then-fresh scenarios.
|
||||||
|
|
||||||
|
### Files changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `tests/test_stale.py` | Add `test_recovery_after_timeout_returns_fresh` and `test_recovery_after_empty_returns_fresh` |
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
|
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
|
||||||
|
|
||||||
generated: 2026-04-22
|
generated: 2026-04-22
|
||||||
last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 done, 2-5 done, 2-6 done, 2-7 done, epic-2 done, epic-3 in-progress, 3-1 done
|
last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 done, 2-5 done, 2-6 done, 2-7 done, epic-2 done, epic-3 in-progress, 3-1 done, 3-2 review
|
||||||
project: planeMapper
|
project: planeMapper
|
||||||
project_key: NOKEY
|
project_key: NOKEY
|
||||||
tracking_system: file-system
|
tracking_system: file-system
|
||||||
@@ -65,7 +65,7 @@ development_status:
|
|||||||
# Epic 3: Stale Data Resilience
|
# Epic 3: Stale Data Resilience
|
||||||
epic-3: in-progress
|
epic-3: in-progress
|
||||||
3-1-stale-state-detection-and-dimmed-display: done
|
3-1-stale-state-detection-and-dimmed-display: done
|
||||||
3-2-automatic-recovery-on-fresh-decode: backlog
|
3-2-automatic-recovery-on-fresh-decode: review
|
||||||
epic-3-retrospective: optional
|
epic-3-retrospective: optional
|
||||||
|
|
||||||
# Epic 4: Reset & Reconfiguration
|
# Epic 4: Reset & Reconfiguration
|
||||||
|
|||||||
@@ -69,3 +69,35 @@ def test_stale_cycle_completes_without_crash(renderer: Renderer) -> None:
|
|||||||
# Should complete without any exception
|
# Should complete without any exception
|
||||||
result = _run_one_cycle(renderer, mock_fetcher, display, last)
|
result = _run_one_cycle(renderer, mock_fetcher, display, last)
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_after_timeout_returns_fresh(renderer: Renderer) -> None:
|
||||||
|
display = NullDisplay()
|
||||||
|
last = [_make_aircraft()]
|
||||||
|
mock_fetcher = MagicMock()
|
||||||
|
# Cycle 1: timeout → stale
|
||||||
|
mock_fetcher.fetch.side_effect = requests.Timeout
|
||||||
|
stale_result = _run_one_cycle(renderer, mock_fetcher, display, last)
|
||||||
|
assert stale_result[0].is_stale is True
|
||||||
|
# Cycle 2: fresh fetch → recovery
|
||||||
|
fresh_aircraft = [_make_aircraft(icao="DEF456")]
|
||||||
|
mock_fetcher.fetch.side_effect = None
|
||||||
|
mock_fetcher.fetch.return_value = fresh_aircraft
|
||||||
|
recovered = _run_one_cycle(renderer, mock_fetcher, display, stale_result)
|
||||||
|
assert len(recovered) == 1
|
||||||
|
assert recovered[0].is_stale is False
|
||||||
|
assert recovered[0].icao == "DEF456"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_after_empty_returns_fresh(renderer: Renderer) -> None:
|
||||||
|
display = NullDisplay()
|
||||||
|
last = [_make_aircraft()]
|
||||||
|
mock_fetcher = MagicMock()
|
||||||
|
# Cycle 1: empty → stale
|
||||||
|
mock_fetcher.fetch.return_value = []
|
||||||
|
stale_result = _run_one_cycle(renderer, mock_fetcher, display, last)
|
||||||
|
assert stale_result[0].is_stale is True
|
||||||
|
# Cycle 2: non-empty → recovery
|
||||||
|
mock_fetcher.fetch.return_value = [_make_aircraft(icao="GHI789")]
|
||||||
|
recovered = _run_one_cycle(renderer, mock_fetcher, display, stale_result)
|
||||||
|
assert recovered[0].is_stale is False
|
||||||
|
|||||||
Reference in New Issue
Block a user