openstreetmap-tools/tests/test_osm_pt_geojson.py
2026-02-27 10:59:27 +00:00

317 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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