"""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"