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) def test_recovery_after_timeout_returns_fresh(renderer: Renderer) -> None: display = NullDisplay() last = [_make_aircraft()] mock_fetcher = MagicMock() # Cycle 1: timeout → stale mock_fetcher.fetch.side_effect = requests.Timeout stale_result = _run_one_cycle(renderer, mock_fetcher, display, last) assert stale_result[0].is_stale is True # Cycle 2: fresh fetch → recovery fresh_aircraft = [_make_aircraft(icao="DEF456")] mock_fetcher.fetch.side_effect = None mock_fetcher.fetch.return_value = fresh_aircraft recovered = _run_one_cycle(renderer, mock_fetcher, display, stale_result) assert len(recovered) == 1 assert recovered[0].is_stale is False assert recovered[0].icao == "DEF456" def test_recovery_after_empty_returns_fresh(renderer: Renderer) -> None: display = NullDisplay() last = [_make_aircraft()] mock_fetcher = MagicMock() # Cycle 1: empty → stale mock_fetcher.fetch.return_value = [] stale_result = _run_one_cycle(renderer, mock_fetcher, display, last) assert stale_result[0].is_stale is True # Cycle 2: non-empty → recovery mock_fetcher.fetch.return_value = [_make_aircraft(icao="GHI789")] recovered = _run_one_cycle(renderer, mock_fetcher, display, stale_result) assert recovered[0].is_stale is False