Add unit tests for station service and enhance documentation
Some checks failed
Backend CI / lint-and-test (push) Failing after 37s

- Introduced unit tests for the station service, covering creation, updating, and archiving of stations.
- Added detailed building block view documentation outlining the architecture of the Rail Game system.
- Created runtime view documentation illustrating key user interactions and system behavior.
- Developed concepts documentation detailing domain models, architectural patterns, and security considerations.
- Updated architecture documentation to reference new detailed sections for building block and runtime views.
This commit is contained in:
2025-10-11 18:52:25 +02:00
parent 2b9877a9d3
commit 615b63ba76
18 changed files with 1662 additions and 443 deletions

View File

@@ -0,0 +1,171 @@
from __future__ import annotations
"""CLI utility to import station data from OpenStreetMap."""
import argparse
import json
import sys
from dataclasses import asdict
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import quote_plus
from backend.app.core.osm_config import (
DEFAULT_REGIONS,
STATION_TAG_FILTERS,
compile_overpass_filters,
)
OVERPASS_ENDPOINT = "https://overpass-api.de/api/interpreter"
def build_argument_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Export OSM station nodes for ingestion"
)
parser.add_argument(
"--output",
type=Path,
default=Path("data/osm_stations.json"),
help="Destination file for the exported station nodes (default: data/osm_stations.json)",
)
parser.add_argument(
"--region",
choices=[region.name for region in DEFAULT_REGIONS] + ["all"],
default="all",
help="Region name to export (default: all)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Do not fetch data; print the Overpass payload only",
)
return parser
def build_overpass_query(region_name: str) -> str:
if region_name == "all":
regions = DEFAULT_REGIONS
else:
regions = tuple(
region for region in DEFAULT_REGIONS if region.name == region_name
)
if not regions:
msg = f"Unknown region {region_name}. Available regions: {[region.name for region in DEFAULT_REGIONS]}"
raise ValueError(msg)
filters = compile_overpass_filters(STATION_TAG_FILTERS)
parts = ["[out:json][timeout:90];", "("]
for region in regions:
parts.append(f" node{filters}\n ({region.to_overpass_arg()});")
parts.append(")")
parts.append("; out body; >; out skel qt;")
return "\n".join(parts)
def perform_request(query: str) -> dict[str, Any]:
import urllib.request
payload = f"data={quote_plus(query)}".encode("utf-8")
request = urllib.request.Request(
OVERPASS_ENDPOINT,
data=payload,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(request, timeout=120) as response:
payload = response.read()
return json.loads(payload)
def normalize_station_elements(
elements: Iterable[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Convert raw Overpass nodes into StationCreate-compatible payloads."""
stations: list[dict[str, Any]] = []
for element in elements:
if element.get("type") != "node":
continue
latitude = element.get("lat")
longitude = element.get("lon")
if latitude is None or longitude is None:
continue
tags: dict[str, Any] = element.get("tags", {})
name = tags.get("name")
if not name:
continue
raw_code = tags.get("ref") or tags.get(
"railway:ref") or tags.get("local_ref")
code = str(raw_code) if raw_code is not None else None
elevation_tag = tags.get("ele") or tags.get("elevation")
try:
elevation = float(
elevation_tag) if elevation_tag is not None else None
except (TypeError, ValueError):
elevation = None
disused = str(tags.get("disused", "no")).lower() in {"yes", "true"}
railway_status = str(tags.get("railway", "")).lower()
abandoned = railway_status in {"abandoned", "disused"}
is_active = not (disused or abandoned)
stations.append(
{
"osm_id": str(element.get("id")),
"name": str(name),
"latitude": float(latitude),
"longitude": float(longitude),
"code": code,
"elevation_m": elevation,
"is_active": is_active,
}
)
return stations
def main(argv: list[str] | None = None) -> int:
parser = build_argument_parser()
args = parser.parse_args(argv)
query = build_overpass_query(args.region)
if args.dry_run:
print(query)
return 0
output_path: Path = args.output
output_path.parent.mkdir(parents=True, exist_ok=True)
data = perform_request(query)
raw_elements = data.get("elements", [])
stations = normalize_station_elements(raw_elements)
payload = {
"metadata": {
"endpoint": OVERPASS_ENDPOINT,
"region": args.region,
"filters": STATION_TAG_FILTERS,
"regions": [asdict(region) for region in DEFAULT_REGIONS],
"raw_count": len(raw_elements),
"station_count": len(stations),
},
"stations": stations,
}
with output_path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2)
print(
f"Normalized {len(stations)} stations from {len(raw_elements)} elements into {output_path}"
)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
"""CLI for loading normalized station JSON into the database."""
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Iterable, Mapping
from backend.app.db.session import SessionLocal
from backend.app.models import StationCreate
from backend.app.repositories import StationRepository
def build_argument_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Load normalized station data into PostGIS"
)
parser.add_argument(
"input",
type=Path,
help="Path to the normalized station JSON file produced by stations_import.py",
)
parser.add_argument(
"--commit/--no-commit",
dest="commit",
default=True,
help="Commit the transaction (default: commit). Use --no-commit for dry runs.",
)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_argument_parser()
args = parser.parse_args(argv)
if not args.input.exists():
parser.error(f"Input file {args.input} does not exist")
with args.input.open("r", encoding="utf-8") as handle:
payload = json.load(handle)
stations_data = payload.get("stations") or []
if not isinstance(stations_data, list):
parser.error("Invalid payload: 'stations' must be a list")
try:
station_creates = _parse_station_entries(stations_data)
except ValueError as exc:
parser.error(str(exc))
created = load_stations(station_creates, commit=args.commit)
print(f"Loaded {created} stations from {args.input}")
return 0
def _parse_station_entries(entries: Iterable[Mapping[str, Any]]) -> list[StationCreate]:
parsed: list[StationCreate] = []
for entry in entries:
try:
parsed.append(StationCreate(**entry))
except Exception as exc: # pragma: no cover - validated in tests
raise ValueError(f"Invalid station entry {entry}: {exc}") from exc
return parsed
def load_stations(stations: Iterable[StationCreate], commit: bool = True) -> int:
created = 0
with SessionLocal() as session:
repo = StationRepository(session)
for create_schema in stations:
repo.create(create_schema)
created += 1
if commit:
session.commit()
else:
session.rollback()
return created
if __name__ == "__main__":
sys.exit(main())