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() -> Any: app_module.app.config["TESTING"] = True return app_module.app.test_client() 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( 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: gwr_fares or {"07:00": {"ticket": "Anytime Day Single", "price": 138.70, "code": "SDS"}}, ) p = (prices or {}).get("10:01", {}) 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": 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() -> None: client = _client() 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 'name="station_crs"' in html assert html.count('type="radio"') == len(app_module.DESTINATIONS) assert "destination-rotterdam" in html 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" ) assert resp.status_code == 302 assert resp.headers["Location"].endswith( "/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120" ) 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" ) html = resp.get_data(as_text=True) assert resp.status_code == 200 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 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/paris/2026-06-22?min_connection=60&max_connection=120" ) html = resp.get_data(as_text=True) assert resp.status_code == 200 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 "Eurostar prices not yet available" not in html 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 ) 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( 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( 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, }, ], ) 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 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 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(gwr_fares_scraper, "fetch", lambda s, d: {}) 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( 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, }, ], ) 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 "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: 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}}) 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 "£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_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(gwr_fares_scraper, "fetch", lambda s, d: {}) 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( 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, }, ], ) 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 "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: 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" ) 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 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" ) 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 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, } } 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( 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 s, d: {}) 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": 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" ) 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 # 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 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( 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"}, } }, ) client = _client() 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 def test_api_advance_fares_404_for_unknown_station() -> None: client = _client() 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: 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 s, d: (_ for _ in ()).throw(Exception("network error")), ) client = _client() resp = client.get("/api/advance_fares/BRI/2026-04-10") data = resp.get_json() assert resp.status_code == 500 assert "error" in data