- Support any station with direct trains to Paddington; station CRS code is now part of the URL (/results/<crs>/<slug>/<date>) - Load station list from data/direct_to_paddington.tsv; show dropdown on index page; 404 for unknown station codes - Fetch live GWR walk-on fares via api.gwr.com for all stations (SSS/SVS/SDS with restrictions already applied per train); cache 30 days - Scrape Paddington arrival platform numbers from RTT - Show unreachable morning Eurostars (before first reachable service only) - Circle line: show actual KX St Pancras arrival times (not check-in estimate) and add a second backup service in the transfer column - Widen page max-width to 1100px for longer station names Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
180 lines
7.3 KiB
Python
180 lines
7.3 KiB
Python
import pytest
|
||
from trip_planner import combine_trips, find_unreachable_morning_eurostars, _fmt_duration
|
||
|
||
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 — 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():
|
||
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():
|
||
# 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():
|
||
# 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']
|
||
|
||
|
||
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_combine_trips_uses_gwr_fares_when_provided():
|
||
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():
|
||
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():
|
||
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) == []
|