Add multi-station support, GWR fares API, and Circle line improvements

- 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>
This commit is contained in:
Edward Betts 2026-04-06 20:22:44 +01:00
parent 71be0dd8cf
commit 3c787b33d3
12 changed files with 810 additions and 262 deletions

View file

@ -6,16 +6,21 @@ def _client():
return app_module.app.test_client()
def _stub_data(monkeypatch, prices=None):
def _stub_data(monkeypatch, prices=None, gwr_fares=None):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch',
lambda travel_date, user_agent: [
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
],
)
monkeypatch.setattr(
app_module.gwr_fares_scraper,
'fetch',
lambda station_crs, travel_date: gwr_fares or {'07:00': {'ticket': 'Anytime Day Single', 'price': 138.70, 'code': 'SDS'}},
)
p = (prices or {}).get('10:01', {})
monkeypatch.setattr(
app_module.eurostar_scraper,
@ -33,7 +38,7 @@ def _stub_data(monkeypatch, prices=None):
)
def test_index_shows_fixed_departure_and_destination_radios():
def test_index_shows_station_dropdown_and_destination_radios():
client = _client()
resp = client.get('/')
@ -42,6 +47,7 @@ def test_index_shows_fixed_departure_and_destination_radios():
assert resp.status_code == 200
assert 'Departure point' in html
assert 'Bristol Temple Meads' in html
assert 'name="station_crs"' in html
assert html.count('type="radio"') == len(app_module.DESTINATIONS)
assert 'destination-rotterdam' in html
@ -49,11 +55,11 @@ def test_index_shows_fixed_departure_and_destination_radios():
def test_search_redirects_to_results_with_selected_params():
client = _client()
resp = client.get('/search?destination=rotterdam&travel_date=2026-04-10&min_connection=60&max_connection=120')
resp = client.get('/search?destination=rotterdam&travel_date=2026-04-10&min_connection=60&max_connection=120&station_crs=BRI')
assert resp.status_code == 302
assert resp.headers['Location'].endswith(
'/results/rotterdam/2026-04-10?min_connection=60&max_connection=120'
'/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120'
)
@ -61,14 +67,14 @@ def test_results_shows_same_day_destination_switcher(monkeypatch):
_stub_data(monkeypatch)
client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert 'Switch destination for Friday 10 April 2026' in html
assert '<span class="chip-current">Paris Gare du Nord</span>' in html
assert '/results/brussels/2026-04-10?min_connection=60&amp;max_connection=120' in html
assert '/results/rotterdam/2026-04-10?min_connection=60&amp;max_connection=120' in html
assert '/results/BRI/brussels/2026-04-10?min_connection=60&amp;max_connection=120' in html
assert '/results/BRI/rotterdam/2026-04-10?min_connection=60&amp;max_connection=120' in html
assert 'ES 9014' in html
@ -76,26 +82,27 @@ def test_results_title_and_social_meta_include_destination(monkeypatch):
_stub_data(monkeypatch)
client = _client()
resp = client.get('/results/lille/2026-04-10?min_connection=60&max_connection=120')
resp = client.get('/results/BRI/lille/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert '<title>Bristol to Lille Europe via Eurostar</title>' in html
assert '<meta property="og:title" content="Bristol to Lille Europe via Eurostar">' in html
assert '<title>Bristol Temple Meads to Lille Europe via Eurostar</title>' in html
assert '<meta property="og:title" content="Bristol Temple Meads to Lille Europe via Eurostar">' in html
assert (
'<meta property="og:description" content="Train options from Bristol Temple Meads '
'to Lille Europe on Friday 10 April 2026 via Paddington, St Pancras, and Eurostar.">'
) in html
assert '<meta property="og:url" content="http://localhost/results/lille/2026-04-10?min_connection=60&amp;max_connection=120">' in html
assert '<meta property="og:url" content="http://localhost/results/BRI/lille/2026-04-10?min_connection=60&amp;max_connection=120">' in html
def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypatch):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {})
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch',
lambda travel_date, user_agent: [
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:30', 'headcode': '1A01'},
{'depart_bristol': '07:05', 'arrive_paddington': '08:36', 'headcode': '1A02'},
{'depart_bristol': '07:10', 'arrive_paddington': '08:46', 'headcode': '1A03'},
@ -116,7 +123,7 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
)
client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True)
assert resp.status_code == 200
@ -130,13 +137,18 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
assert '5h 10m 🐢' not in html
def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
def test_results_shows_only_pre_first_reachable_unreachable_services(monkeypatch):
# GWR arrives 08:45; min=60 → earliest viable Eurostar 09:45; max=120 → latest 10:45.
# 09:30 too early → shown as "Too early"
# 10:15 reachable → shown as a trip (needs circle line XML, so not tested here)
# 12:30 after first reachable → hidden
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {})
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch',
lambda travel_date, user_agent: [
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
],
)
@ -151,15 +163,13 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
)
client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert '2 Eurostar services unavailable from Bristol' in html
assert '09:30' in html
assert 'ES 9001' in html
assert 'ES 9001' in html # before first reachable → shown
assert 'Too early' in html
assert html.index('09:30') < html.index('10:15')
assert 'ES 9003' not in html # after first reachable → hidden
def test_results_shows_eurostar_price_and_total(monkeypatch):
@ -167,7 +177,7 @@ def test_results_shows_eurostar_price_and_total(monkeypatch):
_stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42}})
client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True)
assert resp.status_code == 200
@ -175,13 +185,16 @@ def test_results_shows_eurostar_price_and_total(monkeypatch):
assert '£197.70' in html # Anytime £138.70 + ES £59
def test_results_can_show_only_unreachable_morning_services(monkeypatch):
def test_results_shows_unreachable_service_when_no_trips(monkeypatch):
# Only one Eurostar at 09:30; GWR arrives 08:45 with min=60 → unreachable.
# No trips at all, so the unreachable service is shown as "Too early".
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {})
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch',
lambda travel_date, user_agent: [
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
],
)
@ -194,11 +207,10 @@ def test_results_can_show_only_unreachable_morning_services(monkeypatch):
)
client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert 'No valid journeys found.' not in html
assert '1 Eurostar service unavailable from Bristol' in html
assert '09:30' in html
assert 'ES 9001' in html
assert 'Too early' in html
assert 'No valid journeys found.' not in html