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