bristol-eurostar/tests/test_trip_planner.py
Edward Betts 804fcedfad Show cheapest GWR fare per journey and flag unreachable morning Eurostars
Add cheapest_gwr_ticket() to trip_planner.py encoding the SSS/SVS/SDS
walk-on single restrictions for Bristol Temple Meads → Paddington: on
weekdays, Super Off-Peak (£45) is valid before 05:05 or from 09:58,
Off-Peak (£63.60) from 08:26, and Anytime (£138.70) covers the gap.
Weekends have no restrictions. The fare is included in each trip dict
and displayed in a new GWR Fare column on the results page.

Also wire up find_unreachable_morning_eurostars() into the results view
so early Eurostar services unreachable from Bristol appear in the table,
with tests covering both features.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 10:22:47 +01:00

216 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_unreachable_morning_eurostars_lists_only_unreachable_morning_services():
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 [service['depart_st_pancras'] for service in unreachable] == ['09: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:0508: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_unreachable_morning_eurostars_returns_empty_when_morning_service_is_reachable():
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) == []