Initial commit.

This commit is contained in:
Edward Betts 2026-02-27 10:59:27 +00:00
commit 2a2a42fe5d
5 changed files with 5628 additions and 0 deletions

4877
tests/fixtures/15083963-full.json vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,317 @@
"""Tests for osm-pt-geojson."""
import json
from pathlib import Path
import pytest
import responses as responses_lib
from click.testing import CliRunner
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
# Import the script as a module. The filename has hyphens so we use importlib.
import importlib.machinery
import importlib.util
_loader = importlib.machinery.SourceFileLoader(
"osm_pt_geojson", str(Path(__file__).parent.parent / "osm-pt-geojson")
)
_spec = importlib.util.spec_from_loader("osm_pt_geojson", _loader)
assert _spec
osm = importlib.util.module_from_spec(_spec)
_loader.exec_module(osm)
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 osm.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 osm.node_name(nodes[stop_ids[0]]) == "Arnavutköy Hastane"
assert osm.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 exits with an error."""
runner = CliRunner()
with pytest.raises(SystemExit):
osm.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 = osm.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 osm.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 osm.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 osm.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 osm.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 osm.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 osm.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 osm.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 = osm.build_route_coords(way_ids, ways, nodes)
geojson = osm.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 = osm.build_route_coords(way_ids, ways, nodes)
geojson = osm.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 = osm.build_route_coords(way_ids, ways, nodes)
full = osm.make_geojson(coords, stop_ids, nodes, tags)
full_line_len = len(full["features"][0]["geometry"]["coordinates"])
sliced = osm.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 = osm.build_route_coords(way_ids, ways, nodes)
geojson = osm.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(osm.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(osm.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(osm.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(osm.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(osm.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(
osm.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(
osm.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(
osm.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