From f9b79d5a511038e7ada6fe6539cfd790c4da3ffb Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 7 Mar 2026 12:58:38 +0000 Subject: [PATCH] Fix trip map unbooked flight pins and timezone-safe busy date comparisons --- agenda/busy.py | 21 +++++--- tests/test_busy_timezone.py | 35 +++++++++++++ tests/test_trip.py | 101 ++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 tests/test_busy_timezone.py create mode 100644 tests/test_trip.py diff --git a/agenda/busy.py b/agenda/busy.py index 582bbce..7dc744c 100644 --- a/agenda/busy.py +++ b/agenda/busy.py @@ -2,7 +2,7 @@ import itertools import typing -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone import flask import pycountry @@ -84,13 +84,20 @@ def get_busy_events( def _parse_datetime_field(datetime_obj: datetime | date | str) -> tuple[datetime, date]: """Parse a datetime field that could be datetime object or string.""" if isinstance(datetime_obj, datetime): - return datetime_obj, datetime_obj.date() - if isinstance(datetime_obj, date): - return datetime.combine(datetime_obj, datetime.min.time()), datetime_obj - if isinstance(datetime_obj, str): + dt = datetime_obj + elif isinstance(datetime_obj, date): + dt = datetime.combine(datetime_obj, datetime.min.time(), tzinfo=timezone.utc) + elif isinstance(datetime_obj, str): dt = datetime.fromisoformat(datetime_obj.replace("Z", "+00:00")) - return dt, dt.date() - raise ValueError(f"Invalid datetime format: {datetime_obj}") + else: + raise ValueError(f"Invalid datetime format: {datetime_obj}") + + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + + return dt, dt.date() def _get_accommodation_location( diff --git a/tests/test_busy_timezone.py b/tests/test_busy_timezone.py new file mode 100644 index 0000000..73d35dd --- /dev/null +++ b/tests/test_busy_timezone.py @@ -0,0 +1,35 @@ +"""Regression tests for timezone handling in busy location logic.""" + +from datetime import date, datetime, timezone + +import agenda.busy +from agenda.types import Trip + + +def test_mixed_naive_and_aware_arrivals_do_not_crash() -> None: + """Most recent travel should compare mixed timezone styles safely.""" + trips = [ + Trip( + start=date(2099, 12, 30), + travel=[ + { + "type": "flight", + "arrive": datetime(2100, 1, 1, 10, 0, 0), + "to": "CDG", + "to_airport": {"country": "fr", "city": "Paris"}, + }, + { + "type": "flight", + "arrive": datetime(2100, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "to": "AMS", + "to_airport": {"country": "nl", "city": "Amsterdam"}, + }, + ], + ) + ] + + location = agenda.busy._find_most_recent_travel_before_date(date(2100, 1, 1), trips) + assert location is not None + assert location[0] == "Amsterdam" + assert location[1] is not None + assert location[1].alpha_2 == "NL" diff --git a/tests/test_trip.py b/tests/test_trip.py new file mode 100644 index 0000000..8c07a3c --- /dev/null +++ b/tests/test_trip.py @@ -0,0 +1,101 @@ +"""Tests for trip map coordinate assembly.""" + +from datetime import date + +import agenda.trip +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) + 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"}