Switch destination for {{ travel_date_display }}
- {% for destination_slug, destination_name in destinations.items() %}
+ {% for destination_slug, destination_name, destination_url in destination_links %}
{% if destination_slug == slug %}
{{ destination_name }}
{% else %}
{{ destination_name }}
{% endif %}
{% endfor %}
@@ -38,48 +59,645 @@
-
-
-
-
+
+
{% for mins in valid_max_connections %}
{% endfor %}
+ {% if journey_type == 'return' %}
+ {% for section in sections %}
+
+
{{ 'Outbound' if section.direction == 'outbound' else 'Return' }}:
+ {% if section.direction == 'inbound' %}
+
+
+
+ {% for mins in valid_inbound_return_min_connections %}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
NR:
+
+
+
+
+
+
+
+
Eurostar:
+
+
+
+
+
+
+ {% endfor %}
+
+ Loading fares
+ Loading advance fares
+
+ {% else %}
+ {% set section = sections[0] %}
+
+
+
NR ticket:
+
+
Load advance prices
+
+
+
+
+
+
Loading fares
+
Loading advance fares
+
+
+
Eurostar:
+
+
+
+
+
+
+
+ {% endif %}
- {{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }}
- ·
- {{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }}
+ {% if journey_type == 'return' %}
+ {% for section in sections %}
+ {% if section.direction == 'outbound' %}Outbound{% else %}Return{% endif %}:
+ {{ section.gwr_count }} National Rail service{{ 's' if section.gwr_count != 1 }},
+ {{ section.eurostar_count }} Eurostar service{{ 's' if section.eurostar_count != 1 }}
+ {% if not loop.last %} · {% endif %}
+ {% endfor %}
+ {% else %}
+ {{ gwr_count }} National Rail service{{ 's' if gwr_count != 1 }}
+ ·
+ {{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }}
+ {% endif %}
{% if from_cache %}
· (cached)
{% endif %}
+ {% if provisional_timetable %}
+ · checking exact timetable
+ {% endif %}
{% if error %}
@@ -93,301 +711,41 @@
{% endif %}
-{% if trips or unreachable_morning_services %}
-
- {% if trips %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% endif %}
-
-
-
- National Rail {{ departure_station_name }} → Paddington |
- Transfer Paddington → St Pancras |
- Eurostar St Pancras → {{ destination }} |
- Total |
-
-
-
- {% if trips %}
- {% set best_mins = trips | map(attribute='total_minutes') | min %}
- {% set worst_mins = trips | map(attribute='total_minutes') | max %}
- {% endif %}
- {% for row in result_rows %}
- {% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %}
- {% set row_class = 'row-fast' %}
- {% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
- {% set row_class = 'row-slow' %}
- {% elif row.row_type == 'unreachable' %}
- {% set row_class = 'row-unreachable' %}
- {% elif loop.index is odd %}
- {% set row_class = 'row-alt' %}
- {% else %}
- {% set row_class = '' %}
- {% endif %}
-
- {% if row.row_type == 'trip' %}
-
- {{ row.depart_bristol }} → {{ row.arrive_paddington }}
- ({{ row.gwr_duration }})
- {% if row.headcode or row.arrive_platform %}
-
- {%- if row.headcode %}{{ row.headcode }}{% endif %}
- {%- if row.headcode and row.arrive_platform %} · {% endif %}
- {%- if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}
-
- {% endif %}
- {% if row.ticket_price is not none %}
- £{{ "%.2f"|format(row.ticket_price) }}
- {{ row.ticket_name }}
- {% else %}
- –
- {% endif %}
-
- Adv std:
-
-
- Adv 1st:
-
- |
-
- {{ row.connection_duration }}{% if row.connection_minutes < 80 %} ⚠️{% endif %}
- {% if row.circle_services %}
- {% set c = row.circle_services[0] %}
- Circle {{ c.depart }} → KX {{ c.arrive_kx }} · £{{ "%.2f"|format(c.fare) }}
- {% if row.circle_services | length > 1 %}
- {% set c2 = row.circle_services[1] %}
- next {{ c2.depart }} → KX {{ c2.arrive_kx }} · £{{ "%.2f"|format(c2.fare) }}
- {% endif %}
- {% endif %}
- |
-
- {{ row.depart_st_pancras }} → {{ row.arrive_destination }} (CET)
- {% if row.eurostar_duration or row.train_number %}
-
- {%- if row.eurostar_duration %}({{ row.eurostar_duration }}){% endif %}
- {%- if row.eurostar_duration and row.train_number %} · {% endif %}
- {%- if row.train_number %}{% for part in row.train_number.split(' + ') %}{{ part }}{% if not loop.last %} + {% endif %}{% endfor %}{% endif %}
-
- {% endif %}
- {% if row.eurostar_price is not none %}
- £{{ "%.2f"|format(row.eurostar_price) }}
- Std{% if row.eurostar_seats is not none %} · {{ row.eurostar_seats }}{% endif %}
- {% else %}
- –
- {% endif %}
- {% if row.eurostar_plus_price is not none %}
- £{{ "%.2f"|format(row.eurostar_plus_price) }}
- Plus{% if row.eurostar_plus_seats is not none %} · {{ row.eurostar_plus_seats }}{% endif %}
- {% endif %}
- |
-
- {% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
- {{ row.total_duration }} ⚡
- {% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
- {{ row.total_duration }} 🐢
- {% else %}
- {{ row.total_duration }}
- {% endif %}
-
- |
- {% else %}
-
- Too early
- |
- — |
-
- {{ row.depart_st_pancras }} → {{ row.arrive_destination }} (CET)
- {% if row.eurostar_duration or row.train_number %}
-
- {%- if row.eurostar_duration %}({{ row.eurostar_duration }}){% endif %}
- {%- if row.eurostar_duration and row.train_number %} · {% endif %}
- {%- if row.train_number %}{% for part in row.train_number.split(' + ') %}{{ part }}{% if not loop.last %} + {% endif %}{% endfor %}{% endif %}
-
- {% endif %}
- {% if row.eurostar_price is not none %}
- £{{ "%.2f"|format(row.eurostar_price) }}
- Std{% if row.eurostar_seats is not none %} · {{ row.eurostar_seats }}{% endif %}
- {% else %}
- –
- {% endif %}
- {% if row.eurostar_plus_price is not none %}
- £{{ "%.2f"|format(row.eurostar_plus_price) }}
- Plus{% if row.eurostar_plus_seats is not none %} · {{ row.eurostar_plus_seats }}{% endif %}
- {% endif %}
- |
- — |
- {% endif %}
-
- {% endfor %}
-
-
-
+{% if sections %}
+ {% for section in sections %}
+ {% include "results_section.html" %}
+ {% endfor %}
-
-
-
-{% else %}
-
-
No valid journeys found.
-
- {% if gwr_count == 0 and eurostar_count == 0 %}
- Could not retrieve train data. Check your network connection or try again.
- {% elif gwr_count == 0 %}
- No GWR trains found for this date.
- {% elif eurostar_count == 0 %}
- No Eurostar services found for {{ destination }} on this date.
- {% else %}
- No GWR + Eurostar combination has at least a {{ min_connection }}-minute connection at Paddington/St Pancras.
- {% endif %}
+
-
{% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% endblock %}
diff --git a/templates/results_loading.html b/templates/results_loading.html
new file mode 100644
index 0000000..4f20c12
--- /dev/null
+++ b/templates/results_loading.html
@@ -0,0 +1,118 @@
+{% extends "base.html" %}
+{% block title %}{% if journey_type == 'inbound' %}{{ destination }} to {{ departure_station_name }} via Eurostar{% elif journey_type == 'return' %}{{ departure_station_name }} to {{ destination }} return via Eurostar{% else %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endif %}{% endblock %}
+{% block og_title %}{{ self.title()|trim }}{% endblock %}
+{% block og_description %}Train options from {{ departure_station_name }} to {{ destination }} via Paddington, St Pancras, and Eurostar.{% endblock %}
+{% block twitter_title %}{{ self.title()|trim }}{% endblock %}
+{% block twitter_description %}Train options from {{ departure_station_name }} to {{ destination }} via Paddington, St Pancras, and Eurostar.{% endblock %}
+{% block content %}
+
+
+ ← New search
+
+
+
+
+ {% if journey_type == 'inbound' %}
+ {{ destination }} → {{ departure_station_name }}
+ {% elif journey_type == 'return' %}
+ {{ departure_station_name }} ↔ {{ destination }}
+ {% else %}
+ {{ departure_station_name }} → {{ destination }}
+ {% endif %}
+
+
+ {{ travel_date_display }}{% if return_date_display %} to {{ return_date_display }}{% endif %}
+
+
+
+
+
Loading train times and fares
+
Fetching National Rail, Eurostar, and fare data. Results will appear here as soon as they are ready.
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/templates/results_section.html b/templates/results_section.html
new file mode 100644
index 0000000..be0782b
--- /dev/null
+++ b/templates/results_section.html
@@ -0,0 +1,172 @@
+
+
+ {% if section.direction == 'inbound' %}
+ Return: {{ destination }} → {{ departure_station_name }}
+ {% else %}
+ Outbound: {{ departure_station_name }} → {{ destination }}
+ {% endif %}
+
+
{{ section.date_display }}
+ {% if section.rows %}
+
+
+
+ {% if section.direction == 'inbound' %}
+ Eurostar {{ destination }} → St Pancras |
+ Transfer St Pancras → Paddington |
+ National Rail Paddington → {{ departure_station_name }} |
+ {% else %}
+ National Rail {{ departure_station_name }} → Paddington |
+ Transfer Paddington → St Pancras |
+ Eurostar St Pancras → {{ destination }} |
+ {% endif %}
+ Total click row to select |
+
+
+
+ {% set trip_rows = section.rows | selectattr('row_type', 'equalto', 'trip') | list %}
+ {% if trip_rows %}
+ {% set best_mins = trip_rows | map(attribute='total_minutes') | min %}
+ {% set worst_mins = trip_rows | map(attribute='total_minutes') | max %}
+ {% endif %}
+ {% for row in section.rows %}
+ {% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %}
+ {% set row_class = 'row-fast' %}
+ {% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trip_rows | length > 1 %}
+ {% set row_class = 'row-slow' %}
+ {% elif row.row_type == 'unreachable' %}
+ {% set row_class = 'row-unreachable' %}
+ {% elif loop.index is odd %}
+ {% set row_class = 'row-alt' %}
+ {% else %}
+ {% set row_class = '' %}
+ {% endif %}
+
+ {% if row.row_type == 'trip' %}
+ {% if section.direction == 'inbound' %}
+
+ (CET) {{ row.depart_destination }} → {{ row.arrive_st_pancras }} (UK)
+ check in by {{ row.check_in_by }}
+ {% if row.eurostar_duration or row.train_number %}
+
+ {%- if row.eurostar_duration %}({{ row.eurostar_duration }}){% endif %}
+ {%- if row.eurostar_duration and row.train_number %} · {% endif %}
+ {%- if row.train_number %}{{ row.train_number }}{% endif %}
+
+ {% endif %}
+ Std{% if row.eurostar_price is not none %} £{{ "%.2f"|format(row.eurostar_price) }}{% endif %}
+ SP{% if row.eurostar_plus_price is not none %} £{{ "%.2f"|format(row.eurostar_plus_price) }}{% endif %}
+ then {{ row.connection_duration }} to station{% if row.connection_minutes < 45 %} ⚠️{% endif %}
+ |
+
+ {{ row.connection_duration }}{% if row.connection_minutes < 45 %} ⚠️{% endif %}
+ {% if row.circle_services %}
+ {% if row.circle_services | length > 1 %}
+ {% set c_early = row.circle_services[0] %}
+ {% set c = row.circle_services[1] %}
+ Circle {{ c_early.depart }} → PAD {{ c_early.arrive_pad }} · £{{ "%.2f"|format(c_early.fare) }}
+ next {{ c.depart }} → PAD {{ c.arrive_pad }}
+ {% else %}
+ {% set c = row.circle_services[0] %}
+ Circle {{ c.depart }} → PAD {{ c.arrive_pad }} · £{{ "%.2f"|format(c.fare) }}
+ {% endif %}
+ {% endif %}
+ |
+
+ {{ row.depart_paddington }} → {{ row.arrive_uk_station }}
+ ({{ row.gwr_duration }})
+ {% if row.headcode or row.arrive_platform %}
+ {{ row.headcode }}{% if row.headcode and row.arrive_platform %} · {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}
+ {% endif %}
+ {% if row.ticket_price is not none %}£{{ "%.2f"|format(row.ticket_price) }}{% endif %}
+
+
+ |
+ {% else %}
+
+ {{ row.depart_bristol }} → {{ row.arrive_paddington }}
+ ({{ row.gwr_duration }})
+ {% if row.headcode or row.arrive_platform %}
+ {{ row.headcode }}{% if row.headcode and row.arrive_platform %} · {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}
+ {% endif %}
+ {% if row.ticket_price is not none %}£{{ "%.2f"|format(row.ticket_price) }}{% endif %}
+
+
+ then {{ row.connection_duration }} to Eurostar{% if row.connection_minutes < 80 %} ⚠️{% endif %}
+ |
+
+ {{ row.connection_duration }}{% if row.connection_minutes < 80 %} ⚠️{% endif %}
+ {% if row.circle_services %}
+ {% set c = row.circle_services[0] %}
+ Circle {{ c.depart }} → KX {{ c.arrive_kx }} · £{{ "%.2f"|format(c.fare) }}
+ {% if row.circle_services | length > 1 %}
+ {% set c2 = row.circle_services[1] %}
+ next {{ c2.depart }} → KX {{ c2.arrive_kx }} · £{{ "%.2f"|format(c2.fare) }}
+ {% endif %}
+ {% endif %}
+ |
+
+ {{ row.depart_st_pancras }} → {{ row.arrive_destination }} (CET)
+ {% if row.eurostar_duration or row.train_number %}
+
+ {%- if row.eurostar_duration %}({{ row.eurostar_duration }}){% endif %}
+ {%- if row.eurostar_duration and row.train_number %} · {% endif %}
+ {%- if row.train_number %}{{ row.train_number }}{% endif %}
+
+ {% endif %}
+ Std{% if row.eurostar_price is not none %} £{{ "%.2f"|format(row.eurostar_price) }}{% endif %}
+ SP{% if row.eurostar_plus_price is not none %} £{{ "%.2f"|format(row.eurostar_plus_price) }}{% endif %}
+ |
+ {% endif %}
+
+ {% if row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %}
+ {{ row.total_duration }} ⚡
+ {% elif row.total_minutes >= worst_mins - 5 and trip_rows | length > 1 %}
+ {{ row.total_duration }} 🐢
+ {% else %}
+ {{ row.total_duration }}
+ {% endif %}
+
+ |
+ {% else %}
+
+ Too early
+ |
+ — |
+
+ {% if section.direction == 'inbound' %}
+ {{ row.depart_destination }} → {{ row.arrive_st_pancras }}
+ {% if row.train_number %} {{ row.train_number }}{% endif %}
+ {% else %}
+ {{ row.depart_st_pancras }} → {{ row.arrive_destination }}
+ {% if row.train_number %} {{ row.train_number }}{% endif %}
+ {% endif %}
+
+
+ |
+ |
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% else %}
+
+
No valid journeys found.
+
+ {% if section.gwr_count == 0 and section.eurostar_count == 0 %}
+ Could not retrieve train data. Check your network connection or try again.
+ {% elif section.gwr_count == 0 %}
+ No National Rail trains found for this date.
+ {% elif section.eurostar_count == 0 %}
+ No Eurostar services found for {{ destination }} on this date.
+ {% else %}
+ No National Rail + Eurostar combination has a {{ section.min_connection }}-{{ section.max_connection }} minute connection.
+ {% endif %}
+
+
+ {% endif %}
+
diff --git a/templates/results_shell.html b/templates/results_shell.html
new file mode 100644
index 0000000..b3715c1
--- /dev/null
+++ b/templates/results_shell.html
@@ -0,0 +1,741 @@
+{% extends "base.html" %}
+{% block title %}{% if journey_type == 'inbound' %}{{ destination }} to {{ departure_station_name }} via Eurostar{% elif journey_type == 'return' %}{{ departure_station_name }} to {{ destination }} return via Eurostar{% else %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endif %}{% endblock %}
+{% block og_title %}{{ self.title()|trim }}{% endblock %}
+{% block og_description %}Train options from {{ departure_station_name }} to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
+{% block twitter_title %}{{ self.title()|trim }}{% endblock %}
+{% block twitter_description %}Train options from {{ departure_station_name }} to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
+{% block content %}
+
+
+ ← New search
+
+
+
+
+ {% if journey_type == 'inbound' %}
+ {{ destination }} → {{ departure_station_name }}
+ {% elif journey_type == 'return' %}
+ {{ departure_station_name }} ↔ {{ destination }}
+ {% else %}
+ {{ departure_station_name }} → {{ destination }}
+ {% endif %}
+
+ {% if journey_type == 'return' %}
+
+
+ {% else %}
+
+ {% endif %}
+
+
Switch destination for {{ travel_date_display }}
+
+ {% for destination_slug, destination_name, destination_url in destination_links %}
+ {% if destination_slug == slug %}
+
{{ destination_name }}
+ {% else %}
+
{{ destination_name }}
+ {% endif %}
+ {% endfor %}
+
+
+
+
+
+
+ {% for mins in valid_min_connections %}
+
+ {% endfor %}
+
+
+
+
+
+ {% for mins in valid_max_connections %}
+
+ {% endfor %}
+
+
+
+ {% if journey_type == 'return' %}
+ {% for section in sections %}
+
+
{{ 'Outbound' if section.direction == 'outbound' else 'Return' }}:
+ {% if section.direction == 'inbound' %}
+
+
+
+ {% for mins in valid_inbound_return_min_connections %}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
NR:
+
+
+
+
+
+
+
+
Eurostar:
+
+
+
+
+
+
+ {% endfor %}
+
+ Loading fares
+ Loading advance fares
+
+ {% else %}
+ {% set section = sections[0] %}
+
+
+
NR ticket:
+
+
Load advance prices
+
+
+
+
+
+
Loading fares
+
Loading advance fares
+
+
+
Eurostar:
+
+
+
+
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+{% for section in sections %}
+
+
+
+
Loading {{ 'return' if section.direction == 'inbound' else 'outbound' }} results…
+
+
+{% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/tests/test_app.py b/tests/test_app.py
index 08b97fd..b484e8b 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1,337 +1,992 @@
+from datetime import datetime
+from typing import Any
+
import app as app_module
+import trip_planner as trip_planner_module
+
+rtt_scraper: Any = app_module.rtt_scraper # type: ignore[attr-defined]
+gwr_fares_scraper: Any = app_module.gwr_fares_scraper # type: ignore[attr-defined]
+eurostar_scraper: Any = app_module.eurostar_scraper # type: ignore[attr-defined]
+circle_line: Any = trip_planner_module.circle_line # type: ignore[attr-defined]
-def _client():
- app_module.app.config['TESTING'] = True
+def _client() -> Any:
+ app_module.app.config["TESTING"] = True
return app_module.app.test_client()
-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)
+def _stub_data(monkeypatch: Any, prices: Any = None, gwr_fares: Any = None) -> 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, station_crs='BRI': [
- {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
+ rtt_scraper,
+ "fetch",
+ 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'}},
+ 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', {})
+ p = (prices or {}).get("10:01", {})
monkeypatch.setattr(
- app_module.eurostar_scraper,
- 'fetch',
+ eurostar_scraper,
+ "fetch",
lambda destination, travel_date: [
{
- 'depart_st_pancras': '10:01',
- 'arrive_destination': '13:34',
- 'destination': destination,
- 'train_number': 'ES 9014',
- 'price': p.get('price') if isinstance(p, dict) else None,
- 'seats': p.get('seats') if isinstance(p, dict) else None,
- 'plus_price': p.get('plus_price') if isinstance(p, dict) else None,
- 'plus_seats': p.get('plus_seats') if isinstance(p, dict) else None,
+ "depart_st_pancras": "10:01",
+ "arrive_destination": "13:34",
+ "destination": destination,
+ "train_number": "ES 9014",
+ "price": p.get("price") if isinstance(p, dict) else None,
+ "seats": p.get("seats") if isinstance(p, dict) else None,
+ "plus_price": p.get("plus_price") if isinstance(p, dict) else None,
+ "plus_seats": p.get("plus_seats") if isinstance(p, dict) else None,
},
],
)
-def test_index_shows_station_dropdown_and_destination_radios():
+def test_index_shows_station_dropdown_and_destination_radios() -> None:
client = _client()
- resp = client.get('/')
+ resp = client.get("/")
html = resp.get_data(as_text=True)
assert resp.status_code == 200
- assert 'Departure point' in html
- assert 'Bristol Temple Meads' in html
+ 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
+ assert html.count('name="destination"') == len(app_module.DESTINATIONS)
+ assert 'id="dest-rotterdam"' in html
+ assert "
Cologne HbfCologne Hbf" in html
-def test_search_redirects_to_results_with_selected_params():
+def test_search_redirects_to_results_with_selected_params() -> None:
client = _client()
- resp = client.get('/search?destination=rotterdam&travel_date=2026-04-10&min_connection=60&max_connection=120&station_crs=BRI')
+ 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/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120'
+ assert resp.headers["Location"].endswith(
+ "/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120"
)
-def test_results_shows_same_day_destination_switcher(monkeypatch):
+def test_search_redirects_return_with_return_date() -> None:
+ client = _client()
+
+ resp = client.get(
+ "/search?journey_type=return&destination=paris&travel_date=2026-04-10&return_date=2026-04-17&station_crs=BRI"
+ )
+
+ assert resp.status_code == 302
+ assert resp.headers["Location"].endswith(
+ "/results/BRI/paris/2026-04-10/return/2026-04-17"
+ )
+
+
+def test_nr_weekday_cache_key_includes_timetable_period() -> None:
+ key = app_module._nr_weekday_cache_key("to_paddington", "BRI", "2026-06-22")
+
+ assert key == "weekday_rtt_to_paddington_BRI_2026-05-17_2026-12-12_mon"
+
+
+def test_results_shows_same_day_destination_switcher(monkeypatch: Any) -> None:
_stub_data(monkeypatch)
client = _client()
- resp = client.get('/results/BRI/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 "Switch destination for Friday 10 April 2026" in html
assert '
Paris Gare du Nord' in html
- assert '/results/BRI/brussels/2026-04-10?min_connection=60&max_connection=120' in html
- assert '/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120' in html
- assert 'ES 9014' in html
+ assert (
+ "/results/BRI/brussels/2026-04-10?min_connection=60&max_connection=120"
+ in html
+ )
+ assert (
+ "/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120"
+ in html
+ )
+ assert "ES 9014" in html
-def test_results_title_and_social_meta_include_destination(monkeypatch):
- _stub_data(monkeypatch)
+def test_results_can_render_from_weekday_timetable_cache(monkeypatch: Any) -> None:
+ travel_date = "2026-06-22"
+ cache: dict[str, Any] = {
+ app_module._nr_weekday_cache_key("to_paddington", "BRI", travel_date): [
+ {
+ "depart_bristol": "07:00",
+ "arrive_paddington": "08:45",
+ "headcode": "1A23",
+ },
+ ],
+ app_module._eurostar_weekday_cache_key(
+ "outbound", travel_date, "Paris Gare du Nord"
+ ): [
+ {
+ "depart_st_pancras": "10:01",
+ "arrive_destination": "13:34",
+ "destination": "Paris Gare du Nord",
+ "train_number": "ES 9014",
+ },
+ ],
+ }
+ monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: cache.get(key))
+ monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
+ monkeypatch.setattr(
+ rtt_scraper,
+ "fetch",
+ lambda *args, **kwargs: (_ for _ in ()).throw(
+ AssertionError("should use weekday NR cache")
+ ),
+ )
+ monkeypatch.setattr(
+ eurostar_scraper,
+ "fetch",
+ lambda *args, **kwargs: (_ for _ in ()).throw(
+ AssertionError("should use weekday Eurostar cache")
+ ),
+ )
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch",
+ lambda *args, **kwargs: (_ for _ in ()).throw(
+ AssertionError("should stream prices later")
+ ),
+ )
client = _client()
- resp = client.get('/results/BRI/lille/2026-04-10?min_connection=60&max_connection=120')
+ resp = client.get(
+ "/results/BRI/paris/2026-06-22?min_connection=60&max_connection=120"
+ )
html = resp.get_data(as_text=True)
assert resp.status_code == 200
- assert '
Bristol Temple Meads to Lille Europe via Eurostar' in html
- assert '
' in html
+ assert "07:00 → 08:45" in html
+ assert "10:01 → 13:34" in html
+ assert "ES 9014" in html
+ assert "checking exact timetable" in html
+ assert "/api/results_refresh/BRI/paris/2026-06-22" in html
+ assert "refreshFullResults()" in html
+ assert "window.location.reload()" not in html
+ assert "Checking Eurostar price" in html
+ assert "Eurostar prices not yet available" not in html
+
+
+def test_eurostar_price_status_distinguishes_sold_out() -> None:
+ prices = app_module._eurostar_prices_by_row(
+ "outbound",
+ "outbound",
+ [
+ {
+ "depart_st_pancras": "10:01",
+ "price": None,
+ "seats": 0,
+ "plus_price": None,
+ "plus_seats": None,
+ }
+ ],
+ )
+
+ assert prices["outbound:10:01"]["es_standard"] is None
+ assert prices["outbound:10:01"]["es_standard_status"] == "sold_out"
+ assert prices["outbound:10:01"]["es_plus_status"] == "price_not_returned"
+
+
+def test_results_refresh_reloads_when_exact_timetable_differs(monkeypatch: Any) -> None:
+ travel_date = "2026-06-22"
+ cache: dict[str, Any] = {
+ app_module._nr_weekday_cache_key("to_paddington", "BRI", travel_date): [
+ {
+ "depart_bristol": "07:00",
+ "arrive_paddington": "08:45",
+ "headcode": "1A23",
+ },
+ ],
+ app_module._eurostar_weekday_cache_key(
+ "outbound", travel_date, "Paris Gare du Nord"
+ ): [
+ {
+ "depart_st_pancras": "10:01",
+ "arrive_destination": "13:34",
+ "destination": "Paris Gare du Nord",
+ "train_number": "ES 9014",
+ },
+ ],
+ }
+ monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: cache.get(key))
+ monkeypatch.setattr(
+ app_module, "set_cached", lambda key, data: cache.__setitem__(key, data)
+ )
+ monkeypatch.setattr(
+ rtt_scraper,
+ "fetch",
+ lambda travel_date, user_agent, station_crs="BRI": [
+ {
+ "depart_bristol": "07:05",
+ "arrive_paddington": "08:50",
+ "headcode": "1A24",
+ },
+ ],
+ )
+ monkeypatch.setattr(
+ eurostar_scraper,
+ "fetch",
+ lambda destination, travel_date: [
+ {
+ "depart_st_pancras": "10:01",
+ "arrive_destination": "13:34",
+ "destination": destination,
+ "train_number": "ES 9014",
+ "price": 59,
+ "seats": 42,
+ "plus_price": 89,
+ "plus_seats": 5,
+ },
+ ],
+ )
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch",
+ lambda *args, **kwargs: (_ for _ in ()).throw(
+ AssertionError("reload should stop before fare fetch")
+ ),
+ )
+ client = _client()
+
+ resp = client.get("/api/results_refresh/BRI/paris/2026-06-22")
+ body = resp.get_data(as_text=True)
+
+ assert resp.status_code == 200
+ assert '"type": "reload"' in body
+ assert (
+ cache[app_module._nr_exact_cache_key("to_paddington", "BRI", travel_date)][0][
+ "depart_bristol"
+ ]
+ == "07:05"
+ )
+ assert (
+ cache[app_module._nr_weekday_cache_key("to_paddington", "BRI", travel_date)][0][
+ "depart_bristol"
+ ]
+ == "07:05"
+ )
+
+
+def test_results_refresh_streams_prices_when_timetable_matches(
+ monkeypatch: Any,
+) -> None:
+ travel_date = "2026-06-22"
+ nr_timetable = [
+ {"depart_bristol": "07:00", "arrive_paddington": "08:45", "headcode": "1A23"},
+ ]
+ es_timetable = [
+ {
+ "depart_st_pancras": "10:01",
+ "arrive_destination": "13:34",
+ "destination": "Paris Gare du Nord",
+ "train_number": "ES 9014",
+ },
+ ]
+ cache: dict[str, Any] = {
+ app_module._nr_weekday_cache_key(
+ "to_paddington", "BRI", travel_date
+ ): nr_timetable,
+ app_module._eurostar_weekday_cache_key(
+ "outbound", travel_date, "Paris Gare du Nord"
+ ): es_timetable,
+ }
+ monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: cache.get(key))
+ monkeypatch.setattr(
+ app_module, "set_cached", lambda key, data: cache.__setitem__(key, data)
+ )
+ monkeypatch.setattr(
+ rtt_scraper,
+ "fetch",
+ lambda travel_date, user_agent, station_crs="BRI": nr_timetable,
+ )
+ monkeypatch.setattr(
+ eurostar_scraper,
+ "fetch",
+ lambda destination, travel_date: [
+ {
+ **es_timetable[0],
+ "price": 59,
+ "seats": 42,
+ "plus_price": 89,
+ "plus_seats": 5,
+ },
+ ],
+ )
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch",
+ lambda station_crs, travel_date: {
+ "07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"},
+ },
+ )
+ client = _client()
+
+ resp = client.get("/api/results_refresh/BRI/paris/2026-06-22")
+ body = resp.get_data(as_text=True)
+
+ assert resp.status_code == 200
+ assert '"type": "reload"' not in body
+ assert '"type": "eurostar_prices"' in body
+ assert '"main:10:01"' in body
+ assert '"price": 59' in body
+ assert '"type": "walkon_fares"' in body
+ assert '"price": 138.7' in body
+
+
+def test_results_progressive_shell_loads_without_scraping(monkeypatch: Any) -> None:
+ def fail_fetch(*args: Any, **kwargs: Any) -> None:
+ raise AssertionError("progressive shell should not fetch data")
+
+ monkeypatch.setattr(rtt_scraper, "fetch", fail_fetch)
+ monkeypatch.setattr(eurostar_scraper, "fetch", fail_fetch)
+ monkeypatch.setattr(gwr_fares_scraper, "fetch", fail_fetch)
+ client = _client()
+
+ resp = client.get("/results/BRI/paris/2026-04-10?progressive=1")
+ html = resp.get_data(as_text=True)
+
+ assert resp.status_code == 200
+ assert "Loading train times and fares" in html
+ assert "render=full" in html
+
+
+def test_return_progressive_shell_formats_return_date(monkeypatch: Any) -> None:
+ def fail_fetch(*args: Any, **kwargs: Any) -> None:
+ raise AssertionError("progressive shell should not fetch data")
+
+ monkeypatch.setattr(rtt_scraper, "fetch", fail_fetch)
+ monkeypatch.setattr(eurostar_scraper, "fetch_return", fail_fetch)
+ monkeypatch.setattr(gwr_fares_scraper, "fetch", fail_fetch)
+ client = _client()
+
+ resp = client.get("/results/BRI/paris/2026-04-10/return/2026-04-17?progressive=1")
+ html = resp.get_data(as_text=True)
+
+ assert resp.status_code == 200
+ assert "Friday 10 April 2026 to Friday 17 April 2026" in html
+ assert "to 2026-04-17" not in html
+
+
+def test_results_title_and_social_meta_include_destination(monkeypatch: Any) -> None:
+ _stub_data(monkeypatch)
+ client = _client()
+
+ 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 "
Bristol Temple Meads to Lille Europe via Eurostar" in html
+ assert (
+ '
'
+ in html
+ )
assert (
'
'
) in html
- assert '
' in html
+ assert (
+ '
'
+ 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: {})
+def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(
+ monkeypatch: Any,
+) -> None:
+ monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
+ monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
+ monkeypatch.setattr(gwr_fares_scraper, "fetch", lambda s, d: {})
monkeypatch.setattr(
- app_module.rtt_scraper,
- 'fetch',
- 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'},
- {'depart_bristol': '07:15', 'arrive_paddington': '08:56', 'headcode': '1A04'},
- {'depart_bristol': '07:20', 'arrive_paddington': '09:06', 'headcode': '1A05'},
+ rtt_scraper,
+ "fetch",
+ 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",
+ },
+ {
+ "depart_bristol": "07:15",
+ "arrive_paddington": "08:56",
+ "headcode": "1A04",
+ },
+ {
+ "depart_bristol": "07:20",
+ "arrive_paddington": "09:06",
+ "headcode": "1A05",
+ },
],
)
monkeypatch.setattr(
- app_module.eurostar_scraper,
- 'fetch',
+ eurostar_scraper,
+ "fetch",
lambda destination, travel_date: [
- {'depart_st_pancras': '09:30', 'arrive_destination': '11:50', 'destination': destination, 'train_number': 'ES 1001', 'price': None, 'seats': None},
- {'depart_st_pancras': '09:40', 'arrive_destination': '12:00', 'destination': destination, 'train_number': 'ES 1002', 'price': None, 'seats': None},
- {'depart_st_pancras': '09:50', 'arrive_destination': '12:20', 'destination': destination, 'train_number': 'ES 1003', 'price': None, 'seats': None},
- {'depart_st_pancras': '10:00', 'arrive_destination': '12:35', 'destination': destination, 'train_number': 'ES 1004', 'price': None, 'seats': None},
- {'depart_st_pancras': '10:10', 'arrive_destination': '12:45', 'destination': destination, 'train_number': 'ES 1005', 'price': None, 'seats': None},
+ {
+ "depart_st_pancras": "09:30",
+ "arrive_destination": "11:50",
+ "destination": destination,
+ "train_number": "ES 1001",
+ "price": None,
+ "seats": None,
+ },
+ {
+ "depart_st_pancras": "09:40",
+ "arrive_destination": "12:00",
+ "destination": destination,
+ "train_number": "ES 1002",
+ "price": None,
+ "seats": None,
+ },
+ {
+ "depart_st_pancras": "09:50",
+ "arrive_destination": "12:20",
+ "destination": destination,
+ "train_number": "ES 1003",
+ "price": None,
+ "seats": None,
+ },
+ {
+ "depart_st_pancras": "10:00",
+ "arrive_destination": "12:35",
+ "destination": destination,
+ "train_number": "ES 1004",
+ "price": None,
+ "seats": None,
+ },
+ {
+ "depart_st_pancras": "10:10",
+ "arrive_destination": "12:45",
+ "destination": destination,
+ "train_number": "ES 1005",
+ "price": None,
+ "seats": None,
+ },
],
)
client = _client()
- resp = client.get('/results/BRI/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 html.count('title="Fastest journey"') == 2
assert html.count('title="Slowest journey"') == 2
- assert '4h 50m ⚡' in html
- assert '4h 55m ⚡' in html
- assert '5h 20m 🐢' in html
- assert '5h 25m 🐢' in html
- assert '5h 10m ⚡' not in html
- assert '5h 10m 🐢' not in html
+ assert "4h 50m ⚡" in html
+ assert "4h 55m ⚡" in html
+ assert "5h 20m 🐢" in html
+ assert "5h 25m 🐢" in html
+ assert "5h 10m ⚡" not in html
+ assert "5h 10m 🐢" not in html
-def test_results_shows_only_pre_first_reachable_unreachable_services(monkeypatch):
+def test_results_shows_only_pre_first_reachable_unreachable_services(
+ monkeypatch: Any,
+) -> None:
# 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, "get_cached", lambda key, ttl=None: None)
+ monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
+ monkeypatch.setattr(gwr_fares_scraper, "fetch", lambda s, d: {})
monkeypatch.setattr(
- app_module.rtt_scraper,
- 'fetch',
- lambda travel_date, user_agent, station_crs='BRI': [
- {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
+ rtt_scraper,
+ "fetch",
+ lambda travel_date, user_agent, station_crs="BRI": [
+ {
+ "depart_bristol": "07:00",
+ "arrive_paddington": "08:45",
+ "headcode": "1A23",
+ },
],
)
monkeypatch.setattr(
- app_module.eurostar_scraper,
- 'fetch',
+ eurostar_scraper,
+ "fetch",
lambda destination, travel_date: [
- {'depart_st_pancras': '09:30', 'arrive_destination': '12:00', 'destination': destination, 'train_number': 'ES 9001', 'price': None, 'seats': None},
- {'depart_st_pancras': '10:15', 'arrive_destination': '13:40', 'destination': destination, 'train_number': 'ES 9002', 'price': None, 'seats': None},
- {'depart_st_pancras': '12:30', 'arrive_destination': '15:55', 'destination': destination, 'train_number': 'ES 9003', 'price': None, 'seats': None},
+ {
+ "depart_st_pancras": "09:30",
+ "arrive_destination": "12:00",
+ "destination": destination,
+ "train_number": "ES 9001",
+ "price": None,
+ "seats": None,
+ },
+ {
+ "depart_st_pancras": "10:15",
+ "arrive_destination": "13:40",
+ "destination": destination,
+ "train_number": "ES 9002",
+ "price": None,
+ "seats": None,
+ },
+ {
+ "depart_st_pancras": "12:30",
+ "arrive_destination": "15:55",
+ "destination": destination,
+ "train_number": "ES 9003",
+ "price": None,
+ "seats": None,
+ },
],
)
client = _client()
- resp = client.get('/results/BRI/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 'ES 9001' in html # before first reachable → shown
- assert 'Too early' in html
- assert 'ES 9003' not in html # after first reachable → hidden
+ assert "ES 9001" in html # before first reachable → shown
+ assert "Too early" in html
+ assert "ES 9003" not in html # after first reachable → hidden
-def test_results_shows_eurostar_price_and_total(monkeypatch):
+def test_results_shows_eurostar_price_and_total(monkeypatch: Any) -> None:
# 07:00 on Friday 2026-04-10 → Anytime £138.70 walk-on + ES £59.00
- _stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42}})
+ _stub_data(monkeypatch, prices={"10:01": {"price": 59, "seats": 42}})
client = _client()
- resp = client.get('/results/BRI/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 '£59' in html # Eurostar Standard price
- assert '£138.70' in html # Walk-on price shown in NR cell
- # Total (£197.70) is computed client-side; verify data attributes carry the right values
- assert 'data-walkon="138.7"' in html
+ assert "£59" in html # Eurostar Standard price in initial render
+ assert "£138.70" not in html # Walk-on price is streamed, not server-rendered
assert 'data-es-std="59"' in html
+ assert "/api/walkon_fares/BRI/" in html # client will fetch walk-on fares
-def test_results_shows_unreachable_service_when_no_trips(monkeypatch):
+def test_results_uses_unique_row_keys_for_same_eurostar(monkeypatch: Any) -> None:
+ monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
+ monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch",
+ lambda s, d: {
+ "07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"},
+ "07:30": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"},
+ },
+ )
+ monkeypatch.setattr(
+ rtt_scraper,
+ "fetch",
+ lambda travel_date, user_agent, station_crs="BRI": [
+ {
+ "depart_bristol": "07:00",
+ "arrive_paddington": "08:30",
+ "headcode": "1A01",
+ },
+ {
+ "depart_bristol": "07:30",
+ "arrive_paddington": "09:00",
+ "headcode": "1A02",
+ },
+ ],
+ )
+ monkeypatch.setattr(
+ eurostar_scraper,
+ "fetch",
+ lambda destination, travel_date: [
+ {
+ "depart_st_pancras": "10:01",
+ "arrive_destination": "13:34",
+ "destination": destination,
+ "train_number": "ES 9014",
+ "price": 59,
+ "seats": 42,
+ "plus_price": 89,
+ "plus_seats": 5,
+ },
+ ],
+ )
+ client = _client()
+
+ 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 'data-row-key="main:07:00:10:01"' in html
+ assert 'data-row-key="main:07:30:10:01"' in html
+ assert '"main:07:00:10:01"' in html
+ assert '"main:07:30:10:01"' in html
+
+
+def test_results_shows_unreachable_service_when_no_trips(monkeypatch: Any) -> None:
# 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, "get_cached", lambda key, ttl=None: None)
+ monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
+ monkeypatch.setattr(gwr_fares_scraper, "fetch", lambda s, d: {})
monkeypatch.setattr(
- app_module.rtt_scraper,
- 'fetch',
- lambda travel_date, user_agent, station_crs='BRI': [
- {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
+ rtt_scraper,
+ "fetch",
+ lambda travel_date, user_agent, station_crs="BRI": [
+ {
+ "depart_bristol": "07:00",
+ "arrive_paddington": "08:45",
+ "headcode": "1A23",
+ },
],
)
monkeypatch.setattr(
- app_module.eurostar_scraper,
- 'fetch',
+ eurostar_scraper,
+ "fetch",
lambda destination, travel_date: [
- {'depart_st_pancras': '09:30', 'arrive_destination': '12:00', 'destination': destination, 'train_number': 'ES 9001', 'price': None, 'seats': None},
+ {
+ "depart_st_pancras": "09:30",
+ "arrive_destination": "12:00",
+ "destination": destination,
+ "train_number": "ES 9001",
+ "price": None,
+ "seats": None,
+ },
],
)
client = _client()
- resp = client.get('/results/BRI/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 'ES 9001' in html
- assert 'Too early' in html
- assert 'No valid journeys found.' not in html
+ assert "ES 9001" in html
+ assert "Too early" in html
+ assert "No valid journeys found." not in html
-def test_results_shows_eurostar_plus_price(monkeypatch):
- _stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42, 'plus_price': 89, 'plus_seats': 5}})
+def test_results_shows_eurostar_plus_price(monkeypatch: Any) -> None:
+ _stub_data(
+ monkeypatch,
+ prices={"10:01": {"price": 59, "seats": 42, "plus_price": 89, "plus_seats": 5}},
+ )
client = _client()
- resp = client.get('/results/BRI/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 '£59' in html # Standard price
- assert '£89' in html # Plus price
- assert 'Plus' in html # Plus label
+ assert "£59" in html # Standard price
+ assert "£89" in html # Plus price
+ assert "Plus" in html # Plus label
-def test_results_selectors_present(monkeypatch):
+def test_results_selectors_present(monkeypatch: Any) -> None:
_stub_data(monkeypatch)
client = _client()
- resp = client.get('/results/BRI/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 'nr-type-select' in html
- assert 'es-type-select' in html
- assert 'Load advance prices' in html
- assert 'Plus' in html
+ assert "nr-type-select" in html
+ assert "es-type-select" in html
+ assert "Load advance prices" in html
+ assert "Plus" in html
-def test_results_preloads_cached_advance_fares(monkeypatch):
+def test_results_preloads_cached_advance_fares(monkeypatch: Any) -> None:
advance_data = {
- '07:00': {
- 'advance_std': {'ticket': 'Advance Single', 'price': 45.0, 'code': 'ADV'},
- 'advance_1st': None,
+ "07:00": {
+ "advance_std": {"ticket": "Advance Single", "price": 45.0, "code": "ADV"},
+ "advance_1st": None,
}
}
- def fake_get_cached(key, ttl=None):
- if 'gwr_advance' in key:
+
+ def fake_get_cached(key: str, ttl: Any = None) -> Any:
+ if "gwr_advance" in key:
return advance_data
return None
- monkeypatch.setattr(app_module, 'get_cached', fake_get_cached)
- monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
+
+ monkeypatch.setattr(app_module, "get_cached", fake_get_cached)
+ monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
- app_module.rtt_scraper, 'fetch',
- lambda travel_date, user_agent, station_crs='BRI': [
- {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
+ rtt_scraper,
+ "fetch",
+ 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 s, d: {})
+ monkeypatch.setattr(gwr_fares_scraper, "fetch", lambda s, d: {})
monkeypatch.setattr(
- app_module.eurostar_scraper, 'fetch',
+ eurostar_scraper,
+ "fetch",
lambda destination, travel_date: [
- {'depart_st_pancras': '10:01', 'arrive_destination': '13:34',
- 'destination': destination, 'train_number': 'ES 9014',
- 'price': None, 'seats': None, 'plus_price': None, 'plus_seats': None},
+ {
+ "depart_st_pancras": "10:01",
+ "arrive_destination": "13:34",
+ "destination": destination,
+ "train_number": "ES 9014",
+ "price": None,
+ "seats": None,
+ "plus_price": None,
+ "plus_seats": None,
+ },
],
)
client = _client()
- resp = client.get('/results/BRI/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
# Cached advance fares are embedded in the page JS
assert '"advance_std"' in html
- assert '45.0' in html
+ assert "45.0" in html
# Button is absent (hidden via cachedAdvanceFares check in JS)
# The JS will hide it on load; the data is present for applyAdvanceFares()
- assert 'cachedAdvanceFares' in html
+ assert "cachedAdvanceFares" in html
-def test_api_advance_fares_returns_json(monkeypatch):
- monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
- monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
+def test_results_inbound_uses_reverse_legs(monkeypatch: Any) -> 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.gwr_fares_scraper,
- 'fetch_advance',
+ rtt_scraper,
+ "fetch_from_paddington",
+ lambda travel_date, user_agent, station_crs="BRI": [
+ {
+ "depart_paddington": "17:15",
+ "arrive_destination": "18:55",
+ "headcode": "1B99",
+ },
+ ],
+ )
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch",
+ lambda station_crs, travel_date, direction="to_paddington": {
+ "17:15": {"ticket": "Off-Peak Single", "price": 63.60, "code": "SVS"}
+ },
+ )
+ monkeypatch.setattr(
+ eurostar_scraper,
+ "fetch",
+ lambda destination, travel_date, direction="outbound": [
+ {
+ "depart_destination": "15:12",
+ "arrive_st_pancras": "16:30",
+ "destination": destination,
+ "train_number": "ES 9035",
+ "price": 49,
+ "seats": 43,
+ "plus_price": None,
+ "plus_seats": None,
+ },
+ ],
+ )
+ client = _client()
+
+ resp = client.get("/results/BRI/paris/2026-04-10?journey_type=inbound")
+ html = resp.get_data(as_text=True)
+
+ assert resp.status_code == 200
+ assert "Paris Gare du Nord → Bristol Temple Meads" in html
+ assert "15:12 → 16:30" in html
+ assert "17:15 → 18:55" in html
+ assert "ES 9035" in html
+
+
+def test_results_return_renders_outbound_and_inbound_tables(monkeypatch: Any) -> None:
+ monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
+ monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
+ monkeypatch.setattr(
+ rtt_scraper,
+ "fetch",
+ lambda travel_date, user_agent, station_crs="BRI": [
+ {
+ "depart_bristol": "07:00",
+ "arrive_paddington": "08:45",
+ "headcode": "1A23",
+ },
+ ],
+ )
+ monkeypatch.setattr(
+ rtt_scraper,
+ "fetch_from_paddington",
+ lambda travel_date, user_agent, station_crs="BRI": [
+ {
+ "depart_paddington": "17:15",
+ "arrive_destination": "18:55",
+ "headcode": "1B99",
+ },
+ ],
+ )
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch",
+ lambda station_crs, travel_date, direction="to_paddington": {
+ "07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"},
+ "17:15": {"ticket": "Off-Peak Single", "price": 63.60, "code": "SVS"},
+ },
+ )
+ monkeypatch.setattr(
+ eurostar_scraper,
+ "fetch_return",
+ lambda destination, outbound_date, return_date: {
+ "outbound": [
+ {
+ "depart_st_pancras": "10:01",
+ "arrive_destination": "13:34",
+ "destination": destination,
+ "train_number": "ES 9014",
+ "price": 59,
+ "seats": 42,
+ "plus_price": None,
+ "plus_seats": None,
+ },
+ ],
+ "inbound": [
+ {
+ "depart_destination": "15:12",
+ "arrive_st_pancras": "16:30",
+ "destination": destination,
+ "train_number": "ES 9035",
+ "price": 49,
+ "seats": 43,
+ "plus_price": None,
+ "plus_seats": None,
+ },
+ ],
+ },
+ )
+ monkeypatch.setattr(
+ circle_line,
+ "upcoming_services",
+ lambda earliest_board, count=2, direction="pad_to_kx", preceding=0: (
+ [
+ (datetime(2026, 4, 10, 9, 10), datetime(2026, 4, 10, 9, 25)),
+ (datetime(2026, 4, 10, 9, 15), datetime(2026, 4, 10, 9, 30)),
+ ]
+ if direction == "pad_to_kx"
+ else [
+ (datetime(2026, 4, 17, 16, 40), datetime(2026, 4, 17, 16, 55)),
+ (datetime(2026, 4, 17, 16, 45), datetime(2026, 4, 17, 17, 0)),
+ ]
+ ),
+ )
+ client = _client()
+
+ resp = client.get("/results/BRI/paris/2026-04-10/return/2026-04-17")
+ html = resp.get_data(as_text=True)
+
+ assert resp.status_code == 200
+ assert "Outbound: Bristol Temple Meads → Paris Gare du Nord" in html
+ assert "Return: Paris Gare du Nord → Bristol Temple Meads" in html
+ assert "Friday 10 April 2026" in html
+ assert "Friday 17 April 2026" in html
+ assert "/results/BRI/paris/2026-04-09/return/2026-04-17" in html
+ assert "/results/BRI/paris/2026-04-11/return/2026-04-17" in html
+ assert "/results/BRI/paris/2026-04-10/return/2026-04-16" in html
+ assert "/results/BRI/paris/2026-04-10/return/2026-04-18" in html
+ assert "/results/BRI/paris/2026-04-10/return/2026-04-17" in html
+ assert "journey_type=return" not in html
+ assert "return_date=2026-04-17" not in html
+ assert "Circle 09:10 → KX 09:25" in html
+ assert "next 09:15 → KX 09:30" in html
+ assert "Circle 16:40 → PAD 16:55" in html
+ assert "next 16:45 → PAD 17:00" in html
+ assert 'title="Tight connection">⚠️' in html
+ assert "ES 9014" in html
+ assert "ES 9035" in html
+
+
+def test_api_advance_fares_returns_json(monkeypatch: Any) -> None:
+ monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
+ monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch_advance",
lambda station_crs, travel_date: {
- '07:00': {
- 'advance_std': {'ticket': 'Advance Single', 'price': 45.0, 'code': 'ADV'},
- 'advance_1st': {'ticket': '1st Advance', 'price': 65.0, 'code': 'AFA'},
+ "07:00": {
+ "advance_std": {
+ "ticket": "Advance Single",
+ "price": 45.0,
+ "code": "ADV",
+ },
+ "advance_1st": {"ticket": "1st Advance", "price": 65.0, "code": "AFA"},
}
},
)
client = _client()
- resp = client.get('/api/advance_fares/BRI/2026-04-10')
+ resp = client.get("/api/advance_fares/BRI/2026-04-10")
data = resp.get_json()
assert resp.status_code == 200
- assert '07:00' in data
- assert data['07:00']['advance_std']['price'] == 45.0
- assert data['07:00']['advance_1st']['price'] == 65.0
+ assert "07:00" in data
+ assert data["07:00"]["advance_std"]["price"] == 45.0
+ assert data["07:00"]["advance_1st"]["price"] == 65.0
-def test_api_advance_fares_404_for_unknown_station(monkeypatch):
+def test_api_advance_fares_404_for_unknown_station() -> None:
client = _client()
- resp = client.get('/api/advance_fares/XYZ/2026-04-10')
+ resp = client.get("/api/advance_fares/XYZ/2026-04-10")
assert resp.status_code == 404
-def test_api_advance_fares_returns_error_on_scraper_failure(monkeypatch):
- monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
- monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
+def test_api_advance_fares_returns_error_on_scraper_failure(monkeypatch: Any) -> 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.gwr_fares_scraper,
- 'fetch_advance',
- lambda s, d: (_ for _ in ()).throw(Exception('network error')),
+ gwr_fares_scraper,
+ "fetch_advance",
+ lambda s, d: (_ for _ in ()).throw(Exception("network error")),
)
client = _client()
- resp = client.get('/api/advance_fares/BRI/2026-04-10')
+ resp = client.get("/api/advance_fares/BRI/2026-04-10")
data = resp.get_json()
assert resp.status_code == 500
- assert 'error' in data
+ assert "error" in data
diff --git a/tests/test_cache.py b/tests/test_cache.py
index 006af57..9434942 100644
--- a/tests/test_cache.py
+++ b/tests/test_cache.py
@@ -1,42 +1,53 @@
import os
import time
+from pathlib import Path
+from typing import Any
+
import pytest
from cache import get_cached, set_cached
@pytest.fixture
-def tmp_cache(tmp_path, monkeypatch):
+def tmp_cache(tmp_path: Path, monkeypatch: Any) -> Path:
import cache as cache_module
- monkeypatch.setattr(cache_module, 'CACHE_DIR', str(tmp_path))
+
+ monkeypatch.setattr(cache_module, "CACHE_DIR", str(tmp_path))
return tmp_path
-def test_get_cached_returns_none_for_missing_key(tmp_cache):
- assert get_cached('no_such_key') is None
+def test_get_cached_returns_none_for_missing_key(tmp_cache: Path) -> None:
+ assert get_cached("no_such_key") is None
-def test_set_and_get_cached_roundtrip(tmp_cache):
- set_cached('my_key', {'a': 1})
- assert get_cached('my_key') == {'a': 1}
+def test_set_and_get_cached_roundtrip(tmp_cache: Path) -> None:
+ set_cached("my_key", {"a": 1})
+ assert get_cached("my_key") == {"a": 1}
-def test_get_cached_no_ttl_never_expires(tmp_cache):
- set_cached('k', [1, 2, 3])
+def test_get_cached_no_ttl_never_expires(tmp_cache: Path) -> None:
+ set_cached("k", [1, 2, 3])
# Backdate the file by 2 days
- path = tmp_cache / 'k.json'
+ path = tmp_cache / "k.json"
old = time.time() - 2 * 86400
os.utime(path, (old, old))
- assert get_cached('k') == [1, 2, 3]
+ assert get_cached("k") == [1, 2, 3]
-def test_get_cached_within_ttl(tmp_cache):
- set_cached('k', 'fresh')
- assert get_cached('k', ttl=3600) == 'fresh'
+def test_get_cached_within_ttl(tmp_cache: Path) -> None:
+ set_cached("k", "fresh")
+ assert get_cached("k", ttl=3600) == "fresh"
-def test_get_cached_expired_returns_none(tmp_cache):
- set_cached('k', 'stale')
- path = tmp_cache / 'k.json'
+def test_get_cached_expired_returns_none(tmp_cache: Path) -> None:
+ set_cached("k", "stale")
+ path = tmp_cache / "k.json"
old = time.time() - 25 * 3600 # 25 hours ago
os.utime(path, (old, old))
- assert get_cached('k', ttl=24 * 3600) is None
+ assert get_cached("k", ttl=24 * 3600) is None
+
+
+def test_get_cached_invalid_json_returns_none(tmp_cache: Path) -> None:
+ path = tmp_cache / "broken.json"
+ path.write_text('{"not": "finished"')
+
+ assert get_cached("broken") is None
diff --git a/tests/test_eurostar_scraper.py b/tests/test_eurostar_scraper.py
index b73597f..64eff91 100644
--- a/tests/test_eurostar_scraper.py
+++ b/tests/test_eurostar_scraper.py
@@ -1,30 +1,47 @@
+from typing import Any
+
import pytest
-from scraper.eurostar import _parse_graphql, search_url
+from scraper.eurostar import _parse_graphql, _parse_graphql_leg, search_url
-def _gql_response(journeys: list) -> dict:
- return {'data': {'journeySearch': {'outbound': {'journeys': journeys}}}}
+def _gql_response(journeys: list[dict[str, Any]]) -> dict[str, Any]:
+ return {"data": {"journeySearch": {"outbound": {"journeys": journeys}}}}
-def _journey(departs: str, arrives: str, price=None, seats=None, service_name='', carrier='ES',
- plus_price=None, plus_seats=None) -> dict:
- fares = [{
- 'classOfService': {'code': 'STANDARD'},
- 'prices': {'displayPrice': price},
- 'seats': seats,
- 'legs': [{'serviceName': service_name, 'serviceType': {'code': carrier}}]
- if service_name else [],
- }]
+def _journey(
+ departs: str,
+ arrives: str,
+ price: float | None = None,
+ seats: int | None = None,
+ service_name: str = "",
+ carrier: str = "ES",
+ plus_price: float | None = None,
+ plus_seats: int | None = None,
+) -> dict[str, Any]:
+ fares: list[dict[str, Any]] = [
+ {
+ "classOfService": {"code": "STANDARD"},
+ "prices": {"displayPrice": price},
+ "seats": seats,
+ "legs": (
+ [{"serviceName": service_name, "serviceType": {"code": carrier}}]
+ if service_name
+ else []
+ ),
+ }
+ ]
if plus_price is not None or plus_seats is not None:
- fares.append({
- 'classOfService': {'code': 'PLUS'},
- 'prices': {'displayPrice': plus_price},
- 'seats': plus_seats,
- 'legs': [],
- })
+ fares.append(
+ {
+ "classOfService": {"code": "PLUS"},
+ "prices": {"displayPrice": plus_price},
+ "seats": plus_seats,
+ "legs": [],
+ }
+ )
return {
- 'timing': {'departureTime': departs, 'arrivalTime': arrives},
- 'fares': fares,
+ "timing": {"departureTime": departs, "arrivalTime": arrives},
+ "fares": fares,
}
@@ -32,91 +49,149 @@ def _journey(departs: str, arrives: str, price=None, seats=None, service_name=''
# _parse_graphql
# ---------------------------------------------------------------------------
-def test_parse_graphql_single_journey():
- data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='9014')])
- services = _parse_graphql(data, 'Paris Gare du Nord')
+
+def test_parse_graphql_single_journey() -> None:
+ data = _gql_response(
+ [_journey("09:31", "12:55", price=156, seats=37, service_name="9014")]
+ )
+ services = _parse_graphql(data, "Paris Gare du Nord")
assert len(services) == 1
s = services[0]
- assert s['depart_st_pancras'] == '09:31'
- assert s['arrive_destination'] == '12:55'
- assert s['destination'] == 'Paris Gare du Nord'
- assert s['train_number'] == 'ES 9014'
- assert s['price'] == 156.0
- assert s['seats'] == 37
- assert s['plus_price'] is None
- assert s['plus_seats'] is None
+ assert s["depart_st_pancras"] == "09:31"
+ assert s["arrive_destination"] == "12:55"
+ assert s["destination"] == "Paris Gare du Nord"
+ assert s["train_number"] == "ES 9014"
+ assert s["price"] == 156.0
+ assert s["seats"] == 37
+ assert s["plus_price"] is None
+ assert s["plus_seats"] is None
-def test_parse_graphql_standard_premier_price():
- data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='9014',
- plus_price=220, plus_seats=12)])
- services = _parse_graphql(data, 'Paris Gare du Nord')
+def test_parse_graphql_standard_premier_price() -> None:
+ data = _gql_response(
+ [
+ _journey(
+ "09:31",
+ "12:55",
+ price=156,
+ seats=37,
+ service_name="9014",
+ plus_price=220,
+ plus_seats=12,
+ )
+ ]
+ )
+ services = _parse_graphql(data, "Paris Gare du Nord")
assert len(services) == 1
s = services[0]
- assert s['price'] == 156.0
- assert s['seats'] == 37
- assert s['plus_price'] == 220.0
- assert s['plus_seats'] == 12
+ assert s["price"] == 156.0
+ assert s["seats"] == 37
+ assert s["plus_price"] == 220.0
+ assert s["plus_seats"] == 12
-def test_parse_graphql_plus_price_none_when_not_returned():
- data = _gql_response([_journey('09:31', '12:55', price=156, seats=37)])
- services = _parse_graphql(data, 'Paris Gare du Nord')
- assert services[0]['plus_price'] is None
- assert services[0]['plus_seats'] is None
+def test_parse_graphql_plus_price_none_when_not_returned() -> None:
+ data = _gql_response([_journey("09:31", "12:55", price=156, seats=37)])
+ services = _parse_graphql(data, "Paris Gare du Nord")
+ assert services[0]["plus_price"] is None
+ assert services[0]["plus_seats"] is None
-def test_parse_graphql_half_pound_price():
- data = _gql_response([_journey('09:01', '14:20', price=192.5, seats=25, service_name='9116')])
- services = _parse_graphql(data, 'Amsterdam Centraal')
- assert services[0]['price'] == 192.5
+def test_parse_graphql_half_pound_price() -> None:
+ data = _gql_response(
+ [_journey("09:01", "14:20", price=192.5, seats=25, service_name="9116")]
+ )
+ services = _parse_graphql(data, "Amsterdam Centraal")
+ assert services[0]["price"] == 192.5
-def test_parse_graphql_null_price():
- data = _gql_response([_journey('06:16', '11:09', price=None, seats=0)])
- services = _parse_graphql(data, 'Amsterdam Centraal')
- assert services[0]['price'] is None
- assert services[0]['seats'] == 0
+def test_parse_graphql_null_price() -> None:
+ data = _gql_response([_journey("06:16", "11:09", price=None, seats=0)])
+ services = _parse_graphql(data, "Amsterdam Centraal")
+ assert services[0]["price"] is None
+ assert services[0]["seats"] == 0
-def test_parse_graphql_sorted_by_departure():
- data = _gql_response([
- _journey('10:31', '13:55'),
- _journey('07:31', '10:59'),
- ])
- services = _parse_graphql(data, 'Paris Gare du Nord')
- assert services[0]['depart_st_pancras'] == '07:31'
- assert services[1]['depart_st_pancras'] == '10:31'
+def test_parse_graphql_sorted_by_departure() -> None:
+ data = _gql_response(
+ [
+ _journey("10:31", "13:55"),
+ _journey("07:31", "10:59"),
+ ]
+ )
+ services = _parse_graphql(data, "Paris Gare du Nord")
+ assert services[0]["depart_st_pancras"] == "07:31"
+ assert services[1]["depart_st_pancras"] == "10:31"
-def test_parse_graphql_deduplicates_same_departure_time():
- data = _gql_response([
- _journey('06:16', '11:09', price=None, seats=0),
- _journey('06:16', '11:09', price=None, seats=0),
- _journey('06:16', '11:09', price=None, seats=0),
- ])
- services = _parse_graphql(data, 'Amsterdam Centraal')
+def test_parse_graphql_deduplicates_same_departure_time() -> None:
+ data = _gql_response(
+ [
+ _journey("06:16", "11:09", price=None, seats=0),
+ _journey("06:16", "11:09", price=None, seats=0),
+ _journey("06:16", "11:09", price=None, seats=0),
+ ]
+ )
+ services = _parse_graphql(data, "Amsterdam Centraal")
assert len(services) == 1
-def test_parse_graphql_no_legs_gives_empty_train_number():
- data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='')])
- services = _parse_graphql(data, 'Paris Gare du Nord')
- assert services[0]['train_number'] == ''
+def test_parse_graphql_no_legs_gives_empty_train_number() -> None:
+ data = _gql_response(
+ [_journey("09:31", "12:55", price=156, seats=37, service_name="")]
+ )
+ services = _parse_graphql(data, "Paris Gare du Nord")
+ assert services[0]["train_number"] == ""
-def test_parse_graphql_empty_journeys():
+def test_parse_graphql_empty_journeys() -> None:
data = _gql_response([])
- assert _parse_graphql(data, 'Paris Gare du Nord') == []
+ assert _parse_graphql(data, "Paris Gare du Nord") == []
+
+
+def test_parse_graphql_inbound_leg() -> None:
+ data: dict[str, Any] = {
+ "data": {
+ "journeySearch": {
+ "inbound": {
+ "journeys": [
+ _journey(
+ "17:12", "18:30", price=49, seats=43, service_name="9035"
+ )
+ ]
+ }
+ }
+ }
+ }
+ services = _parse_graphql_leg(data, "Paris Gare du Nord", "inbound", "inbound")
+
+ assert services == [
+ {
+ "depart_destination": "17:12",
+ "arrive_st_pancras": "18:30",
+ "destination": "Paris Gare du Nord",
+ "train_number": "ES 9035",
+ "price": 49.0,
+ "seats": 43,
+ "plus_price": None,
+ "plus_seats": None,
+ }
+ ]
# ---------------------------------------------------------------------------
# search_url
# ---------------------------------------------------------------------------
-def test_search_url():
- url = search_url('Paris Gare du Nord', '2026-04-10')
+
+def test_search_url() -> None:
+ url = search_url("Paris Gare du Nord", "2026-04-10")
assert url == (
- 'https://www.eurostar.com/search/uk-en'
- '?adult=1&origin=7015400&destination=8727100&outbound=2026-04-10'
+ "https://www.eurostar.com/search/uk-en"
+ "?adult=1&origin=7015400&destination=8727100&outbound=2026-04-10"
)
+
+
+def test_search_url_return() -> None:
+ url = search_url("Paris Gare du Nord", "2026-04-10", return_date="2026-04-17")
+ assert url.endswith("&outbound=2026-04-10&inbound=2026-04-17")
diff --git a/tests/test_playwright_return_fares.py b/tests/test_playwright_return_fares.py
new file mode 100644
index 0000000..01205ff
--- /dev/null
+++ b/tests/test_playwright_return_fares.py
@@ -0,0 +1,560 @@
+import threading
+from typing import Any, Generator
+
+import pytest
+from werkzeug.serving import make_server
+
+import app as app_module
+
+playwright_sync = pytest.importorskip("playwright.sync_api")
+sync_playwright = playwright_sync.sync_playwright
+
+rtt_scraper: Any = app_module.rtt_scraper # type: ignore[attr-defined]
+gwr_fares_scraper: Any = app_module.gwr_fares_scraper # type: ignore[attr-defined]
+eurostar_scraper: Any = app_module.eurostar_scraper # type: ignore[attr-defined]
+
+
+def _stub_return_data(monkeypatch: Any) -> None:
+ monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
+ monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
+ monkeypatch.setattr(
+ rtt_scraper,
+ "fetch",
+ lambda travel_date, user_agent, station_crs="BRI": [
+ {
+ "depart_bristol": "07:00",
+ "arrive_paddington": "08:45",
+ "headcode": "1A23",
+ },
+ ],
+ )
+ monkeypatch.setattr(
+ rtt_scraper,
+ "fetch_from_paddington",
+ lambda travel_date, user_agent, station_crs="BRI": [
+ {
+ "depart_paddington": "17:15",
+ "arrive_destination": "18:55",
+ "headcode": "1B99",
+ },
+ ],
+ )
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch",
+ lambda station_crs, travel_date, direction="to_paddington": {
+ "07:00": {
+ "ticket": "Anytime Day Single",
+ "price": 138.70,
+ "code": "SDS",
+ },
+ "17:15": {
+ "ticket": "Off-Peak Single",
+ "price": 63.60,
+ "code": "SVS",
+ },
+ },
+ )
+
+ def fake_advance_streaming(
+ station_crs: str,
+ travel_date: str,
+ direction: str = "to_paddington",
+ ) -> Generator[dict[str, Any], None, None]:
+ if direction == "from_paddington":
+ yield {
+ "17:15": {
+ "advance_std": {
+ "ticket": "Advance Single",
+ "price": 25.0,
+ "code": "ADV",
+ },
+ "advance_1st": {
+ "ticket": "1st Advance",
+ "price": 45.0,
+ "code": "AFA",
+ },
+ }
+ }
+ else:
+ yield {
+ "07:00": {
+ "advance_std": {
+ "ticket": "Advance Single",
+ "price": 50.0,
+ "code": "ADV",
+ },
+ "advance_1st": {
+ "ticket": "1st Advance",
+ "price": 80.0,
+ "code": "AFA",
+ },
+ }
+ }
+
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch_advance_streaming",
+ fake_advance_streaming,
+ )
+
+ def fake_advance(
+ station_crs: str, travel_date: str, direction: str = "to_paddington"
+ ) -> dict[str, Any]:
+ pages = list(fake_advance_streaming(station_crs, travel_date, direction))
+ return pages[0] if pages else {}
+
+ monkeypatch.setattr(gwr_fares_scraper, "fetch_advance", fake_advance)
+ monkeypatch.setattr(
+ eurostar_scraper,
+ "fetch_return",
+ lambda destination, outbound_date, return_date: {
+ "outbound": [
+ {
+ "depart_st_pancras": "10:01",
+ "arrive_destination": "13:34",
+ "destination": destination,
+ "train_number": "ES 9014",
+ "price": 59,
+ "seats": 42,
+ "plus_price": 89,
+ "plus_seats": 5,
+ },
+ ],
+ "inbound": [
+ {
+ "depart_destination": "15:12",
+ "arrive_st_pancras": "16:30",
+ "destination": destination,
+ "train_number": "ES 9035",
+ "price": 49,
+ "seats": 43,
+ "plus_price": 79,
+ "plus_seats": 6,
+ },
+ ],
+ },
+ )
+
+
+def _stub_single_data(monkeypatch: Any) -> None:
+ monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
+ monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
+ monkeypatch.setattr(
+ rtt_scraper,
+ "fetch",
+ lambda travel_date, user_agent, station_crs="BRI": [
+ {
+ "depart_bristol": "07:00",
+ "arrive_paddington": "08:45",
+ "headcode": "1A23",
+ },
+ ],
+ )
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch",
+ lambda station_crs, travel_date: {
+ "07:00": {
+ "ticket": "Anytime Day Single",
+ "price": 138.70,
+ "code": "SDS",
+ },
+ },
+ )
+ advance_fares: dict[str, Any] = {
+ "07:00": {
+ "advance_std": {
+ "ticket": "Advance Single",
+ "price": 50.0,
+ "code": "ADV",
+ },
+ "advance_1st": {
+ "ticket": "1st Advance",
+ "price": 80.0,
+ "code": "AFA",
+ },
+ },
+ }
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch_advance",
+ lambda station_crs, travel_date: advance_fares,
+ )
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch_advance_streaming",
+ lambda station_crs, travel_date: iter([advance_fares]),
+ )
+ monkeypatch.setattr(
+ eurostar_scraper,
+ "fetch",
+ lambda destination, travel_date: [
+ {
+ "depart_st_pancras": "10:01",
+ "arrive_destination": "13:34",
+ "destination": destination,
+ "train_number": "ES 9014",
+ "price": 59,
+ "seats": 42,
+ "plus_price": 89,
+ "plus_seats": 5,
+ },
+ ],
+ )
+
+
+@pytest.fixture
+def local_server(monkeypatch: Any) -> Generator[str, None, None]:
+ _stub_return_data(monkeypatch)
+ app_module.app.config["TESTING"] = True
+ server = make_server("127.0.0.1", 0, app_module.app)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ yield f"http://127.0.0.1:{server.server_port}"
+ finally:
+ server.shutdown()
+ thread.join(timeout=5)
+
+
+@pytest.fixture
+def single_server(monkeypatch: Any) -> Generator[str, None, None]:
+ _stub_single_data(monkeypatch)
+ app_module.app.config["TESTING"] = True
+ server = make_server("127.0.0.1", 0, app_module.app)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ yield f"http://127.0.0.1:{server.server_port}"
+ finally:
+ server.shutdown()
+ thread.join(timeout=5)
+
+
+def _launch_browser(playwright: Any) -> Any:
+ try:
+ return playwright.chromium.launch(headless=True)
+ except Exception as exc:
+ pytest.skip(f"Chromium browser unavailable for Playwright: {exc}")
+
+
+def test_single_advance_standard_totals_after_click(single_server: str) -> None:
+ with sync_playwright() as p:
+ browser = _launch_browser(p)
+ page = browser.new_page()
+ page.goto(
+ f"{single_server}/results/BRI/paris/2026-07-20",
+ wait_until="domcontentloaded",
+ )
+
+ page.get_by_role("button", name="Advance Std").click()
+
+ page.wait_for_function(
+ "Array.from(document.querySelectorAll('.total-price'))"
+ ".some(el => el.textContent.includes('£112.10'))",
+ timeout=10000,
+ )
+ assert "nr_class=advance_std" in page.url
+ totals = [el.inner_text() for el in page.locator(".total-price").all()]
+ assert totals == ["£112.10"]
+ browser.close()
+
+
+def test_single_next_date_advance_standard_labels_unreachable_rows(
+ monkeypatch: Any,
+) -> None:
+ monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
+ monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
+ monkeypatch.setattr(
+ rtt_scraper,
+ "fetch",
+ lambda travel_date, user_agent, station_crs="BRI": [
+ {
+ "depart_bristol": "07:00",
+ "arrive_paddington": "08:45",
+ "headcode": "1A23",
+ },
+ ],
+ )
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch",
+ lambda station_crs, travel_date: {
+ "07:00": {
+ "ticket": "Anytime Day Single",
+ "price": 138.70,
+ "code": "SDS",
+ },
+ },
+ )
+ advance_fares: dict[str, Any] = {
+ "07:00": {
+ "advance_std": {
+ "ticket": "Advance Single",
+ "price": 50.0,
+ "code": "ADV",
+ },
+ "advance_1st": None,
+ },
+ }
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch_advance",
+ lambda station_crs, travel_date: advance_fares,
+ )
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch_advance_streaming",
+ lambda station_crs, travel_date: iter([advance_fares]),
+ )
+ monkeypatch.setattr(
+ eurostar_scraper,
+ "fetch",
+ lambda destination, travel_date: [
+ {
+ "depart_st_pancras": "09:30",
+ "arrive_destination": "12:30",
+ "destination": destination,
+ "train_number": "ES 9001",
+ "price": 59,
+ "seats": 42,
+ "plus_price": None,
+ "plus_seats": None,
+ },
+ {
+ "depart_st_pancras": "10:01",
+ "arrive_destination": "13:34",
+ "destination": destination,
+ "train_number": "ES 9014",
+ "price": 59,
+ "seats": 42,
+ "plus_price": None,
+ "plus_seats": None,
+ },
+ ],
+ )
+ app_module.app.config["TESTING"] = True
+ server = make_server("127.0.0.1", 0, app_module.app)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ with sync_playwright() as p:
+ browser = _launch_browser(p)
+ page = browser.new_page()
+ page.goto(
+ f"http://127.0.0.1:{server.server_port}"
+ "/results/BRI/brussels/2026-06-16",
+ wait_until="domcontentloaded",
+ )
+ page.get_by_role("link", name="Next →").click()
+ page.wait_for_url("**/2026-06-17**", timeout=10000)
+ page.get_by_role("button", name="Advance Std").click()
+ page.wait_for_function(
+ "Array.from(document.querySelectorAll('.total-price'))"
+ ".some(el => el.textContent.includes('£112.10'))",
+ timeout=10000,
+ )
+
+ assert page.get_by_text("No connection").count() == 1
+ totals = [el.inner_text() for el in page.locator(".total-price").all()]
+ assert totals == ["£112.10"]
+ browser.close()
+ finally:
+ server.shutdown()
+ thread.join(timeout=5)
+
+
+def test_single_advance_standard_premier_totals_on_initial_url(
+ single_server: str,
+) -> None:
+ with sync_playwright() as p:
+ browser = _launch_browser(p)
+ page = browser.new_page()
+ page.goto(
+ f"{single_server}/results/BRI/paris/2026-07-20"
+ "?nr_class=advance_std&es_class=plus",
+ wait_until="domcontentloaded",
+ )
+
+ page.wait_for_function(
+ "Array.from(document.querySelectorAll('.total-price'))"
+ ".some(el => el.textContent.includes('£142.10'))",
+ timeout=10000,
+ )
+ totals = [el.inner_text() for el in page.locator(".total-price").all()]
+ assert totals == ["£142.10"]
+ browser.close()
+
+
+def test_single_advance_first_falls_back_to_walkon_when_unavailable(
+ monkeypatch: Any,
+) -> None:
+ monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
+ monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
+ monkeypatch.setattr(
+ rtt_scraper,
+ "fetch",
+ lambda travel_date, user_agent, station_crs="BRI": [
+ {
+ "depart_bristol": "07:00",
+ "arrive_paddington": "08:45",
+ "headcode": "1A23",
+ },
+ ],
+ )
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch",
+ lambda station_crs, travel_date: {
+ "07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"},
+ },
+ )
+ advance_fares: dict[str, Any] = {
+ "07:00": {
+ "advance_std": {"ticket": "Advance Single", "price": 50.0, "code": "ADV"},
+ "advance_1st": None,
+ },
+ }
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch_advance",
+ lambda station_crs, travel_date: advance_fares,
+ )
+ monkeypatch.setattr(
+ gwr_fares_scraper,
+ "fetch_advance_streaming",
+ lambda station_crs, travel_date: iter([advance_fares]),
+ )
+ monkeypatch.setattr(
+ eurostar_scraper,
+ "fetch",
+ lambda destination, travel_date: [
+ {
+ "depart_st_pancras": "10:01",
+ "arrive_destination": "13:34",
+ "destination": destination,
+ "train_number": "ES 9014",
+ "price": 59,
+ "seats": 42,
+ "plus_price": 89,
+ "plus_seats": 5,
+ },
+ ],
+ )
+ app_module.app.config["TESTING"] = True
+ server = make_server("127.0.0.1", 0, app_module.app)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ with sync_playwright() as p:
+ browser = _launch_browser(p)
+ page = browser.new_page()
+ page.goto(
+ f"http://127.0.0.1:{server.server_port}"
+ "/results/BRI/paris/2026-07-20?nr_class=advance_1st&es_class=standard",
+ wait_until="domcontentloaded",
+ )
+
+ page.wait_for_function(
+ "Array.from(document.querySelectorAll('.total-price'))"
+ ".some(el => el.textContent.includes('£200.80'))",
+ timeout=10000,
+ )
+ totals = [el.inner_text() for el in page.locator(".total-price").all()]
+ assert totals == ["£200.80"]
+ assert "No NR fare" not in " ".join(totals)
+ browser.close()
+ finally:
+ server.shutdown()
+ thread.join(timeout=5)
+
+
+def test_return_advance_first_standard_premier_totals(local_server: str) -> None:
+ with sync_playwright() as p:
+ browser = _launch_browser(p)
+ page = browser.new_page()
+ page.goto(f"{local_server}/", wait_until="domcontentloaded")
+ page.locator("#journey-return").check(force=True)
+ page.locator("#destination-paris").check(force=True)
+ page.locator("#travel_date").fill("2026-07-20")
+ page.locator("#return_date").fill("2026-07-27")
+ page.locator('button[type="submit"]').click()
+ page.wait_for_url("**/results/**", timeout=10000)
+
+ page.get_by_role("button", name="Advance 1st").click()
+ page.get_by_role("button", name="Standard Premier").click()
+
+ page.wait_for_function(
+ "Array.from(document.querySelectorAll('.total-price'))"
+ ".some(el => el.textContent.includes('£172.10'))",
+ timeout=10000,
+ )
+ page.wait_for_function(
+ "Array.from(document.querySelectorAll('.total-price'))"
+ ".some(el => el.textContent.includes('£127.10'))",
+ timeout=10000,
+ )
+
+ assert "/results/BRI/paris/2026-07-20/return/2026-07-27" in page.url
+ assert "journey_type=return" not in page.url
+ assert "return_date=2026-07-27" not in page.url
+ assert "nr_class=advance_1st" in page.url
+ assert "es_class=plus" in page.url
+ totals = [el.inner_text() for el in page.locator(".total-price").all()]
+ assert totals == ["£172.10 💸", "£127.10 🪙"]
+ browser.close()
+
+
+def test_return_calendar_selects_outbound_before_return(local_server: str) -> None:
+ with sync_playwright() as p:
+ browser = _launch_browser(p)
+ page = browser.new_page()
+ page.goto(f"{local_server}/", wait_until="domcontentloaded")
+
+ page.locator("#journey-return").check(force=True)
+ assert page.locator("#cal-hint").inner_text() == "Select outbound date"
+ assert page.locator("#travel_date").input_value() == ""
+ assert page.locator("#return_date").input_value() == ""
+
+ page.get_by_role("button", name="10 June 2026").click()
+ assert page.locator("#travel_date").input_value() == "2026-06-10"
+ assert page.locator("#return_date").input_value() == ""
+ assert "Now select return date" in page.locator("#cal-hint").inner_text()
+
+ page.get_by_role("button", name="17 June 2026").click()
+ assert page.locator("#travel_date").input_value() == "2026-06-10"
+ assert page.locator("#return_date").input_value() == "2026-06-17"
+ assert "Return: Wed 17 Jun" in page.locator("#cal-hint").inner_text()
+
+ page.locator('button[type="submit"]').click()
+ page.wait_for_url("**/results/BRI/paris/2026-06-10/return/2026-06-17", timeout=10000)
+ browser.close()
+
+
+def test_return_advance_first_standard_premier_totals_on_initial_url(
+ local_server: str,
+) -> None:
+ with sync_playwright() as p:
+ browser = _launch_browser(p)
+ page = browser.new_page()
+ page.goto(
+ f"{local_server}/results/BRI/paris/2026-07-20/return/2026-07-27"
+ "?nr_class=advance_1st&es_class=plus",
+ wait_until="domcontentloaded",
+ )
+
+ page.wait_for_function(
+ "Array.from(document.querySelectorAll('.total-price'))"
+ ".some(el => el.textContent.includes('£172.10'))",
+ timeout=10000,
+ )
+ page.wait_for_function(
+ "Array.from(document.querySelectorAll('.total-price'))"
+ ".some(el => el.textContent.includes('£127.10'))",
+ timeout=10000,
+ )
+
+ totals = [el.inner_text() for el in page.locator(".total-price").all()]
+ assert totals == ["£172.10 💸", "£127.10 🪙"]
+ browser.close()
diff --git a/tests/test_rtt_scraper.py b/tests/test_rtt_scraper.py
index 7bceb61..98590f6 100644
--- a/tests/test_rtt_scraper.py
+++ b/tests/test_rtt_scraper.py
@@ -1,71 +1,74 @@
import pytest
from scraper.realtime_trains import _fmt, _parse_services
-
# ---------------------------------------------------------------------------
# _fmt
# ---------------------------------------------------------------------------
-def test_fmt_four_digits():
- assert _fmt('0830') == '08:30'
-def test_fmt_already_colon():
- assert _fmt('08:30') == '08:30'
+def test_fmt_four_digits() -> None:
+ assert _fmt("0830") == "08:30"
-def test_fmt_strips_non_digits():
- assert _fmt('08h30') == '08:30'
+
+def test_fmt_already_colon() -> None:
+ assert _fmt("08:30") == "08:30"
+
+
+def test_fmt_strips_non_digits() -> None:
+ assert _fmt("08h30") == "08:30"
# ---------------------------------------------------------------------------
# _parse_services
# ---------------------------------------------------------------------------
+
def _make_html(services: list[tuple[str, str]], time_class: str) -> str:
"""Build a minimal servicelist HTML with (train_id, time) pairs."""
- items = ''
+ items = ""
for tid, time in services:
- items += f'''
+ items += f"""
{tid}
{time}
- '''
+ """
return f'
{items}
'
-def test_parse_services_departures():
- html = _make_html([('1A23', '0700'), ('2B45', '0830')], 'd')
- result = _parse_services(html, 'div.time.plan.d')
- assert result == {'1A23': '07:00', '2B45': '08:30'}
+def test_parse_services_departures() -> None:
+ html = _make_html([("1A23", "0700"), ("2B45", "0830")], "d")
+ result = _parse_services(html, "div.time.plan.d")
+ assert result == {"1A23": "07:00", "2B45": "08:30"}
-def test_parse_services_arrivals():
- html = _make_html([('1A23', '0845')], 'a')
- result = _parse_services(html, 'div.time.plan.a')
- assert result == {'1A23': '08:45'}
+def test_parse_services_arrivals() -> None:
+ html = _make_html([("1A23", "0845")], "a")
+ result = _parse_services(html, "div.time.plan.a")
+ assert result == {"1A23": "08:45"}
-def test_parse_services_no_servicelist():
- assert _parse_services('', 'div.time.plan.d') == {}
+def test_parse_services_no_servicelist() -> None:
+ assert _parse_services("", "div.time.plan.d") == {}
-def test_parse_services_skips_missing_time():
- html = '''
+def test_parse_services_skips_missing_time() -> None:
+ html = """
'''
- result = _parse_services(html, 'div.time.plan.d')
- assert '1A23' not in result
- assert result == {'2B45': '09:00'}
+
"""
+ result = _parse_services(html, "div.time.plan.d")
+ assert "1A23" not in result
+ assert result == {"2B45": "09:00"}
-def test_parse_services_skips_empty_time():
- html = '''
+def test_parse_services_skips_empty_time() -> None:
+ html = """