"""Unit tests for the EKB geoportal building ingest pipeline. Covers: - ekb_geoportal_client: GeoJSON FeatureCollection → TopoBuilding parsing + centroid, skip of features without street/house, graceful empty on bad input. - ekb_geoportal_ingest: house normalization ("7 б" → "7б"), part-feature grouping, tile grid bounds. - geocoder._geoportal_house_match + geocode() wiring: mock db row → GeocodeResult (provider="cache", confidence="exact"); parse-fail / no-row → falls through. All network + DB mocked. """ from __future__ import annotations import os import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) from app.services.scrapers.ekb_geoportal_client import ( # noqa: E402 TopoBuilding, parse_feature_collection, ) from app.tasks.ekb_geoportal_ingest import ( # noqa: E402 _group_buildings, _tile_grid, normalize_house, normalize_street, ) # ── client: GeoJSON parsing + centroid ─────────────────────────────────────── def _multipolygon(coords: list) -> dict: return {"type": "MultiPolygon", "coordinates": coords} # A unit square [0,0]-[2,2] → centroid (1, 1) in (lon, lat). _SQUARE = [[[[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0], [0.0, 0.0]]]] def test_parse_feature_collection_basic() -> None: fc = { "type": "FeatureCollection", "features": [ { "type": "Feature", "geometry": _multipolygon(_SQUARE), "properties": { "topo_street": "Космонавтов", "topo_num_house": "7б", "topo_floor": 9, "topo_purpose": "жилое", "topo_material": "3", "key": 12345, }, } ], } out = parse_feature_collection(fc) assert len(out) == 1 b = out[0] assert isinstance(b, TopoBuilding) assert b.street == "Космонавтов" assert b.house == "7б" assert b.floors == 9 assert b.purpose == "жилое" assert b.material == "3" assert b.key == 12345 # centroid of the [0,0]-[2,2] square = (1, 1): lon=x, lat=y assert b.lon == pytest.approx(1.0) assert b.lat == pytest.approx(1.0) def test_parse_skips_features_without_street_or_house() -> None: fc = { "features": [ { "geometry": _multipolygon(_SQUARE), "properties": {"topo_street": "", "topo_num_house": "7б"}, }, { "geometry": _multipolygon(_SQUARE), "properties": {"topo_street": "Ленина", "topo_num_house": " "}, }, { "geometry": _multipolygon(_SQUARE), "properties": {"topo_num_house": "5"}, # no street key }, ] } assert parse_feature_collection(fc) == [] def test_parse_skips_feature_without_geometry() -> None: fc = { "features": [ { "geometry": None, "properties": {"topo_street": "Ленина", "topo_num_house": "5"}, } ] } assert parse_feature_collection(fc) == [] def test_parse_empty_or_malformed_collection() -> None: assert parse_feature_collection({}) == [] assert parse_feature_collection({"features": "nope"}) == [] assert parse_feature_collection({"features": [None, 42]}) == [] # ── normalization ──────────────────────────────────────────────────────────── @pytest.mark.parametrize( ("raw", "expected"), [ ("7 б", "7б"), ("7Б", "7б"), (" 7б ", "7б"), ("1в/1", "1в/1"), ("12", "12"), ("3 А", "3а"), ], ) def test_normalize_house(raw: str, expected: str) -> None: assert normalize_house(raw) == expected def test_normalize_street() -> None: assert normalize_street(" Космонавтов ") == "космонавтов" assert normalize_street("8 Марта") == "8 марта" # ── grouping ───────────────────────────────────────────────────────────────── def test_group_buildings_merges_parts() -> None: parts = [ TopoBuilding("Космонавтов", "7 б", 9, "жилое", "3", 1, lat=2.0, lon=4.0), TopoBuilding("Космонавтов", "7б", None, None, None, 2, lat=4.0, lon=6.0), TopoBuilding("Космонавтов", "7а", 5, None, None, 3, lat=1.0, lon=1.0), ] grouped = _group_buildings(parts) # "7 б" and "7б" normalize to the same house → one merged entry assert ("космонавтов", "7б") in grouped assert ("космонавтов", "7а") in grouped assert len(grouped) == 2 merged = grouped[("космонавтов", "7б")] # centroid = mean of (2,4) and (4,6) assert merged["lat_sum"] / merged["n"] == pytest.approx(3.0) assert merged["lon_sum"] / merged["n"] == pytest.approx(5.0) assert merged["floors"] == 9 # max across parts (other is None) assert merged["purpose"] == "жилое" assert merged["material"] == "3" assert sorted(merged["source_keys"]) == [1, 2] # ── tile grid ──────────────────────────────────────────────────────────────── def test_tile_grid_covers_bbox() -> None: tiles = _tile_grid(60.45, 56.70, 60.85, 56.95) assert len(tiles) > 0 # every tile within bbox bounds for lon_lo, lat_lo, lon_hi, lat_hi in tiles: assert 60.45 <= lon_lo < lon_hi <= 60.85 + 1e-9 assert 56.70 <= lat_lo < lat_hi <= 56.95 + 1e-9 # first tile anchored at SW corner assert tiles[0][0] == pytest.approx(60.45) assert tiles[0][1] == pytest.approx(56.70) # ── geocoder._geoportal_house_match + geocode() wiring ─────────────────────── def test_geoportal_house_match_returns_suggestion() -> None: from app.services.geocoder import GeocodeSuggestion, _geoportal_house_match row = MagicMock() row.street = "Космонавтов" row.house = "7б" row.lat = 56.864 row.lon = 60.611 db = MagicMock() result = MagicMock() result.first.return_value = row db.execute.return_value = result hit = _geoportal_house_match(db, "Космонавтов", "7 б") assert isinstance(hit, GeocodeSuggestion) assert hit.kind == "house" assert hit.lat == 56.864 assert hit.lon == 60.611 # house param normalized: "7 б" → "7б" params = db.execute.call_args.args[1] assert params["house"] == "7б" assert params["street"] == "космонавтов" def test_geoportal_house_match_none_when_no_row() -> None: from app.services.geocoder import _geoportal_house_match db = MagicMock() result = MagicMock() result.first.return_value = None db.execute.return_value = result assert _geoportal_house_match(db, "Космонавтов", "7б") is None def test_geoportal_house_match_none_on_db_error() -> None: from app.services.geocoder import _geoportal_house_match db = MagicMock() db.execute.side_effect = RuntimeError("table missing") assert _geoportal_house_match(db, "Космонавтов", "7б") is None def test_geoportal_house_match_none_for_empty_house() -> None: from app.services.geocoder import _geoportal_house_match db = MagicMock() assert _geoportal_house_match(db, "Космонавтов", " ") is None db.execute.assert_not_called() async def test_geocode_uses_geoportal_first() -> None: """Parse + geoportal hit → GeocodeResult(provider=cache, exact); cad NOT called.""" from app.services.geocoder import GeocodeSuggestion, geocode db = MagicMock() hit = GeocodeSuggestion( label="Космонавтов, 7б", full_address="Космонавтов, 7б", lat=56.864, lon=60.611, kind="house", ) with ( patch("app.services.geocoder._cache_get", return_value=None), patch("app.services.geocoder._geoportal_house_match", return_value=hit) as mock_geo, patch("app.services.geocoder._cadastral_house_match") as mock_cad, patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, patch("app.services.geocoder._cache_put"), patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex, ): result = await geocode("Космонавтов 7б", db) assert result is not None assert result.provider == "cache" assert result.confidence == "exact" assert result.lat == 56.864 assert result.lon == 60.611 mock_geo.assert_called_once() # geoportal hit short-circuits everything downstream mock_cad.assert_not_called() mock_forward.assert_not_called() mock_yandex.assert_not_called() async def test_geocode_falls_through_to_cadastral_when_geoportal_misses() -> None: """Geoportal None → cadastral house-match IS called next.""" from app.services.geocoder import GeocodeSuggestion, geocode db = MagicMock() cad_hit = GeocodeSuggestion( label="ул. Серова, д. 27", full_address="ул. Серова, д. 27, Екатеринбург", lat=56.81188, lon=60.59739, kind="house", ) with ( patch("app.services.geocoder._cache_get", return_value=None), patch("app.services.geocoder._geoportal_house_match", return_value=None) as mock_geo, patch("app.services.geocoder._cadastral_house_match", return_value=cad_hit) as mock_cad, patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, patch("app.services.geocoder._cache_put"), patch("app.services.geocoder._yandex_lookup", new_callable=AsyncMock) as mock_yandex, ): result = await geocode("Серова 27", db) assert result is not None assert result.lat == 56.81188 mock_geo.assert_called_once() mock_cad.assert_called_once() mock_forward.assert_not_called() mock_yandex.assert_not_called() async def test_geocode_skips_geoportal_when_parse_fails() -> None: """Unparseable address → geoportal NOT called.""" from app.services.geocoder import geocode db = MagicMock() with ( patch("app.services.geocoder._cache_get", return_value=None), patch("app.services.geocoder._geoportal_house_match") as mock_geo, patch("app.services.geocoder._cadastral_house_match") as mock_cad, patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) as mock_forward, patch("app.services.geocoder._cache_put"), patch("app.services.geocoder.settings") as mock_settings, patch( "app.services.geocoder._nominatim_lookup", new_callable=AsyncMock, return_value=None, ), ): mock_settings.yandex_geocoder_api_key = None result = await geocode("полный мусор без дома", db) assert result is None mock_geo.assert_not_called() mock_cad.assert_not_called() mock_forward.assert_called_once()