Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
9.5 KiB
Story 2.1: Aircraft Data Model & Fetcher
Status: ready-for-dev
Story
As the radar system,
I want an Aircraft dataclass with safe-default optional fields and a FetcherInterface with both an HttpFetcher (live dump1090) and a FileFixtureFetcher (for testing),
So that all downstream rendering code works with typed Aircraft objects and the fetch boundary is cleanly isolated from raw JSON.
Acceptance Criteria
AC1: Given a valid dump1090 JSON response with all fields present When HttpFetcher.fetch() is called Then it returns a list[Aircraft] with all fields populated correctly
AC2: Given the dump1090 response contains aircraft with missing callsign, altitude, or category When HttpFetcher.fetch() is called Then the corresponding fields use safe defaults (callsign="", altitude_ft=0, category="") and no exception is raised
AC3: Given the dump1090 HTTP request exceeds FETCH_TIMEOUT_S (5 seconds) When HttpFetcher.fetch() is called Then a requests.Timeout is raised (not caught here — the loop boundary handles it)
AC4: Given an aircraft entry has the MLAT flag set in the JSON When HttpFetcher.fetch() is called Then the resulting Aircraft has is_mlat=True
AC5: Given a FileFixtureFetcher pointed at tests/fixtures/aircraft_sample.json When .fetch() is called Then it returns the equivalent list[Aircraft] with no network call made
Tasks / Subtasks
-
Task 1: Implement
Aircraftdataclass insrc/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 annotationsandfrom dataclasses import dataclassimports - 1.3 Confirm
ruff check .passes with zero violations after change
- 1.1 Replace the existing stub with the full dataclass as specified in architecture:
-
Task 2: Add
DUMP1090_URLconstant tosrc/planemapper/constants.py(AC: #1, #3)- 2.1 Add
DUMP1090_URL = "http://localhost:8080/data/aircraft.json"toconstants.py - 2.2 Confirm existing constants are unaffected and
ruff check .passes
- 2.1 Add
-
Task 3: Implement
HttpFetcherinsrc/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) -> Aircraftprivate module-level helper:- Map
hex→icao - Map
lat→lat,lon→lon - Map
flight→callsignwith.strip()and default"" - Map
altitude→altitude_ft: useint(val)ifisinstance(val, int)else0(handles string"ground"and missing) - Map
category→categorywith default"" - Map
mlat→is_mlat:bool(entry.get("mlat"))(empty list →False, non-empty →True) is_stalealways defaults toFalse
- Map
- 3.3 Implement
HttpFetcherclass:fetch(self) -> list[Aircraft]callsrequests.get(DUMP1090_URL, timeout=FETCH_TIMEOUT_S)- Parses top-level JSON key
"aircraft"as a list - Skips entries missing
latorlon(cannot be plotted) - Returns
[_parse_aircraft(e) for e in entries] - Does NOT catch
requests.Timeout— let it propagate
- 3.4 Confirm
HttpFetchersatisfiesFetcherInterfacestructurally (no explicit inheritance needed)
- 3.1 Add imports:
-
Task 4: Implement
FileFixtureFetcherinsrc/planemapper/fetcher.py(AC: #5)- 4.1 Add
FileFixtureFetcherclass afterHttpFetcher:- Constructor:
__init__(self, path: Path)storesself._path = path fetch(self) -> list[Aircraft]reads JSON fromself._path, parses with same_parse_aircrafthelper- Same skip logic for missing
lat/lon
- Constructor:
- 4.2 Confirm
FileFixtureFetchersatisfiesFetcherInterfacestructurally
- 4.1 Add
-
Task 5: Update
tests/fixtures/aircraft_sample.jsonwith realistic dump1090 data (AC: #1, #2, #4, #5)- 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 toaltitude_ft=0 - Entry 4: MLAT aircraft —
mlatis a non-empty list (e.g.["lat", "lon"])
- Entry 1: complete aircraft — all fields present (
- 5.2 Ensure all four entries have
latandlonso none are skipped
- 5.1 Replace the empty
-
Task 6: Write tests in
tests/test_fetcher.pycovering all 5 ACs (AC: #1–#5)- 6.1 Test AC1: use
responseslibrary orunittest.mock.patchto mockrequests.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.getto raiserequests.Timeout; assertHttpFetcher.fetch()propagates it (does not catch it) - 6.4 Test AC4: mock a response where the MLAT aircraft has
"mlat": ["lat", "lon"]; assertis_mlat=True - 6.5 Test AC5: point
FileFixtureFetcherattests/fixtures/aircraft_sample.json; assert the expectedlist[Aircraft]is returned with norequests.getcall made
- 6.1 Test AC1: use
-
Task 7: Update
tests/test_models.py— verify dataclass (AC: #1, #2)- 7.1 Confirm
test_aircraft_defaultsandtest_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
altitudeedge-case if not already covered: createAircraft(icao="X", lat=0.0, lon=0.0, altitude_ft=0)and assertaltitude_ft == 0
- 7.1 Confirm
-
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
- 8.1
Dev Notes
Critical Context
DUMP1090_URL constant:
Add to src/planemapper/constants.py:
DUMP1090_URL = "http://localhost:8080/data/aircraft.json"
FETCH_TIMEOUT_S = 5 is already present in constants.py — do not duplicate it.
Aircraft dataclass — already partially stubbed:
src/planemapper/models.py already contains the full Aircraft dataclass as specified in the architecture. Verify it matches exactly before replacing. The test_models.py tests (test_aircraft_defaults, test_aircraft_full) are also already in place from story 1.1 and test the real dataclass — confirm they pass.
altitude edge case:
dump1090 can return altitude as an integer (feet), the string "ground", or omit the field entirely. The safe pattern:
raw_alt = entry.get("altitude", 0)
altitude_ft = int(raw_alt) if isinstance(raw_alt, int) else 0
Do NOT do int(str_val) — the string "ground" will raise ValueError.
flight field whitespace:
dump1090 pads callsigns with trailing spaces (e.g. "BAW123 "). Always .strip():
callsign = entry.get("flight", "").strip()
MLAT detection:
is_mlat = bool(entry.get("mlat"))
An empty list [] is falsy → False. A non-empty list ["lat", "lon"] is truthy → True. A missing key defaults to None → False.
Aircraft entries without position:
Some dump1090 entries lack lat/lon (e.g. aircraft heard only via Mode S squawk with no ADS-B position). Skip them — they cannot be rendered on the map:
if "lat" not in entry or "lon" not in entry:
continue
FetcherInterface is a Protocol — no explicit inheritance:
HttpFetcher and FileFixtureFetcher satisfy FetcherInterface structurally. Do NOT write class HttpFetcher(FetcherInterface) — Protocol subclassing for implementation is an antipattern in Python typing.
Shared parse logic:
Both HttpFetcher and FileFixtureFetcher must use the same _parse_aircraft(entry: dict) -> Aircraft helper. Do not duplicate field-mapping logic between the two classes.
Test isolation for FileFixtureFetcher:
In the AC5 test, pass the actual path to tests/fixtures/aircraft_sample.json. Use pathlib.Path(__file__).parent / "fixtures" / "aircraft_sample.json" to get an absolute path that works regardless of the working directory when pytest is invoked.
Mock strategy for HttpFetcher tests:
Use unittest.mock.patch("planemapper.fetcher.requests.get") to intercept the HTTP call. Configure the mock's return value with .json.return_value = {"aircraft": [...]}. For the timeout test, use side_effect=requests.Timeout.
dump1090 JSON shape (top-level):
{
"aircraft": [
{
"hex": "4ca7f2",
"lat": 53.3498,
"lon": -6.2603,
"flight": "EIN123 ",
"altitude": 12000,
"category": "A3",
"mlat": []
}
]
}
Fixture file — recommended four-entry shape:
{
"aircraft": [
{
"hex": "4ca7f2", "lat": 53.3498, "lon": -6.2603,
"flight": "EIN123 ", "altitude": 12000, "category": "A3", "mlat": []
},
{
"hex": "4001a1", "lat": 53.4200, "lon": -6.1100,
"altitude": 5000, "category": "A1", "mlat": []
},
{
"hex": "4002b2", "lat": 53.2800, "lon": -6.4000,
"flight": "RYR456 ", "category": "A3", "mlat": []
},
{
"hex": "4003c3", "lat": 53.5000, "lon": -5.9000,
"flight": "MIL001 ", "altitude": 1500, "category": "B1",
"mlat": ["lat", "lon"]
}
]
}