Initial Vibe coded project

This commit is contained in:
Olaf committed 2026-10-20 14:18:35 +02:00
commit 0891cf12a5
21 files changed
+2830

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
import math
import random
import pytest
from flight_map.curves import Cubic, interpolate
def test_spline_interpolates_samples_with_continuous_tangent() -> None:
points = [(0.0, 0.0), (1.0, 2.0), (5.0, 3.0), (6.0, -1.0)]
curves = interpolate(points)
assert len(curves) == len(points) - 1
for index, curve in enumerate(curves):
assert curve.point(0) == pytest.approx(points[index])
assert curve.point(1) == pytest.approx(points[index + 1])
for first, second in zip(curves, curves[1:], strict=False):
incoming = (first.end[0] - first.control2[0], first.end[1] - first.control2[1])
outgoing = (second.control1[0] - second.start[0], second.control1[1] - second.start[1])
cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0]
dot = incoming[0] * outgoing[0] + incoming[1] * outgoing[1]
assert abs(cross) < 1e-10 and dot > 0
# A non-collinear path must actually curve, not just encode straight lines as cubics.
assert curves[0].point(0.5) != pytest.approx((0.5, 1.0))
def test_two_points_remain_a_straight_path_and_duplicates_are_finite() -> None:
curve = interpolate([(0, 0), (9, 6)])[0]
assert curve.point(0.5) == pytest.approx((4.5, 3))
for segment in interpolate([(0, 0), (0, 0), (1, 2), (1, 2), (3, 0)]):
assert all(math.isfinite(coordinate) for p in segment.points for coordinate in p)
assert interpolate([]) == []
assert interpolate([(0, 0)]) == []
def test_curve_portion_preserves_original_shape() -> None:
curve = Cubic((-2, 0), (1, 5), (3, -5), (6, 0))
portion = curve.portion(0.2, 0.8)
for fraction in (0, 0.1, 0.5, 0.8, 1):
assert portion.point(fraction) == pytest.approx(curve.point(0.2 + fraction * 0.6))
with pytest.raises(ValueError):
curve.portion(0.8, 0.2)
def test_curve_can_enter_bounds_when_both_endpoints_are_outside() -> None:
curve = Cubic((-2, 0), (-1, 3), (1, 3), (2, 0))
rectangle = (-1.0, 1.0, 1.0, 3.0)
portions = curve.clipped(rectangle)
assert len(portions) == 1
assert portions[0].start[0] == pytest.approx(-1)
assert portions[0].end[0] == pytest.approx(1)
assert portions[0].point(0.5)[1] > 2
def test_clipping_handles_multiple_entries_tangency_and_boundary_lines() -> None:
curve = Cubic((-2, 0), (4, 0), (-4, 0), (2, 0))
rectangle = (-0.1, -1.0, 0.1, 1.0)
assert len(curve.inside_intervals(rectangle)) == 3
boundary = Cubic((-2, 0), (-1, 0), (1, 0), (2, 0))
assert len(boundary.clipped((-1, 0, 1, 1))) == 1
# Only touches the lower edge; it has no visible interval inside the rectangle.
tangent = Cubic((0, -3), (1, 1), (2, 1), (3, -3))
assert tangent.clipped((-1, 0, 4, 4)) == []
def test_random_clipping_matches_dense_curve_membership() -> None:
randomizer = random.Random(42)
rectangle = (-1.0, -1.0, 1.0, 1.0)
for _ in range(100):
points = [(randomizer.uniform(-3, 3), randomizer.uniform(-3, 3)) for _ in range(4)]
curve = Cubic(*points)
intervals = curve.inside_intervals(rectangle)
for index in range(201):
t = index / 200
x, y = curve.point(t)
inside = -1 <= x <= 1 and -1 <= y <= 1
selected = any(first - 1e-10 <= t <= last + 1e-10 for first, last in intervals)
assert selected == inside
for portion in curve.clipped(rectangle):
for index in range(51):
x, y = portion.point(index / 50)
assert -1 - 1e-8 <= x <= 1 + 1e-8 and -1 - 1e-8 <= y <= 1 + 1e-8
def test_transformation_preserves_bezier_shape() -> None:
curve = Cubic((0, 0), (1, 2), (2, -1), (3, 0))
transformed = curve.transform([2, 0, 0, -2, 10, 20])
for t in (0, 0.1, 0.6, 1):
x, y = curve.point(t)
assert transformed.point(t) == pytest.approx((2 * x + 10, -2 * y + 20))
+563
View File
@@ -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")
+92
View File
@@ -0,0 +1,92 @@
import sqlite3
from pathlib import Path
import pytest
from flight_map.cli import main
from flight_map.geo import DEFAULT_BOUNDS
from flight_map.storage import (
Observation,
create_session,
database,
get_session,
initialize,
load_observations,
purge_session,
save_observations,
)
def seed(path: Path) -> tuple[str, str]:
with database(path) as connection:
initialize(connection)
sessions = [create_session(connection, DEFAULT_BOUNDS, 2000, 30, 100) for _ in range(2)]
for session in sessions:
save_observations(
connection,
session,
[
Observation("abc123", "TEST", 110, 4.82, 52.24, 900, 1000),
],
120,
)
return sessions[0].id, sessions[1].id
def test_purge_only_selected_session(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
path = tmp_path / "test.sqlite"
target, other = seed(path)
assert main(["purge", "--db", str(path), "--session", target, "--yes"]) == 0
assert f"Purged session {target} and 1 observation(s)." in capsys.readouterr().out
with database(path, readonly=True) as connection:
with pytest.raises(ValueError, match="not found"):
get_session(connection, target)
assert load_observations(connection, target, 100, 120) == []
assert get_session(connection, other).bounds == DEFAULT_BOUNDS
assert len(load_observations(connection, other, 100, 120)) == 1
assert connection.execute("PRAGMA foreign_key_check").fetchall() == []
def test_confirmation_and_unknown_session_preserve_data(tmp_path: Path) -> None:
path = tmp_path / "test.sqlite"
target, _ = seed(path)
before = path.read_bytes()
with pytest.raises(SystemExit):
main(["purge", "--db", str(path), "--session", target])
assert path.read_bytes() == before
assert main(["purge", "--db", str(path), "--session", "missing", "--yes"]) == 1
assert path.read_bytes() == before
def test_missing_database_is_not_created(tmp_path: Path) -> None:
path = tmp_path / "missing.sqlite"
assert main(["purge", "--db", str(path), "--session", "missing", "--yes"]) == 1
assert not path.exists()
def test_purge_rolls_back_observations_if_session_delete_fails(tmp_path: Path) -> None:
path = tmp_path / "test.sqlite"
target, _ = seed(path)
with database(path) as connection:
connection.execute("""
CREATE TRIGGER prevent_session_delete BEFORE DELETE ON sessions
BEGIN SELECT RAISE(ABORT, 'test failure'); END
""")
connection.commit()
with pytest.raises(sqlite3.IntegrityError, match="test failure"):
purge_session(connection, target)
assert get_session(connection, target).id == target
assert len(load_observations(connection, target, 100, 120)) == 1
connection.execute("DROP TRIGGER prevent_session_delete")
connection.commit()
assert purge_session(connection, target) == 1
def test_empty_session_can_be_purged(tmp_path: Path) -> None:
path = tmp_path / "test.sqlite"
with database(path) as connection:
initialize(connection)
session = create_session(connection, DEFAULT_BOUNDS, 2000, 30, 100)
assert purge_session(connection, session.id) == 0
with pytest.raises(ValueError, match="not found"):
get_session(connection, session.id)