All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / 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 56s
Deep-review fast-follows on the non-EKB city gate:
1. Word-boundary the "екатеринбург" short-circuit in _names_non_ekb_city
(new _EKATERINBURG_RE = \bекатеринбург\b, .search() instead of substring
`in`). A bare-substring check matched "Екатеринбургское шоссе" (a real
street in satellite towns like Pervouralsk) as if it named EKB, wrongly
keeping EKB-only local tiers on for a non-EKB address. Still returns False
for the existing homonym false-positive test case (word boundary is
satisfied there — "екатеринбург" appears as a standalone token).
2. Gate suggest()'s Tier 1 (same root cause as geocode()): the EKB-only
cadastral matchers _cadastral_house_match / _cadastral_forward_sync ran
unconditionally, so a non-EKB oblast autocomplete query could surface an
EKB building via street+house collision. Gated behind
`not _names_non_ekb_city(query)`, mirroring geocode()'s use_local_ekb.
External suggest tiers (DaData/Yandex/Nominatim) untouched — still the
path for non-EKB autocomplete.
3. Extended SVERDLOVSK_OBLAST_CITIES with unambiguous, deal-heavy oblast
cities: алапаевск, сухой лог, кушва, красноуральск, карпинск, нижняя
тура, верхний тагил, нижние серги. Deliberately did NOT add "лесной"
(reviewer flagged as reviewer-optional, left to judgment): DB check
(ekb_geoportal_buildings) confirms a real EKB street named exactly
"Лесной" (3 buildings) — an exact whole-word collision indistinguishable
from ЗАТО Лесной by word-boundary matching alone (unlike "Серова"/"Серов",
which are different word forms). Adding it would misclassify a bare
EKB address ("Лесной, 5", no "Екатеринбург" mention) as non-EKB.
Tests: word-boundary EKB regression test, suggest() gating test, two new
gazetteer entries (Верхняя Пышма multi-word, Сухой Лог).
411 lines
15 KiB
Python
411 lines
15 KiB
Python
"""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()
|
||
|
||
|
||
# ── 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()
|