agenda/tests/test_trip.py

619 lines
19 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 StrDict, 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_get_trip_routes_adds_bristol_feeder_for_london_eurostar(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Booked London Eurostar trips should show the assumed Bristol rail feeder."""
london_st_pancras = {
"name": "London St Pancras",
"latitude": 51.531921,
"longitude": -0.126361,
"routes": {"Paris Gare du Nord": "London_St_Pancras_to_Paris_Gare_du_Nord"},
}
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"},
}
bristol_temple_meads = {
"name": "Bristol Temple Meads",
"latitude": 51.449,
"longitude": -2.581,
"routes": {"London Paddington": "gwml"},
}
london_paddington = {
"name": "London Paddington",
"latitude": 51.516,
"longitude": -0.176,
"routes": {"Bristol Temple Meads": "gwml"},
}
trip = Trip(
start=date(2026, 7, 20),
travel=[
{
"type": "train",
"from": "London St Pancras",
"to": "Paris Gare du Nord",
"operator": "Eurostar",
"from_station": london_st_pancras,
"to_station": paris_gare_du_nord,
"legs": [
{
"from": "London St Pancras",
"to": "Paris Gare du Nord",
"operator": "Eurostar",
"from_station": london_st_pancras,
"to_station": paris_gare_du_nord,
}
],
}
],
)
stations = [
london_st_pancras,
paris_gare_du_nord,
bristol_temple_meads,
london_paddington,
]
def fake_parse_yaml(name: str, data_dir: str) -> object:
if name == "stations":
return stations
raise AssertionError(f"unexpected YAML load: {name}")
monkeypatch.setattr(agenda.trip.travel, "parse_yaml", fake_parse_yaml)
routes = agenda.trip.get_trip_routes(trip, "/tmp/personal-data")
assert routes == [
{
"type": "train",
"key": "train_Bristol Temple Meads_London Paddington",
"geojson_filename": "train_routes/gwml",
},
{
"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_add_coordinates_for_eurostar_home_feeder_adds_bristol_station(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""London Eurostar trips should get an assumed Bristol station pin."""
london_st_pancras = {
"name": "London St Pancras",
"latitude": 51.531921,
"longitude": -0.126361,
}
paris_gare_du_nord = {
"name": "Paris Gare du Nord",
"latitude": 48.88111111111111,
"longitude": 2.355277777777778,
}
bristol_temple_meads = {
"name": "Bristol Temple Meads",
"latitude": 51.449,
"longitude": -2.581,
}
trip = Trip(
start=date(2026, 7, 20),
travel=[
{
"type": "train",
"from": "London St Pancras",
"to": "Paris Gare du Nord",
"operator": "Eurostar",
"from_station": london_st_pancras,
"to_station": paris_gare_du_nord,
"legs": [
{
"from": "London St Pancras",
"to": "Paris Gare du Nord",
"operator": "Eurostar",
"from_station": london_st_pancras,
"to_station": paris_gare_du_nord,
}
],
}
],
)
def fake_parse_yaml(name: str, data_dir: str) -> object:
if name == "stations":
return [london_st_pancras, paris_gare_du_nord, bristol_temple_meads]
raise AssertionError(f"unexpected YAML load: {name}")
monkeypatch.setattr(agenda.trip.travel, "parse_yaml", fake_parse_yaml)
coordinates: list[StrDict] = []
agenda.trip.add_coordinates_for_eurostar_home_feeder(
trip, coordinates, "/tmp/personal-data"
)
assert coordinates == [
{
"name": "Bristol Temple Meads",
"type": "station",
"latitude": 51.449,
"longitude": -2.581,
}
]
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"
def test_conference_free_days_ignores_morning_train_departure_after() -> None:
"""A morning train on the day after a conference leaves no explore day."""
trip = Trip(
start=date(2027, 1, 29),
conferences=[
{
"name": "FOSDEM",
"start": date(2027, 1, 30),
"end": date(2027, 1, 31),
}
],
travel=[
{
"type": "train",
"depart": datetime(2027, 2, 1, 8, 57, tzinfo=timezone.utc),
"arrive": datetime(2027, 2, 1, 9, 57, tzinfo=timezone.utc),
"legs": [
{
"depart": datetime(2027, 2, 1, 8, 57, tzinfo=timezone.utc),
"arrive": datetime(2027, 2, 1, 9, 57, tzinfo=timezone.utc),
}
],
}
],
)
assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 0)
def test_conference_free_days_ignores_afternoon_train_arrival_before() -> None:
"""An afternoon train on the day before a conference leaves no explore day."""
trip = Trip(
start=date(2027, 1, 29),
conferences=[
{
"name": "FOSDEM",
"start": date(2027, 1, 30),
"end": date(2027, 1, 31),
}
],
travel=[
{
"type": "train",
"depart": datetime(2027, 1, 29, 13, 4, tzinfo=timezone.utc),
"arrive": datetime(2027, 1, 29, 16, 5, tzinfo=timezone.utc),
"legs": [
{
"depart": datetime(2027, 1, 29, 13, 4, tzinfo=timezone.utc),
"arrive": datetime(2027, 1, 29, 16, 5, tzinfo=timezone.utc),
}
],
}
],
)
assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (0, 0)
def test_conference_free_days_keeps_morning_train_arrival_before() -> None:
"""A morning train arrival still leaves the afternoon to explore."""
trip = Trip(
start=date(2027, 1, 29),
conferences=[
{
"name": "FOSDEM",
"start": date(2027, 1, 30),
"end": date(2027, 1, 31),
}
],
travel=[
{
"type": "train",
"depart": datetime(2027, 1, 29, 8, 0, tzinfo=timezone.utc),
"arrive": datetime(2027, 1, 29, 11, 30, tzinfo=timezone.utc),
}
],
)
assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 0)
def test_conference_free_days_keeps_date_only_arrival_before() -> None:
"""Date-only travel should not be treated as a known afternoon arrival."""
trip = Trip(
start=date(2027, 1, 29),
conferences=[
{
"name": "FOSDEM",
"start": date(2027, 1, 30),
"end": date(2027, 1, 31),
}
],
travel=[
{
"type": "train",
"depart": date(2027, 1, 29),
"arrive": date(2027, 1, 29),
}
],
)
assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 0)
def test_conference_free_days_keeps_afternoon_train_departure_after() -> None:
"""An afternoon train still leaves the morning to explore."""
trip = Trip(
start=date(2027, 1, 29),
conferences=[
{
"name": "FOSDEM",
"start": date(2027, 1, 30),
"end": date(2027, 1, 31),
}
],
travel=[
{
"type": "train",
"depart": datetime(2027, 2, 1, 13, 4, tzinfo=timezone.utc),
"arrive": datetime(2027, 2, 1, 16, 5, tzinfo=timezone.utc),
}
],
)
assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 1)
def test_conference_free_days_keeps_date_only_departure_after() -> None:
"""Date-only travel should not be treated as a known morning departure."""
trip = Trip(
start=date(2027, 1, 29),
conferences=[
{
"name": "FOSDEM",
"start": date(2027, 1, 30),
"end": date(2027, 1, 31),
}
],
travel=[
{
"type": "train",
"depart": date(2027, 2, 1),
"arrive": date(2027, 2, 1),
}
],
)
assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 1)