Add travel booking and place import scripts
This commit is contained in:
parent
a87c9f993e
commit
5f6cb57c2a
10 changed files with 1177 additions and 3 deletions
101
tests/test_build_place_yaml.py
Normal file
101
tests/test_build_place_yaml.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""Tests for agenda.build_place_yaml."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from agenda import build_place_yaml
|
||||
|
||||
|
||||
def test_upsert_station_adds_new_station(tmp_path: Path) -> None:
|
||||
"""Station upsert should add a new station to stations.yaml."""
|
||||
path = tmp_path / "stations.yaml"
|
||||
path.write_text("""- name: London St Pancras
|
||||
latitude: 51.531921
|
||||
longitude: -0.126361
|
||||
country: gb
|
||||
wikidata: Q720102
|
||||
routes: {}
|
||||
""")
|
||||
|
||||
replaced = build_place_yaml.upsert_station(
|
||||
tmp_path,
|
||||
{
|
||||
"name": "Paris Gare du Nord",
|
||||
"latitude": 48.8809,
|
||||
"longitude": 2.3553,
|
||||
"country": "fr",
|
||||
"wikidata": "Q624511",
|
||||
"routes": {},
|
||||
},
|
||||
)
|
||||
|
||||
stations = yaml.safe_load(path.read_text())
|
||||
assert replaced is False
|
||||
assert [station["name"] for station in stations] == [
|
||||
"London St Pancras",
|
||||
"Paris Gare du Nord",
|
||||
]
|
||||
assert "\n\n- name: Paris Gare du Nord\n" in path.read_text()
|
||||
|
||||
|
||||
def test_upsert_station_replaces_existing_station(tmp_path: Path) -> None:
|
||||
"""Station upsert should replace an existing station with the same name."""
|
||||
path = tmp_path / "stations.yaml"
|
||||
path.write_text("""- name: Paris Gare du Nord
|
||||
latitude: 0
|
||||
longitude: 0
|
||||
country: fr
|
||||
wikidata: Q624511
|
||||
routes: {}
|
||||
""")
|
||||
|
||||
replaced = build_place_yaml.upsert_station(
|
||||
tmp_path,
|
||||
{
|
||||
"name": "Paris Gare du Nord",
|
||||
"latitude": 48.8809,
|
||||
"longitude": 2.3553,
|
||||
"country": "fr",
|
||||
"wikidata": "Q624511",
|
||||
"routes": {},
|
||||
},
|
||||
)
|
||||
|
||||
stations = yaml.safe_load(path.read_text())
|
||||
assert replaced is True
|
||||
assert len(stations) == 1
|
||||
assert stations[0]["latitude"] == 48.8809
|
||||
assert "\n\n- name:" not in path.read_text()
|
||||
|
||||
|
||||
def test_upsert_airport_adds_mapping_entry(tmp_path: Path) -> None:
|
||||
"""Airport upsert should add a new IATA-keyed airport entry."""
|
||||
path = tmp_path / "airports.yaml"
|
||||
path.write_text("""LHR:
|
||||
iata: LHR
|
||||
name: Heathrow Airport
|
||||
city: London
|
||||
country: gb
|
||||
latitude: 51.47
|
||||
longitude: -0.4543
|
||||
qid: Q8691
|
||||
""")
|
||||
|
||||
replaced = build_place_yaml.upsert_airport(
|
||||
tmp_path,
|
||||
{
|
||||
"iata": "ORY",
|
||||
"name": "Paris Orly Airport",
|
||||
"city": "Paris Orly Airport",
|
||||
"country": "fr",
|
||||
"latitude": 48.723333,
|
||||
"longitude": 2.379444,
|
||||
"qid": "Q193353",
|
||||
},
|
||||
)
|
||||
|
||||
airports = yaml.safe_load(path.read_text())
|
||||
assert replaced is False
|
||||
assert list(airports) == ["LHR", "ORY"]
|
||||
assert airports["ORY"]["country"] == "fr"
|
||||
187
tests/test_generate_booking_yaml.py
Normal file
187
tests/test_generate_booking_yaml.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"""Tests for agenda.generate_booking_yaml."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from agenda import generate_booking_yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeTrip:
|
||||
"""Small stand-in for agenda.types.Trip in matching tests."""
|
||||
|
||||
start: date
|
||||
end: date | None
|
||||
|
||||
|
||||
def test_yaml_format_description_includes_train_spec() -> None:
|
||||
"""Train prompt docs should come from personal-data-yaml.md."""
|
||||
description = generate_booking_yaml.yaml_format_description(
|
||||
generate_booking_yaml.BOOKING_CONFIGS["train"]
|
||||
)
|
||||
|
||||
assert "## General Rules" in description
|
||||
assert "## Cross-File References" in description
|
||||
assert "## `trains.yaml`" in description
|
||||
assert "`operator`: booking/operator label" in description
|
||||
|
||||
|
||||
def test_build_prompt_uses_spec_and_keeps_trip_exclusion() -> None:
|
||||
"""The generated prompt should use the documented schema."""
|
||||
prompt = generate_booking_yaml.build_prompt(
|
||||
"Eurostar booking details",
|
||||
generate_booking_yaml.BOOKING_CONFIGS["train"],
|
||||
current_bookings="- operator: eurostar\n",
|
||||
)
|
||||
|
||||
assert "Use this YAML format specification" in prompt
|
||||
assert "## `trains.yaml`" in prompt
|
||||
assert 'Exclude the top-level "trip" key' in prompt
|
||||
assert "Eurostar booking details" in prompt
|
||||
|
||||
|
||||
def test_booking_text_from_args_fetches_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""URL arguments should be fetched instead of reading stdin."""
|
||||
monkeypatch.setattr(
|
||||
generate_booking_yaml,
|
||||
"url_to_booking_text",
|
||||
lambda url: f"fetched {url}",
|
||||
)
|
||||
|
||||
assert generate_booking_yaml.booking_text_from_args(["https://example.com"]) == (
|
||||
"fetched https://example.com"
|
||||
)
|
||||
|
||||
|
||||
def test_matching_trip_date_uses_built_trip_end(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Trip matching should use build_trip_list-derived end dates."""
|
||||
monkeypatch.setattr(
|
||||
generate_booking_yaml,
|
||||
"build_trips",
|
||||
lambda data_dir: [ # noqa: ARG005
|
||||
FakeTrip(date(2026, 2, 6), date(2026, 2, 9)),
|
||||
FakeTrip(date(2026, 3, 3), date(2026, 3, 5)),
|
||||
],
|
||||
)
|
||||
|
||||
assert generate_booking_yaml.matching_trip_date(
|
||||
generate_booking_yaml.datetime_from_yaml_value("2026-02-08 12:00:00+01:00"),
|
||||
Path("/tmp/personal-data"),
|
||||
) == date(2026, 2, 6)
|
||||
|
||||
|
||||
def test_matching_trip_date_falls_back_to_departure_date(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A booking outside known trips should use its first departure date."""
|
||||
monkeypatch.setattr(
|
||||
generate_booking_yaml, "build_trips", lambda data_dir: [] # noqa: ARG005
|
||||
)
|
||||
|
||||
assert generate_booking_yaml.matching_trip_date(
|
||||
generate_booking_yaml.datetime_from_yaml_value("2026-04-10 12:00:00+01:00"),
|
||||
Path("/tmp/personal-data"),
|
||||
) == date(2026, 4, 10)
|
||||
|
||||
|
||||
def test_import_train_booking_adds_trip_and_inserts_chronologically(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Generated train bookings should be written into trains.yaml in order."""
|
||||
(tmp_path / "trains.yaml").write_text("""- operator: early
|
||||
from: A
|
||||
to: B
|
||||
trip: 2026-02-01
|
||||
depart: 2026-02-01 10:00:00+00:00
|
||||
arrive: 2026-02-01 11:00:00+00:00
|
||||
legs: []
|
||||
|
||||
- operator: late
|
||||
from: C
|
||||
to: D
|
||||
trip: 2026-02-10
|
||||
depart: 2026-02-10 10:00:00+00:00
|
||||
arrive: 2026-02-10 11:00:00+00:00
|
||||
legs: []
|
||||
""")
|
||||
monkeypatch.setattr(
|
||||
generate_booking_yaml,
|
||||
"matching_trip_date",
|
||||
lambda depart, data_dir: date(2026, 2, 6),
|
||||
)
|
||||
|
||||
count = generate_booking_yaml.import_booking_yaml(
|
||||
"""- operator: eurostar
|
||||
from: London St Pancras
|
||||
to: Brussels Midi
|
||||
depart: 2026-02-06 15:04:00+00:00
|
||||
arrive: 2026-02-06 18:12:00+01:00
|
||||
legs: []
|
||||
""",
|
||||
generate_booking_yaml.BOOKING_CONFIGS["train"],
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
|
||||
written = yaml.safe_load((tmp_path / "trains.yaml").read_text())
|
||||
assert count == 1
|
||||
assert [item["operator"] for item in written] == ["early", "eurostar", "late"]
|
||||
assert written[1]["trip"] == date(2026, 2, 6)
|
||||
assert list(written[1]).index("trip") == 3
|
||||
assert "\n\n- operator: eurostar\n" in (tmp_path / "trains.yaml").read_text()
|
||||
|
||||
|
||||
def test_import_flight_booking_adds_trip_and_inserts_chronologically(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Generated flight bookings should be written into flights.yaml in order."""
|
||||
(tmp_path / "flights.yaml").write_text("""---
|
||||
- booking_reference: OLD
|
||||
trip: 2026-02-01
|
||||
flights:
|
||||
- depart: 2026-02-01 10:00:00+00:00
|
||||
from: BRS
|
||||
to: AMS
|
||||
flight_number: '1'
|
||||
airline: U2
|
||||
|
||||
- booking_reference: NEWER
|
||||
trip: 2026-02-10
|
||||
flights:
|
||||
- depart: 2026-02-10 10:00:00+00:00
|
||||
from: BRS
|
||||
to: AMS
|
||||
flight_number: '2'
|
||||
airline: U2
|
||||
""")
|
||||
monkeypatch.setattr(
|
||||
generate_booking_yaml,
|
||||
"matching_trip_date",
|
||||
lambda depart, data_dir: date(2026, 2, 6),
|
||||
)
|
||||
|
||||
count = generate_booking_yaml.import_booking_yaml(
|
||||
"""booking_reference: MID
|
||||
flights:
|
||||
- depart: 2026-02-06 10:00:00+00:00
|
||||
from: BRS
|
||||
to: AMS
|
||||
flight_number: '3'
|
||||
airline: U2
|
||||
""",
|
||||
generate_booking_yaml.BOOKING_CONFIGS["flight"],
|
||||
data_dir=tmp_path,
|
||||
)
|
||||
|
||||
written = yaml.safe_load((tmp_path / "flights.yaml").read_text())
|
||||
assert count == 1
|
||||
assert [item["booking_reference"] for item in written] == ["OLD", "MID", "NEWER"]
|
||||
assert written[1]["trip"] == date(2026, 2, 6)
|
||||
assert list(written[1]).index("trip") == 1
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
from datetime import date
|
||||
|
||||
import agenda.trip
|
||||
import pytest
|
||||
from agenda.types import Trip
|
||||
from web_view import app
|
||||
|
||||
|
|
@ -101,3 +102,68 @@ def test_get_coordinates_and_routes_adds_unbooked_flight_airports() -> None:
|
|||
coord["name"] for coord in coordinates if coord["type"] == "airport"
|
||||
}
|
||||
assert airport_names == {"Heathrow Airport", "Paris Charles de Gaulle Airport"}
|
||||
|
||||
|
||||
def test_get_trip_routes_assumes_unbooked_paris_trip_is_by_train(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Paris conferences without booked travel should show rail, not flight."""
|
||||
trip = Trip(
|
||||
start=date(2026, 7, 20),
|
||||
conferences=[
|
||||
{
|
||||
"name": "Paris Conf",
|
||||
"location": "Paris",
|
||||
"country": "fr",
|
||||
}
|
||||
],
|
||||
)
|
||||
stations = [
|
||||
{
|
||||
"name": "London St Pancras",
|
||||
"latitude": 51.531921,
|
||||
"longitude": -0.126361,
|
||||
"routes": {"Paris Gare du Nord": "London_St_Pancras_to_Paris_Gare_du_Nord"},
|
||||
},
|
||||
{
|
||||
"name": "Paris Gare du Nord",
|
||||
"latitude": 48.88111111111111,
|
||||
"longitude": 2.355277777777778,
|
||||
"routes": {"London St Pancras": "London_St_Pancras_to_Paris_Gare_du_Nord"},
|
||||
},
|
||||
]
|
||||
|
||||
def fake_parse_yaml(name: str, data_dir: str) -> object:
|
||||
if name == "stations":
|
||||
return stations
|
||||
if name == "airports":
|
||||
return {
|
||||
"LHR": {
|
||||
"name": "Heathrow Airport",
|
||||
"latitude": 51.47,
|
||||
"longitude": -0.45,
|
||||
},
|
||||
"CDG": {
|
||||
"name": "Paris Charles de Gaulle Airport",
|
||||
"city": "Paris",
|
||||
"country": "fr",
|
||||
"latitude": 49.01,
|
||||
"longitude": 2.55,
|
||||
},
|
||||
}
|
||||
raise AssertionError(f"unexpected YAML load: {name}")
|
||||
|
||||
monkeypatch.setattr(agenda.trip.travel, "parse_yaml", fake_parse_yaml)
|
||||
monkeypatch.setattr(
|
||||
agenda.trip, "load_flight_destination_rules", lambda _data_dir: []
|
||||
)
|
||||
|
||||
routes = agenda.trip.get_trip_routes(trip, "/tmp/personal-data")
|
||||
|
||||
assert routes == [
|
||||
{
|
||||
"type": "train",
|
||||
"key": "train_London St Pancras_Paris Gare du Nord",
|
||||
"geojson_filename": "train_routes/London_St_Pancras_to_Paris_Gare_du_Nord",
|
||||
}
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue