import pytest from trip_planner import ( combine_inbound_trips, combine_trips, find_unreachable_morning_eurostars, _fmt_duration, ) DATE = "2026-03-30" # --------------------------------------------------------------------------- # _fmt_duration # --------------------------------------------------------------------------- def test_fmt_duration_hours_and_minutes() -> None: assert _fmt_duration(95) == "1h 35m" def test_fmt_duration_exact_hours() -> None: assert _fmt_duration(120) == "2h" def test_fmt_duration_minutes_only() -> None: assert _fmt_duration(45) == "45m" # --------------------------------------------------------------------------- # combine_trips — basic pairing # --------------------------------------------------------------------------- GWR_FAST = {"depart_bristol": "07:00", "arrive_paddington": "08:45"} # 1h 45m GWR_SLOW = { "depart_bristol": "07:00", "arrive_paddington": "09:26", } # 2h 26m — connection too short for ES_PARIS ES_PARIS = { "depart_st_pancras": "10:01", "arrive_destination": "13:34", "destination": "Paris Gare du Nord", } ES_EARLY = { "depart_st_pancras": "09:00", "arrive_destination": "12:00", "destination": "Paris Gare du Nord", } def test_valid_trip_is_returned() -> None: trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) assert len(trips) == 1 t = trips[0] assert t["depart_bristol"] == "07:00" assert t["arrive_paddington"] == "08:45" assert t["depart_st_pancras"] == "10:01" assert t["arrive_destination"] == "13:34" assert t["destination"] == "Paris Gare du Nord" def test_gwr_too_slow_excluded() -> None: # arrive 09:26, Eurostar 10:01 → 35 min connection < 50 min minimum trips = combine_trips([GWR_SLOW], [ES_PARIS], DATE) assert trips == [] def test_eurostar_too_early_excluded() -> None: # Eurostar departs before min connection time has elapsed trips = combine_trips([GWR_FAST], [ES_EARLY], DATE) assert trips == [] def test_no_trains_returns_empty() -> None: assert combine_trips([], [], DATE) == [] def test_no_gwr_returns_empty() -> None: assert combine_trips([], [ES_PARIS], DATE) == [] def test_no_eurostar_returns_empty() -> None: assert combine_trips([GWR_FAST], [], DATE) == [] # --------------------------------------------------------------------------- # Connection window constraints # --------------------------------------------------------------------------- def test_min_connection_enforced() -> None: # Arrive Paddington 08:45, need 75 min → earliest St Pancras 10:00 # ES at 09:59 should be excluded, 10:00 should be included es_too_close = { "depart_st_pancras": "09:59", "arrive_destination": "13:00", "destination": "Paris Gare du Nord", } es_ok = { "depart_st_pancras": "10:00", "arrive_destination": "13:00", "destination": "Paris Gare du Nord", } assert ( combine_trips([GWR_FAST], [es_too_close], DATE, min_connection_minutes=75) == [] ) trips = combine_trips([GWR_FAST], [es_ok], DATE, min_connection_minutes=75) assert len(trips) == 1 def test_max_connection_enforced() -> None: # Arrive Paddington 08:45, max 140 min → latest St Pancras 11:05 es_ok = { "depart_st_pancras": "11:05", "arrive_destination": "14:00", "destination": "Paris Gare du Nord", } es_too_late = { "depart_st_pancras": "11:06", "arrive_destination": "14:00", "destination": "Paris Gare du Nord", } trips = combine_trips([GWR_FAST], [es_ok], DATE, max_connection_minutes=140) assert len(trips) == 1 assert ( combine_trips([GWR_FAST], [es_too_late], DATE, max_connection_minutes=140) == [] ) # --------------------------------------------------------------------------- # Only earliest valid Eurostar per GWR departure # --------------------------------------------------------------------------- def test_only_earliest_eurostar_per_gwr() -> None: es1 = { "depart_st_pancras": "10:01", "arrive_destination": "13:34", "destination": "Paris Gare du Nord", } es2 = { "depart_st_pancras": "11:01", "arrive_destination": "14:34", "destination": "Paris Gare du Nord", } trips = combine_trips([GWR_FAST], [es1, es2], DATE) assert len(trips) == 1 assert trips[0]["depart_st_pancras"] == "10:01" # --------------------------------------------------------------------------- # Multiple GWR trains → multiple trips # --------------------------------------------------------------------------- def test_multiple_gwr_trains() -> None: gwr2 = {"depart_bristol": "08:00", "arrive_paddington": "09:45"} es = { "depart_st_pancras": "11:01", "arrive_destination": "14:34", "destination": "Paris Gare du Nord", } trips = combine_trips([GWR_FAST, gwr2], [es], DATE, max_connection_minutes=140) assert len(trips) == 2 assert trips[0]["depart_bristol"] == "07:00" assert trips[1]["depart_bristol"] == "08:00" # --------------------------------------------------------------------------- # Duration fields # --------------------------------------------------------------------------- def test_gwr_duration_in_trip() -> None: trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) assert trips[0]["gwr_duration"] == "1h 45m" def test_total_duration_in_trip() -> None: # depart 07:00, arrive 13:34 → 6h 34m trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) assert trips[0]["total_duration"] == "6h 34m" def test_connection_duration_in_trip() -> None: # arrive Paddington 08:45, depart St Pancras 10:01 → 1h 16m trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) assert trips[0]["connection_duration"] == "1h 16m" def test_find_unreachable_eurostars_excludes_connectable_services() -> None: # GWR arrives 08:45; default min=50/max=110 → viable window 09:35–10:35. # 09:30 too early, 10:15 connectable, 12:30 beyond max connection. gwr = [ {"depart_bristol": "07:00", "arrive_paddington": "08:45"}, ] eurostar = [ { "depart_st_pancras": "09:30", "arrive_destination": "12:00", "destination": "Paris Gare du Nord", "train_number": "ES 9001", }, { "depart_st_pancras": "10:15", "arrive_destination": "13:40", "destination": "Paris Gare du Nord", "train_number": "ES 9002", }, { "depart_st_pancras": "12:30", "arrive_destination": "15:55", "destination": "Paris Gare du Nord", "train_number": "ES 9003", }, ] unreachable = find_unreachable_morning_eurostars(gwr, eurostar, DATE) assert [s["depart_st_pancras"] for s in unreachable] == ["09:30", "12:30"] def test_combine_trips_includes_ticket_fields() -> None: trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) assert len(trips) == 1 t = trips[0] assert "ticket_name" in t assert "ticket_price" in t assert "ticket_code" in t def test_combine_trips_uses_gwr_fares_when_provided() -> None: fares = { "07:00": {"ticket": "Super Off-Peak Single", "price": 49.30, "code": "SSS"} } trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares=fares) assert len(trips) == 1 assert trips[0]["ticket_price"] == 49.30 assert trips[0]["ticket_code"] == "SSS" def test_combine_trips_ticket_price_none_when_no_fares() -> None: trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares={}) assert len(trips) == 1 assert trips[0]["ticket_price"] is None def test_find_unreachable_eurostars_returns_empty_when_all_connectable() -> None: gwr = [ {"depart_bristol": "07:00", "arrive_paddington": "08:45"}, ] eurostar = [ { "depart_st_pancras": "10:15", "arrive_destination": "13:40", "destination": "Paris Gare du Nord", "train_number": "ES 9002", }, ] assert find_unreachable_morning_eurostars(gwr, eurostar, DATE) == [] def test_combine_inbound_trips_pairs_eurostar_to_paddington_departure() -> None: eurostar = [ { "depart_destination": "15:12", "arrive_st_pancras": "16:30", "destination": "Paris Gare du Nord", "train_number": "ES 9035", } ] gwr = [ { "depart_paddington": "17:15", "arrive_destination": "18:55", "headcode": "1B99", } ] fares = {"17:15": {"ticket": "Off-Peak Single", "price": 63.60, "code": "SVS"}} trips = combine_inbound_trips( eurostar, gwr, DATE, min_connection_minutes=30, max_connection_minutes=120, gwr_fares=fares, ) assert len(trips) == 1 assert trips[0]["depart_destination"] == "15:12" assert trips[0]["arrive_st_pancras"] == "16:30" assert trips[0]["depart_paddington"] == "17:15" assert trips[0]["arrive_uk_station"] == "18:55" assert trips[0]["ticket_price"] == 63.60 assert trips[0]["check_in_by"] == "14:42"