Implement story 2.1: aircraft data model and fetcher

Add HttpFetcher and FileFixtureFetcher with shared _parse_aircraft helper,
DUMP1090_URL constant, realistic fixture data, and full test coverage for
all acceptance criteria (AC1–AC5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Edholm
2026-04-22 23:03:20 -04:00
parent 7d89166880
commit 6208134a1c
5 changed files with 236 additions and 35 deletions
@@ -1,6 +1,6 @@
# Story 2.1: Aircraft Data Model & Fetcher # Story 2.1: Aircraft Data Model & Fetcher
Status: ready-for-dev Status: review
## Story ## Story
@@ -22,18 +22,18 @@ AC5: **Given** a `FileFixtureFetcher` pointed at `tests/fixtures/aircraft_sample
## Tasks / Subtasks ## Tasks / Subtasks
- [ ] Task 1: Implement `Aircraft` dataclass in `src/planemapper/models.py` (AC: #1, #2, #4) - [x] 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` - [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`
- [ ] 1.2 Add `from __future__ import annotations` and `from dataclasses import dataclass` imports - [x] 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] 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) - [x] 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` - [x] 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] 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) - [x] 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` - [x] 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] 3.2 Implement `_parse_aircraft(entry: dict) -> Aircraft` private module-level helper:
- Map `hex``icao` - Map `hex``icao`
- Map `lat``lat`, `lon``lon` - Map `lat``lat`, `lon``lon`
- Map `flight``callsign` with `.strip()` and default `""` - 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 `category``category` with default `""`
- Map `mlat``is_mlat`: `bool(entry.get("mlat"))` (empty list → `False`, non-empty → `True`) - Map `mlat``is_mlat`: `bool(entry.get("mlat"))` (empty list → `False`, non-empty → `True`)
- `is_stale` always defaults to `False` - `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)` - `fetch(self) -> list[Aircraft]` calls `requests.get(DUMP1090_URL, timeout=FETCH_TIMEOUT_S)`
- Parses top-level JSON key `"aircraft"` as a list - Parses top-level JSON key `"aircraft"` as a list
- Skips entries missing `lat` or `lon` (cannot be plotted) - Skips entries missing `lat` or `lon` (cannot be plotted)
- Returns `[_parse_aircraft(e) for e in entries]` - Returns `[_parse_aircraft(e) for e in entries]`
- Does NOT catch `requests.Timeout` — let it propagate - 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) - [x] Task 4: Implement `FileFixtureFetcher` in `src/planemapper/fetcher.py` (AC: #5)
- [ ] 4.1 Add `FileFixtureFetcher` class after `HttpFetcher`: - [x] 4.1 Add `FileFixtureFetcher` class after `HttpFetcher`:
- Constructor: `__init__(self, path: Path)` stores `self._path = path` - Constructor: `__init__(self, path: Path)` stores `self._path = path`
- `fetch(self) -> list[Aircraft]` reads JSON from `self._path`, parses with same `_parse_aircraft` helper - `fetch(self) -> list[Aircraft]` reads JSON from `self._path`, parses with same `_parse_aircraft` helper
- Same skip logic for missing `lat`/`lon` - 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) - [x] 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] 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 1: complete aircraft — all fields present (`hex`, `lat`, `lon`, `flight`, `altitude`, `category`, `mlat: []`)
- Entry 2: missing `flight` (callsign) — should default to `""` - Entry 2: missing `flight` (callsign) — should default to `""`
- Entry 3: missing `altitude` — should default to `altitude_ft=0` - Entry 3: missing `altitude` — should default to `altitude_ft=0`
- Entry 4: MLAT aircraft — `mlat` is a non-empty list (e.g. `["lat", "lon"]`) - 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) - [x] 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 - [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
- [ ] 6.2 Test AC2: mock a response with missing `callsign`, `altitude`, `category`; assert defaults are applied and no exception raised - [x] 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) - [x] 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` - [x] 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] 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) - [x] 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 - [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
- [ ] 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] 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 - [x] Task 8: Run quality gates
- [ ] 8.1 `pytest tests/` — all tests pass, 0 failures - [x] 8.1 `pytest tests/` — all tests pass, 0 failures
- [ ] 8.2 `ruff check .` — zero violations - [x] 8.2 `ruff check .` — zero violations
- [ ] 8.3 `ruff format --check .` — no formatting issues - [x] 8.3 `ruff format --check .` — no formatting issues
## Dev Notes ## Dev Notes
+2
View File
@@ -1,5 +1,7 @@
from pathlib import Path from pathlib import Path
DUMP1090_URL = "http://localhost:8080/data/aircraft.json"
DISPLAY_WIDTH = 800 DISPLAY_WIDTH = 800
DISPLAY_HEIGHT = 480 DISPLAY_HEIGHT = 480
+39
View File
@@ -1,7 +1,46 @@
import json
from pathlib import Path
from typing import Protocol from typing import Protocol
import requests
from planemapper.constants import DUMP1090_URL, FETCH_TIMEOUT_S
from planemapper.models import Aircraft from planemapper.models import Aircraft
class FetcherInterface(Protocol): class FetcherInterface(Protocol):
def fetch(self) -> list[Aircraft]: ... 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]
+39 -1
View File
@@ -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"]
}
]
}
+124 -2
View File
@@ -1,2 +1,124 @@
def test_placeholder() -> None: from pathlib import Path
pass 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"