agenda/tests/test_trip.py

325 lines
9.9 KiB
Python

"""Tests for trip map coordinate assembly."""
import json
import pathlib
from datetime import date, datetime, timezone
import agenda.trip
import pytest
from agenda.types import Trip
from web_view import app
def test_add_coordinates_for_unbooked_flights_adds_missing_airports() -> None:
"""Unbooked routes should contribute missing airport pins."""
routes = [
{
"type": "unbooked_flight",
"key": "LHR_Paris_fr",
"from_iata": "LHR",
"to_iata": "CDG",
"from": (51.47, -0.45),
"to": (49.01, 2.55),
}
]
coordinates = [
{
"name": "Heathrow Airport",
"type": "airport",
"latitude": 51.47,
"longitude": -0.45,
}
]
airports = {
"LHR": {
"name": "Heathrow Airport",
"latitude": 51.47,
"longitude": -0.45,
},
"CDG": {
"name": "Paris Charles de Gaulle Airport",
"latitude": 49.01,
"longitude": 2.55,
},
}
with app.app_context():
original_parse_yaml = agenda.trip.travel.parse_yaml
try:
agenda.trip.travel.parse_yaml = lambda _name, _data_dir: airports
agenda.trip.add_coordinates_for_unbooked_flights(
routes, coordinates, app.config["PERSONAL_DATA"]
)
finally:
agenda.trip.travel.parse_yaml = original_parse_yaml
airport_names = {
coord["name"] for coord in coordinates if coord["type"] == "airport"
}
assert airport_names == {"Heathrow Airport", "Paris Charles de Gaulle Airport"}
def test_get_coordinates_and_routes_adds_unbooked_flight_airports() -> None:
"""Trip list map data should include pins for unbooked flights."""
trips = [Trip(start=date(2026, 7, 20))]
unbooked_routes = [
{
"type": "unbooked_flight",
"key": "LHR_Paris_fr",
"from_iata": "LHR",
"to_iata": "CDG",
"from": (51.47, -0.45),
"to": (49.01, 2.55),
}
]
airports = {
"LHR": {
"name": "Heathrow Airport",
"latitude": 51.47,
"longitude": -0.45,
},
"CDG": {
"name": "Paris Charles de Gaulle Airport",
"latitude": 49.01,
"longitude": 2.55,
},
}
with app.app_context():
original_collect_trip_coordinates = agenda.trip.collect_trip_coordinates
original_get_trip_routes = agenda.trip.get_trip_routes
original_parse_yaml = agenda.trip.travel.parse_yaml
try:
agenda.trip.collect_trip_coordinates = lambda _trip: []
agenda.trip.get_trip_routes = lambda _trip, _data_dir: unbooked_routes
agenda.trip.travel.parse_yaml = lambda _name, _data_dir: airports
coordinates, _routes = agenda.trip.get_coordinates_and_routes(trips)
finally:
agenda.trip.collect_trip_coordinates = original_collect_trip_coordinates
agenda.trip.get_trip_routes = original_get_trip_routes
agenda.trip.travel.parse_yaml = original_parse_yaml
airport_names = {
coord["name"] for coord in coordinates if coord["type"] == "airport"
}
assert airport_names == {"Heathrow Airport", "Paris Charles de Gaulle Airport"}
def test_get_trip_routes_assumes_unbooked_paris_trip_is_by_train(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Paris conferences without booked travel should show rail, not flight."""
trip = Trip(
start=date(2026, 7, 20),
conferences=[
{
"name": "Paris Conf",
"location": "Paris",
"country": "fr",
}
],
)
stations = [
{
"name": "London St Pancras",
"latitude": 51.531921,
"longitude": -0.126361,
"routes": {"Paris Gare du Nord": "London_St_Pancras_to_Paris_Gare_du_Nord"},
},
{
"name": "Paris Gare du Nord",
"latitude": 48.88111111111111,
"longitude": 2.355277777777778,
"routes": {"London St Pancras": "London_St_Pancras_to_Paris_Gare_du_Nord"},
},
]
def fake_parse_yaml(name: str, data_dir: str) -> object:
if name == "stations":
return stations
if name == "airports":
return {
"LHR": {
"name": "Heathrow Airport",
"latitude": 51.47,
"longitude": -0.45,
},
"CDG": {
"name": "Paris Charles de Gaulle Airport",
"city": "Paris",
"country": "fr",
"latitude": 49.01,
"longitude": 2.55,
},
}
raise AssertionError(f"unexpected YAML load: {name}")
monkeypatch.setattr(agenda.trip.travel, "parse_yaml", fake_parse_yaml)
monkeypatch.setattr(
agenda.trip, "load_flight_destination_rules", lambda _data_dir: []
)
routes = agenda.trip.get_trip_routes(trip, "/tmp/personal-data")
assert routes == [
{
"type": "train",
"key": "train_London St Pancras_Paris Gare du Nord",
"geojson_filename": "train_routes/London_St_Pancras_to_Paris_Gare_du_Nord",
}
]
def test_load_cars_infers_route_labels_and_home_marker(
tmp_path: pathlib.Path,
) -> None:
"""Car journeys should load direct GeoJSON routes and home endpoint markers."""
(tmp_path / "car_routes").mkdir()
(tmp_path / "car_journeys.yaml").write_text("""---
- trip: 2026-07-16
depart: 2026-07-16
arrive: 2026-07-16
route: PCH_to_EMF.geojson
""")
(tmp_path / "car_routes" / "PCH_to_EMF.geojson").write_text(
json.dumps(
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "LineString",
"coordinates": [[-2.60283, 51.44083], [-1.2, 52.0]],
},
}
)
)
cars = agenda.trip.load_cars(str(tmp_path))
assert len(cars) == 1
assert cars[0]["type"] == "car"
assert cars[0]["from"] == "PCH"
assert cars[0]["to"] == "EMF"
assert cars[0]["geojson_filename"] == "PCH_to_EMF"
assert cars[0]["distance"] > 0
assert cars[0]["co2_kg"] == pytest.approx(
cars[0]["distance"] * agenda.trip.CAR_CO2_KG_PER_KM
)
assert cars[0]["from_location"] == {
"name": "PCH",
"type": "home",
"latitude": 51.44083,
"longitude": -2.60283,
}
assert "to_location" not in cars[0]
def test_load_cars_can_show_endpoint_markers(tmp_path: pathlib.Path) -> None:
"""Car endpoint markers should be opt-in."""
(tmp_path / "car_routes").mkdir()
(tmp_path / "car_journeys.yaml").write_text("""---
- trip: 2026-07-16
depart: 2026-07-16
arrive: 2026-07-16
route: PCH_to_EMF.geojson
show_markers: true
""")
(tmp_path / "car_routes" / "PCH_to_EMF.geojson").write_text(
json.dumps(
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "LineString",
"coordinates": [[-2.60283, 51.44083], [-1.2, 52.0]],
},
}
)
)
cars = agenda.trip.load_cars(str(tmp_path))
assert cars[0]["from_location"] == {
"name": "PCH",
"type": "home",
"latitude": 51.44083,
"longitude": -2.60283,
}
assert cars[0]["to_location"] == {
"name": "EMF",
"type": "car_stop",
"latitude": 52.0,
"longitude": -1.2,
}
def test_get_trip_routes_includes_car_geojson() -> None:
"""Car routes should render from the car_routes directory."""
trip = Trip(
start=date(2026, 7, 16),
travel=[
{
"type": "car",
"from": "PCH",
"to": "EMF",
"geojson_filename": "PCH_to_EMF",
}
],
)
routes = agenda.trip.get_trip_routes(trip, "/tmp/personal-data")
assert routes == [
{
"type": "car",
"key": "car_PCH_EMF_PCH_to_EMF",
"geojson_filename": "car_routes/PCH_to_EMF",
}
]
def test_trip_title_ignores_generated_drive_stop_labels() -> None:
"""Fallback trip titles should ignore synthetic split-car stop labels."""
trip = Trip(
start=date(2025, 12, 29),
travel=[
{
"type": "car",
"depart": datetime(2025, 12, 29, 11, 4, tzinfo=timezone.utc),
"arrive": datetime(2025, 12, 29, 11, 10, tzinfo=timezone.utc),
"from": "PCH",
"to": "Drive stop 1",
},
{
"type": "car",
"depart": datetime(2025, 12, 29, 11, 27, tzinfo=timezone.utc),
"arrive": datetime(2025, 12, 29, 11, 40, tzinfo=timezone.utc),
"from": "Drive stop 1",
"to": "Drive stop 2",
},
{
"type": "car",
"depart": datetime(2025, 12, 29, 14, 6, tzinfo=timezone.utc),
"arrive": datetime(2025, 12, 29, 15, 22, tzinfo=timezone.utc),
"from": "Drive stop 2",
"to": "St Ives",
},
{
"type": "car",
"depart": datetime(2026, 1, 5, 12, 46, tzinfo=timezone.utc),
"arrive": datetime(2026, 1, 5, 13, 0, tzinfo=timezone.utc),
"from": "St Ives",
"to": "Drive stop 1",
},
{
"type": "car",
"depart": datetime(2026, 1, 5, 16, 58, tzinfo=timezone.utc),
"arrive": datetime(2026, 1, 5, 17, 29, tzinfo=timezone.utc),
"from": "Drive stop 3",
"to": "PCH",
},
],
)
assert trip.title == "St Ives"