From 25076dc1f31b87e2eac8bd750664ecd7884d5965 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Wed, 22 Apr 2026 23:33:36 -0400 Subject: [PATCH] feat(2-6): stateful Renderer, DisplayInterface, and pipeline smoke test Implements story 2-6: Renderer class with per-aircraft trail history (deque capped at TRAIL_MAX_DOTS=5), NullDisplay with DEBUG logging, WaveshareDisplay stub, and end-to-end pipeline smoke test. All 96 tests pass; ruff check and format clean. Co-Authored-By: Claude Sonnet 4.6 --- ...stateful-renderer-and-display-interface.md | 44 ++++++------- .../sprint-status.yaml | 4 +- src/planemapper/display.py | 12 +++- src/planemapper/renderer/renderer.py | 34 +++++++++- tests/test_pipeline.py | 34 +++++++++- tests/test_renderer.py | 63 ++++++++++++++++++- 6 files changed, 161 insertions(+), 30 deletions(-) diff --git a/_bmad-output/implementation-artifacts/2-6-stateful-renderer-and-display-interface.md b/_bmad-output/implementation-artifacts/2-6-stateful-renderer-and-display-interface.md index 9014b55..285e50e 100644 --- a/_bmad-output/implementation-artifacts/2-6-stateful-renderer-and-display-interface.md +++ b/_bmad-output/implementation-artifacts/2-6-stateful-renderer-and-display-interface.md @@ -1,6 +1,6 @@ # Story 2.6: Stateful Renderer & Display Interface -Status: ready-for-dev +Status: review ## Story @@ -22,22 +22,22 @@ AC5: **Given** the `test_pipeline.py` smoke test (`FileFixtureFetcher → Render ## Tasks / Subtasks -- [ ] Task 1: Update `NullDisplay` in `src/planemapper/display.py` (AC: #4) - - [ ] 1.1 Add `import logging` and `log = logging.getLogger(__name__)` - - [ ] 1.2 Update `NullDisplay.show()` to log at DEBUG level: +- [x] Task 1: Update `NullDisplay` in `src/planemapper/display.py` (AC: #4) + - [x] 1.1 Add `import logging` and `log = logging.getLogger(__name__)` + - [x] 1.2 Update `NullDisplay.show()` to log at DEBUG level: ```python log.debug("NullDisplay.show: %dx%d", image.width, image.height) ``` - - [ ] 1.3 Add `WaveshareDisplay` stub: + - [x] 1.3 Add `WaveshareDisplay` stub: ```python class WaveshareDisplay: def show(self, image: Image.Image) -> None: raise NotImplementedError ``` -- [ ] Task 2: Implement `Renderer` in `src/planemapper/renderer/renderer.py` (AC: #1, #2, #3) - - [ ] 2.1 Replace `# stub` with full implementation - - [ ] 2.2 Imports: +- [x] Task 2: Implement `Renderer` in `src/planemapper/renderer/renderer.py` (AC: #1, #2, #3) + - [x] 2.1 Replace `# stub` with full implementation + - [x] 2.2 Imports: ```python import collections from PIL import Image @@ -48,7 +48,7 @@ AC5: **Given** the `test_pipeline.py` smoke test (`FileFixtureFetcher → Render from planemapper.renderer.overlay import draw_home_marker from planemapper.renderer.aircraft import draw_aircraft ``` - - [ ] 2.3 Implement `__init__` with `base_map`, `bounds`, `_trails` dict: + - [x] 2.3 Implement `__init__` with `base_map`, `bounds`, `_trails` dict: ```python def __init__( self, @@ -59,7 +59,7 @@ AC5: **Given** the `test_pipeline.py` smoke test (`FileFixtureFetcher → Render self._bounds = bounds self._trails: dict[str, collections.deque[tuple[int, int]]] = {} ``` - - [ ] 2.4 Implement `render(aircraft_list)` with full pipeline: + - [x] 2.4 Implement `render(aircraft_list)` with full pipeline: ```python def render(self, aircraft_list: list[Aircraft]) -> Image.Image: image = self._base_map.copy() @@ -76,14 +76,14 @@ AC5: **Given** the `test_pipeline.py` smoke test (`FileFixtureFetcher → Render return image ``` -- [ ] Task 3: Write tests in `tests/test_renderer.py` (AC: #1, #2, #3) - - [ ] 3.1 Replace placeholder with full test module - - [ ] 3.2 Test AC1: create white 800×480 RGB base map, call `render([])` with empty aircraft list, assert returned image is `PIL.Image.Image` with size `(800, 480)` - - [ ] 3.3 Test AC2: call `render()` twice with same aircraft; after second call, assert `renderer._trails` has an entry keyed on the aircraft's ICAO - - [ ] 3.4 Test AC3: call `render()` with one aircraft, then call `render([])` with empty list; assert `renderer._trails` still has the aircraft ICAO entry +- [x] Task 3: Write tests in `tests/test_renderer.py` (AC: #1, #2, #3) + - [x] 3.1 Replace placeholder with full test module + - [x] 3.2 Test AC1: create white 800×480 RGB base map, call `render([])` with empty aircraft list, assert returned image is `PIL.Image.Image` with size `(800, 480)` + - [x] 3.3 Test AC2: call `render()` twice with same aircraft; after second call, assert `renderer._trails` has an entry keyed on the aircraft's ICAO + - [x] 3.4 Test AC3: call `render()` with one aircraft, then call `render([])` with empty list; assert `renderer._trails` still has the aircraft ICAO entry -- [ ] Task 4: Write/update pipeline smoke test in `tests/test_pipeline.py` (AC: #5) - - [ ] 4.1 Replace placeholder with full smoke test: +- [x] Task 4: Write/update pipeline smoke test in `tests/test_pipeline.py` (AC: #5) + - [x] 4.1 Replace placeholder with full smoke test: - Use `FileFixtureFetcher(Path("tests/fixtures/aircraft_sample.json"))` - Create a fake 800×480 white RGB base map (`Image.new("RGB", (800, 480), "white")`) - Create `Renderer(base_map, bounds)` where `bounds = MapBounds(53.0, -6.0, 100.0)` @@ -91,12 +91,12 @@ AC5: **Given** the `test_pipeline.py` smoke test (`FileFixtureFetcher → Render - Call `renderer.render(fetcher.fetch())` - Assert returned image is `PIL.Image.Image` with size `(800, 480)` - Call `NullDisplay().show(image)` — assert no exception - - [ ] 4.2 Ensure monkeypatch of `AIRSPACE_PATH` is applied before `render()` is called + - [x] 4.2 Ensure monkeypatch of `AIRSPACE_PATH` is applied before `render()` is called -- [ ] Task 5: Run quality gates - - [ ] 5.1 `python -m pytest tests/` — all tests pass - - [ ] 5.2 `python -m ruff check .` — zero violations - - [ ] 5.3 `python -m ruff format --check .` — no formatting issues +- [x] Task 5: Run quality gates + - [x] 5.1 `python -m pytest tests/` — all tests pass + - [x] 5.2 `python -m ruff check .` — zero violations + - [x] 5.3 `python -m ruff format --check .` — no formatting issues ## Implementation Notes diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 84bb053..f37fafd 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 ready-for-dev, epic-2 in-progress +last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 done, 2-5 done, 2-6 review, epic-2 in-progress project: planeMapper project_key: NOKEY tracking_system: file-system @@ -58,7 +58,7 @@ development_status: 2-3-home-marker-and-airspace-outlines: done 2-4-altitude-colour-bands-and-aircraft-type-icons: done 2-5-per-aircraft-drawing: done - 2-6-stateful-renderer-and-display-interface: ready-for-dev + 2-6-stateful-renderer-and-display-interface: review 2-7-operational-radar-loop-startup-screen-and-systemd-wiring: backlog epic-2-retrospective: optional diff --git a/src/planemapper/display.py b/src/planemapper/display.py index f47c509..5a1a66d 100644 --- a/src/planemapper/display.py +++ b/src/planemapper/display.py @@ -1,7 +1,12 @@ +from __future__ import annotations + +import logging from typing import Protocol from PIL import Image +log = logging.getLogger(__name__) + class DisplayInterface(Protocol): def show(self, image: Image.Image) -> None: ... @@ -9,4 +14,9 @@ class DisplayInterface(Protocol): class NullDisplay: def show(self, image: Image.Image) -> None: - pass + log.debug("NullDisplay.show: %dx%d", image.width, image.height) + + +class WaveshareDisplay: + def show(self, image: Image.Image) -> None: + raise NotImplementedError diff --git a/src/planemapper/renderer/renderer.py b/src/planemapper/renderer/renderer.py index d352c7e..83c708d 100644 --- a/src/planemapper/renderer/renderer.py +++ b/src/planemapper/renderer/renderer.py @@ -1 +1,33 @@ -# stub +from __future__ import annotations + +import collections + +from PIL import Image + +from planemapper.constants import TRAIL_MAX_DOTS +from planemapper.models import Aircraft +from planemapper.renderer.aircraft import draw_aircraft +from planemapper.renderer.airspace import draw_airspace +from planemapper.renderer.overlay import draw_home_marker +from planemapper.renderer.projection import MapBounds, project + + +class Renderer: + def __init__(self, base_map: Image.Image, bounds: MapBounds) -> None: + self._base_map = base_map + self._bounds = bounds + self._trails: dict[str, collections.deque[tuple[int, int]]] = {} + + def render(self, aircraft_list: list[Aircraft]) -> Image.Image: + image = self._base_map.copy() + draw_airspace(image, self._bounds) + draw_home_marker(image, self._bounds) + for aircraft in aircraft_list: + pos = project(aircraft.lat, aircraft.lon, self._bounds) + trail = self._trails.get(aircraft.icao, collections.deque()) + draw_aircraft(image, aircraft, pos, trail) + trail.appendleft(pos) + while len(trail) > TRAIL_MAX_DOTS: + trail.pop() + self._trails[aircraft.icao] = trail + return image diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index b8bbd70..fb61299 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1,2 +1,32 @@ -def test_placeholder() -> None: - pass +from __future__ import annotations + +import pathlib + +import pytest +from PIL import Image + +from planemapper.display import NullDisplay +from planemapper.fetcher import FileFixtureFetcher +from planemapper.renderer.projection import MapBounds +from planemapper.renderer.renderer import Renderer + +FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures" + + +def test_full_pipeline_smoke(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "planemapper.renderer.airspace.AIRSPACE_PATH", + FIXTURE_DIR / "airspace_sample.geojson", + ) + base_map = Image.new("RGB", (800, 480), color=(255, 255, 255)) + bounds = MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0) + fetcher = FileFixtureFetcher(FIXTURE_DIR / "aircraft_sample.json") + renderer = Renderer(base_map, bounds) + display = NullDisplay() + + aircraft_list = fetcher.fetch() + result = renderer.render(aircraft_list) + + assert isinstance(result, Image.Image) + assert result.size == (800, 480) + display.show(result) # should not raise diff --git a/tests/test_renderer.py b/tests/test_renderer.py index b8bbd70..8721d4c 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -1,2 +1,61 @@ -def test_placeholder() -> None: - pass +from __future__ import annotations + +import pathlib + +import pytest +from PIL import Image + +from planemapper.models import Aircraft +from planemapper.renderer.projection import MapBounds +from planemapper.renderer.renderer import Renderer + +FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures" + + +@pytest.fixture +def white_base_map() -> Image.Image: + return Image.new("RGB", (800, 480), color=(255, 255, 255)) + + +@pytest.fixture +def bounds() -> MapBounds: + return MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0) + + +@pytest.fixture +def renderer( + white_base_map: Image.Image, bounds: MapBounds, monkeypatch: pytest.MonkeyPatch +) -> Renderer: + monkeypatch.setattr( + "planemapper.renderer.airspace.AIRSPACE_PATH", + FIXTURE_DIR / "airspace_sample.geojson", + ) + return Renderer(white_base_map, bounds) + + +def _aircraft(icao: str = "ABC123", **kwargs) -> Aircraft: + defaults = {"lat": 53.1, "lon": -6.1} + defaults.update(kwargs) + return Aircraft(icao=icao, **defaults) + + +def test_render_returns_800x480(renderer: Renderer) -> None: + result = renderer.render([]) + assert isinstance(result, Image.Image) + assert result.size == (800, 480) + + +def test_trail_accumulated_across_renders(renderer: Renderer) -> None: + ac = _aircraft(icao="ABC123") + renderer.render([ac]) + renderer.render([ac]) + assert "ABC123" in renderer._trails + assert len(renderer._trails["ABC123"]) == 2 + + +def test_absent_aircraft_trail_retained(renderer: Renderer) -> None: + ac = _aircraft(icao="ABC123") + renderer.render([ac]) + renderer.render([]) # aircraft absent + assert "ABC123" in renderer._trails + assert len(renderer._trails["ABC123"]) == 1