diff --git a/_bmad-output/implementation-artifacts/3-2-automatic-recovery-on-fresh-decode.md b/_bmad-output/implementation-artifacts/3-2-automatic-recovery-on-fresh-decode.md new file mode 100644 index 0000000..766df9f --- /dev/null +++ b/_bmad-output/implementation-artifacts/3-2-automatic-recovery-on-fresh-decode.md @@ -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` | diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index e27a333..1f0484a 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -35,7 +35,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) 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_key: NOKEY tracking_system: file-system @@ -65,7 +65,7 @@ development_status: # Epic 3: Stale Data Resilience epic-3: in-progress 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 4: Reset & Reconfiguration diff --git a/tests/test_stale.py b/tests/test_stale.py index c561a81..dda4420 100644 --- a/tests/test_stale.py +++ b/tests/test_stale.py @@ -69,3 +69,35 @@ def test_stale_cycle_completes_without_crash(renderer: Renderer) -> None: # Should complete without any exception result = _run_one_cycle(renderer, mock_fetcher, display, last) 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