Initial commit.
This commit is contained in:
commit
2a2a42fe5d
5 changed files with 5628 additions and 0 deletions
4877
tests/fixtures/15083963-full.json
vendored
Normal file
4877
tests/fixtures/15083963-full.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
317
tests/test_osm_pt_geojson.py
Normal file
317
tests/test_osm_pt_geojson.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue