diff --git a/_bmad-output/implementation-artifacts/2-1-aircraft-data-model-and-fetcher.md b/_bmad-output/implementation-artifacts/2-1-aircraft-data-model-and-fetcher.md index eff942f..24e7511 100644 --- a/_bmad-output/implementation-artifacts/2-1-aircraft-data-model-and-fetcher.md +++ b/_bmad-output/implementation-artifacts/2-1-aircraft-data-model-and-fetcher.md @@ -1,6 +1,6 @@ # Story 2.1: Aircraft Data Model & Fetcher -Status: ready-for-dev +Status: review ## Story @@ -22,18 +22,18 @@ AC5: **Given** a `FileFixtureFetcher` pointed at `tests/fixtures/aircraft_sample ## Tasks / Subtasks -- [ ] Task 1: Implement `Aircraft` dataclass in `src/planemapper/models.py` (AC: #1, #2, #4) - - [ ] 1.1 Replace the existing stub with the full dataclass as specified in architecture: `icao: str`, `lat: float`, `lon: float`, `heading: float = 0.0`, `altitude_ft: int = 0`, `callsign: str = ""`, `category: str = ""`, `is_mlat: bool = False`, `is_stale: bool = False` - - [ ] 1.2 Add `from __future__ import annotations` and `from dataclasses import dataclass` imports - - [ ] 1.3 Confirm `ruff check .` passes with zero violations after change +- [x] Task 1: Implement `Aircraft` dataclass in `src/planemapper/models.py` (AC: #1, #2, #4) + - [x] 1.1 Replace the existing stub with the full dataclass as specified in architecture: `icao: str`, `lat: float`, `lon: float`, `heading: float = 0.0`, `altitude_ft: int = 0`, `callsign: str = ""`, `category: str = ""`, `is_mlat: bool = False`, `is_stale: bool = False` + - [x] 1.2 Add `from __future__ import annotations` and `from dataclasses import dataclass` imports + - [x] 1.3 Confirm `ruff check .` passes with zero violations after change -- [ ] Task 2: Add `DUMP1090_URL` constant to `src/planemapper/constants.py` (AC: #1, #3) - - [ ] 2.1 Add `DUMP1090_URL = "http://localhost:8080/data/aircraft.json"` to `constants.py` - - [ ] 2.2 Confirm existing constants are unaffected and `ruff check .` passes +- [x] Task 2: Add `DUMP1090_URL` constant to `src/planemapper/constants.py` (AC: #1, #3) + - [x] 2.1 Add `DUMP1090_URL = "http://localhost:8080/data/aircraft.json"` to `constants.py` + - [x] 2.2 Confirm existing constants are unaffected and `ruff check .` passes -- [ ] Task 3: Implement `HttpFetcher` in `src/planemapper/fetcher.py` (AC: #1, #2, #3, #4) - - [ ] 3.1 Add imports: `import requests`, `from pathlib import Path`, `from planemapper.constants import DUMP1090_URL, FETCH_TIMEOUT_S` - - [ ] 3.2 Implement `_parse_aircraft(entry: dict) -> Aircraft` private module-level helper: +- [x] Task 3: Implement `HttpFetcher` in `src/planemapper/fetcher.py` (AC: #1, #2, #3, #4) + - [x] 3.1 Add imports: `import requests`, `from pathlib import Path`, `from planemapper.constants import DUMP1090_URL, FETCH_TIMEOUT_S` + - [x] 3.2 Implement `_parse_aircraft(entry: dict) -> Aircraft` private module-level helper: - Map `hex` → `icao` - Map `lat` → `lat`, `lon` → `lon` - Map `flight` → `callsign` with `.strip()` and default `""` @@ -41,44 +41,44 @@ AC5: **Given** a `FileFixtureFetcher` pointed at `tests/fixtures/aircraft_sample - Map `category` → `category` with default `""` - Map `mlat` → `is_mlat`: `bool(entry.get("mlat"))` (empty list → `False`, non-empty → `True`) - `is_stale` always defaults to `False` - - [ ] 3.3 Implement `HttpFetcher` class: + - [x] 3.3 Implement `HttpFetcher` class: - `fetch(self) -> list[Aircraft]` calls `requests.get(DUMP1090_URL, timeout=FETCH_TIMEOUT_S)` - Parses top-level JSON key `"aircraft"` as a list - Skips entries missing `lat` or `lon` (cannot be plotted) - Returns `[_parse_aircraft(e) for e in entries]` - Does NOT catch `requests.Timeout` — let it propagate - - [ ] 3.4 Confirm `HttpFetcher` satisfies `FetcherInterface` structurally (no explicit inheritance needed) + - [x] 3.4 Confirm `HttpFetcher` satisfies `FetcherInterface` structurally (no explicit inheritance needed) -- [ ] Task 4: Implement `FileFixtureFetcher` in `src/planemapper/fetcher.py` (AC: #5) - - [ ] 4.1 Add `FileFixtureFetcher` class after `HttpFetcher`: +- [x] Task 4: Implement `FileFixtureFetcher` in `src/planemapper/fetcher.py` (AC: #5) + - [x] 4.1 Add `FileFixtureFetcher` class after `HttpFetcher`: - Constructor: `__init__(self, path: Path)` stores `self._path = path` - `fetch(self) -> list[Aircraft]` reads JSON from `self._path`, parses with same `_parse_aircraft` helper - Same skip logic for missing `lat`/`lon` - - [ ] 4.2 Confirm `FileFixtureFetcher` satisfies `FetcherInterface` structurally + - [x] 4.2 Confirm `FileFixtureFetcher` satisfies `FetcherInterface` structurally -- [ ] Task 5: Update `tests/fixtures/aircraft_sample.json` with realistic dump1090 data (AC: #1, #2, #4, #5) - - [ ] 5.1 Replace the empty `{"aircraft": []}` stub with a JSON object containing four aircraft entries: +- [x] Task 5: Update `tests/fixtures/aircraft_sample.json` with realistic dump1090 data (AC: #1, #2, #4, #5) + - [x] 5.1 Replace the empty `{"aircraft": []}` stub with a JSON object containing four aircraft entries: - Entry 1: complete aircraft — all fields present (`hex`, `lat`, `lon`, `flight`, `altitude`, `category`, `mlat: []`) - Entry 2: missing `flight` (callsign) — should default to `""` - Entry 3: missing `altitude` — should default to `altitude_ft=0` - Entry 4: MLAT aircraft — `mlat` is a non-empty list (e.g. `["lat", "lon"]`) - - [ ] 5.2 Ensure all four entries have `lat` and `lon` so none are skipped + - [x] 5.2 Ensure all four entries have `lat` and `lon` so none are skipped -- [ ] Task 6: Write tests in `tests/test_fetcher.py` covering all 5 ACs (AC: #1–#5) - - [ ] 6.1 Test AC1: use `responses` library or `unittest.mock.patch` to mock `requests.get`; assert all fields on a fully-populated aircraft are correct - - [ ] 6.2 Test AC2: mock a response with missing `callsign`, `altitude`, `category`; assert defaults are applied and no exception raised - - [ ] 6.3 Test AC3: mock `requests.get` to raise `requests.Timeout`; assert `HttpFetcher.fetch()` propagates it (does not catch it) - - [ ] 6.4 Test AC4: mock a response where the MLAT aircraft has `"mlat": ["lat", "lon"]`; assert `is_mlat=True` - - [ ] 6.5 Test AC5: point `FileFixtureFetcher` at `tests/fixtures/aircraft_sample.json`; assert the expected `list[Aircraft]` is returned with no `requests.get` call made +- [x] Task 6: Write tests in `tests/test_fetcher.py` covering all 5 ACs (AC: #1–#5) + - [x] 6.1 Test AC1: use `responses` library or `unittest.mock.patch` to mock `requests.get`; assert all fields on a fully-populated aircraft are correct + - [x] 6.2 Test AC2: mock a response with missing `callsign`, `altitude`, `category`; assert defaults are applied and no exception raised + - [x] 6.3 Test AC3: mock `requests.get` to raise `requests.Timeout`; assert `HttpFetcher.fetch()` propagates it (does not catch it) + - [x] 6.4 Test AC4: mock a response where the MLAT aircraft has `"mlat": ["lat", "lon"]`; assert `is_mlat=True` + - [x] 6.5 Test AC5: point `FileFixtureFetcher` at `tests/fixtures/aircraft_sample.json`; assert the expected `list[Aircraft]` is returned with no `requests.get` call made -- [ ] Task 7: Update `tests/test_models.py` — verify dataclass (AC: #1, #2) - - [ ] 7.1 Confirm `test_aircraft_defaults` and `test_aircraft_full` (already present from story 1.1 QA) still pass after the full dataclass is in place — no stub test needed, these are real assertions - - [ ] 7.2 Add a test for the `altitude` edge-case if not already covered: create `Aircraft(icao="X", lat=0.0, lon=0.0, altitude_ft=0)` and assert `altitude_ft == 0` +- [x] Task 7: Update `tests/test_models.py` — verify dataclass (AC: #1, #2) + - [x] 7.1 Confirm `test_aircraft_defaults` and `test_aircraft_full` (already present from story 1.1 QA) still pass after the full dataclass is in place — no stub test needed, these are real assertions + - [x] 7.2 Add a test for the `altitude` edge-case if not already covered: create `Aircraft(icao="X", lat=0.0, lon=0.0, altitude_ft=0)` and assert `altitude_ft == 0` -- [ ] Task 8: Run quality gates - - [ ] 8.1 `pytest tests/` — all tests pass, 0 failures - - [ ] 8.2 `ruff check .` — zero violations - - [ ] 8.3 `ruff format --check .` — no formatting issues +- [x] Task 8: Run quality gates + - [x] 8.1 `pytest tests/` — all tests pass, 0 failures + - [x] 8.2 `ruff check .` — zero violations + - [x] 8.3 `ruff format --check .` — no formatting issues ## Dev Notes diff --git a/src/planemapper/constants.py b/src/planemapper/constants.py index c5b38f8..df2cbed 100644 --- a/src/planemapper/constants.py +++ b/src/planemapper/constants.py @@ -1,5 +1,7 @@ from pathlib import Path +DUMP1090_URL = "http://localhost:8080/data/aircraft.json" + DISPLAY_WIDTH = 800 DISPLAY_HEIGHT = 480 diff --git a/src/planemapper/fetcher.py b/src/planemapper/fetcher.py index 3b4b0f9..f686c4c 100644 --- a/src/planemapper/fetcher.py +++ b/src/planemapper/fetcher.py @@ -1,7 +1,46 @@ +import json +from pathlib import Path from typing import Protocol +import requests + +from planemapper.constants import DUMP1090_URL, FETCH_TIMEOUT_S from planemapper.models import Aircraft class FetcherInterface(Protocol): def fetch(self) -> list[Aircraft]: ... + + +def _parse_aircraft(entry: dict) -> Aircraft: + raw_alt = entry.get("altitude", 0) + altitude_ft = int(raw_alt) if isinstance(raw_alt, int) else 0 + return Aircraft( + icao=entry["hex"], + lat=float(entry["lat"]), + lon=float(entry["lon"]), + heading=float(entry.get("track", 0.0)), + altitude_ft=altitude_ft, + callsign=entry.get("flight", "").strip(), + category=entry.get("category", ""), + is_mlat=bool(entry.get("mlat")), + is_stale=False, + ) + + +class HttpFetcher: + def fetch(self) -> list[Aircraft]: + resp = requests.get(DUMP1090_URL, timeout=FETCH_TIMEOUT_S) + resp.raise_for_status() + data = resp.json() + return [_parse_aircraft(e) for e in data.get("aircraft", []) if "lat" in e and "lon" in e] + + +class FileFixtureFetcher: + def __init__(self, path: Path) -> None: + self._path = path + + def fetch(self) -> list[Aircraft]: + with self._path.open() as f: + data = json.load(f) + return [_parse_aircraft(e) for e in data.get("aircraft", []) if "lat" in e and "lon" in e] diff --git a/tests/fixtures/aircraft_sample.json b/tests/fixtures/aircraft_sample.json index 6104cbd..b16dec5 100644 --- a/tests/fixtures/aircraft_sample.json +++ b/tests/fixtures/aircraft_sample.json @@ -1 +1,39 @@ -{"aircraft": []} +{ + "aircraft": [ + { + "hex": "4ca7f2", + "lat": 53.3498, + "lon": -6.2603, + "flight": "EIN123 ", + "altitude": 12000, + "category": "A3", + "track": 270.0, + "mlat": [] + }, + { + "hex": "4001a1", + "lat": 53.42, + "lon": -6.11, + "altitude": 5000, + "category": "A1", + "mlat": [] + }, + { + "hex": "4002b2", + "lat": 53.28, + "lon": -6.4, + "flight": "RYR456 ", + "category": "A3", + "mlat": [] + }, + { + "hex": "4003c3", + "lat": 53.5, + "lon": -5.9, + "flight": "MIL001 ", + "altitude": 1500, + "category": "B1", + "mlat": ["lat", "lon"] + } + ] +} diff --git a/tests/test_fetcher.py b/tests/test_fetcher.py index b8bbd70..f5e7dd0 100644 --- a/tests/test_fetcher.py +++ b/tests/test_fetcher.py @@ -1,2 +1,124 @@ -def test_placeholder() -> None: - pass +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from planemapper.fetcher import FileFixtureFetcher, HttpFetcher + +_FIXTURES = Path(__file__).parent / "fixtures" / "aircraft_sample.json" + +_FULL_RESPONSE = { + "aircraft": [ + { + "hex": "4ca7f2", + "lat": 53.3498, + "lon": -6.2603, + "flight": "EIN123 ", + "altitude": 12000, + "category": "A3", + "track": 270.0, + "mlat": [], + } + ] +} + +_DEFAULTS_RESPONSE = { + "aircraft": [ + { + "hex": "aabbcc", + "lat": 51.0, + "lon": -1.0, + } + ] +} + +_MLAT_RESPONSE = { + "aircraft": [ + { + "hex": "dddddd", + "lat": 52.0, + "lon": -2.0, + "mlat": ["lat", "lon"], + } + ] +} + + +def _mock_get(payload: dict) -> MagicMock: + mock_resp = MagicMock() + mock_resp.json.return_value = payload + mock_resp.raise_for_status = MagicMock() + return mock_resp + + +def test_http_fetcher_full_response() -> None: + with patch("planemapper.fetcher.requests.get", return_value=_mock_get(_FULL_RESPONSE)): + aircraft = HttpFetcher().fetch() + assert len(aircraft) == 1 + a = aircraft[0] + assert a.icao == "4ca7f2" + assert a.lat == 53.3498 + assert a.lon == -6.2603 + assert a.callsign == "EIN123" + assert a.altitude_ft == 12000 + assert a.category == "A3" + assert a.heading == 270.0 + assert a.is_mlat is False + assert a.is_stale is False + + +def test_http_fetcher_missing_fields_use_defaults() -> None: + with patch("planemapper.fetcher.requests.get", return_value=_mock_get(_DEFAULTS_RESPONSE)): + aircraft = HttpFetcher().fetch() + assert len(aircraft) == 1 + a = aircraft[0] + assert a.callsign == "" + assert a.altitude_ft == 0 + assert a.category == "" + assert a.is_mlat is False + + +def test_http_fetcher_altitude_ground_string() -> None: + payload = {"aircraft": [{"hex": "aaa", "lat": 51.0, "lon": -1.0, "altitude": "ground"}]} + with patch("planemapper.fetcher.requests.get", return_value=_mock_get(payload)): + aircraft = HttpFetcher().fetch() + assert aircraft[0].altitude_ft == 0 + + +def test_http_fetcher_skips_entries_without_position() -> None: + payload = {"aircraft": [{"hex": "nopos"}, {"hex": "haspos", "lat": 51.0, "lon": -1.0}]} + with patch("planemapper.fetcher.requests.get", return_value=_mock_get(payload)): + aircraft = HttpFetcher().fetch() + assert len(aircraft) == 1 + assert aircraft[0].icao == "haspos" + + +def test_http_fetcher_propagates_timeout() -> None: + with patch("planemapper.fetcher.requests.get", side_effect=requests.Timeout): + with pytest.raises(requests.Timeout): + HttpFetcher().fetch() + + +def test_http_fetcher_mlat_flag() -> None: + with patch("planemapper.fetcher.requests.get", return_value=_mock_get(_MLAT_RESPONSE)): + aircraft = HttpFetcher().fetch() + assert aircraft[0].is_mlat is True + + +def test_file_fixture_fetcher_returns_aircraft() -> None: + aircraft = FileFixtureFetcher(_FIXTURES).fetch() + assert len(aircraft) == 4 + + +def test_file_fixture_fetcher_no_network_call() -> None: + with patch("planemapper.fetcher.requests.get") as mock_get: + FileFixtureFetcher(_FIXTURES).fetch() + mock_get.assert_not_called() + + +def test_file_fixture_fetcher_mlat_aircraft() -> None: + aircraft = FileFixtureFetcher(_FIXTURES).fetch() + mlat_aircraft = [a for a in aircraft if a.is_mlat] + assert len(mlat_aircraft) == 1 + assert mlat_aircraft[0].icao == "4003c3"