gendesign/tradein-mvp/backend/tests/test_ekb_geoportal_ingest.py
bot-backend 64d113d649
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(tradein): EKB geoportal building ingest + geocoder tier (complete city buildings)
2026-06-19 17:51:07 +03:00

329 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.

"""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б"),
("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()