paddington-eurostar/tests/test_trip_planner.py
Edward Betts 13c4341f3a Add full type annotations and black formatting across all modules
Annotated all functions with mypy --strict-compatible types (-> None, dict[str,
Any], Generator types, etc.), added # type: ignore for untyped third-party libs
(lxml), and reformatted with black. All 18 source files now pass mypy --strict
with zero errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:48:53 +01:00

297 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

import pytest
from trip_planner import (
combine_inbound_trips,
combine_trips,
find_unreachable_morning_eurostars,
_fmt_duration,
)
DATE = "2026-03-30"
# ---------------------------------------------------------------------------
# _fmt_duration
# ---------------------------------------------------------------------------
def test_fmt_duration_hours_and_minutes() -> None:
assert _fmt_duration(95) == "1h 35m"
def test_fmt_duration_exact_hours() -> None:
assert _fmt_duration(120) == "2h"
def test_fmt_duration_minutes_only() -> None:
assert _fmt_duration(45) == "45m"
# ---------------------------------------------------------------------------
# combine_trips — basic pairing
# ---------------------------------------------------------------------------
GWR_FAST = {"depart_bristol": "07:00", "arrive_paddington": "08:45"} # 1h 45m
GWR_SLOW = {
"depart_bristol": "07:00",
"arrive_paddington": "09:26",
} # 2h 26m — connection too short for ES_PARIS
ES_PARIS = {
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": "Paris Gare du Nord",
}
ES_EARLY = {
"depart_st_pancras": "09:00",
"arrive_destination": "12:00",
"destination": "Paris Gare du Nord",
}
def test_valid_trip_is_returned() -> None:
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE)
assert len(trips) == 1
t = trips[0]
assert t["depart_bristol"] == "07:00"
assert t["arrive_paddington"] == "08:45"
assert t["depart_st_pancras"] == "10:01"
assert t["arrive_destination"] == "13:34"
assert t["destination"] == "Paris Gare du Nord"
def test_gwr_too_slow_excluded() -> None:
# arrive 09:26, Eurostar 10:01 → 35 min connection < 50 min minimum
trips = combine_trips([GWR_SLOW], [ES_PARIS], DATE)
assert trips == []
def test_eurostar_too_early_excluded() -> None:
# Eurostar departs before min connection time has elapsed
trips = combine_trips([GWR_FAST], [ES_EARLY], DATE)
assert trips == []
def test_no_trains_returns_empty() -> None:
assert combine_trips([], [], DATE) == []
def test_no_gwr_returns_empty() -> None:
assert combine_trips([], [ES_PARIS], DATE) == []
def test_no_eurostar_returns_empty() -> None:
assert combine_trips([GWR_FAST], [], DATE) == []
# ---------------------------------------------------------------------------
# Connection window constraints
# ---------------------------------------------------------------------------
def test_min_connection_enforced() -> None:
# Arrive Paddington 08:45, need 75 min → earliest St Pancras 10:00
# ES at 09:59 should be excluded, 10:00 should be included
es_too_close = {
"depart_st_pancras": "09:59",
"arrive_destination": "13:00",
"destination": "Paris Gare du Nord",
}
es_ok = {
"depart_st_pancras": "10:00",
"arrive_destination": "13:00",
"destination": "Paris Gare du Nord",
}
assert (
combine_trips([GWR_FAST], [es_too_close], DATE, min_connection_minutes=75) == []
)
trips = combine_trips([GWR_FAST], [es_ok], DATE, min_connection_minutes=75)
assert len(trips) == 1
def test_max_connection_enforced() -> None:
# Arrive Paddington 08:45, max 140 min → latest St Pancras 11:05
es_ok = {
"depart_st_pancras": "11:05",
"arrive_destination": "14:00",
"destination": "Paris Gare du Nord",
}
es_too_late = {
"depart_st_pancras": "11:06",
"arrive_destination": "14:00",
"destination": "Paris Gare du Nord",
}
trips = combine_trips([GWR_FAST], [es_ok], DATE, max_connection_minutes=140)
assert len(trips) == 1
assert (
combine_trips([GWR_FAST], [es_too_late], DATE, max_connection_minutes=140) == []
)
# ---------------------------------------------------------------------------
# Only earliest valid Eurostar per GWR departure
# ---------------------------------------------------------------------------
def test_only_earliest_eurostar_per_gwr() -> None:
es1 = {
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": "Paris Gare du Nord",
}
es2 = {
"depart_st_pancras": "11:01",
"arrive_destination": "14:34",
"destination": "Paris Gare du Nord",
}
trips = combine_trips([GWR_FAST], [es1, es2], DATE)
assert len(trips) == 1
assert trips[0]["depart_st_pancras"] == "10:01"
# ---------------------------------------------------------------------------
# Multiple GWR trains → multiple trips
# ---------------------------------------------------------------------------
def test_multiple_gwr_trains() -> None:
gwr2 = {"depart_bristol": "08:00", "arrive_paddington": "09:45"}
es = {
"depart_st_pancras": "11:01",
"arrive_destination": "14:34",
"destination": "Paris Gare du Nord",
}
trips = combine_trips([GWR_FAST, gwr2], [es], DATE, max_connection_minutes=140)
assert len(trips) == 2
assert trips[0]["depart_bristol"] == "07:00"
assert trips[1]["depart_bristol"] == "08:00"
# ---------------------------------------------------------------------------
# Duration fields
# ---------------------------------------------------------------------------
def test_gwr_duration_in_trip() -> None:
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE)
assert trips[0]["gwr_duration"] == "1h 45m"
def test_total_duration_in_trip() -> None:
# depart 07:00, arrive 13:34 → 6h 34m
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE)
assert trips[0]["total_duration"] == "6h 34m"
def test_connection_duration_in_trip() -> None:
# arrive Paddington 08:45, depart St Pancras 10:01 → 1h 16m
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE)
assert trips[0]["connection_duration"] == "1h 16m"
def test_find_unreachable_eurostars_excludes_connectable_services() -> None:
# GWR arrives 08:45; default min=50/max=110 → viable window 09:3510:35.
# 09:30 too early, 10:15 connectable, 12:30 beyond max connection.
gwr = [
{"depart_bristol": "07:00", "arrive_paddington": "08:45"},
]
eurostar = [
{
"depart_st_pancras": "09:30",
"arrive_destination": "12:00",
"destination": "Paris Gare du Nord",
"train_number": "ES 9001",
},
{
"depart_st_pancras": "10:15",
"arrive_destination": "13:40",
"destination": "Paris Gare du Nord",
"train_number": "ES 9002",
},
{
"depart_st_pancras": "12:30",
"arrive_destination": "15:55",
"destination": "Paris Gare du Nord",
"train_number": "ES 9003",
},
]
unreachable = find_unreachable_morning_eurostars(gwr, eurostar, DATE)
assert [s["depart_st_pancras"] for s in unreachable] == ["09:30", "12:30"]
def test_combine_trips_includes_ticket_fields() -> None:
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE)
assert len(trips) == 1
t = trips[0]
assert "ticket_name" in t
assert "ticket_price" in t
assert "ticket_code" in t
def test_combine_trips_uses_gwr_fares_when_provided() -> None:
fares = {
"07:00": {"ticket": "Super Off-Peak Single", "price": 49.30, "code": "SSS"}
}
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares=fares)
assert len(trips) == 1
assert trips[0]["ticket_price"] == 49.30
assert trips[0]["ticket_code"] == "SSS"
def test_combine_trips_ticket_price_none_when_no_fares() -> None:
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares={})
assert len(trips) == 1
assert trips[0]["ticket_price"] is None
def test_find_unreachable_eurostars_returns_empty_when_all_connectable() -> None:
gwr = [
{"depart_bristol": "07:00", "arrive_paddington": "08:45"},
]
eurostar = [
{
"depart_st_pancras": "10:15",
"arrive_destination": "13:40",
"destination": "Paris Gare du Nord",
"train_number": "ES 9002",
},
]
assert find_unreachable_morning_eurostars(gwr, eurostar, DATE) == []
def test_combine_inbound_trips_pairs_eurostar_to_paddington_departure() -> None:
eurostar = [
{
"depart_destination": "15:12",
"arrive_st_pancras": "16:30",
"destination": "Paris Gare du Nord",
"train_number": "ES 9035",
}
]
gwr = [
{
"depart_paddington": "17:15",
"arrive_destination": "18:55",
"headcode": "1B99",
}
]
fares = {"17:15": {"ticket": "Off-Peak Single", "price": 63.60, "code": "SVS"}}
trips = combine_inbound_trips(
eurostar,
gwr,
DATE,
min_connection_minutes=30,
max_connection_minutes=120,
gwr_fares=fares,
)
assert len(trips) == 1
assert trips[0]["depart_destination"] == "15:12"
assert trips[0]["arrive_st_pancras"] == "16:30"
assert trips[0]["depart_paddington"] == "17:15"
assert trips[0]["arrive_uk_station"] == "18:55"
assert trips[0]["ticket_price"] == 63.60
assert trips[0]["check_in_by"] == "14:42"