All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
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
CI Trade-In / backend-tests (pull_request) Successful in 1m36s
Удалены (0 runtime importers в app/, scripts/, packages/; проверено grep'ом, включая intra-legacy импорты в оставшихся 21 legacy-модуле scrapers/): - backend/app/services/scrapers/avito_imv.py - backend/app/services/scrapers/domclick_detail.py - backend/app/services/scrapers/ekb_geoportal_client.py - backend/app/services/scrapers/yandex_detail.py - backend/app/services/scrapers/yandex_valuation.py Тесты — удалены (pure legacy-vs-kit parity, kit-only coverage уже есть в другом месте): - tests/scrapers/test_ekb_geoportal_client_kit_parity.py - tests/scrapers/test_domclick_detail_kit_parity.py Тесты — конвертированы в kit-only (импорт легаси заменён на scraper_kit.*, покрытие сохранено без потерь): - tests/scrapers/test_avito_imv_kit_parity.py (parity-тесты убраны, config-footgun regression на kit-стороне оставлен) - tests/scrapers/test_avito_imv_browser_transport.py - tests/scrapers/test_yandex_valuation_kit_migration.py (parity убрана, footgun- регрессия mandatory config/delay_provider оставлена) - tests/scrapers/test_domclick_detail.py (+ exceptions переведены на scraper_kit.domclick_exceptions, т.к. kit parse_detail_html поднимает их) - tests/test_avito_imv_parse.py - tests/test_ekb_geoportal_ingest.py (client-часть; ingest/geocoder-часть не трогали — не зависят от удалённого модуля) - tests/test_yandex_detail.py - tests/test_yandex_detail_structural.py - tests/test_yandex_valuation.py (YandexValuationScraper() → config=_KIT_CONFIG, kit конструктор требует обязательный config) - tests/test_yandex_valuation_save.py - tests/test_extval_house_id_write_path.py (только yandex_valuation-часть; cian_valuation остался нетронутым — живой легаси-модуль) - tests/test_yandex_history_area_filter.py Тесты — частично отредактированы (убраны только части про удалённые модули, живые легаси-модули/их parity не тронуты): - tests/scrapers/test_admin_domclick_ingest_kit_parity.py (base.py/ScrapedLot parity остался) - tests/scrapers/test_admin_yandex_kit_parity.py (yandex_realty parity остался) - tests/scrapers/test_admin_avito_kit_parity.py (avito.py/avito_houses.py parity остался; _parse_price parity убран) - tests/scrapers/test_avito_unix_date_tz_consistency.py (IMV-ветка переведена на kit avito.imv, легаси avito/avito_houses/avito_shared/avito_detail не тронуты) - tests/tasks/test_yandex_detail_backfill.py (subject — kit-задача, уже вызывает scraper_kit.providers.yandex.detail; save_detail_enrichment coverage-тесты внизу файла переведены на kit) - tests/test_scraper_kit_yandex_golden_parity.py (detail/valuation parity убраны; serp/newbuilding/house_type_normalizer/build_url для yandex_realty/ yandex_newbuilding не тронуты) - tests/test_scraper_proxy.py (YandexValuation proxy-тесты переведены на kit config= DI; test_avito_imv_own_session_receives_proxies удалён — дублирует test_avito_imv_kit_parity.py footgun-тесты) - tests/test_yandex_scrapers_delay_wiring.py (yandex_detail/yandex_valuation → kit delay_provider=; yandex_realty/yandex_newbuilding не тронуты) - tests/test_scraper_kit_group_c_backfill_kit_parity.py (docstring уточнение, без функциональных изменений) Полный backend-suite зелёный (3253 passed, 6 skipped) кроме известного pre-existing flake test_search_api.py::test_search_cache_hit (#2208, не регрессия этого PR). ruff check + ruff format — чисто на всех изменённых файлах.
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""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.
|
||
|
||
Легаси `app.services.scrapers.ekb_geoportal_client` удалён (#2277 финальный шаг
|
||
scraper_kit-миграции, 0 runtime importers) — client-тесты переведены на kit-
|
||
эквивалент `scraper_kit.providers.ekb_geoportal.client` (уже используемый живым
|
||
`app.tasks.ekb_geoportal_ingest`), покрытие не изменилось.
|
||
"""
|
||
|
||
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 scraper_kit.providers.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()
|