feat(3-1): stale state detection and dimmed display

When fetch times out or returns empty after prior data, retain last aircraft
with is_stale=True and render them as black outlines so the display stays
informative rather than blank or crashing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Edholm
2026-04-22 23:47:04 -04:00
parent 316e7aa9a8
commit 833a7f0917
6 changed files with 131 additions and 28 deletions
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import dataclasses
import pathlib
from unittest.mock import MagicMock
import pytest
import requests
from PIL import Image
from planemapper.display import NullDisplay
from planemapper.main import _run_one_cycle
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 renderer(monkeypatch: pytest.MonkeyPatch) -> Renderer:
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)
return Renderer(base_map, bounds)
def _make_aircraft(icao: str = "ABC123") -> Aircraft:
return Aircraft(icao=icao, lat=53.1, lon=-6.1, altitude_ft=10000, callsign="TST1")
def test_timeout_returns_stale_last_aircraft(renderer: Renderer) -> None:
display = NullDisplay()
last = [_make_aircraft()]
mock_fetcher = MagicMock()
mock_fetcher.fetch.side_effect = requests.Timeout
result = _run_one_cycle(renderer, mock_fetcher, display, last)
assert len(result) == 1
assert result[0].is_stale is True
def test_empty_fetch_returns_stale_when_had_previous(renderer: Renderer) -> None:
display = NullDisplay()
last = [_make_aircraft()]
mock_fetcher = MagicMock()
mock_fetcher.fetch.return_value = []
result = _run_one_cycle(renderer, mock_fetcher, display, last)
assert len(result) == 1
assert result[0].is_stale is True
def test_stale_aircraft_rendered_without_exception(renderer: Renderer) -> None:
display = NullDisplay()
stale_aircraft = dataclasses.replace(_make_aircraft(), is_stale=True)
mock_fetcher = MagicMock()
mock_fetcher.fetch.side_effect = requests.Timeout
result = _run_one_cycle(renderer, mock_fetcher, display, [stale_aircraft])
assert result[0].is_stale is True
def test_stale_cycle_completes_without_crash(renderer: Renderer) -> None:
display = NullDisplay()
last = [_make_aircraft()]
mock_fetcher = MagicMock()
mock_fetcher.fetch.side_effect = requests.Timeout
# Should complete without any exception
result = _run_one_cycle(renderer, mock_fetcher, display, last)
assert isinstance(result, list)