- Refactor core.py: replace sys.exit() calls with OsmError exceptions
so the library is safe to use from Flask and other callers
- Add fetch_sibling_routes and fetch_route_master_routes to core.py
- Add Flask web frontend (web/app.py, templates, static assets):
- Map view with Leaflet; full route drawn in grey on load
- Sidebar stop list; active-slot UX for From/To selection
- Segment preview and download (full route or selected segment)
- Include-stops toggle applied client-side
- Bookmarkable URLs: GET /<relation_id>
- Clear selection button
- Other directions panel (sibling routes from same route_master)
- route_master handling: draws all member routes in colour on map
with links to each individual direction
- Add SVG favicon
- Add py.typed marker; add .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
305 lines
11 KiB
Python
305 lines
11 KiB
Python
"""Tests for osm-pt-geojson."""
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
import responses as responses_lib
|
||
from click.testing import CliRunner
|
||
|
||
from osm_geojson.pt import core
|
||
from osm_geojson.pt.cli import cli, output_geojson
|
||
from osm_geojson.pt.core import OsmError
|
||
|
||
FIXTURES = Path(__file__).parent / "fixtures"
|
||
FULL_URL = "https://www.openstreetmap.org/api/0.6/relation/15083963/full.json"
|
||
RELATION_ID = 15083963
|
||
|
||
|
||
@pytest.fixture()
|
||
def full_data() -> dict:
|
||
"""Load the saved full API response for relation 15083963 (M11 Istanbul Metro)."""
|
||
return json.loads((FIXTURES / "15083963-full.json").read_text())
|
||
|
||
|
||
@pytest.fixture()
|
||
def parsed(full_data: dict) -> tuple:
|
||
"""Return parsed elements (nodes, ways, stop_ids, way_ids, tags) for relation 15083963."""
|
||
return core.parse_elements(full_data, RELATION_ID)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# parse_elements
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_parse_elements_stop_count(parsed: tuple) -> None:
|
||
"""All ten stops on the M11 are extracted in order."""
|
||
nodes, ways, stop_ids, way_ids, tags = parsed
|
||
assert len(stop_ids) == 10
|
||
|
||
|
||
def test_parse_elements_first_and_last_stop(parsed: tuple) -> None:
|
||
"""The first and last stops match the route terminus names."""
|
||
nodes, ways, stop_ids, way_ids, tags = parsed
|
||
assert core.node_name(nodes[stop_ids[0]]) == "Arnavutköy Hastane"
|
||
assert core.node_name(nodes[stop_ids[-1]]) == "Gayrettepe"
|
||
|
||
|
||
def test_parse_elements_way_count(parsed: tuple) -> None:
|
||
"""All ten member ways are extracted."""
|
||
nodes, ways, stop_ids, way_ids, tags = parsed
|
||
assert len(way_ids) == 10
|
||
|
||
|
||
def test_parse_elements_tags(parsed: tuple) -> None:
|
||
"""Route tags are returned correctly."""
|
||
_, _, _, _, tags = parsed
|
||
assert tags["ref"] == "M11"
|
||
assert tags["route"] == "subway"
|
||
|
||
|
||
def test_parse_elements_unknown_relation(full_data: dict) -> None:
|
||
"""Requesting a relation ID not present in the response raises OsmError."""
|
||
with pytest.raises(OsmError):
|
||
core.parse_elements(full_data, 9999999)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# build_route_coords
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_build_route_coords_returns_coords(parsed: tuple) -> None:
|
||
"""Chained coordinates are non-empty and fall within the Istanbul bounding box."""
|
||
nodes, ways, stop_ids, way_ids, tags = parsed
|
||
coords = core.build_route_coords(way_ids, ways, nodes)
|
||
assert len(coords) > 0
|
||
for coord in coords:
|
||
assert len(coord) == 2
|
||
lon, lat = coord
|
||
assert 28.0 < lon < 29.1
|
||
assert 40.0 < lat < 42.0
|
||
|
||
|
||
def test_build_route_coords_empty_ways() -> None:
|
||
"""An empty way list returns an empty coordinate list."""
|
||
assert core.build_route_coords([], {}, {}) == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# nearest_coord_index
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_nearest_coord_index_exact() -> None:
|
||
"""Returns the index of an exact coordinate match."""
|
||
coords = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]
|
||
assert core.nearest_coord_index(3.0, 4.0, coords) == 1
|
||
|
||
|
||
def test_nearest_coord_index_approximate() -> None:
|
||
"""Returns the index of the closest coordinate when there is no exact match."""
|
||
coords = [[0.0, 0.0], [10.0, 0.0], [20.0, 0.0]]
|
||
assert core.nearest_coord_index(9.0, 0.0, coords) == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# node_name
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_node_name_uses_name_tag() -> None:
|
||
"""Prefers the name tag when present."""
|
||
node = {"id": 1, "lat": 0.0, "lon": 0.0, "tags": {"name": "Central", "ref": "C1"}}
|
||
assert core.node_name(node) == "Central"
|
||
|
||
|
||
def test_node_name_falls_back_to_ref() -> None:
|
||
"""Falls back to the ref tag when there is no name tag."""
|
||
node = {"id": 1, "lat": 0.0, "lon": 0.0, "tags": {"ref": "C1"}}
|
||
assert core.node_name(node) == "C1"
|
||
|
||
|
||
def test_node_name_falls_back_to_id() -> None:
|
||
"""Falls back to the node ID when tags are present but empty."""
|
||
node = {"id": 42, "lat": 0.0, "lon": 0.0, "tags": {}}
|
||
assert core.node_name(node) == "42"
|
||
|
||
|
||
def test_node_name_no_tags() -> None:
|
||
"""Falls back to the node ID when the tags key is absent."""
|
||
node = {"id": 99, "lat": 0.0, "lon": 0.0}
|
||
assert core.node_name(node) == "99"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# make_geojson
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_make_geojson_full(parsed: tuple) -> None:
|
||
"""Full output contains one LineString and one Point per stop."""
|
||
nodes, ways, stop_ids, way_ids, tags = parsed
|
||
coords = core.build_route_coords(way_ids, ways, nodes)
|
||
geojson = core.make_geojson(coords, stop_ids, nodes, tags)
|
||
|
||
assert geojson["type"] == "FeatureCollection"
|
||
features = geojson["features"]
|
||
line_features = [f for f in features if f["geometry"]["type"] == "LineString"]
|
||
point_features = [f for f in features if f["geometry"]["type"] == "Point"]
|
||
|
||
assert len(line_features) == 1
|
||
assert len(point_features) == 10
|
||
|
||
|
||
def test_make_geojson_no_stops(parsed: tuple) -> None:
|
||
"""With no_stops=True, only the LineString feature is included."""
|
||
nodes, ways, stop_ids, way_ids, tags = parsed
|
||
coords = core.build_route_coords(way_ids, ways, nodes)
|
||
geojson = core.make_geojson(coords, stop_ids, nodes, tags, no_stops=True)
|
||
|
||
features = geojson["features"]
|
||
assert all(f["geometry"]["type"] == "LineString" for f in features)
|
||
|
||
|
||
def test_make_geojson_slice(parsed: tuple) -> None:
|
||
"""Slicing by coord index produces a shorter LineString with the correct length."""
|
||
nodes, ways, stop_ids, way_ids, tags = parsed
|
||
coords = core.build_route_coords(way_ids, ways, nodes)
|
||
full = core.make_geojson(coords, stop_ids, nodes, tags)
|
||
full_line_len = len(full["features"][0]["geometry"]["coordinates"])
|
||
|
||
sliced = core.make_geojson(coords, stop_ids, nodes, tags, idx_from=10, idx_to=50)
|
||
sliced_line_len = len(sliced["features"][0]["geometry"]["coordinates"])
|
||
|
||
assert sliced_line_len == 41 # 50 - 10 + 1
|
||
assert sliced_line_len < full_line_len
|
||
|
||
|
||
def test_make_geojson_linestring_properties(parsed: tuple) -> None:
|
||
"""The LineString feature carries route properties from the OSM relation tags."""
|
||
nodes, ways, stop_ids, way_ids, tags = parsed
|
||
coords = core.build_route_coords(way_ids, ways, nodes)
|
||
geojson = core.make_geojson(coords, stop_ids, nodes, tags)
|
||
|
||
props = geojson["features"][0]["properties"]
|
||
assert props["ref"] == "M11"
|
||
assert props["route"] == "subway"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI — list-stations
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@responses_lib.activate
|
||
def test_cli_list_stations(full_data: dict) -> None:
|
||
"""list-stations prints the route name and all stop names."""
|
||
responses_lib.add(responses_lib.GET, FULL_URL, json=full_data)
|
||
runner = CliRunner()
|
||
result = runner.invoke(cli, ["list-stations", str(RELATION_ID)])
|
||
assert result.exit_code == 0
|
||
assert "M11" in result.output
|
||
assert "Arnavutköy Hastane" in result.output
|
||
assert "Gayrettepe" in result.output
|
||
|
||
|
||
@responses_lib.activate
|
||
def test_cli_list_stations_http_error() -> None:
|
||
"""list-stations exits with code 1 on an HTTP error response."""
|
||
responses_lib.add(responses_lib.GET, FULL_URL, status=503)
|
||
runner = CliRunner()
|
||
result = runner.invoke(cli, ["list-stations", str(RELATION_ID)])
|
||
assert result.exit_code == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI — full-route
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@responses_lib.activate
|
||
def test_cli_full_route_geojson(full_data: dict) -> None:
|
||
"""full-route outputs a valid GeoJSON FeatureCollection to stdout."""
|
||
responses_lib.add(responses_lib.GET, FULL_URL, json=full_data)
|
||
runner = CliRunner()
|
||
result = runner.invoke(cli, ["full-route", str(RELATION_ID)])
|
||
assert result.exit_code == 0
|
||
geojson = json.loads(result.output)
|
||
assert geojson["type"] == "FeatureCollection"
|
||
|
||
|
||
@responses_lib.activate
|
||
def test_cli_full_route_no_stops(full_data: dict) -> None:
|
||
"""full-route --no-stops omits Point features from the output."""
|
||
responses_lib.add(responses_lib.GET, FULL_URL, json=full_data)
|
||
runner = CliRunner()
|
||
result = runner.invoke(cli, ["full-route", str(RELATION_ID), "--no-stops"])
|
||
assert result.exit_code == 0
|
||
geojson = json.loads(result.output)
|
||
types = [f["geometry"]["type"] for f in geojson["features"]]
|
||
assert "Point" not in types
|
||
|
||
|
||
@responses_lib.activate
|
||
def test_cli_full_route_output_file(full_data: dict, tmp_path) -> None:
|
||
"""full-route -o writes valid GeoJSON to the specified file."""
|
||
responses_lib.add(responses_lib.GET, FULL_URL, json=full_data)
|
||
out = tmp_path / "route.geojson"
|
||
runner = CliRunner()
|
||
result = runner.invoke(cli, ["full-route", str(RELATION_ID), "-o", str(out)])
|
||
assert result.exit_code == 0
|
||
assert out.exists()
|
||
geojson = json.loads(out.read_text())
|
||
assert geojson["type"] == "FeatureCollection"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI — route-between
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@responses_lib.activate
|
||
def test_cli_route_between(full_data: dict) -> None:
|
||
"""route-between includes both endpoint stops in the output."""
|
||
responses_lib.add(responses_lib.GET, FULL_URL, json=full_data)
|
||
runner = CliRunner()
|
||
result = runner.invoke(
|
||
cli,
|
||
["route-between", str(RELATION_ID), "Arnavutköy Hastane", "Gayrettepe"],
|
||
)
|
||
assert result.exit_code == 0
|
||
geojson = json.loads(result.output)
|
||
stop_names = [
|
||
f["properties"]["name"]
|
||
for f in geojson["features"]
|
||
if f["geometry"]["type"] == "Point"
|
||
]
|
||
assert "Arnavutköy Hastane" in stop_names
|
||
assert "Gayrettepe" in stop_names
|
||
|
||
|
||
@responses_lib.activate
|
||
def test_cli_route_between_unknown_station(full_data: dict) -> None:
|
||
"""route-between exits with code 1 when a station name is not found."""
|
||
responses_lib.add(responses_lib.GET, FULL_URL, json=full_data)
|
||
runner = CliRunner()
|
||
result = runner.invoke(
|
||
cli,
|
||
["route-between", str(RELATION_ID), "Nonexistent", "Gayrettepe"],
|
||
)
|
||
assert result.exit_code == 1
|
||
|
||
|
||
@responses_lib.activate
|
||
def test_cli_route_between_stops_subset(full_data: dict) -> None:
|
||
"""route-between only includes stops between the two named stations."""
|
||
responses_lib.add(responses_lib.GET, FULL_URL, json=full_data)
|
||
runner = CliRunner()
|
||
result = runner.invoke(
|
||
cli,
|
||
["route-between", str(RELATION_ID), "İstanbul Havalimanı", "Hasdal"],
|
||
)
|
||
assert result.exit_code == 0
|
||
geojson = json.loads(result.output)
|
||
stop_names = [
|
||
f["properties"]["name"]
|
||
for f in geojson["features"]
|
||
if f["geometry"]["type"] == "Point"
|
||
]
|
||
assert "İstanbul Havalimanı" in stop_names
|
||
assert "Hasdal" in stop_names
|
||
assert "Arnavutköy Hastane" not in stop_names
|
||
assert "Gayrettepe" not in stop_names
|