Initial Vibe coded project
This commit is contained in:
commit
0891cf12a5
21 files changed
+2830
No files matched your search
@@ -0,0 +1,563 @@
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import rasterio
|
||||
from rasterio.transform import from_bounds
|
||||
|
||||
from flight_map.cli import main, parser, timestamp
|
||||
from flight_map.geo import DEFAULT_BOUNDS, Bounds, Projection
|
||||
from flight_map.lettering import GLYPHS, lettering
|
||||
from flight_map.maps import map_data, map_lines
|
||||
from flight_map.network import request_json
|
||||
from flight_map.plot import SVG_NS, PlotOptions, render
|
||||
from flight_map.record import OpenSky, collect
|
||||
from flight_map.storage import (
|
||||
Observation,
|
||||
Session,
|
||||
checkpoint,
|
||||
create_session,
|
||||
database,
|
||||
get_session,
|
||||
initialize,
|
||||
load_observations,
|
||||
save_observations,
|
||||
)
|
||||
from flight_map.terrain import Elevation
|
||||
from flight_map.tracks import make_tracks, split_tracks
|
||||
|
||||
T0 = 1_792_497_600.0
|
||||
|
||||
|
||||
def observation(**changes: Any) -> Observation:
|
||||
return replace(Observation("abc123", "KLM123", T0 + 10, 4.80, 52.24, 900, 1043), **changes)
|
||||
|
||||
|
||||
def raster(path: Path, value: float, *, bounds: Bounds = DEFAULT_BOUNDS) -> Path:
|
||||
with rasterio.open(
|
||||
path,
|
||||
"w",
|
||||
driver="GTiff",
|
||||
height=10,
|
||||
width=10,
|
||||
count=1,
|
||||
dtype="float32",
|
||||
crs="EPSG:4326",
|
||||
transform=from_bounds(*bounds.values, 10, 10),
|
||||
nodata=-9999,
|
||||
) as dataset:
|
||||
dataset.write(np.full((1, 10, 10), value, dtype="float32"))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recorded(tmp_path: Path) -> tuple[Path, Session]:
|
||||
path = tmp_path / "flights.sqlite"
|
||||
with database(path) as connection:
|
||||
initialize(connection)
|
||||
session = create_session(connection, DEFAULT_BOUNDS, 2000, 30, T0)
|
||||
items = [
|
||||
observation(longitude=4.77, timestamp=T0 + 10, geo_altitude=843),
|
||||
observation(longitude=4.82, timestamp=T0 + 40, geo_altitude=1043),
|
||||
observation(longitude=4.88, timestamp=T0 + 70, geo_altitude=1243),
|
||||
]
|
||||
save_observations(connection, session, items, T0 + 80)
|
||||
checkpoint(connection, session.id, T0 + 90, "stopped")
|
||||
return path, session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_map(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "map.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"type": "way",
|
||||
"id": 1,
|
||||
"geometry": [
|
||||
{"lon": 4.77, "lat": 52.235},
|
||||
{"lon": 4.90, "lat": 52.245},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "way",
|
||||
"id": 2,
|
||||
"geometry": [
|
||||
{"lon": 4.80, "lat": 52.22},
|
||||
{"lon": 4.83, "lat": 52.26},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def test_default_and_reversed_bounds() -> None:
|
||||
assert DEFAULT_BOUNDS.values == (4.784498, 52.232025, 4.863525, 52.253985)
|
||||
assert Bounds.corners(4.863525, 52.253985, 4.784498, 52.232025) == DEFAULT_BOUNDS
|
||||
assert DEFAULT_BOUNDS.contains(4.83, 52.24)
|
||||
expanded = DEFAULT_BOUNDS.expand(2000)
|
||||
assert expanded.west < DEFAULT_BOUNDS.west and expanded.north > DEFAULT_BOUNDS.north
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"values",
|
||||
[
|
||||
(1, 1, 1, 2),
|
||||
(180, 1, 181, 2),
|
||||
(-179, 1, 179, 2),
|
||||
(1, -90, 2, 90),
|
||||
(1, 2, float("nan"), 3),
|
||||
],
|
||||
)
|
||||
def test_invalid_bounds(values: tuple[float, float, float, float]) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
Bounds(*values)
|
||||
|
||||
|
||||
def test_projection_corners() -> None:
|
||||
projection = Projection(DEFAULT_BOUNDS)
|
||||
southwest = projection.point(DEFAULT_BOUNDS.west, DEFAULT_BOUNDS.south)
|
||||
northeast = projection.point(DEFAULT_BOUNDS.east, DEFAULT_BOUNDS.north)
|
||||
assert projection.rectangle.bounds == (*southwest, *northeast)
|
||||
assert northeast[0] > southwest[0] and northeast[1] > southwest[1]
|
||||
|
||||
|
||||
def test_persistence_and_deduplication(recorded: tuple[Path, Session]) -> None:
|
||||
path, original = recorded
|
||||
with database(path) as connection:
|
||||
session = get_session(connection, original.id)
|
||||
assert session.bounds == DEFAULT_BOUNDS
|
||||
assert session.collection_bounds == original.collection_bounds
|
||||
assert session.status == "stopped"
|
||||
items = load_observations(connection, session.id, T0, T0 + 100)
|
||||
assert len(items) == 3
|
||||
save_observations(connection, session, items, T0 + 100)
|
||||
assert len(load_observations(connection, session.id, T0, T0 + 100)) == 3
|
||||
assert items[0].geo_altitude == 843
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
get_session(connection, "missing")
|
||||
|
||||
|
||||
def test_observations_outside_session_are_not_saved(recorded: tuple[Path, Session]) -> None:
|
||||
path, session = recorded
|
||||
with database(path) as connection:
|
||||
save_observations(
|
||||
connection,
|
||||
session,
|
||||
[
|
||||
observation(timestamp=T0 - 1),
|
||||
observation(timestamp=T0 + 500),
|
||||
observation(longitude=5.5),
|
||||
],
|
||||
T0 + 100,
|
||||
)
|
||||
assert len(load_observations(connection, session.id, T0, T0 + 100)) == 3
|
||||
|
||||
|
||||
def test_database_readonly(recorded: tuple[Path, Session]) -> None:
|
||||
path, _ = recorded
|
||||
with database(path, readonly=True) as connection:
|
||||
with pytest.raises(sqlite3.OperationalError, match="readonly"):
|
||||
connection.execute("DELETE FROM observations")
|
||||
|
||||
|
||||
def test_terrain_and_datum_conversion(tmp_path: Path) -> None:
|
||||
terrain = raster(tmp_path / "terrain.tif", -2)
|
||||
geoid = raster(tmp_path / "geoid.tif", 43)
|
||||
with Elevation(terrain, "orthometric", geoid, True) as elevation:
|
||||
assert elevation.mean_ground(DEFAULT_BOUNDS, 4) == -2
|
||||
assert elevation.aircraft_altitude(observation()) == 1000
|
||||
with Elevation(terrain, "orthometric", geoid, False) as elevation:
|
||||
assert elevation.aircraft_altitude(observation()) is None
|
||||
with Elevation(terrain, "orthometric", None, True) as elevation:
|
||||
assert elevation.aircraft_altitude(observation()) is None
|
||||
with Elevation(terrain, "wgs84-ellipsoid", None, True) as elevation:
|
||||
assert elevation.aircraft_altitude(observation()) == 1043
|
||||
assert elevation.aircraft_altitude(observation(geo_altitude=None)) is None
|
||||
|
||||
|
||||
def test_terrain_missing_data(tmp_path: Path) -> None:
|
||||
terrain = raster(tmp_path / "terrain.tif", -9999)
|
||||
with Elevation(terrain, "wgs84-ellipsoid") as elevation:
|
||||
assert elevation.mean_ground(DEFAULT_BOUNDS, 4) is None
|
||||
small = Bounds(4.80, 52.24, 4.81, 52.25)
|
||||
raster(terrain, 0, bounds=small)
|
||||
with Elevation(terrain, "wgs84-ellipsoid") as elevation:
|
||||
assert elevation.mean_ground(DEFAULT_BOUNDS, 4) is None
|
||||
|
||||
|
||||
def test_equal_area_ground_average(tmp_path: Path) -> None:
|
||||
terrain = raster(tmp_path / "terrain.tif", 0)
|
||||
with rasterio.open(terrain, "r+") as dataset:
|
||||
values = np.zeros((10, 10), dtype="float32")
|
||||
values[:, 5:] = 10
|
||||
dataset.write(values, 1)
|
||||
with Elevation(terrain, "wgs84-ellipsoid") as elevation:
|
||||
assert elevation.mean_ground(DEFAULT_BOUNDS, 10) == pytest.approx(5)
|
||||
|
||||
|
||||
def test_height_is_time_weighted_and_excludes_extensions() -> None:
|
||||
bounds = Bounds(0, 0, 2, 1)
|
||||
observations = [
|
||||
observation(longitude=-1, latitude=0.5, timestamp=T0, geo_altitude=0),
|
||||
observation(longitude=1, latitude=0.5, timestamp=T0 + 20, geo_altitude=200),
|
||||
observation(longitude=3, latitude=0.5, timestamp=T0 + 60, geo_altitude=600),
|
||||
]
|
||||
with Elevation(None, "wgs84-ellipsoid", None, True) as elevation:
|
||||
tracks = make_tracks(observations, Projection(bounds), elevation, 10, 120)
|
||||
# Inside: 10s at mean altitude 150 + 20s at mean altitude 300.
|
||||
assert len(tracks) == 1
|
||||
assert tracks[0].average_height == pytest.approx(240)
|
||||
assert tracks[0].start == pytest.approx(T0 + 10)
|
||||
assert tracks[0].end == pytest.approx(T0 + 40)
|
||||
|
||||
|
||||
def test_stationary_positions_and_missing_altitude() -> None:
|
||||
items = [
|
||||
observation(),
|
||||
observation(timestamp=T0 + 25),
|
||||
observation(timestamp=T0 + 40, longitude=4.82, geo_altitude=None),
|
||||
]
|
||||
with Elevation(None, "wgs84-ellipsoid", None, True) as elevation:
|
||||
tracks = make_tracks(items, Projection(DEFAULT_BOUNDS), elevation, -2, 120)
|
||||
assert len(tracks) == 1 and tracks[0].average_height is None
|
||||
with Elevation(None) as elevation:
|
||||
assert make_tracks(items[:2], Projection(DEFAULT_BOUNDS), elevation, None, 120) == []
|
||||
|
||||
|
||||
def test_track_splitting_and_ground_filter() -> None:
|
||||
items = [observation(timestamp=T0 + i * 30) for i in range(6)]
|
||||
items[2] = replace(items[2], on_ground=True)
|
||||
items[4] = replace(items[4], callsign="OTHER")
|
||||
items[5] = replace(items[5], callsign="OTHER")
|
||||
assert [len(track) for track in split_tracks(items, 120)] == [2, 2]
|
||||
assert split_tracks([observation(), observation(timestamp=T0 + 500)], 120) == []
|
||||
|
||||
|
||||
def test_stroke_font_has_no_fills_or_missing_common_characters() -> None:
|
||||
for character in GLYPHS:
|
||||
assert all(line.length > 0 for line in lettering(character, 0, 0, 2))
|
||||
with pytest.raises(ValueError, match="Unsupported"):
|
||||
lettering("@", 0, 0, 2)
|
||||
|
||||
|
||||
def test_end_to_end_offline_repeatable(
|
||||
recorded: tuple[Path, Session],
|
||||
local_map: Path,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
path, session = recorded
|
||||
monkeypatch.setattr("flight_map.record.OpenSky.observations", Mock(side_effect=AssertionError))
|
||||
monkeypatch.setattr("flight_map.maps.request_json", Mock(side_effect=AssertionError))
|
||||
terrain = raster(tmp_path / "terrain.tif", 41, bounds=session.collection_bounds)
|
||||
before = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
output = tmp_path / "map.svg"
|
||||
for name in ("map.svg", "second.svg"):
|
||||
assert (
|
||||
render(
|
||||
path,
|
||||
session.id,
|
||||
tmp_path / name,
|
||||
local_map=local_map,
|
||||
terrain=terrain,
|
||||
terrain_reference="wgs84-ellipsoid",
|
||||
confirm_geometric_ellipsoid=True,
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert output.read_bytes() == (tmp_path / "second.svg").read_bytes()
|
||||
assert hashlib.sha256(path.read_bytes()).hexdigest() == before
|
||||
root = ET.parse(output).getroot()
|
||||
assert root.attrib["width"] == "297mm" and root.attrib["height"] == "210mm"
|
||||
assert root.attrib["fill"] == "none"
|
||||
assert root.attrib["viewBox"] == "0 0 297 210"
|
||||
forbidden = {"image", "text", "clipPath", "mask", "use"}
|
||||
assert not any(element.tag.split("}")[-1] in forbidden for element in root.iter())
|
||||
assert {g.attrib.get("id") for g in root.findall(f"{{{SVG_NS}}}g")} == {
|
||||
"map",
|
||||
"flights",
|
||||
"lettering",
|
||||
}
|
||||
flight_paths = root.findall(f".//{{{SVG_NS}}}g[@id='flights']//{{{SVG_NS}}}path")
|
||||
assert flight_paths and all("C " in element.attrib["d"] for element in flight_paths)
|
||||
flight = root.find(f".//{{{SVG_NS}}}g[@id='flight-1']")
|
||||
assert flight is not None and flight.attrib["data-callsign"] == "KLM123"
|
||||
assert flight.attrib["data-average-height-metres"] != "unavailable"
|
||||
for element in root.iter(f"{{{SVG_NS}}}path"):
|
||||
assert "clip-path" not in element.attrib
|
||||
for x, y in re.findall(r"(-?\d+\.\d+),(-?\d+\.\d+)", element.attrib["d"]):
|
||||
assert 0 <= float(x) <= 297 and 0 <= float(y) <= 210
|
||||
assert "HEIGHT" not in output.read_text() # Lettering is geometry, not SVG text.
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
render(path, session.id, output, local_map=local_map)
|
||||
|
||||
|
||||
def test_invalid_plot_time_and_empty_data(recorded: tuple[Path, Session], tmp_path: Path) -> None:
|
||||
path, session = recorded
|
||||
with pytest.raises(ValueError, match="within"):
|
||||
render(path, session.id, tmp_path / "bad.svg", start=T0 - 10)
|
||||
with pytest.raises(ValueError, match="No recorded"):
|
||||
render(path, session.id, tmp_path / "bad.svg", start=T0 + 81, end=T0 + 85)
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
PlotOptions(width=float("nan")).validate()
|
||||
with pytest.raises(ValueError, match="no room"):
|
||||
PlotOptions(width=10).validate()
|
||||
with pytest.raises(ValueError, match="must not overwrite"):
|
||||
render(path, session.id, path, force=True)
|
||||
with pytest.raises(ValueError, match="must not overwrite"):
|
||||
render(
|
||||
path, session.id, tmp_path / "terrain.tif", terrain=tmp_path / "terrain.tif", force=True
|
||||
)
|
||||
|
||||
|
||||
def test_map_cache_and_errors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
payload = {
|
||||
"elements": [
|
||||
{
|
||||
"type": "way",
|
||||
"geometry": [
|
||||
{"lon": 4.8, "lat": 52.24},
|
||||
{"lon": 4.82, "lat": 52.24},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
fetch = Mock(return_value=payload)
|
||||
monkeypatch.setattr("flight_map.maps.request_json", fetch)
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path) == payload
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path) == payload
|
||||
fetch.assert_called_once()
|
||||
with pytest.raises(ValueError, match="No drawable"):
|
||||
map_lines({"elements": []}, Projection(DEFAULT_BOUNDS))
|
||||
with pytest.raises(ValueError, match="out geom"):
|
||||
map_lines({"elements": [{"type": "way"}]}, Projection(DEFAULT_BOUNDS))
|
||||
fetch.return_value = {"elements": [], "remark": "runtime error"}
|
||||
with pytest.raises(ValueError, match="could not complete"):
|
||||
map_data(DEFAULT_BOUNDS, tmp_path / "other")
|
||||
|
||||
|
||||
def test_cache_refresh_is_atomic_and_keys_include_area(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
old = {"elements": [], "version": 1}
|
||||
new = {"elements": [], "version": 2}
|
||||
fetch = Mock(return_value=old)
|
||||
monkeypatch.setattr("flight_map.maps.request_json", fetch)
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path) == old
|
||||
entry = next(tmp_path.glob("osm-*.json"))
|
||||
fetch.return_value = new
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path) == old
|
||||
assert fetch.call_count == 1
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path, refresh=True) == new
|
||||
assert json.loads(entry.read_text()) == new
|
||||
assert not list(tmp_path.glob("*.tmp"))
|
||||
fetch.side_effect = RuntimeError("offline")
|
||||
with pytest.raises(RuntimeError, match="offline"):
|
||||
map_data(DEFAULT_BOUNDS, tmp_path, refresh=True)
|
||||
assert json.loads(entry.read_text()) == new
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path) == new
|
||||
fetch.side_effect = None
|
||||
map_data(Bounds(4.8, 52.2, 4.9, 52.3), tmp_path)
|
||||
assert len(list(tmp_path.glob("osm-*.json"))) == 2
|
||||
entry.write_text("{")
|
||||
with pytest.raises(ValueError, match="refresh-map"):
|
||||
map_data(DEFAULT_BOUNDS, tmp_path)
|
||||
assert map_data(DEFAULT_BOUNDS, tmp_path, refresh=True) == new
|
||||
with pytest.raises(ValueError, match="cannot be combined"):
|
||||
map_data(DEFAULT_BOUNDS, tmp_path, entry, refresh=True)
|
||||
|
||||
|
||||
def test_interrupted_cache_write_preserves_existing_entry(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
payload = {"elements": []}
|
||||
monkeypatch.setattr("flight_map.maps.request_json", Mock(return_value=payload))
|
||||
map_data(DEFAULT_BOUNDS, tmp_path)
|
||||
entry = next(tmp_path.glob("osm-*.json"))
|
||||
before = entry.read_bytes()
|
||||
monkeypatch.setattr("flight_map.maps.os.replace", Mock(side_effect=OSError("write failed")))
|
||||
with pytest.raises(OSError, match="write failed"):
|
||||
map_data(DEFAULT_BOUNDS, tmp_path, refresh=True)
|
||||
assert entry.read_bytes() == before
|
||||
assert not list(tmp_path.glob("*.tmp"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
{"elements": [None]},
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"type": "way",
|
||||
"geometry": [
|
||||
{"lon": float("nan"), "lat": 52.2},
|
||||
{"lon": 4.8, "lat": 52.3},
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
{"elements": [{"type": "way", "geometry": [None, None]}]},
|
||||
],
|
||||
)
|
||||
def test_invalid_map_geometry_is_reported(data: dict[str, Any]) -> None:
|
||||
with pytest.raises(ValueError, match="Map"):
|
||||
map_lines(data, Projection(DEFAULT_BOUNDS))
|
||||
|
||||
|
||||
def test_map_detail_is_selected_for_pen_width() -> None:
|
||||
geometry = [
|
||||
{"lon": 4.80, "lat": 52.24},
|
||||
{"lon": 4.8001, "lat": 52.2401},
|
||||
{"lon": 4.80, "lat": 52.24},
|
||||
]
|
||||
data = {
|
||||
"elements": [
|
||||
{"type": "way", "tags": {"building": "yes"}, "geometry": geometry},
|
||||
{"type": "way", "tags": {"highway": "footway"}, "geometry": geometry},
|
||||
{"type": "way", "tags": {"waterway": "stream"}, "geometry": geometry},
|
||||
]
|
||||
}
|
||||
projection = Projection(DEFAULT_BOUNDS)
|
||||
assert len(map_lines(data, projection, min_building_size=20)) == 1
|
||||
assert len(map_lines(data, projection, min_building_size=0)) == 2
|
||||
|
||||
|
||||
def test_opensky_parsing_and_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for key in ("OPENSKY_CLIENT_ID", "OPENSKY_CLIENT_SECRET", "OPENSKY_ACCESS_TOKEN"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
state = ["abc123", " KLM123 ", "NL", T0, T0, 4.82, 52.24, 900, False, 100, 30, 0, None, 1043]
|
||||
fetch = Mock(return_value={"states": [state]})
|
||||
monkeypatch.setattr("flight_map.record.request_json", fetch)
|
||||
client = OpenSky()
|
||||
items = client.observations(DEFAULT_BOUNDS)
|
||||
assert items[0].callsign == "KLM123" and items[0].geo_altitude == 1043
|
||||
assert "lamin=52.232025" in fetch.call_args.args[0]
|
||||
state[5] = None
|
||||
assert client.observations(DEFAULT_BOUNDS) == []
|
||||
fetch.return_value = {"states": None}
|
||||
assert client.observations(DEFAULT_BOUNDS) == []
|
||||
fetch.return_value = {"wrong": []}
|
||||
with pytest.raises(RuntimeError, match="missing"):
|
||||
client.observations(DEFAULT_BOUNDS)
|
||||
monkeypatch.setenv("OPENSKY_CLIENT_ID", "test-client")
|
||||
with pytest.raises(ValueError, match="both"):
|
||||
OpenSky()
|
||||
monkeypatch.setenv("OPENSKY_CLIENT_SECRET", "test-secret")
|
||||
fetch.side_effect = [{"access_token": "test-token", "expires_in": 300}, {"states": []}]
|
||||
OpenSky().observations(DEFAULT_BOUNDS)
|
||||
assert fetch.call_args.kwargs["headers"]["Authorization"] == "Bearer test-token"
|
||||
|
||||
|
||||
def test_collection_checkpoints_without_map_services(
|
||||
recorded: tuple[Path, Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
path, session = recorded
|
||||
monkeypatch.setattr("flight_map.maps.request_json", Mock(side_effect=AssertionError))
|
||||
client = Mock(spec=OpenSky)
|
||||
client.observations.return_value = []
|
||||
with database(path) as connection:
|
||||
collect(connection, session, client, duration=0)
|
||||
assert get_session(connection, session.id).status == "stopped"
|
||||
client.observations.side_effect = RuntimeError("outage")
|
||||
with pytest.raises(RuntimeError, match="outage"):
|
||||
collect(connection, session, client, duration=0)
|
||||
assert get_session(connection, session.id).status == "failed"
|
||||
client.observations.side_effect = KeyboardInterrupt
|
||||
collect(connection, session, client)
|
||||
assert get_session(connection, session.id).status == "interrupted"
|
||||
|
||||
|
||||
def test_cli_defaults_custom_bounds_and_resume(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
path = tmp_path / "flights.sqlite"
|
||||
collector = Mock()
|
||||
monkeypatch.setattr("flight_map.record.collect", collector)
|
||||
monkeypatch.setattr("flight_map.record.OpenSky", Mock())
|
||||
assert main(["collect", "--db", str(path), "--duration", "1"]) == 0
|
||||
session = collector.call_args.args[1]
|
||||
assert session.bounds == DEFAULT_BOUNDS
|
||||
custom = ["--bounds", "4.90", "52.30", "4.80", "52.20"]
|
||||
assert main(["collect", "--db", str(path), *custom, "--duration", "1"]) == 0
|
||||
custom_session = collector.call_args.args[1]
|
||||
assert custom_session.bounds == Bounds(4.8, 52.2, 4.9, 52.3)
|
||||
assert main(["collect", "--db", str(path), "--resume", session.id, "--duration", "1"]) == 0
|
||||
assert collector.call_args.args[1].bounds == DEFAULT_BOUNDS
|
||||
assert main(["collect", "--db", str(path), "--resume", session.id, *custom]) == 1
|
||||
assert main(["sessions", "--db", str(path)]) == 0
|
||||
|
||||
|
||||
def test_cli_validation_and_plot(
|
||||
recorded: tuple[Path, Session],
|
||||
local_map: Path,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
path, session = recorded
|
||||
assert (
|
||||
main(
|
||||
[
|
||||
"plot",
|
||||
"--db",
|
||||
str(path),
|
||||
"--session",
|
||||
session.id,
|
||||
"--map-data",
|
||||
str(local_map),
|
||||
"--output",
|
||||
str(tmp_path / "cli.svg"),
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert (
|
||||
main(
|
||||
[
|
||||
"plot",
|
||||
"--db",
|
||||
str(path),
|
||||
"--session",
|
||||
"missing",
|
||||
"--output",
|
||||
str(tmp_path / "bad.svg"),
|
||||
]
|
||||
)
|
||||
== 1
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
parser().parse_args(["collect", "--db", str(path), "--interval", "nan"])
|
||||
with pytest.raises(Exception, match="time zone"):
|
||||
timestamp("2026-10-20T12:00:00")
|
||||
assert timestamp("2026-10-20T12:00:00Z") == timestamp("2026-10-20T14:00:00+02:00")
|
||||
|
||||
|
||||
def test_network_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from email.message import Message
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
headers = Message()
|
||||
headers["Retry-After"] = "60"
|
||||
fetch = Mock(side_effect=HTTPError("https://example.invalid", 429, "limited", headers, None))
|
||||
monkeypatch.setattr("flight_map.network.urlopen", fetch)
|
||||
with pytest.raises(RuntimeError, match="429.*60"):
|
||||
request_json("https://example.invalid")
|
||||
fetch.side_effect = URLError("offline")
|
||||
with pytest.raises(RuntimeError, match="connection failed"):
|
||||
request_json("https://example.invalid")
|
||||
Reference in new issue
Block a user