feat: Enhance track model and import functionality
- Added new fields to TrackModel: status, is_bidirectional, and coordinates. - Updated network service to handle new track attributes and geometry extraction. - Introduced CLI scripts for importing and loading tracks from OpenStreetMap. - Implemented normalization of track elements to ensure valid geometries. - Enhanced tests for track model, network service, and import/load scripts. - Updated frontend to accommodate new track attributes and improve route computation. - Documented OSM ingestion process in architecture and runtime views.
This commit is contained in:
@@ -29,11 +29,15 @@ def test_track_model_properties() -> None:
|
||||
end_station_id="station-2",
|
||||
length_meters=1500.0,
|
||||
max_speed_kph=120.0,
|
||||
status="operational",
|
||||
is_bidirectional=True,
|
||||
coordinates=[(52.52, 13.405), (52.6, 13.5)],
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
assert track.length_meters > 0
|
||||
assert track.start_station_id != track.end_station_id
|
||||
assert len(track.coordinates) == 2
|
||||
|
||||
|
||||
def test_train_model_operating_tracks() -> None:
|
||||
|
||||
@@ -26,6 +26,9 @@ def sample_entities() -> dict[str, SimpleNamespace]:
|
||||
end_station_id=station.id,
|
||||
length_meters=1234.5,
|
||||
max_speed_kph=160,
|
||||
status="operational",
|
||||
is_bidirectional=True,
|
||||
track_geometry=None,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
@@ -47,7 +50,8 @@ def test_network_snapshot_prefers_repository_data(
|
||||
track = sample_entities["track"]
|
||||
train = sample_entities["train"]
|
||||
|
||||
monkeypatch.setattr(StationRepository, "list_active", lambda self: [station])
|
||||
monkeypatch.setattr(StationRepository, "list_active",
|
||||
lambda self: [station])
|
||||
monkeypatch.setattr(TrackRepository, "list_all", lambda self: [track])
|
||||
monkeypatch.setattr(TrainRepository, "list_all", lambda self: [train])
|
||||
|
||||
@@ -55,7 +59,8 @@ def test_network_snapshot_prefers_repository_data(
|
||||
|
||||
assert snapshot["stations"]
|
||||
assert snapshot["stations"][0]["name"] == station.name
|
||||
assert snapshot["tracks"][0]["lengthMeters"] == pytest.approx(track.length_meters)
|
||||
assert snapshot["tracks"][0]["lengthMeters"] == pytest.approx(
|
||||
track.length_meters)
|
||||
assert snapshot["trains"][0]["designation"] == train.designation
|
||||
assert snapshot["trains"][0]["operatingTrackIds"] == []
|
||||
|
||||
@@ -71,4 +76,5 @@ def test_network_snapshot_falls_back_when_repositories_empty(
|
||||
|
||||
assert snapshot["stations"]
|
||||
assert snapshot["trains"]
|
||||
assert any(station["name"] == "Central" for station in snapshot["stations"])
|
||||
assert any(station["name"] ==
|
||||
"Central" for station in snapshot["stations"])
|
||||
|
||||
69
backend/tests/test_tracks_import.py
Normal file
69
backend/tests/test_tracks_import.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.scripts import tracks_import
|
||||
|
||||
|
||||
def test_normalize_track_elements_excludes_invalid_geometries() -> None:
|
||||
elements = [
|
||||
{
|
||||
"type": "way",
|
||||
"id": 123,
|
||||
"geometry": [
|
||||
{"lat": 52.5, "lon": 13.4},
|
||||
{"lat": 52.6, "lon": 13.5},
|
||||
],
|
||||
"tags": {
|
||||
"name": "Main Line",
|
||||
"railway": "rail",
|
||||
"maxspeed": "120",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "way",
|
||||
"id": 456,
|
||||
"geometry": [
|
||||
{"lat": 51.0},
|
||||
],
|
||||
"tags": {"railway": "rail"},
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"id": 789,
|
||||
},
|
||||
]
|
||||
|
||||
tracks = tracks_import.normalize_track_elements(elements)
|
||||
|
||||
assert len(tracks) == 1
|
||||
track = tracks[0]
|
||||
assert track["osmId"] == "123"
|
||||
assert track["name"] == "Main Line"
|
||||
assert track["maxSpeedKph"] == 120.0
|
||||
assert track["status"] == "operational"
|
||||
assert track["isBidirectional"] is True
|
||||
assert track["coordinates"] == [[52.5, 13.4], [52.6, 13.5]]
|
||||
assert track["lengthMeters"] > 0
|
||||
|
||||
|
||||
def test_normalize_track_elements_marks_oneway_and_status() -> None:
|
||||
elements = [
|
||||
{
|
||||
"type": "way",
|
||||
"id": 42,
|
||||
"geometry": [
|
||||
{"lat": 48.1, "lon": 11.5},
|
||||
{"lat": 48.2, "lon": 11.6},
|
||||
],
|
||||
"tags": {
|
||||
"railway": "disused",
|
||||
"oneway": "yes",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
tracks = tracks_import.normalize_track_elements(elements)
|
||||
|
||||
assert len(tracks) == 1
|
||||
track = tracks[0]
|
||||
assert track["status"] == "disused"
|
||||
assert track["isBidirectional"] is False
|
||||
168
backend/tests/test_tracks_load.py
Normal file
168
backend/tests/test_tracks_load.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
from geoalchemy2.shape import from_shape
|
||||
from shapely.geometry import Point
|
||||
|
||||
from backend.scripts import tracks_load
|
||||
|
||||
|
||||
def test_parse_track_entries_returns_models() -> None:
|
||||
entries = [
|
||||
{
|
||||
"name": "Connector",
|
||||
"coordinates": [[52.5, 13.4], [52.6, 13.5]],
|
||||
"lengthMeters": 1500,
|
||||
"maxSpeedKph": 120,
|
||||
"status": "operational",
|
||||
"isBidirectional": True,
|
||||
}
|
||||
]
|
||||
|
||||
parsed = tracks_load._parse_track_entries(entries)
|
||||
|
||||
assert parsed[0].name == "Connector"
|
||||
assert parsed[0].coordinates[0] == (52.5, 13.4)
|
||||
assert parsed[0].length_meters == 1500
|
||||
assert parsed[0].max_speed_kph == 120
|
||||
|
||||
|
||||
def test_parse_track_entries_invalid_raises_value_error() -> None:
|
||||
entries = [
|
||||
{
|
||||
"coordinates": [[52.5, 13.4]],
|
||||
}
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
tracks_load._parse_track_entries(entries)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummySession:
|
||||
committed: bool = False
|
||||
rolled_back: bool = False
|
||||
|
||||
def __enter__(self) -> "DummySession":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
pass
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rolled_back = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyStation:
|
||||
id: str
|
||||
location: object
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyStationRepository:
|
||||
session: DummySession
|
||||
stations: List[DummyStation]
|
||||
|
||||
def list_active(self) -> List[DummyStation]:
|
||||
return self.stations
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyTrackRepository:
|
||||
session: DummySession
|
||||
created: list = field(default_factory=list)
|
||||
existing: list = field(default_factory=list)
|
||||
|
||||
def list_all(self):
|
||||
return self.existing
|
||||
|
||||
def create(self, data): # pragma: no cover - simple delegation
|
||||
self.created.append(data)
|
||||
|
||||
|
||||
def _point(lat: float, lon: float) -> object:
|
||||
return from_shape(Point(lon, lat), srid=4326)
|
||||
|
||||
|
||||
def test_load_tracks_creates_entries(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session_instance = DummySession()
|
||||
station_repo_instance = DummyStationRepository(
|
||||
session_instance,
|
||||
stations=[
|
||||
DummyStation(id="station-a", location=_point(52.5, 13.4)),
|
||||
DummyStation(id="station-b", location=_point(52.6, 13.5)),
|
||||
],
|
||||
)
|
||||
track_repo_instance = DummyTrackRepository(session_instance)
|
||||
|
||||
monkeypatch.setattr(tracks_load, "SessionLocal", lambda: session_instance)
|
||||
monkeypatch.setattr(tracks_load, "StationRepository",
|
||||
lambda session: station_repo_instance)
|
||||
monkeypatch.setattr(tracks_load, "TrackRepository",
|
||||
lambda session: track_repo_instance)
|
||||
|
||||
parsed = tracks_load._parse_track_entries(
|
||||
[
|
||||
{
|
||||
"name": "Connector",
|
||||
"coordinates": [[52.5, 13.4], [52.6, 13.5]],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
created = tracks_load.load_tracks(parsed, commit=True)
|
||||
|
||||
assert created == 1
|
||||
assert session_instance.committed is True
|
||||
assert track_repo_instance.created
|
||||
track = track_repo_instance.created[0]
|
||||
assert track.start_station_id == "station-a"
|
||||
assert track.end_station_id == "station-b"
|
||||
assert track.coordinates == [(52.5, 13.4), (52.6, 13.5)]
|
||||
|
||||
|
||||
def test_load_tracks_skips_existing_pairs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session_instance = DummySession()
|
||||
station_repo_instance = DummyStationRepository(
|
||||
session_instance,
|
||||
stations=[
|
||||
DummyStation(id="station-a", location=_point(52.5, 13.4)),
|
||||
DummyStation(id="station-b", location=_point(52.6, 13.5)),
|
||||
],
|
||||
)
|
||||
existing_track = type("ExistingTrack", (), {
|
||||
"start_station_id": "station-a",
|
||||
"end_station_id": "station-b",
|
||||
})
|
||||
track_repo_instance = DummyTrackRepository(
|
||||
session_instance,
|
||||
existing=[existing_track],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(tracks_load, "SessionLocal", lambda: session_instance)
|
||||
monkeypatch.setattr(tracks_load, "StationRepository",
|
||||
lambda session: station_repo_instance)
|
||||
monkeypatch.setattr(tracks_load, "TrackRepository",
|
||||
lambda session: track_repo_instance)
|
||||
|
||||
parsed = tracks_load._parse_track_entries(
|
||||
[
|
||||
{
|
||||
"name": "Connector",
|
||||
"coordinates": [[52.5, 13.4], [52.6, 13.5]],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
created = tracks_load.load_tracks(parsed, commit=False)
|
||||
|
||||
assert created == 0
|
||||
assert session_instance.rolled_back is True
|
||||
assert not track_repo_instance.created
|
||||
Reference in New Issue
Block a user