Replace the Eurostar timetable link with the search URL (eurostar.com/search/uk-en?adult=1&origin=…&destination=…&outbound=…) so the footer links directly to the page that shows prices for the specific date and destination. Add a Bristol Temple Meads → Paddington departures link on RTT alongside the existing Paddington arrivals link. Also update "morning service unavailable" badge and tests to reflect the removal of the morning-only cutoff filter from find_unreachable_morning_eurostars. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
218 lines
8.9 KiB
Python
218 lines
8.9 KiB
Python
import pytest
|
||
from trip_planner import combine_trips, find_unreachable_morning_eurostars, _fmt_duration, cheapest_gwr_ticket
|
||
|
||
DATE = '2026-03-30'
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _fmt_duration
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_fmt_duration_hours_and_minutes():
|
||
assert _fmt_duration(95) == '1h 35m'
|
||
|
||
def test_fmt_duration_exact_hours():
|
||
assert _fmt_duration(120) == '2h'
|
||
|
||
def test_fmt_duration_minutes_only():
|
||
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 — over limit
|
||
|
||
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():
|
||
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():
|
||
# 2h 26m GWR journey exceeds MAX_GWR_MINUTES (110)
|
||
trips = combine_trips([GWR_SLOW], [ES_PARIS], DATE)
|
||
assert trips == []
|
||
|
||
|
||
def test_eurostar_too_early_excluded():
|
||
# Eurostar departs before min connection time has elapsed
|
||
trips = combine_trips([GWR_FAST], [ES_EARLY], DATE)
|
||
assert trips == []
|
||
|
||
|
||
def test_no_trains_returns_empty():
|
||
assert combine_trips([], [], DATE) == []
|
||
|
||
def test_no_gwr_returns_empty():
|
||
assert combine_trips([], [ES_PARIS], DATE) == []
|
||
|
||
def test_no_eurostar_returns_empty():
|
||
assert combine_trips([GWR_FAST], [], DATE) == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Connection window constraints
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_min_connection_enforced():
|
||
# 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():
|
||
# 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():
|
||
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():
|
||
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():
|
||
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE)
|
||
assert trips[0]['gwr_duration'] == '1h 45m'
|
||
|
||
|
||
def test_total_duration_in_trip():
|
||
# 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():
|
||
# 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():
|
||
# 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']
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# cheapest_gwr_ticket — Bristol Temple Meads → Paddington
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# 2026-03-30 is a Monday; 2026-03-28 is a Saturday
|
||
|
||
def test_cheapest_ticket_weekday_super_off_peak_morning():
|
||
# 05:00 on Monday: dep ≤ 05:04 → Super Off-Peak
|
||
t = cheapest_gwr_ticket('05:00', '2026-03-30')
|
||
assert t['ticket'] == 'Super Off-Peak'
|
||
assert t['price'] == 45.00
|
||
|
||
def test_cheapest_ticket_weekday_anytime_window():
|
||
# 07:00 on Monday: 05:05–08:25 → Anytime only
|
||
t = cheapest_gwr_ticket('07:00', '2026-03-30')
|
||
assert t['ticket'] == 'Anytime'
|
||
assert t['price'] == 138.70
|
||
|
||
def test_cheapest_ticket_weekday_off_peak():
|
||
# 08:30 on Monday: dep ≥ 08:26 but < 09:58 → Off-Peak
|
||
t = cheapest_gwr_ticket('08:30', '2026-03-30')
|
||
assert t['ticket'] == 'Off-Peak'
|
||
assert t['price'] == 63.60
|
||
|
||
def test_cheapest_ticket_weekday_super_off_peak_late():
|
||
# 10:00 on Monday: dep ≥ 09:58 → Super Off-Peak
|
||
t = cheapest_gwr_ticket('10:00', '2026-03-30')
|
||
assert t['ticket'] == 'Super Off-Peak'
|
||
assert t['price'] == 45.00
|
||
|
||
def test_cheapest_ticket_boundary_super_off_peak_cutoff():
|
||
# 05:04 is last valid minute for early Super Off-Peak
|
||
assert cheapest_gwr_ticket('05:04', '2026-03-30')['ticket'] == 'Super Off-Peak'
|
||
# 05:05 falls into the Anytime window (off-peak starts at 08:26)
|
||
assert cheapest_gwr_ticket('05:05', '2026-03-30')['ticket'] == 'Anytime'
|
||
|
||
def test_cheapest_ticket_boundary_off_peak_start():
|
||
assert cheapest_gwr_ticket('08:25', '2026-03-30')['ticket'] == 'Anytime'
|
||
assert cheapest_gwr_ticket('08:26', '2026-03-30')['ticket'] == 'Off-Peak'
|
||
|
||
def test_cheapest_ticket_boundary_super_off_peak_resumes():
|
||
assert cheapest_gwr_ticket('09:57', '2026-03-30')['ticket'] == 'Off-Peak'
|
||
assert cheapest_gwr_ticket('09:58', '2026-03-30')['ticket'] == 'Super Off-Peak'
|
||
|
||
def test_cheapest_ticket_weekend_always_super_off_peak():
|
||
# Saturday — no restrictions
|
||
t = cheapest_gwr_ticket('07:00', '2026-03-28')
|
||
assert t['ticket'] == 'Super Off-Peak'
|
||
assert t['price'] == 45.00
|
||
|
||
def test_combine_trips_includes_ticket_fields():
|
||
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_find_unreachable_eurostars_returns_empty_when_all_connectable():
|
||
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) == []
|