Next alpha stage commit
This commit is contained in:
@@ -1,19 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
import app.jobs as jobs_module
|
||||
import app.main as main_module
|
||||
from app.config import settings
|
||||
from app.db import init_db, session_scope
|
||||
from app.db_lock import DatabaseWriteBusy, database_write_lock
|
||||
from app.jobs import run_worker_once
|
||||
from app.harmonization import active_harmonized_gtfs_dataset_ids, active_harmonized_gtfs_shadowed_route_ids
|
||||
from app.jobs import add_job_event, run_worker_once
|
||||
from app.main import app
|
||||
from app.models import Dataset, GtfsRoute, Job, Source
|
||||
from app.models import Dataset, GtfsHarmonizedSnapshotRoute, GtfsRoute, Job, RouteMatch, Source
|
||||
from app.pipeline.matcher import run_route_matching
|
||||
from app.pipeline.route_layer import rebuild_route_layer
|
||||
from app.source_catalog import import_ingestable_sources
|
||||
from app.worker_supervisor import WorkerHandle
|
||||
|
||||
|
||||
def test_api_sample_and_geojson():
|
||||
@@ -127,6 +132,28 @@ def test_route_matching_job_endpoint_completes():
|
||||
assert event_types[-1] == "completed"
|
||||
|
||||
|
||||
def test_job_events_endpoint_returns_latest_window_in_chronological_order():
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/sample/reset").status_code == 200
|
||||
with session_scope() as session:
|
||||
job = Job(kind="maintenance", status="running", description="event window test")
|
||||
session.add(job)
|
||||
session.flush()
|
||||
for index in range(5):
|
||||
add_job_event(
|
||||
session,
|
||||
job,
|
||||
event_type=f"event_{index + 1}",
|
||||
message=f"message {index + 1}",
|
||||
progress_current=index + 1,
|
||||
progress_total=5,
|
||||
)
|
||||
job_id = job.id
|
||||
|
||||
events = client.get(f"/api/jobs/{job_id}/events?limit=3").json()["events"]
|
||||
assert [event["event_type"] for event in events] == ["event_3", "event_4", "event_5"]
|
||||
|
||||
|
||||
def test_qa_summary_endpoint_exposes_harmonization_sections():
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/sample/reset").status_code == 200
|
||||
@@ -168,14 +195,342 @@ def test_gtfs_harmonization_inventory_and_detail():
|
||||
|
||||
reviewed = client.patch(
|
||||
f"/api/harmonization/gtfs/sources/{feed['source']['id']}/review",
|
||||
json={"license": "CC-BY-4.0", "review_status": "approved", "review_note": "Operator publication allowed.", "enabled": True},
|
||||
json={
|
||||
"license": "CC-BY-4.0",
|
||||
"review_status": "approved",
|
||||
"review_note": "Operator publication allowed.",
|
||||
"can_import": "yes",
|
||||
"can_derive": "yes",
|
||||
"can_redistribute": "yes",
|
||||
"requires_attribution": "yes",
|
||||
"commercial_restrictions": "no",
|
||||
"authority_level": "regional_authority",
|
||||
"enabled": True,
|
||||
},
|
||||
).json()
|
||||
assert reviewed["source"]["license"] == "CC-BY-4.0"
|
||||
assert reviewed["source"]["qa_review"]["status"] == "approved"
|
||||
assert reviewed["source"]["qa_review"]["note"] == "Operator publication allowed."
|
||||
assert reviewed["source"]["qa_review"]["can_redistribute"] == "yes"
|
||||
assert reviewed["source"]["qa_review"]["authority_level"] == "regional_authority"
|
||||
assert reviewed["license"]["redistribution_status"] == "allowed"
|
||||
assert reviewed["source"]["enabled"] is True
|
||||
|
||||
|
||||
def test_gtfs_harmonized_snapshot_shadows_lower_authority_duplicate_feed():
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/sample/reset").status_code == 200
|
||||
|
||||
with session_scope() as session:
|
||||
primary_source = session.scalar(select(Source).where(Source.kind == "gtfs"))
|
||||
primary_dataset = next(dataset for dataset in primary_source.datasets if dataset.kind == "gtfs" and dataset.is_active)
|
||||
primary_source.priority = "P0"
|
||||
primary_source.notes = _gtfs_review_note("national_official")
|
||||
|
||||
duplicate_source = Source(
|
||||
name="Sample Berlin regional duplicate",
|
||||
kind="gtfs",
|
||||
url="https://example.invalid/regional-duplicate.zip",
|
||||
country="DE",
|
||||
license="sample",
|
||||
priority="P1",
|
||||
notes=_gtfs_review_note("regional_authority"),
|
||||
)
|
||||
session.add(duplicate_source)
|
||||
session.flush()
|
||||
duplicate_dataset = Dataset(
|
||||
source_id=duplicate_source.id,
|
||||
kind="gtfs",
|
||||
local_path="data/sample/regional-duplicate.gtfs.zip",
|
||||
sha256="d" * 64,
|
||||
is_active=True,
|
||||
status="imported",
|
||||
)
|
||||
session.add(duplicate_dataset)
|
||||
session.flush()
|
||||
routes = session.scalars(select(GtfsRoute).where(GtfsRoute.dataset_id == primary_dataset.id)).all()
|
||||
for route in routes:
|
||||
session.add(
|
||||
GtfsRoute(
|
||||
dataset_id=duplicate_dataset.id,
|
||||
route_id=f"dup_{route.route_id}",
|
||||
agency_id=route.agency_id,
|
||||
short_name=route.short_name,
|
||||
long_name=route.long_name,
|
||||
route_type=route.route_type,
|
||||
mode=route.mode,
|
||||
route_scope=route.route_scope,
|
||||
operator_name=route.operator_name,
|
||||
route_key=route.route_key,
|
||||
operator_key=route.operator_key,
|
||||
min_lon=route.min_lon,
|
||||
min_lat=route.min_lat,
|
||||
max_lon=route.max_lon,
|
||||
max_lat=route.max_lat,
|
||||
)
|
||||
)
|
||||
primary_dataset_id = primary_dataset.id
|
||||
duplicate_dataset_id = duplicate_dataset.id
|
||||
|
||||
snapshot = client.get("/api/harmonization/gtfs/snapshot").json()
|
||||
assert snapshot["summary"]["raw_active_datasets"] == 2
|
||||
assert snapshot["summary"]["included_datasets"] == 1
|
||||
assert snapshot["summary"]["shadowed_datasets"] == 1
|
||||
assert snapshot["dataset_ids"] == [primary_dataset_id]
|
||||
shadowed = next(item for item in snapshot["datasets"] if item["dataset_id"] == duplicate_dataset_id)
|
||||
assert shadowed["role"] == "shadowed"
|
||||
assert shadowed["shadowed_by_dataset_id"] == primary_dataset_id
|
||||
assert shadowed["route_key_overlap_ratio"] >= 0.8
|
||||
|
||||
with session_scope() as session:
|
||||
assert active_harmonized_gtfs_dataset_ids(session) == [primary_dataset_id]
|
||||
|
||||
queued = client.post("/api/jobs/gtfs-harmonized-snapshot?activate=true").json()
|
||||
assert queued["kind"] == "gtfs_harmonized_snapshot"
|
||||
assert run_worker_once(worker_id="test-worker")["processed"] == 1
|
||||
job = client.get(f"/api/jobs/{queued['id']}").json()
|
||||
assert job["status"] == "completed"
|
||||
assert job["result"]["snapshot"]["persisted"] is True
|
||||
assert job["result"]["snapshot"]["dataset_ids"] == [primary_dataset_id]
|
||||
|
||||
persisted_snapshot = client.get("/api/harmonization/gtfs/snapshot").json()
|
||||
assert persisted_snapshot["persisted"] is True
|
||||
assert persisted_snapshot["dataset_ids"] == [primary_dataset_id]
|
||||
workflow = client.get("/api/workbench/gtfs-workflow").json()
|
||||
assert workflow["snapshot"]["persisted"] is True
|
||||
assert workflow["inventory_summary"]["snapshot_shadowed_datasets"] == 1
|
||||
diffs = client.get("/api/harmonization/gtfs/diffs").json()
|
||||
assert diffs["summary"]["runs"] == 0
|
||||
|
||||
dedup_job = client.post("/api/jobs/map-gtfs-review?limit=50").json()
|
||||
assert run_worker_once(worker_id="test-worker")["processed"] == 1
|
||||
dedup_result = client.get(f"/api/jobs/{dedup_job['id']}").json()["result"]["review_result"]
|
||||
assert dedup_result["dedup_items"] > 0
|
||||
dedup_items = client.get("/api/workbench/map-gtfs/review-items?queue=gtfs_deduplication&status=open&limit=20").json()["items"]
|
||||
assert dedup_items
|
||||
assert dedup_items[0]["metadata"]["data_differs"] is True
|
||||
|
||||
|
||||
def test_gtfs_harmonized_snapshot_partially_shadows_aggregator_routes_by_bbox():
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/sample/reset").status_code == 200
|
||||
|
||||
with session_scope() as session:
|
||||
aggregator_source = session.scalar(select(Source).where(Source.kind == "gtfs"))
|
||||
aggregator_source.notes = _gtfs_review_note("aggregator")
|
||||
aggregator_dataset = next(dataset for dataset in aggregator_source.datasets if dataset.kind == "gtfs" and dataset.is_active)
|
||||
local_route = session.scalar(select(GtfsRoute).where(GtfsRoute.dataset_id == aggregator_dataset.id).order_by(GtfsRoute.id))
|
||||
assert local_route is not None
|
||||
local_route.min_lon = 13.35
|
||||
local_route.min_lat = 52.48
|
||||
local_route.max_lon = 13.45
|
||||
local_route.max_lat = 52.55
|
||||
far_route = GtfsRoute(
|
||||
dataset_id=aggregator_dataset.id,
|
||||
route_id="far-same-key",
|
||||
agency_id=local_route.agency_id,
|
||||
short_name=local_route.short_name,
|
||||
long_name=local_route.long_name,
|
||||
route_type=local_route.route_type,
|
||||
mode=local_route.mode,
|
||||
route_scope=local_route.route_scope,
|
||||
operator_name=local_route.operator_name,
|
||||
route_key=local_route.route_key,
|
||||
operator_key=local_route.operator_key,
|
||||
min_lon=8.35,
|
||||
min_lat=49.35,
|
||||
max_lon=8.45,
|
||||
max_lat=49.45,
|
||||
)
|
||||
session.add(far_route)
|
||||
regional_source = Source(
|
||||
name="Sample regional operator overlay",
|
||||
kind="gtfs",
|
||||
url="https://example.invalid/regional-overlay.zip",
|
||||
country="DE",
|
||||
license="sample",
|
||||
priority="P1",
|
||||
notes=_gtfs_review_note("operator"),
|
||||
)
|
||||
session.add(regional_source)
|
||||
session.flush()
|
||||
regional_dataset = Dataset(
|
||||
source_id=regional_source.id,
|
||||
kind="gtfs",
|
||||
local_path="data/sample/regional-overlay.gtfs.zip",
|
||||
sha256="e" * 64,
|
||||
is_active=True,
|
||||
status="imported",
|
||||
)
|
||||
session.add(regional_dataset)
|
||||
session.flush()
|
||||
session.add(
|
||||
GtfsRoute(
|
||||
dataset_id=regional_dataset.id,
|
||||
route_id="regional-same-key",
|
||||
agency_id=local_route.agency_id,
|
||||
short_name=local_route.short_name,
|
||||
long_name=local_route.long_name,
|
||||
route_type=local_route.route_type,
|
||||
mode=local_route.mode,
|
||||
route_scope=local_route.route_scope,
|
||||
operator_name="Regional Operator",
|
||||
route_key=local_route.route_key,
|
||||
operator_key="regional operator",
|
||||
min_lon=13.36,
|
||||
min_lat=52.49,
|
||||
max_lon=13.44,
|
||||
max_lat=52.54,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
local_route_id = local_route.id
|
||||
far_route_id = far_route.id
|
||||
aggregator_dataset_id = aggregator_dataset.id
|
||||
regional_dataset_id = regional_dataset.id
|
||||
|
||||
snapshot = client.get("/api/harmonization/gtfs/snapshot").json()
|
||||
assert snapshot["summary"]["included_datasets"] == 2
|
||||
assert snapshot["summary"]["shadowed_datasets"] == 0
|
||||
assert snapshot["summary"]["route_shadowed_routes"] == 1
|
||||
aggregator_item = next(item for item in snapshot["datasets"] if item["dataset_id"] == aggregator_dataset_id)
|
||||
assert aggregator_item["role"] == "included"
|
||||
assert aggregator_item["route_shadow_count"] == 1
|
||||
assert aggregator_item["route_shadowed_by_dataset_ids"] == [regional_dataset_id]
|
||||
workflow = client.get("/api/workbench/gtfs-workflow").json()
|
||||
assert workflow["snapshot_diagnostics"]["needs_rebuild"] is True
|
||||
assert workflow["snapshot_diagnostics"]["computed"]["route_shadowed_routes"] == 1
|
||||
|
||||
with session_scope() as session:
|
||||
shadowed_route_ids = active_harmonized_gtfs_shadowed_route_ids(session)
|
||||
assert local_route_id in shadowed_route_ids
|
||||
assert far_route_id not in shadowed_route_ids
|
||||
|
||||
snapshot_job = client.post("/api/jobs/gtfs-harmonized-snapshot?activate=true").json()
|
||||
assert run_worker_once(worker_id="test-worker")["processed"] == 1
|
||||
snapshot_result = client.get(f"/api/jobs/{snapshot_job['id']}").json()["result"]["snapshot"]
|
||||
assert snapshot_result["persisted"] is True
|
||||
assert snapshot_result["summary"]["snapshot_route_rows"] >= 1
|
||||
assert snapshot_result["summary"]["snapshot_route_included_rows"] >= 1
|
||||
assert snapshot_result["summary"]["snapshot_route_shadowed_rows"] == 1
|
||||
assert snapshot_result["summary"]["route_shadowed_routes"] == 1
|
||||
workflow = client.get("/api/workbench/gtfs-workflow").json()
|
||||
assert workflow["snapshot_diagnostics"]["needs_rebuild"] is False
|
||||
assert workflow["snapshot_diagnostics"]["active"]["route_rows"] >= 1
|
||||
assert workflow["snapshot_route_preview"]["summary"]["filtered_total"] == 1
|
||||
assert workflow["snapshot_route_preview"]["routes"][0]["gtfs_route_id"] == local_route_id
|
||||
with session_scope() as session:
|
||||
session.execute(delete(GtfsHarmonizedSnapshotRoute).where(GtfsHarmonizedSnapshotRoute.snapshot_id == snapshot_result["id"]))
|
||||
legacy_workflow = client.get("/api/workbench/gtfs-workflow").json()
|
||||
assert legacy_workflow["snapshot_diagnostics"]["needs_rebuild"] is True
|
||||
assert "route_ownership_not_materialized" in legacy_workflow["snapshot_diagnostics"]["reasons"]
|
||||
assert legacy_workflow["snapshot_route_preview"]["routes"] == []
|
||||
backfill_job = client.post("/api/jobs/gtfs-harmonized-snapshot?activate=true").json()
|
||||
assert run_worker_once(worker_id="test-worker")["processed"] == 1
|
||||
backfilled_snapshot = client.get(f"/api/jobs/{backfill_job['id']}").json()["result"]["snapshot"]
|
||||
assert backfilled_snapshot["id"] == snapshot_result["id"]
|
||||
assert backfilled_snapshot["summary"]["snapshot_route_rows"] >= 1
|
||||
assert backfilled_snapshot["summary"]["route_shadowed_routes"] == 1
|
||||
backfilled_workflow = client.get("/api/workbench/gtfs-workflow").json()
|
||||
assert backfilled_workflow["snapshot_route_preview"]["summary"]["filtered_total"] == 1
|
||||
assert backfilled_workflow["snapshot_route_preview"]["routes"][0]["gtfs_route_id"] == local_route_id
|
||||
route_rows = client.get("/api/harmonization/gtfs/snapshot/routes?role=shadowed&limit=20").json()
|
||||
assert route_rows["summary"]["filtered_total"] == 1
|
||||
assert route_rows["routes"][0]["gtfs_route_id"] == local_route_id
|
||||
assert route_rows["routes"][0]["shadowed_by_dataset_id"] == regional_dataset_id
|
||||
filtered_route_rows = client.get(
|
||||
"/api/harmonization/gtfs/snapshot/routes",
|
||||
params={"role": "shadowed", "mode": route_rows["routes"][0]["mode"], "q": route_rows["routes"][0]["route_ref"], "limit": 20},
|
||||
).json()
|
||||
assert filtered_route_rows["summary"]["filtered_total"] == 1
|
||||
assert filtered_route_rows["routes"][0]["gtfs_route_id"] == local_route_id
|
||||
csv_response = client.get("/api/harmonization/gtfs/snapshot/routes.csv?role=shadowed&limit=20")
|
||||
assert csv_response.status_code == 200
|
||||
assert "text/csv" in csv_response.headers["content-type"]
|
||||
assert "gtfs_route_id" in csv_response.text.splitlines()[0]
|
||||
assert str(local_route_id) in csv_response.text
|
||||
with session_scope() as session:
|
||||
persisted_shadowed_route_ids = active_harmonized_gtfs_shadowed_route_ids(session)
|
||||
assert local_route_id in persisted_shadowed_route_ids
|
||||
assert far_route_id not in persisted_shadowed_route_ids
|
||||
session.execute(delete(RouteMatch).where(RouteMatch.gtfs_route_id == local_route_id))
|
||||
match_result = run_route_matching(session, batch_size=50)
|
||||
assert match_result["shadowed_skipped"] == 1
|
||||
assert session.scalar(select(RouteMatch).where(RouteMatch.gtfs_route_id == local_route_id)) is None
|
||||
route_layer_result = rebuild_route_layer(session, commit_between_steps=False)
|
||||
assert route_layer_result["shadowed_routes_skipped"] >= 1
|
||||
|
||||
dedup_job = client.post("/api/jobs/map-gtfs-review?limit=100").json()
|
||||
assert run_worker_once(worker_id="test-worker")["processed"] == 1
|
||||
dedup_result = client.get(f"/api/jobs/{dedup_job['id']}").json()["result"]["review_result"]
|
||||
assert dedup_result["dedup_items"] > 0
|
||||
dedup_items = client.get("/api/workbench/map-gtfs/review-items?queue=gtfs_deduplication&status=open&limit=100").json()["items"]
|
||||
assert any(
|
||||
item["gtfs_route"]["id"] == local_route_id
|
||||
and item["metadata"]["snapshot"]["reason"] == "route_covered_by_higher_precedence_dataset"
|
||||
for item in dedup_items
|
||||
)
|
||||
dedup_detail_id = next(item["id"] for item in dedup_items if item["gtfs_route"]["id"] == local_route_id)
|
||||
dedup_detail = client.get(f"/api/workbench/map-gtfs/review-items/{dedup_detail_id}").json()
|
||||
assert dedup_detail["metadata"]["shadowed_dataset"]["dataset_id"] == aggregator_dataset_id
|
||||
assert dedup_detail["metadata"]["included_dataset"]["dataset_id"] == regional_dataset_id
|
||||
filtered_dedup = client.get(
|
||||
"/api/workbench/map-gtfs/review-items",
|
||||
params={
|
||||
"queue": "gtfs_deduplication",
|
||||
"status": "open",
|
||||
"severity": "warn",
|
||||
"mode": route_rows["routes"][0]["mode"],
|
||||
"q": route_rows["routes"][0]["route_ref"],
|
||||
"limit": 20,
|
||||
},
|
||||
).json()
|
||||
assert any(item["gtfs_route"]["id"] == local_route_id for item in filtered_dedup["items"])
|
||||
|
||||
|
||||
def test_map_gtfs_workbench_review_queue_and_decision_replay_foundation():
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/sample/reset").status_code == 200
|
||||
|
||||
queued = client.post("/api/jobs/map-gtfs-review?limit=20").json()
|
||||
assert queued["kind"] == "map_gtfs_review"
|
||||
assert run_worker_once(worker_id="test-worker")["processed"] == 1
|
||||
job = client.get(f"/api/jobs/{queued['id']}").json()
|
||||
assert job["status"] == "completed"
|
||||
assert job["result"]["review_result"]["route_items"] > 0
|
||||
|
||||
listed = client.get("/api/workbench/map-gtfs/review-items?queue=route_matching&status=open&limit=20").json()
|
||||
assert listed["items"]
|
||||
route_item = next(item for item in listed["items"] if item["route_match_id"])
|
||||
assert route_item["queue"] == "route_matching"
|
||||
assert route_item["gtfs_route"]
|
||||
|
||||
accepted = client.post(f"/api/matches/{route_item['route_match_id']}/accept").json()
|
||||
assert accepted["status"] == "accepted"
|
||||
|
||||
summary = client.get("/api/workbench/map-gtfs/summary").json()
|
||||
assert summary["active_decisions"] >= 1
|
||||
resolved = client.get("/api/workbench/map-gtfs/review-items?queue=route_matching&status=resolved&limit=20").json()
|
||||
assert any(item["fingerprint"] == route_item["fingerprint"] and item["decision_id"] for item in resolved["items"])
|
||||
|
||||
|
||||
def _gtfs_review_note(authority_level: str) -> str:
|
||||
return "[GTFS QA] " + json.dumps(
|
||||
{
|
||||
"status": "approved",
|
||||
"note": "",
|
||||
"can_import": "yes",
|
||||
"can_derive": "yes",
|
||||
"can_redistribute": "yes",
|
||||
"requires_attribution": "yes",
|
||||
"commercial_restrictions": "no",
|
||||
"authority_level": authority_level,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_jobs_can_be_dismissed_from_default_view():
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/sample/reset").status_code == 200
|
||||
@@ -276,6 +631,73 @@ def test_worker_once_returns_idle_when_claim_is_busy(monkeypatch):
|
||||
assert jobs_module.run_worker_once(worker_id="test-worker") == {"worker_id": "test-worker", "processed": 0}
|
||||
|
||||
|
||||
def test_worker_control_endpoints(monkeypatch):
|
||||
calls = []
|
||||
handle = WorkerHandle(
|
||||
index=0,
|
||||
worker_id="server-worker-1",
|
||||
pid=1234,
|
||||
status="started",
|
||||
pid_file=Path("data/workers/server-worker-1.pid"),
|
||||
log_file=Path("data/workers/server-worker-1.log"),
|
||||
started_by_server=True,
|
||||
)
|
||||
stopped = WorkerHandle(
|
||||
index=0,
|
||||
worker_id="server-worker-1",
|
||||
pid=None,
|
||||
status="stopped",
|
||||
pid_file=Path("data/workers/server-worker-1.pid"),
|
||||
log_file=Path("data/workers/server-worker-1.log"),
|
||||
)
|
||||
|
||||
def fake_start_queue_workers(*, force=False):
|
||||
calls.append(("start", force))
|
||||
return [handle]
|
||||
|
||||
def fake_stop_configured_queue_workers():
|
||||
calls.append(("stop", None))
|
||||
return [stopped]
|
||||
|
||||
def fake_restart_queue_workers():
|
||||
calls.append(("restart", None))
|
||||
return {"stopped": [stopped], "started": [handle]}
|
||||
|
||||
monkeypatch.setattr(main_module, "start_queue_workers", fake_start_queue_workers)
|
||||
monkeypatch.setattr(main_module, "stop_configured_queue_workers", fake_stop_configured_queue_workers)
|
||||
monkeypatch.setattr(main_module, "restart_queue_workers", fake_restart_queue_workers)
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"queue_worker_status",
|
||||
lambda: [
|
||||
{
|
||||
"index": 0,
|
||||
"worker_id": "server-worker-1",
|
||||
"pid": 1234,
|
||||
"running": True,
|
||||
"stale": False,
|
||||
"status": "running",
|
||||
"pid_file": "data/workers/server-worker-1.pid",
|
||||
"log_file": "data/workers/server-worker-1.log",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
started = client.post("/api/workers/start").json()
|
||||
assert ("start", True) in calls
|
||||
assert started["operations"]["started"][0]["status"] == "started"
|
||||
assert started["workers"][0]["running"] is True
|
||||
|
||||
stopped_response = client.post("/api/workers/stop").json()
|
||||
assert ("stop", None) in calls
|
||||
assert stopped_response["operations"]["stopped"][0]["status"] == "stopped"
|
||||
|
||||
restarted = client.post("/api/workers/restart").json()
|
||||
assert ("restart", None) in calls
|
||||
assert restarted["operations"]["started"][0]["worker_id"] == "server-worker-1"
|
||||
|
||||
|
||||
def test_running_job_can_be_stopped_while_write_lock_is_held():
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/sample/reset").status_code == 200
|
||||
|
||||
@@ -5,8 +5,11 @@ import csv
|
||||
from app import feed_discovery
|
||||
from app.feed_discovery import (
|
||||
FeedCandidate,
|
||||
apply_source_hierarchy,
|
||||
build_gtfs_discovery_manifests,
|
||||
enrich_ptna_candidate_from_details,
|
||||
parse_opendata_oepnv_dataset_links,
|
||||
parse_opendata_oepnv_detail_page,
|
||||
parse_ptna_country_page,
|
||||
parse_ptna_detail_fields,
|
||||
select_test_run_candidates,
|
||||
@@ -105,6 +108,7 @@ def test_build_gtfs_discovery_manifests_from_stubbed_sources(tmp_path, monkeypat
|
||||
monkeypatch.setattr(feed_discovery, "fetch_mobility_database_candidates", lambda **_: mobility)
|
||||
monkeypatch.setattr(feed_discovery, "fetch_mobility_acceptance_candidates", lambda **_: [])
|
||||
monkeypatch.setattr(feed_discovery, "fetch_ptna_candidates", lambda **_: ptna)
|
||||
monkeypatch.setattr(feed_discovery, "fetch_opendata_oepnv_candidates", lambda **_: [])
|
||||
monkeypatch.setattr(feed_discovery, "load_curated_ingestable_seed", lambda **_: curated)
|
||||
|
||||
report = build_gtfs_discovery_manifests(output_dir=tmp_path, countries=["DE", "CH"], test_limit=10)
|
||||
@@ -116,6 +120,83 @@ def test_build_gtfs_discovery_manifests_from_stubbed_sources(tmp_path, monkeypat
|
||||
assert "ptna" in next(row for row in ingestable_rows if row["url"] == "https://example.test/rnv.zip")["source_basis"]
|
||||
|
||||
|
||||
def test_parse_opendata_oepnv_catalog_and_details():
|
||||
catalog_html = """
|
||||
<a href="/ht/de/organisation/verkehrsunternehmen/rnv/startseite?tx_vrrkit_view%5Baction%5D=details&tx_vrrkit_view%5Bcontroller%5D=View&tx_vrrkit_view%5Bdataset_name%5D=soll-fahrplandaten-rnv">
|
||||
<span itemprop="headline">Soll-Fahrplandaten (GTFS - General Transit Feed Specification)</span>
|
||||
</a>
|
||||
"""
|
||||
links = parse_opendata_oepnv_dataset_links(catalog_html, "https://www.opendata-oepnv.de/ht/de/datensaetze")
|
||||
|
||||
assert len(links) == 1
|
||||
assert links[0].slug == "soll-fahrplandaten-rnv"
|
||||
assert links[0].title.startswith("Soll-Fahrplandaten")
|
||||
|
||||
detail_html = """
|
||||
<div class="element-header">Soll-Fahrplandaten (GTFS - General Transit Feed Specification)</div>
|
||||
<p>In dieser Ressource hinterlegen wir stets den aktuellsten GTFS-Datensatz.</p>
|
||||
<a href="https://gtfs-sandbox-dds.rnv-online.de/latest/gtfs.zip">Download [ZIP]</a>
|
||||
<td id="dodp_license"><a href="/?id=20">Creative Commons</a></td>
|
||||
"""
|
||||
candidate = parse_opendata_oepnv_detail_page(
|
||||
detail_html,
|
||||
detail_url=links[0].url,
|
||||
slug=links[0].slug,
|
||||
title_hint=links[0].title,
|
||||
)
|
||||
|
||||
assert candidate.discovery_source == "opendata_oepnv"
|
||||
assert candidate.provider == "Rhein-Neckar-Verkehr"
|
||||
assert candidate.selected_url == "https://gtfs-sandbox-dds.rnv-online.de/latest/gtfs.zip"
|
||||
assert candidate.data_type == "gtfs"
|
||||
assert candidate.authority_level == "operator_feed"
|
||||
assert candidate.import_policy == "qa_or_override_not_additive"
|
||||
|
||||
|
||||
def test_opendata_oepnv_registration_required_is_not_direct_ingestable():
|
||||
detail_html = """
|
||||
<div class="element-header">Deutschlandweite Sollfahrplandaten (GTFS)</div>
|
||||
<p>This Download is only available for registered Users.</p>
|
||||
<td id="dodp_license"><a href="http://www.opendefinition.org/licenses/cc-by">Creative Commons Attribution</a></td>
|
||||
"""
|
||||
candidate = parse_opendata_oepnv_detail_page(
|
||||
detail_html,
|
||||
detail_url="https://www.opendata-oepnv.de/ht/de/organisation/delfi/startseite?tx_vrrkit_view%5Bdataset_name%5D=deutschlandweite-sollfahrplandaten-gtfs",
|
||||
slug="deutschlandweite-sollfahrplandaten-gtfs",
|
||||
)
|
||||
|
||||
assert candidate.status == "registration_required"
|
||||
assert candidate.selected_url == ""
|
||||
assert candidate.authority_level == "national_official"
|
||||
assert candidate.overlap_role == "review_baseline"
|
||||
assert candidate.import_policy == "review_before_import"
|
||||
|
||||
|
||||
def test_source_hierarchy_marks_mirrors_and_regional_feeds():
|
||||
mirror = FeedCandidate(
|
||||
discovery_source="curated_seed",
|
||||
country="DE",
|
||||
provider="DE generated national",
|
||||
selected_url="https://scraped.data.public-transport.earth/de/gtfs.zip",
|
||||
source_basis="European transport feeds mirror",
|
||||
)
|
||||
regional = FeedCandidate(
|
||||
discovery_source="mobility_database",
|
||||
country="DE",
|
||||
provider="Verkehrsverbund Berlin-Brandenburg",
|
||||
selected_url="https://www.vbb.de/vbbgtfs",
|
||||
is_official="True",
|
||||
)
|
||||
|
||||
apply_source_hierarchy(mirror)
|
||||
apply_source_hierarchy(regional)
|
||||
|
||||
assert mirror.authority_level == "mirror"
|
||||
assert mirror.import_policy == "bootstrap_only"
|
||||
assert regional.authority_level == "regional_authority"
|
||||
assert regional.import_policy == "qa_or_override_not_additive"
|
||||
|
||||
|
||||
def test_select_test_run_candidates_keeps_overlapping_german_feeds():
|
||||
candidates = [
|
||||
FeedCandidate(
|
||||
|
||||
@@ -5,10 +5,24 @@ import zipfile
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.data_management import delete_dataset
|
||||
from app.db import reset_db, session_scope
|
||||
from app.gtfs_storage import sidecar_path, stop_time_count, stop_times_by_trip
|
||||
from app.journey import find_journeys, search_scheduled_stops
|
||||
from app.models import Dataset, GtfsCalendar, Source
|
||||
from app.models import (
|
||||
Dataset,
|
||||
GtfsCalendar,
|
||||
GtfsFeedDiffItem,
|
||||
GtfsFeedDiffRun,
|
||||
GtfsRoute,
|
||||
GtfsRoutePatternLink,
|
||||
GtfsTripRoutePatternLink,
|
||||
MapGtfsDecision,
|
||||
MapGtfsReviewItem,
|
||||
RoutePattern,
|
||||
Source,
|
||||
)
|
||||
from app.pipeline.gtfs import _copy_stage_row_payload
|
||||
from app.pipeline.run import run_source
|
||||
|
||||
|
||||
@@ -70,3 +84,316 @@ def test_gtfs_import_uses_staging_bulk_loader_and_reports_chunks(tmp_path, monke
|
||||
assert "gtfs_file_chunk" in event_types
|
||||
assert "gtfs_activation_sidecar_stop_times" in event_types
|
||||
assert "gtfs_activation_completed" in event_types
|
||||
|
||||
|
||||
def test_gtfs_calendar_stage_payload_casts_weekday_flags_to_bool():
|
||||
columns = ["service_id", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", "start_date", "end_date"]
|
||||
payload = _copy_stage_row_payload(
|
||||
"gtfs_calendars",
|
||||
5,
|
||||
columns,
|
||||
("Special", 0, 1, "0", "1", False, True, "", 20260401, 20261211),
|
||||
)
|
||||
|
||||
assert payload == {
|
||||
"dataset_id": 5,
|
||||
"service_id": "Special",
|
||||
"monday": False,
|
||||
"tuesday": True,
|
||||
"wednesday": False,
|
||||
"thursday": True,
|
||||
"friday": False,
|
||||
"saturday": True,
|
||||
"sunday": False,
|
||||
"start_date": 20260401,
|
||||
"end_date": 20261211,
|
||||
}
|
||||
|
||||
|
||||
def test_gtfs_reimports_same_hash_when_stop_time_limit_changes(tmp_path, monkeypatch):
|
||||
reset_db()
|
||||
gtfs_path = tmp_path / "limited.gtfs.zip"
|
||||
with zipfile.ZipFile(gtfs_path, "w") as zf:
|
||||
zf.writestr("agency.txt", "agency_id,agency_name,agency_url,agency_timezone\nA,Agency,https://example.invalid,Europe/Berlin\n")
|
||||
zf.writestr("stops.txt", "stop_id,stop_name,stop_lat,stop_lon\nA,Alpha,52.0,13.0\nB,Beta,52.1,13.1\nC,Gamma,52.2,13.2\n")
|
||||
zf.writestr("routes.txt", "route_id,agency_id,route_short_name,route_long_name,route_type\nR,A,R1,Alpha - Gamma,3\n")
|
||||
zf.writestr("trips.txt", "route_id,service_id,trip_id\nR,daily,t1\n")
|
||||
zf.writestr("calendar.txt", "service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date\ndaily,1,1,1,1,1,1,1,20260101,20261231\n")
|
||||
zf.writestr(
|
||||
"stop_times.txt",
|
||||
"\n".join(
|
||||
[
|
||||
"trip_id,arrival_time,departure_time,stop_id,stop_sequence",
|
||||
"t1,08:00:00,08:00:00,A,1",
|
||||
"t1,08:05:00,08:05:00,B,2",
|
||||
"t1,08:10:00,08:10:00,C,3",
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
|
||||
with session_scope() as session:
|
||||
source = Source(name="Limited GTFS", kind="gtfs", url=str(gtfs_path))
|
||||
session.add(source)
|
||||
session.flush()
|
||||
|
||||
monkeypatch.setattr("app.pipeline.gtfs.settings.gtfs_stop_times_import_limit", 2)
|
||||
first_dataset = run_source(session, source)
|
||||
first_id = first_dataset.id
|
||||
assert stop_time_count(session, first_id) == 2
|
||||
|
||||
monkeypatch.setattr("app.pipeline.gtfs.settings.gtfs_stop_times_import_limit", 0)
|
||||
second_dataset = run_source(session, source)
|
||||
assert second_dataset.id != first_id
|
||||
assert stop_time_count(session, second_dataset.id) == 3
|
||||
|
||||
|
||||
def test_gtfs_reimport_stores_update_diff_before_replacing_old_dataset(tmp_path):
|
||||
reset_db()
|
||||
gtfs_path = tmp_path / "updating.gtfs.zip"
|
||||
_write_update_diff_gtfs(gtfs_path, route_long_name="Alpha - Beta", beta_name="Beta")
|
||||
|
||||
with session_scope() as session:
|
||||
source = Source(name="Updating GTFS", kind="gtfs", url=str(gtfs_path))
|
||||
session.add(source)
|
||||
session.flush()
|
||||
first_dataset = run_source(session, source)
|
||||
first_id = first_dataset.id
|
||||
|
||||
_write_update_diff_gtfs(
|
||||
gtfs_path,
|
||||
route_long_name="Alpha - Beta revised",
|
||||
beta_name="Beta moved",
|
||||
include_extra_stop=True,
|
||||
include_extra_route=True,
|
||||
)
|
||||
|
||||
with session_scope() as session:
|
||||
source = session.scalar(select(Source).where(Source.name == "Updating GTFS"))
|
||||
second_dataset = run_source(session, source)
|
||||
second_id = second_dataset.id
|
||||
|
||||
diff_run = session.scalar(select(GtfsFeedDiffRun).where(GtfsFeedDiffRun.new_dataset_id == second_id))
|
||||
assert diff_run is not None
|
||||
assert diff_run.previous_dataset_id == first_id
|
||||
assert session.get(Dataset, first_id) is None
|
||||
summary = json.loads(diff_run.summary_json)
|
||||
assert summary["routes"]["changed"] == 1
|
||||
assert summary["routes"]["added"] == 1
|
||||
assert summary["stops"]["changed"] == 1
|
||||
assert summary["stops"]["added"] == 1
|
||||
|
||||
items = {
|
||||
(item.item_type, item.object_key, item.change_type)
|
||||
for item in session.scalars(select(GtfsFeedDiffItem).where(GtfsFeedDiffItem.diff_run_id == diff_run.id))
|
||||
}
|
||||
assert ("route", "R1", "changed") in items
|
||||
assert ("route", "R2", "added") in items
|
||||
assert ("stop", "B", "changed") in items
|
||||
assert ("stop", "C", "added") in items
|
||||
|
||||
|
||||
def test_gtfs_reimport_replays_unchanged_decisions_and_queues_changed_items(tmp_path):
|
||||
reset_db()
|
||||
gtfs_path = tmp_path / "decision-replay.gtfs.zip"
|
||||
_write_update_diff_gtfs(gtfs_path, route_long_name="Alpha - Beta", beta_name="Beta", include_stable_route=True)
|
||||
|
||||
with session_scope() as session:
|
||||
source = Source(name="Decision replay GTFS", kind="gtfs", url=str(gtfs_path))
|
||||
session.add(source)
|
||||
session.flush()
|
||||
first_dataset = run_source(session, source)
|
||||
first_id = first_dataset.id
|
||||
stable_route = session.scalar(
|
||||
select(GtfsRoute).where(GtfsRoute.dataset_id == first_id, GtfsRoute.route_id == "R_KEEP")
|
||||
)
|
||||
assert stable_route is not None
|
||||
selector = {
|
||||
"gtfs": {
|
||||
"source_id": source.id,
|
||||
"dataset_id": first_id,
|
||||
"route_id": stable_route.route_id,
|
||||
"route_key": stable_route.route_key,
|
||||
"ref": stable_route.short_name,
|
||||
"mode": stable_route.mode,
|
||||
}
|
||||
}
|
||||
session.add(
|
||||
MapGtfsDecision(
|
||||
decision_key="test-stable-route-decision",
|
||||
decision_type="route_osm_relation",
|
||||
status="accepted",
|
||||
active=True,
|
||||
source_id=source.id,
|
||||
gtfs_dataset_id=first_id,
|
||||
gtfs_route_id=stable_route.id,
|
||||
confidence=0.91,
|
||||
selector_json=json.dumps(selector, sort_keys=True),
|
||||
action_json=json.dumps({"status": "accepted", "osm": {"osm_id": 12345}}, sort_keys=True),
|
||||
note="keep stable route",
|
||||
reviewer="test",
|
||||
metadata_json=json.dumps({"manual": True}, sort_keys=True),
|
||||
)
|
||||
)
|
||||
|
||||
_write_update_diff_gtfs(
|
||||
gtfs_path,
|
||||
route_long_name="Alpha - Beta revised",
|
||||
beta_name="Beta moved",
|
||||
include_extra_stop=True,
|
||||
include_extra_route=True,
|
||||
include_stable_route=True,
|
||||
)
|
||||
|
||||
with session_scope() as session:
|
||||
source = session.scalar(select(Source).where(Source.name == "Decision replay GTFS"))
|
||||
second_dataset = run_source(session, source)
|
||||
second_id = second_dataset.id
|
||||
stable_route = session.scalar(
|
||||
select(GtfsRoute).where(GtfsRoute.dataset_id == second_id, GtfsRoute.route_id == "R_KEEP")
|
||||
)
|
||||
assert stable_route is not None
|
||||
|
||||
replayed = session.scalar(
|
||||
select(MapGtfsDecision).where(MapGtfsDecision.gtfs_dataset_id == second_id, MapGtfsDecision.gtfs_route_id == stable_route.id)
|
||||
)
|
||||
assert replayed is not None
|
||||
assert replayed.reviewer == "decision_replay"
|
||||
assert replayed.confidence == 0.91
|
||||
replay_metadata = json.loads(replayed.metadata_json or "{}")
|
||||
assert replay_metadata["manual"] is True
|
||||
assert replay_metadata["replay"]["previous_dataset_id"] == first_id
|
||||
assert replay_metadata["replay"]["new_dataset_id"] == second_id
|
||||
assert session.scalar(select(func.count()).select_from(MapGtfsDecision).where(MapGtfsDecision.gtfs_dataset_id == first_id)) == 0
|
||||
|
||||
review_items = session.scalars(select(MapGtfsReviewItem).where(MapGtfsReviewItem.queue == "gtfs_update_diff")).all()
|
||||
review_keys = {
|
||||
(item.item_type, (json.loads(item.metadata_json or "{}")).get("object_key"), (json.loads(item.metadata_json or "{}")).get("change_type"))
|
||||
for item in review_items
|
||||
}
|
||||
assert ("gtfs_route_update", "R1", "changed") in review_keys
|
||||
assert ("gtfs_stop_update", "B", "changed") in review_keys
|
||||
|
||||
|
||||
def test_gtfs_dataset_delete_removes_route_pattern_links_by_pattern_id():
|
||||
reset_db()
|
||||
with session_scope() as session:
|
||||
source = Source(name="Delete GTFS", kind="gtfs", url="./delete.zip")
|
||||
other_source = Source(name="Other GTFS", kind="gtfs", url="./other.zip")
|
||||
session.add_all([source, other_source])
|
||||
session.flush()
|
||||
dataset = Dataset(source_id=source.id, kind="gtfs", local_path="./delete.zip", sha256="a" * 64, is_active=False, status="imported")
|
||||
other_dataset = Dataset(source_id=other_source.id, kind="gtfs", local_path="./other.zip", sha256="b" * 64, is_active=True, status="imported")
|
||||
session.add_all([dataset, other_dataset])
|
||||
session.flush()
|
||||
route = GtfsRoute(
|
||||
dataset_id=dataset.id,
|
||||
route_id="R1",
|
||||
short_name="R1",
|
||||
long_name="Delete route",
|
||||
route_type=3,
|
||||
mode="bus",
|
||||
)
|
||||
session.add(route)
|
||||
session.flush()
|
||||
pattern = RoutePattern(
|
||||
pattern_key="gtfs:delete:R1:S1",
|
||||
route_ref="R1",
|
||||
route_name="Delete route",
|
||||
mode="bus",
|
||||
source_kind="gtfs_proposed",
|
||||
status="active",
|
||||
gtfs_route_id=route.id,
|
||||
gtfs_shape_id="S1",
|
||||
geometry_geojson='{"type":"LineString","coordinates":[[13.0,52.0],[13.1,52.1]]}',
|
||||
)
|
||||
session.add(pattern)
|
||||
session.flush()
|
||||
session.add(
|
||||
GtfsRoutePatternLink(
|
||||
dataset_id=other_dataset.id,
|
||||
gtfs_route_id=route.id,
|
||||
route_id="R1",
|
||||
shape_id="S1",
|
||||
route_pattern_id=pattern.id,
|
||||
confidence=1,
|
||||
status="linked",
|
||||
source_kind="gtfs_proposed",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
GtfsTripRoutePatternLink(
|
||||
dataset_id=other_dataset.id,
|
||||
trip_id="T1",
|
||||
route_id="R1",
|
||||
shape_id="S1",
|
||||
route_pattern_id=pattern.id,
|
||||
source_kind="gtfs_proposed",
|
||||
confidence=1,
|
||||
status="linked",
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
result = delete_dataset(session, dataset.id)
|
||||
|
||||
assert result["deleted"] is True
|
||||
assert session.scalar(select(func.count()).select_from(RoutePattern).where(RoutePattern.id == pattern.id)) == 0
|
||||
assert session.scalar(select(func.count()).select_from(GtfsRoutePatternLink)) == 0
|
||||
assert session.scalar(select(func.count()).select_from(GtfsTripRoutePatternLink)) == 0
|
||||
|
||||
|
||||
def _write_update_diff_gtfs(
|
||||
gtfs_path,
|
||||
*,
|
||||
route_long_name: str,
|
||||
beta_name: str,
|
||||
include_extra_stop: bool = False,
|
||||
include_extra_route: bool = False,
|
||||
include_stable_route: bool = False,
|
||||
) -> None:
|
||||
stops = [
|
||||
"A,Alpha,52.0,13.0",
|
||||
f"B,{beta_name},52.1,13.1",
|
||||
]
|
||||
if include_extra_stop:
|
||||
stops.append("C,Gamma,52.2,13.2")
|
||||
routes = [
|
||||
f"R1,A,R1,{route_long_name},3",
|
||||
]
|
||||
if include_extra_route:
|
||||
routes.append("R2,A,R2,Alpha - Gamma,3")
|
||||
if include_stable_route:
|
||||
routes.append("R_KEEP,A,RK,Stable route,3")
|
||||
trips = [
|
||||
"R1,daily,t1",
|
||||
]
|
||||
if include_extra_route:
|
||||
trips.append("R2,daily,t2")
|
||||
if include_stable_route:
|
||||
trips.append("R_KEEP,daily,t_keep")
|
||||
stop_times = [
|
||||
"t1,08:00:00,08:00:00,A,1",
|
||||
"t1,08:10:00,08:10:00,B,2",
|
||||
]
|
||||
if include_extra_route:
|
||||
stop_times.extend(
|
||||
[
|
||||
"t2,09:00:00,09:00:00,A,1",
|
||||
"t2,09:15:00,09:15:00,C,2",
|
||||
]
|
||||
)
|
||||
if include_stable_route:
|
||||
stop_times.extend(
|
||||
[
|
||||
"t_keep,10:00:00,10:00:00,A,1",
|
||||
"t_keep,10:12:00,10:12:00,B,2",
|
||||
]
|
||||
)
|
||||
with zipfile.ZipFile(gtfs_path, "w") as zf:
|
||||
zf.writestr("agency.txt", "agency_id,agency_name,agency_url,agency_timezone\nA,Agency,https://example.invalid,Europe/Berlin\n")
|
||||
zf.writestr("stops.txt", "stop_id,stop_name,stop_lat,stop_lon\n" + "\n".join(stops) + "\n")
|
||||
zf.writestr("routes.txt", "route_id,agency_id,route_short_name,route_long_name,route_type\n" + "\n".join(routes) + "\n")
|
||||
zf.writestr("trips.txt", "route_id,service_id,trip_id\n" + "\n".join(trips) + "\n")
|
||||
zf.writestr("calendar.txt", "service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date\ndaily,1,1,1,1,1,1,1,20260101,20261231\n")
|
||||
zf.writestr("stop_times.txt", "trip_id,arrival_time,departure_time,stop_id,stop_sequence\n" + "\n".join(stop_times) + "\n")
|
||||
|
||||
147
tests/test_journey_search.py
Normal file
147
tests/test_journey_search.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.journey import _filter_dominated_journeys
|
||||
from app.journey_search import _rank_journeys, _select_diverse_journeys
|
||||
|
||||
|
||||
def test_progressive_selection_keeps_fast_and_low_walk_transit_options():
|
||||
fast_with_walk = _journey(
|
||||
"fast",
|
||||
arrival=30_000,
|
||||
transfers=0,
|
||||
legs=[
|
||||
_leg("tram", "22", "A", "B"),
|
||||
_leg("walk", "walk", "B", "Z", distance_m=1200),
|
||||
],
|
||||
)
|
||||
slower_low_walk = _journey(
|
||||
"low-walk",
|
||||
arrival=31_200,
|
||||
transfers=1,
|
||||
legs=[
|
||||
_leg("tram", "22", "A", "Hbf"),
|
||||
_leg("bus", "34", "Hbf", "Y"),
|
||||
_leg("walk", "walk", "Y", "Z", distance_m=120),
|
||||
],
|
||||
)
|
||||
walk_only = _journey(
|
||||
"walk-only",
|
||||
arrival=30_900,
|
||||
transfers=0,
|
||||
legs=[_leg("walk", "walk", "A", "Z", distance_m=1800)],
|
||||
)
|
||||
|
||||
ranked = _rank_journeys([slower_low_walk, walk_only, fast_with_walk], "recommended")
|
||||
selected = _select_diverse_journeys(ranked, limit=3)
|
||||
|
||||
assert len(selected) <= 3
|
||||
assert {journey["id"] for journey in selected} == {"fast", "low-walk", "walk-only"}
|
||||
|
||||
|
||||
def test_progressive_selection_collapses_same_journey_from_duplicate_gtfs_feeds():
|
||||
rnv = _journey(
|
||||
"rnv",
|
||||
arrival=29_820,
|
||||
transfers=0,
|
||||
legs=[
|
||||
_leg("walk", "walk", "Jakob-Neu-Straße 2", "Eppelheim Rathaus", dataset_id=10, source_name=None),
|
||||
_leg("tram", "RNV 22", "Eppelheim Rathaus", "Heidelberg Hbf", dataset_id=10, source_name="DE Verkehrsverbund Rhein-Neckar GTFS"),
|
||||
],
|
||||
)
|
||||
delfi = _journey(
|
||||
"delfi",
|
||||
arrival=29_820,
|
||||
transfers=0,
|
||||
legs=[
|
||||
_leg("walk", "walk", "Jakob-Neu-Straße 2", "Eppelheim Rathaus", dataset_id=4, source_name=None),
|
||||
_leg("tram", "RNV 22", "Eppelheim Rathaus", "Heidelberg Hbf", dataset_id=4, source_name="DELFI / GTFS.de Germany", stop_count=8),
|
||||
],
|
||||
)
|
||||
|
||||
ranked = _rank_journeys([rnv, delfi], "recommended")
|
||||
selected = _select_diverse_journeys(ranked, limit=3)
|
||||
|
||||
assert [journey["id"] for journey in selected] == ["delfi"]
|
||||
|
||||
|
||||
def test_dominance_removes_only_slower_more_transfer_same_endpoint_options():
|
||||
direct = _journey(
|
||||
"direct",
|
||||
arrival=30_000,
|
||||
transfers=0,
|
||||
legs=[
|
||||
_leg("tram", "22", "A", "platform-a", canonical_to=500),
|
||||
_leg("walk", "walk", "platform-a", "Z", distance_m=800),
|
||||
],
|
||||
)
|
||||
faster_transfer = _journey(
|
||||
"faster-transfer",
|
||||
arrival=29_500,
|
||||
transfers=1,
|
||||
legs=[
|
||||
_leg("bus", "34", "A", "X"),
|
||||
_leg("tram", "5", "X", "platform-b", canonical_to=500),
|
||||
_leg("walk", "walk", "platform-b", "Z", distance_m=800),
|
||||
],
|
||||
)
|
||||
slower_transfer = _journey(
|
||||
"slower-transfer",
|
||||
arrival=31_000,
|
||||
transfers=1,
|
||||
legs=[
|
||||
_leg("bus", "713", "A", "Y"),
|
||||
_leg("tram", "22", "Y", "platform-c", canonical_to=500),
|
||||
_leg("walk", "walk", "platform-c", "Z", distance_m=800),
|
||||
],
|
||||
)
|
||||
|
||||
filtered = _filter_dominated_journeys([slower_transfer, faster_transfer, direct])
|
||||
|
||||
assert {journey["id"] for journey in filtered} == {"direct", "faster-transfer"}
|
||||
|
||||
|
||||
def _journey(journey_id: str, *, arrival: int, transfers: int, legs: list[dict]) -> dict:
|
||||
departure = 28_800
|
||||
return {
|
||||
"id": journey_id,
|
||||
"departure_seconds": departure,
|
||||
"arrival_seconds": arrival,
|
||||
"duration_seconds": arrival - departure,
|
||||
"duration_minutes": (arrival - departure + 59) // 60,
|
||||
"transfers": transfers,
|
||||
"legs": legs,
|
||||
}
|
||||
|
||||
|
||||
def _leg(
|
||||
mode: str,
|
||||
route: str,
|
||||
from_stop: str,
|
||||
to_stop: str,
|
||||
*,
|
||||
distance_m: float = 0,
|
||||
canonical_to: int | None = None,
|
||||
dataset_id: int = 1,
|
||||
source_name: str | None = "Test GTFS",
|
||||
stop_count: int = 2,
|
||||
) -> dict:
|
||||
to_payload = {"stop_id": to_stop}
|
||||
stops = [{"stop_id": from_stop}, {"stop_id": to_stop}]
|
||||
if canonical_to is not None:
|
||||
stops[-1]["canonical_stop"] = {"id": canonical_to}
|
||||
return {
|
||||
"dataset_id": dataset_id,
|
||||
"source_name": source_name,
|
||||
"mode": mode,
|
||||
"route_id": route,
|
||||
"route_ref": route,
|
||||
"trip_id": f"{route}-trip" if mode != "walk" else None,
|
||||
"from": {"stop_id": from_stop},
|
||||
"to": to_payload,
|
||||
"departure_time": "08:00:00",
|
||||
"arrival_time": "08:10:00",
|
||||
"distance_m": distance_m,
|
||||
"stop_count": stop_count if mode != "walk" else None,
|
||||
"intermediate_stop_count": max(0, stop_count - 2) if mode != "walk" else None,
|
||||
"stops": stops,
|
||||
}
|
||||
@@ -6,7 +6,7 @@ from shapely.geometry import LineString
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import init_db, session_scope
|
||||
from app.models import GtfsRoute, OsmFeature, RouteMatch, RoutePattern
|
||||
from app.models import Dataset, GtfsRoute, OsmFeature, RouteMatch, RoutePattern, Source
|
||||
from app.osm_classification import infer_osm_route_scope
|
||||
from app.pipeline.gtfs import _gtfs_mode
|
||||
from app.pipeline.matcher import _build_osm_route_index, _candidate_osm_routes, route_match_scope, run_route_matching, score_route_pair
|
||||
@@ -53,6 +53,47 @@ def test_route_matching_preserves_unchanged_match_rows():
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_route_matching_can_target_single_gtfs_dataset():
|
||||
init_db()
|
||||
with session_scope() as session:
|
||||
load_sample_project(session)
|
||||
source = Source(
|
||||
name="Second feed",
|
||||
kind="gtfs",
|
||||
url="./second-feed.zip",
|
||||
country="DE",
|
||||
)
|
||||
session.add(source)
|
||||
session.flush()
|
||||
dataset = Dataset(
|
||||
source_id=source.id,
|
||||
kind="gtfs",
|
||||
local_path="second-feed.zip",
|
||||
sha256="second-feed",
|
||||
is_active=True,
|
||||
status="imported",
|
||||
)
|
||||
session.add(dataset)
|
||||
session.flush()
|
||||
route = GtfsRoute(
|
||||
dataset_id=dataset.id,
|
||||
route_id="second-only",
|
||||
short_name="ZZ",
|
||||
mode="bus",
|
||||
min_lon=13.0,
|
||||
min_lat=52.0,
|
||||
max_lon=13.1,
|
||||
max_lat=52.1,
|
||||
)
|
||||
session.add(route)
|
||||
session.flush()
|
||||
|
||||
result = run_route_matching(session, gtfs_dataset_ids=[dataset.id])
|
||||
|
||||
assert result["routes"] == 1
|
||||
assert session.scalar(select(RouteMatch).where(RouteMatch.gtfs_route_id == route.id)) is not None
|
||||
|
||||
|
||||
def test_route_layer_reuses_unchanged_route_patterns():
|
||||
init_db()
|
||||
with session_scope() as session:
|
||||
@@ -241,6 +282,37 @@ def test_common_short_ref_candidates_are_spatially_ranked():
|
||||
candidates = _candidate_osm_routes(route, _build_osm_route_index([far, near]))
|
||||
|
||||
assert candidates[0].osm_id == "near"
|
||||
assert "far" not in {candidate.osm_id for candidate in candidates}
|
||||
|
||||
|
||||
def test_known_bbox_route_does_not_fallback_to_global_mode_candidates():
|
||||
route = GtfsRoute(
|
||||
route_id="bus-x-berlin",
|
||||
short_name="X",
|
||||
mode="bus",
|
||||
min_lon=13.30,
|
||||
min_lat=52.40,
|
||||
max_lon=13.40,
|
||||
max_lat=52.50,
|
||||
route_key="x",
|
||||
)
|
||||
far = OsmFeature(
|
||||
id=1,
|
||||
osm_type="relation",
|
||||
osm_id="far-bus",
|
||||
kind="route",
|
||||
mode="bus",
|
||||
ref="9",
|
||||
min_lon=7.0,
|
||||
min_lat=50.0,
|
||||
max_lon=7.1,
|
||||
max_lat=50.1,
|
||||
route_key="9",
|
||||
)
|
||||
|
||||
candidates = _candidate_osm_routes(route, _build_osm_route_index([far]))
|
||||
|
||||
assert candidates == []
|
||||
|
||||
|
||||
def test_exact_ref_far_away_is_not_promoted_without_spatial_or_geometry_evidence():
|
||||
|
||||
@@ -6,8 +6,9 @@ from shapely.geometry import LineString, Point, shape
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import reset_db, session_scope
|
||||
from app.journey import find_journeys, search_scheduled_stops
|
||||
from app.journey import find_journeys, nearest_scheduled_stops, search_scheduled_stops
|
||||
from app.models import (
|
||||
CanonicalStop,
|
||||
CanonicalStopLink,
|
||||
Dataset,
|
||||
GtfsCalendar,
|
||||
@@ -33,6 +34,7 @@ from app.pipeline.route_layer import (
|
||||
rebuild_route_layer,
|
||||
)
|
||||
from app.pipeline.utils import geometry_json_and_bbox, norm_ref
|
||||
from app.routing import direct_route_between_points
|
||||
|
||||
|
||||
def test_directional_candidate_selection_prefers_matching_osm_geometry_orientation():
|
||||
@@ -245,6 +247,318 @@ def test_opposite_gtfs_shapes_share_osm_visual_route_and_reverse_journey_segment
|
||||
assert tuple(coords[-1]) == (0.0, 0.0)
|
||||
|
||||
|
||||
def test_exact_stop_token_does_not_expand_to_merged_canonical_stop():
|
||||
reset_db()
|
||||
with session_scope() as session:
|
||||
source = Source(name="Merged Station GTFS", kind="gtfs", url="./merged.zip")
|
||||
session.add(source)
|
||||
session.flush()
|
||||
dataset = Dataset(
|
||||
source_id=source.id,
|
||||
kind="gtfs",
|
||||
local_path="./merged.zip",
|
||||
sha256="merged",
|
||||
is_active=True,
|
||||
status="imported",
|
||||
)
|
||||
session.add(dataset)
|
||||
session.flush()
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
GtfsStop(dataset_id=dataset.id, stop_id="station_bus", name="Example Hbf", lat=52.0, lon=13.0000),
|
||||
GtfsStop(dataset_id=dataset.id, stop_id="station_train", name="Example Hbf", lat=52.0001, lon=13.0001),
|
||||
GtfsStop(dataset_id=dataset.id, stop_id="target", name="Target", lat=52.01, lon=13.01),
|
||||
GtfsRoute(
|
||||
dataset_id=dataset.id,
|
||||
route_id="bus",
|
||||
short_name="B",
|
||||
route_type=3,
|
||||
mode="bus",
|
||||
route_key=norm_ref("B"),
|
||||
),
|
||||
GtfsRoute(
|
||||
dataset_id=dataset.id,
|
||||
route_id="train",
|
||||
short_name="T",
|
||||
route_type=2,
|
||||
mode="train",
|
||||
route_key=norm_ref("T"),
|
||||
),
|
||||
GtfsTrip(dataset_id=dataset.id, route_id="bus", trip_id="bus_trip", service_id="daily"),
|
||||
GtfsTrip(dataset_id=dataset.id, route_id="train", trip_id="train_trip", service_id="daily"),
|
||||
_stop_time(dataset.id, "train_trip", "station_train", 1, "08:00:00", 28800),
|
||||
_stop_time(dataset.id, "train_trip", "target", 2, "08:05:00", 29100),
|
||||
_stop_time(dataset.id, "bus_trip", "station_bus", 1, "08:20:00", 30000),
|
||||
_stop_time(dataset.id, "bus_trip", "target", 2, "08:35:00", 30900),
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
rebuild_route_layer(session)
|
||||
|
||||
grouped = search_scheduled_stops(session, "Example Hbf")
|
||||
assert grouped
|
||||
assert grouped[0]["grouped"] is True
|
||||
|
||||
exact_hits = nearest_scheduled_stops(session, lat=52.0, lon=13.0, radius_m=30, grouped=False)
|
||||
assert exact_hits[0]["id"] == f"stop:{dataset.id}:station_bus"
|
||||
assert exact_hits[0]["grouped"] is False
|
||||
|
||||
journey = find_journeys(
|
||||
session,
|
||||
from_stop_id=f"stop:{dataset.id}:station_bus",
|
||||
to_stop_id="target",
|
||||
departure="07:55",
|
||||
max_transfers=0,
|
||||
limit=1,
|
||||
)
|
||||
|
||||
assert journey["journeys"]
|
||||
legs = journey["journeys"][0]["legs"]
|
||||
assert legs[0]["from"]["stop_id"] == "station_bus"
|
||||
assert legs[0]["mode"] in {"walk", "bus"}
|
||||
if legs[0]["mode"] == "walk":
|
||||
assert legs[0]["to"]["stop_id"] == "station_train"
|
||||
assert legs[1]["trip_id"] == "train_trip"
|
||||
else:
|
||||
assert legs[0]["trip_id"] == "bus_trip"
|
||||
|
||||
|
||||
def test_exact_stop_can_walk_to_adjacent_concrete_stop_for_transit():
|
||||
reset_db()
|
||||
with session_scope() as session:
|
||||
source = Source(name="Adjacent Stops GTFS", kind="gtfs", url="./adjacent.zip")
|
||||
session.add(source)
|
||||
session.flush()
|
||||
dataset = Dataset(
|
||||
source_id=source.id,
|
||||
kind="gtfs",
|
||||
local_path="./adjacent.zip",
|
||||
sha256="adjacent",
|
||||
is_active=True,
|
||||
status="imported",
|
||||
)
|
||||
session.add(dataset)
|
||||
session.flush()
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
GtfsStop(dataset_id=dataset.id, stop_id="station_bus", name="Example Hbf", lat=52.0, lon=13.0000),
|
||||
GtfsStop(dataset_id=dataset.id, stop_id="station_train", name="Example Hbf", lat=52.0001, lon=13.0001),
|
||||
GtfsStop(dataset_id=dataset.id, stop_id="target", name="Target", lat=52.01, lon=13.01),
|
||||
GtfsStop(dataset_id=dataset.id, stop_id="elsewhere", name="Elsewhere", lat=52.02, lon=13.02),
|
||||
GtfsRoute(
|
||||
dataset_id=dataset.id,
|
||||
route_id="bus",
|
||||
short_name="B",
|
||||
route_type=3,
|
||||
mode="bus",
|
||||
route_key=norm_ref("B"),
|
||||
),
|
||||
GtfsRoute(
|
||||
dataset_id=dataset.id,
|
||||
route_id="train",
|
||||
short_name="T",
|
||||
route_type=2,
|
||||
mode="train",
|
||||
route_key=norm_ref("T"),
|
||||
),
|
||||
GtfsTrip(dataset_id=dataset.id, route_id="bus", trip_id="bus_unrelated", service_id="daily"),
|
||||
GtfsTrip(dataset_id=dataset.id, route_id="train", trip_id="train_trip", service_id="daily"),
|
||||
_stop_time(dataset.id, "bus_unrelated", "station_bus", 1, "09:00:00", 32400),
|
||||
_stop_time(dataset.id, "bus_unrelated", "elsewhere", 2, "09:20:00", 33600),
|
||||
_stop_time(dataset.id, "train_trip", "station_train", 1, "08:05:00", 29100),
|
||||
_stop_time(dataset.id, "train_trip", "target", 2, "08:15:00", 29700),
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
rebuild_route_layer(session)
|
||||
|
||||
journey = find_journeys(
|
||||
session,
|
||||
from_stop_id=f"stop:{dataset.id}:station_bus",
|
||||
to_stop_id="target",
|
||||
departure="08:00",
|
||||
max_transfers=0,
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert journey["journeys"]
|
||||
legs = journey["journeys"][0]["legs"]
|
||||
assert [leg["mode"] for leg in legs] == ["walk", "train"]
|
||||
assert legs[0]["from"]["stop_id"] == "station_bus"
|
||||
assert legs[0]["to"]["stop_id"] == "station_train"
|
||||
assert legs[0]["arrival_time"] == legs[1]["departure_time"]
|
||||
assert legs[0]["departure_time"] != "08:00:00"
|
||||
assert legs[1]["trip_id"] == "train_trip"
|
||||
walk_points = [
|
||||
feature
|
||||
for feature in journey["journeys"][0]["features"]["features"]
|
||||
if feature["geometry"]["type"] == "Point" and feature["properties"].get("mode") == "walk"
|
||||
]
|
||||
assert [point["properties"]["stop_id"] for point in walk_points] == ["station_bus", "station_train"]
|
||||
|
||||
|
||||
def test_walk_only_journey_reuses_routed_geometry(monkeypatch):
|
||||
reset_db()
|
||||
with session_scope() as session:
|
||||
source = Source(name="Walk Geometry GTFS", kind="gtfs", url="./walk.zip")
|
||||
session.add(source)
|
||||
session.flush()
|
||||
dataset = Dataset(
|
||||
source_id=source.id,
|
||||
kind="gtfs",
|
||||
local_path="./walk.zip",
|
||||
sha256="walk",
|
||||
is_active=True,
|
||||
status="imported",
|
||||
)
|
||||
session.add(dataset)
|
||||
session.flush()
|
||||
|
||||
origin = GtfsStop(dataset_id=dataset.id, stop_id="origin", name="Origin", lat=52.0, lon=13.0)
|
||||
target = GtfsStop(dataset_id=dataset.id, stop_id="target", name="Target", lat=52.001, lon=13.002)
|
||||
session.add_all(
|
||||
[
|
||||
origin,
|
||||
target,
|
||||
GtfsRoute(dataset_id=dataset.id, route_id="marker", short_name="M", route_type=3, mode="bus"),
|
||||
GtfsTrip(dataset_id=dataset.id, route_id="marker", trip_id="origin_marker", service_id="daily"),
|
||||
GtfsTrip(dataset_id=dataset.id, route_id="marker", trip_id="target_marker", service_id="daily"),
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
_stop_time(dataset.id, "origin_marker", "origin", 1, "07:00:00", 25200),
|
||||
_stop_time(dataset.id, "target_marker", "target", 1, "07:00:00", 25200),
|
||||
]
|
||||
)
|
||||
origin_place = CanonicalStop(
|
||||
stop_key=f"gtfs:{dataset.id}:origin",
|
||||
name="Origin",
|
||||
normalized_name="origin",
|
||||
lat=52.0,
|
||||
lon=13.0,
|
||||
mode="bus",
|
||||
)
|
||||
target_place = CanonicalStop(
|
||||
stop_key=f"gtfs:{dataset.id}:target",
|
||||
name="Target",
|
||||
normalized_name="target",
|
||||
lat=52.001,
|
||||
lon=13.002,
|
||||
mode="bus",
|
||||
)
|
||||
session.add_all([origin_place, target_place])
|
||||
session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
CanonicalStopLink(
|
||||
canonical_stop_id=origin_place.id,
|
||||
layer="timetable",
|
||||
object_type="gtfs_stop",
|
||||
dataset_id=dataset.id,
|
||||
object_id=origin.id,
|
||||
external_id="origin",
|
||||
role="primary",
|
||||
confidence=1.0,
|
||||
),
|
||||
CanonicalStopLink(
|
||||
canonical_stop_id=target_place.id,
|
||||
layer="timetable",
|
||||
object_type="gtfs_stop",
|
||||
dataset_id=dataset.id,
|
||||
object_id=target.id,
|
||||
external_id="target",
|
||||
role="primary",
|
||||
confidence=1.0,
|
||||
),
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
|
||||
routed_coordinates = [[13.0, 52.0], [13.001, 52.0007], [13.002, 52.001]]
|
||||
|
||||
def fake_route_between_points(*args, **kwargs):
|
||||
assert kwargs["mode"] == "walk"
|
||||
return {
|
||||
"distance_m": 240.0,
|
||||
"duration_seconds": 180.2,
|
||||
"features": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "LineString", "coordinates": routed_coordinates},
|
||||
"properties": {"mode": "walk"},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr("app.journey.route_between_points", fake_route_between_points)
|
||||
|
||||
result = find_journeys(
|
||||
session,
|
||||
from_stop_id="origin",
|
||||
to_stop_id="target",
|
||||
departure="08:00",
|
||||
max_transfers=0,
|
||||
limit=3,
|
||||
)
|
||||
|
||||
walk_journey = next(
|
||||
journey
|
||||
for journey in result["journeys"]
|
||||
if [leg["mode"] for leg in journey["legs"]] == ["walk"]
|
||||
)
|
||||
walk_feature = next(
|
||||
feature
|
||||
for feature in walk_journey["features"]["features"]
|
||||
if feature["geometry"]["type"] == "LineString"
|
||||
)
|
||||
assert walk_feature["geometry"]["coordinates"] == routed_coordinates
|
||||
assert walk_feature["properties"]["geometry_source"] == "routing_layer:walk"
|
||||
assert walk_journey["legs"][0]["distance_m"] == 240.0
|
||||
assert walk_journey["legs"][0]["duration_seconds"] == 181
|
||||
|
||||
|
||||
def test_direct_point_routing_supports_bike_mode_and_aliases():
|
||||
reset_db()
|
||||
with session_scope() as session:
|
||||
walk = direct_route_between_points(
|
||||
session,
|
||||
from_lon=13.0,
|
||||
from_lat=52.0,
|
||||
to_lon=13.002,
|
||||
to_lat=52.001,
|
||||
mode="walk",
|
||||
)
|
||||
bike = direct_route_between_points(
|
||||
session,
|
||||
from_lon=13.0,
|
||||
from_lat=52.0,
|
||||
to_lon=13.002,
|
||||
to_lat=52.001,
|
||||
mode="bike",
|
||||
)
|
||||
bicycle = direct_route_between_points(
|
||||
session,
|
||||
from_lon=13.0,
|
||||
from_lat=52.0,
|
||||
to_lon=13.002,
|
||||
to_lat=52.001,
|
||||
mode="bicycle",
|
||||
)
|
||||
|
||||
assert bike["mode"] == "bike"
|
||||
assert bicycle["mode"] == "bike"
|
||||
assert bike["duration_seconds"] < walk["duration_seconds"]
|
||||
bike_feature = bike["features"]["features"][0]
|
||||
assert bike_feature["properties"]["mode"] == "bike"
|
||||
|
||||
|
||||
def test_journey_geometry_rejects_remote_route_pattern_and_uses_trip_shape():
|
||||
reset_db()
|
||||
with session_scope() as session:
|
||||
@@ -959,6 +1273,105 @@ def test_journey_service_date_filters_duplicate_clock_time_trips():
|
||||
assert [journey["legs"][0]["trip_id"] for journey in monday["journeys"]] == ["weekday_trip"]
|
||||
|
||||
|
||||
def test_direct_nearby_destination_walk_dominates_same_endpoint_transfer():
|
||||
reset_db()
|
||||
with session_scope() as session:
|
||||
source = Source(name="Nearby Destination GTFS", kind="gtfs", url="./nearby.zip")
|
||||
session.add(source)
|
||||
session.flush()
|
||||
dataset = Dataset(
|
||||
source_id=source.id,
|
||||
kind="gtfs",
|
||||
local_path="./nearby.zip",
|
||||
sha256="nearby",
|
||||
is_active=True,
|
||||
status="imported",
|
||||
)
|
||||
session.add(dataset)
|
||||
session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
GtfsStop(dataset_id=dataset.id, stop_id="origin", name="Eppelheim Rathaus", lat=49.4010, lon=8.6330),
|
||||
GtfsStop(dataset_id=dataset.id, stop_id="market", name="Pfaffengrund Marktstrasse", lat=49.4050, lon=8.6410),
|
||||
GtfsStop(dataset_id=dataset.id, stop_id="henkel", name="Pfaffengrund Henkel-Teroson", lat=49.4090, lon=8.6500),
|
||||
GtfsStop(dataset_id=dataset.id, stop_id="hbf", name="Heidelberg Hbf", lat=49.4030, lon=8.6550),
|
||||
GtfsStop(dataset_id=dataset.id, stop_id="target", name="Heidelberg Ochsenkopf", lat=49.4108, lon=8.6520),
|
||||
GtfsStop(dataset_id=dataset.id, stop_id="marker", name="Target Marker", lat=49.4120, lon=8.6540),
|
||||
GtfsRoute(
|
||||
dataset_id=dataset.id,
|
||||
route_id="rnv22",
|
||||
short_name="RNV 22",
|
||||
route_type=0,
|
||||
mode="tram",
|
||||
route_key=norm_ref("RNV 22"),
|
||||
),
|
||||
GtfsRoute(
|
||||
dataset_id=dataset.id,
|
||||
route_id="marker",
|
||||
short_name="M",
|
||||
route_type=3,
|
||||
mode="bus",
|
||||
route_key=norm_ref("M"),
|
||||
),
|
||||
GtfsRoute(
|
||||
dataset_id=dataset.id,
|
||||
route_id="rnv5",
|
||||
short_name="RNV 5",
|
||||
route_type=0,
|
||||
mode="tram",
|
||||
route_key=norm_ref("RNV 5"),
|
||||
),
|
||||
GtfsTrip(dataset_id=dataset.id, route_id="rnv22", trip_id="through_tram", service_id="daily"),
|
||||
GtfsTrip(dataset_id=dataset.id, route_id="rnv22", trip_id="later_tram", service_id="daily"),
|
||||
GtfsTrip(dataset_id=dataset.id, route_id="marker", trip_id="target_marker", service_id="daily"),
|
||||
GtfsTrip(dataset_id=dataset.id, route_id="rnv5", trip_id="exact_destination_tram", service_id="daily"),
|
||||
_stop_time(dataset.id, "through_tram", "origin", 1, "08:04:00", 29040),
|
||||
_stop_time(dataset.id, "through_tram", "market", 2, "08:08:00", 29280),
|
||||
_stop_time(dataset.id, "through_tram", "henkel", 3, "08:09:00", 29340),
|
||||
_stop_time(dataset.id, "through_tram", "hbf", 4, "08:17:00", 29820),
|
||||
_stop_time(dataset.id, "later_tram", "market", 1, "08:18:00", 29880),
|
||||
_stop_time(dataset.id, "later_tram", "henkel", 2, "08:19:00", 29940),
|
||||
_stop_time(dataset.id, "target_marker", "marker", 1, "07:00:00", 25200),
|
||||
_stop_time(dataset.id, "target_marker", "target", 2, "07:01:00", 25260),
|
||||
_stop_time(dataset.id, "exact_destination_tram", "hbf", 1, "08:28:00", 30480),
|
||||
_stop_time(dataset.id, "exact_destination_tram", "target", 2, "08:31:00", 30660),
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
rebuild_route_layer(session)
|
||||
|
||||
result = find_journeys(
|
||||
session,
|
||||
from_stop_id="origin",
|
||||
to_stop_id="target",
|
||||
departure="08:00",
|
||||
max_transfers=1,
|
||||
limit=5,
|
||||
)
|
||||
|
||||
assert result["journeys"]
|
||||
direct_with_final_walk = [
|
||||
journey
|
||||
for journey in result["journeys"]
|
||||
if [leg.get("trip_id") for leg in journey["legs"] if leg.get("mode") != "walk"] == ["through_tram"]
|
||||
and journey["legs"][-1]["mode"] == "walk"
|
||||
and str(journey["legs"][-1]["from"]["stop_id"]).startswith("canonical:")
|
||||
and journey["legs"][-1]["from"]["name"] == "Pfaffengrund Henkel-Teroson"
|
||||
and journey["transfers"] == 0
|
||||
]
|
||||
assert direct_with_final_walk
|
||||
assert all(
|
||||
[leg.get("trip_id") for leg in journey["legs"] if leg.get("mode") != "walk"]
|
||||
!= ["through_tram", "later_tram"]
|
||||
for journey in result["journeys"]
|
||||
)
|
||||
assert any(
|
||||
[leg.get("trip_id") for leg in journey["legs"] if leg.get("mode") != "walk"]
|
||||
== ["through_tram", "exact_destination_tram"]
|
||||
for journey in result["journeys"]
|
||||
)
|
||||
|
||||
|
||||
def _route_candidate(
|
||||
feature_id: int,
|
||||
osm_id: str,
|
||||
|
||||
Reference in New Issue
Block a user