gendesign/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py
bot-backend af609c18f3
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
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 2m20s
fix(tradein/geocoder): починить маркер села + fail-closed для явного city_hint (#2582)
H1 (deep-review PR #2589): `с\.` в _LOCALITY_MARKER_RE был мертворождён —
хвостовой \b не может сработать между "." и пробелом (оба не-словесные),
формат «с. <село>» (104/77к прод-адресов) не ловился НИ РАЗУ, зато ложно
срабатывал на инициалах «им с.п.королева». Живой инцидент воспроизведён:
"с. Новоалексеевское, ул. 8 Марта,37" снапалось в ЕКБ (совпадение до 5-го
знака, село в 35 км западнее). Заменил на lookahead-форму для однобуквенных
маркеров (с/д/дер/пгт/ст): "\.\s*(?=[а-яё])" — различает "д. 5" (дом-номер,
не матчит) от "д. Кашино" (деревня, матчит), что заодно позволило безопасно
включить "д."/"дер." (раньше исключённые из-за коллизии с house-маркером).

M2: гейт по city_hint оставался list-based (_names_non_ekb_city(city_hint)) —
для СТРУКТУРНОГО хинта (не свободного текста) fail-closed форма должна быть
обратной: "не Екатеринбург → закрыто", а не "есть в списке 37 → закрыто".
city_hint принимает произвольную строку без валидации (API напрямую, минуя
6-городный UI-selector) — city_hint="Реж" держал гейт открытым, geoportal
отдавал точный ЕКБ-дом, city_ambiguous=False делало это уверенно-неверным.

M3: добавлены изолирующие тесты — предыдущая параметризация "незнакомый
топоним" всегда несла ещё и "обл."/"республика"/"край" одновременно с
проверяемым сигналом, поэтому мёртвый "с\." прошёл незамеченным. Плюс честно
зафиксирована известная дыра (гейт по city_hint) — "реж, ленина 5" без
маркера остаётся в ЕКБ-дефолте (архитектурное решение отдельно, не список
городов).

docstring `_names_unrecognized_locality`/`_ekb_local_tiers_allowed` приведён
в соответствие с фактическим поведением regex.
2026-07-31 19:17:47 +03:00

624 lines
25 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 anchored cadastral street+house matcher.
Covers:
- `_parse_street_house`: PURE parsing of DaData full form, bare «Street House»,
корпус/квартира stripping, digit-leading street («8 Марта 204»). Plus None cases.
- `_cadastral_house_match`: mocked db session (FDW not in unit-test DB) → asserts
GeocodeSuggestion built from row / None on no row / None on error.
- geocode() + suggest() wiring: parse → house-match called first; parse-fail or
no-hit → falls back to legacy `_cadastral_forward_sync`.
"""
from __future__ import annotations
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# DATABASE_URL required by config before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint stub — not installed in CI without GTK.
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
from app.services.geocoder import ( # noqa: E402
GeocodeResult,
GeocodeSuggestion,
_cadastral_house_match,
_parse_street_house,
geocode,
suggest,
)
# ── _parse_street_house: pure unit tests ─────────────────────────────────────
@pytest.mark.parametrize(
("address", "expected"),
[
# DaData full form with district
(
"620144, Свердловская обл, г Екатеринбург, Ленинский р-н, ул Серова, д 35",
("серова", "35"),
),
# пр-кт + house with letter + квартира noise → drop кв, keep "26а"
(
"620091, Свердловская обл, г Екатеринбург, пр-кт Космонавтов, д 26а, кв 335",
("космонавтов", "26а"),
),
# корпус noise "к 3" dropped
("ул Щербакова, д 77 к 3", ("щербакова", "77")),
# bare typed form
("Серова 27", ("серова", "27")),
# street starting with a digit
("8 Марта 204", ("8 марта", "204")),
# human-typed full form with abbreviations
(
"Свердловская область, г. Екатеринбург, ул. Серова, д. 27",
("серова", "27"),
),
# bare forms (validated prod addresses present in base)
("Малышева 30", ("малышева", "30")),
("Сурикова 31", ("сурикова", "31")),
("Шаумяна 28", ("шаумяна", "28")),
("Щербакова 77", ("щербакова", "77")),
# not-in-base bare forms still must PARSE (matcher returns None, not parser)
("Космонавтов 7", ("космонавтов", "7")),
("Космонавтов 26", ("космонавтов", "26")),
("Педагогическая 15", ("педагогическая", "15")),
("Фрунзе 75", ("фрунзе", "75")),
("Белинского 83", ("белинского", "83")),
],
)
def test_parse_street_house_valid_forms(address: str, expected: tuple[str, str]) -> None:
assert _parse_street_house(address) == expected
@pytest.mark.parametrize(
"address",
[
"",
" ",
"полный мусор без дома",
"Екатеринбург",
"Свердловская область",
],
)
def test_parse_street_house_returns_none_for_garbage(address: str) -> None:
assert _parse_street_house(address) is None
# ── _cadastral_house_match: mocked FDW session ───────────────────────────────
def test_house_match_returns_suggestion_when_row_found() -> None:
"""DB returns a row → GeocodeSuggestion built from it."""
row = MagicMock()
row.readable_address = "Свердловская область, г. Екатеринбург, ул. Серова, д. 27"
row.lat = 56.81188
row.lon = 60.59739
db = MagicMock()
result = MagicMock()
result.first.return_value = row
db.execute.return_value = result
hit = _cadastral_house_match(db, "серова", "27")
assert hit is not None
assert isinstance(hit, GeocodeSuggestion)
assert hit.kind == "house"
assert hit.lat == 56.81188
assert hit.lon == 60.59739
assert hit.full_address == row.readable_address
def test_house_match_returns_none_when_no_row() -> None:
"""No matching row → None."""
db = MagicMock()
result = MagicMock()
result.first.return_value = None
db.execute.return_value = result
assert _cadastral_house_match(db, "космонавтов", "7") is None
def test_house_match_returns_none_on_db_error() -> None:
"""FDW raises → None, no exception bubbles out (graceful)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("FDW connection failed")
assert _cadastral_house_match(db, "серова", "27") is None
def test_house_match_returns_none_for_non_numeric_house() -> None:
"""House with no leading digits → None before any DB call."""
db = MagicMock()
assert _cadastral_house_match(db, "серова", "abc") is None
db.execute.assert_not_called()
def test_house_match_passes_only_digits_as_regex_param() -> None:
"""house='26а' → :house_digits bound param is '26' (letter stripped for regex)."""
row = MagicMock()
row.readable_address = "г. Екатеринбург, пр-кт Космонавтов, д. 26а"
row.lat = 56.9
row.lon = 60.6
db = MagicMock()
result = MagicMock()
result.first.return_value = row
db.execute.return_value = result
_cadastral_house_match(db, "космонавтов", "26а")
# second positional arg to execute() is the bound-params dict
params = db.execute.call_args.args[1]
assert params["house_digits"] == "26"
assert params["house_full"] == "26а"
assert params["street"] == "космонавтов"
# ── geocode() wiring ─────────────────────────────────────────────────────────
async def test_geocode_uses_house_match_before_legacy_forward() -> None:
"""Parse succeeds + house-match hits → returns result, legacy forward NOT called."""
db = MagicMock()
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),
patch(
"app.services.geocoder._cadastral_house_match",
return_value=hit,
) as mock_house,
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
assert result.lon == 60.59739
assert result.confidence == "exact"
mock_house.assert_called_once()
# house-match hit → legacy raw-ILIKE forward never invoked
mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() -> None:
"""Parse succeeds but house-match returns None → legacy forward IS called."""
db = MagicMock()
legacy_hit = GeocodeSuggestion(
label="ул. Дублёр, 1",
full_address="ул. Дублёр, 1, Екатеринбург",
lat=56.84,
lon=60.61,
kind="house",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match", return_value=None),
patch(
"app.services.geocoder._cadastral_house_match",
return_value=None,
) as mock_house,
patch(
"app.services.geocoder._cadastral_forward_sync",
return_value=[legacy_hit],
) 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.84
mock_house.assert_called_once()
mock_forward.assert_called_once()
mock_yandex.assert_not_called()
async def test_geocode_skips_house_match_when_parse_fails() -> None:
"""Unparseable address → house-match NOT called, legacy forward used."""
db = MagicMock()
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch(
"app.services.geocoder._cadastral_house_match",
) as mock_house,
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_house.assert_not_called()
mock_forward.assert_called_once()
# ── geocode() non-EKB city gate (#11) ────────────────────────────────────────
async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None:
"""Non-EKB oblast-адрес (Нижний Тагил) → EKB-only локальные тиры (geoportal/
cadastral) НЕ вызываются, результат идёт от oblast-aware Nominatim (#11).
Без гейта «проспект Ленина, 1» коллизирует с одноимённым ЕКБ-домом через
geoportal/cadastral и снапается в ЕКБ вместо Нижнего Тагила.
"""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=57.905,
lon=59.950,
full_address="проспект Ленина, 1, Нижний Тагил",
provider="nominatim",
confidence="approximate",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match") as mock_geoportal,
patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") 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=nominatim_result,
) as mock_nominatim,
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("Нижний Тагил, проспект Ленина, 1", db)
assert result is not None
assert result.lat == pytest.approx(57.905)
assert result.lon == pytest.approx(59.950)
mock_geoportal.assert_not_called()
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()
# ── geocode() fail-closed gate for UNRECOGNIZED locality (#2582) ────────────
# Живые прод-случаи из geocode_cache: адрес называет город/регион ВНЕ списка
# из 37 (или другой регион РФ целиком) — до фикса `_names_non_ekb_city`
# молчаливо считал это «город не назван» → EKB-only тиры отвечали первыми и
# снапали улицу+дом в координаты ЕКБ.
@pytest.mark.parametrize(
("address", "label"),
[
("республика крым, ялта, ул. репина, 18с2", "Ялта (другой регион)"),
("челябинская обл., трёхгорный, ул. ленина, 15", "Трёхгорный (другой регион)"),
(
"свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8",
"Лёвиха (город области ВНЕ списка 37)",
),
("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"),
(
"с. Новоалексеевское, ул. 8 Марта,37",
"Новоалексеевское (живой инцидент #2589 H1 — «с.»-маркер без доп. «обл.»)",
),
],
)
async def test_geocode_skips_ekb_local_tiers_for_unrecognized_locality(
address: str, label: str
) -> None:
"""EKB-only локальные тиры (geoportal/cadastral) НЕ вызываются для адреса,
называющего незнакомый топоним — идём сразу к внешнему oblast/nation-aware
провайдеру (#2582)."""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=45.0,
lon=34.0,
full_address=address,
provider="nominatim",
confidence="approximate",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match") as mock_geoportal,
patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") 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=nominatim_result,
) as mock_nominatim,
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode(address, db)
assert result is not None, label
mock_geoportal.assert_not_called()
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()
@pytest.mark.parametrize(
("address", "label"),
[
("Серова 27", "ЕКБ без города (основной трафик формы оценки)"),
("Малышева 30", "ЕКБ без города (основной трафик формы оценки)"),
("г. Екатеринбург, ул. Серова, д. 27", "ЕКБ с явным городом"),
("ул. Серова, д. 27", "house-маркер «д.» не закрывает гейт (#2589 H1 regression)"),
],
)
async def test_geocode_still_uses_ekb_local_tiers_for_real_ekb_address(
address: str, label: str
) -> None:
"""Регрессия: реальный ЕКБ-адрес (с городом и без) по-прежнему резолвится
через geoportal (fast-path) — fail-closed гейт не должен деградировать
основной трафик (#2582)."""
db = MagicMock()
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=hit) as mock_geoportal,
patch("app.services.geocoder._cache_put"),
):
result = await geocode(address, db)
assert result is not None, label
assert result.lat == pytest.approx(56.81188), label
mock_geoportal.assert_called_once()
async def test_geocode_explicit_ekaterinburg_city_hint_still_uses_local_tiers() -> None:
"""Слияние #2580/#2582: явный `city_hint="Екатеринбург"` (без города в самом
тексте адреса, без конфликта) по-прежнему открывает ЕКБ-only локальные
тиры — ветка D из объединённого гейта `_ekb_local_tiers_allowed` не должна
была стать недостижимой после merge."""
db = MagicMock()
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=hit) as mock_geoportal,
patch("app.services.geocoder._cache_put"),
):
result = await geocode("Серова 27", db, city_hint="Екатеринбург")
assert result is not None
assert result.lat == pytest.approx(56.81188)
mock_geoportal.assert_called_once()
async def test_geocode_city_hint_unknown_city_skips_local_ekb_tiers() -> None:
"""#2589 M2 (deep-review fix): `city_hint="Реж"` — город ВНЕ списка 37 и
без структурного маркера в самом тексте адреса — обязан закрыть ЕКБ-only
локальные тиры так же, как известный `city_hint`. До фикса
`hint_names_non_ekb = _names_non_ekb_city(city_hint)` был list-based и
держал гейт открытым для ЛЮБОГО города вне списка 37 (Реж, седьмой город
после расширения UI-selector'а, произвольный текст через API) — geoportal
отдавал точный ЕКБ-хит, а `city_ambiguous=False` (хинт ведь БЫЛ) делало
это уверенно-неверным результатом (хуже, чем честный `city_ambiguous=True`
без хинта вообще)."""
db = MagicMock()
ekb_hit = GeocodeSuggestion(
label="Ленина, 1, Екатеринбург",
full_address="Ленина, 1, Екатеринбург",
lat=56.83788,
lon=60.58018,
kind="house",
)
rezh_result = GeocodeResult(
lat=57.371, lon=61.386, full_address="Ленина, 1, Реж", provider="nominatim"
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._cache_put"),
patch(
"app.services.geocoder._geoportal_house_match", return_value=ekb_hit
) as mock_geoportal,
patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit) as mock_house,
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=rezh_result,
),
):
mock_settings.yandex_geocoder_api_key = None
result = await geocode("Ленина, 1", db, city_hint="Реж")
mock_geoportal.assert_not_called()
mock_house.assert_not_called()
assert result is not None
assert result.lat == pytest.approx(57.371) # Реж, НЕ подставленный ЕКБ-хит (56.83788)
assert result.lat != pytest.approx(56.83788)
# ── suggest() wiring ─────────────────────────────────────────────────────────
async def test_suggest_uses_house_match_before_legacy_forward() -> None:
"""Parse + house-match hit → returns single suggestion, legacy forward not called."""
db = MagicMock()
hit = GeocodeSuggestion(
label="ул. Серова, д. 27",
full_address="ул. Серова, д. 27, Екатеринбург",
lat=56.81188,
lon=60.59739,
kind="house",
)
with (
patch(
"app.services.geocoder._cadastral_house_match",
return_value=hit,
) as mock_house,
patch(
"app.services.geocoder._cadastral_forward_sync",
) as mock_forward,
patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
):
results = await suggest("Серова 27", db=db, limit=8)
assert len(results) == 1
assert results[0].lat == 56.81188
mock_house.assert_called_once()
mock_forward.assert_not_called()
mock_yandex.assert_not_called()
async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() -> None:
"""Parse succeeds, house-match None → legacy forward IS called."""
db = MagicMock()
legacy = [
GeocodeSuggestion(
label="ул. Дублёр, 1",
full_address="ул. Дублёр, 1, Екатеринбург",
lat=56.84,
lon=60.61,
kind="house",
)
]
with (
patch(
"app.services.geocoder._cadastral_house_match",
return_value=None,
) as mock_house,
patch(
"app.services.geocoder._cadastral_forward_sync",
return_value=legacy,
) as mock_forward,
patch("app.services.geocoder._yandex_suggest", new_callable=AsyncMock) as mock_yandex,
):
results = await suggest("Серова 27", db=db, limit=8)
assert len(results) == 1
assert results[0].lat == 56.84
mock_house.assert_called_once()
mock_forward.assert_called_once()
mock_yandex.assert_not_called()
async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
"""Non-EKB oblast query (Нижний Тагил) → EKB-only cadastral Tier 1
(`_cadastral_house_match` / `_cadastral_forward_sync`) NOT called; falls
through to external tiers, same root cause as `geocode()` (#11)."""
db = MagicMock()
nominatim_hit = [
GeocodeSuggestion(
label="проспект Ленина, 1",
full_address="проспект Ленина, 1, Нижний Тагил",
lat=57.905,
lon=59.950,
kind="house",
)
]
with (
patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_suggest",
new_callable=AsyncMock,
return_value=nominatim_hit,
) as mock_nominatim,
):
mock_settings.dadata_api_token = None
mock_settings.yandex_geocoder_api_key = None
results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8)
assert len(results) == 1
assert results[0].lat == pytest.approx(57.905)
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()
@pytest.mark.parametrize(
("query", "label"),
[
("республика крым, ялта, ул. репина, 18с2", "Ялта (другой регион)"),
("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"),
],
)
async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality(
query: str, label: str
) -> None:
"""Незнакомый топоним (не в списке 37, не Екатеринбург) → EKB-only
cadastral Tier 1 не вызывается; falls through к внешним тирам (#2582)."""
db = MagicMock()
nominatim_hit = [
GeocodeSuggestion(
label="ул. Репина, 18с2",
full_address=query,
lat=44.5,
lon=34.16,
kind="house",
)
]
with (
patch("app.services.geocoder._cadastral_house_match") as mock_house,
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
patch("app.services.geocoder.settings") as mock_settings,
patch(
"app.services.geocoder._nominatim_suggest",
new_callable=AsyncMock,
return_value=nominatim_hit,
) as mock_nominatim,
):
mock_settings.dadata_api_token = None
mock_settings.yandex_geocoder_api_key = None
results = await suggest(query, db=db, limit=8)
assert len(results) == 1, label
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()