All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
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 2m44s
`_cadastral_house_match` сравнивал дом только по ЦИФРАМ: литера была
опциональна в WHERE (`[а-яё]?`) и участвовала лишь как tie-break в
ORDER BY. Баг двусторонний и молчаливый:
«Новгородцевой 13б» → «дом 13» (дома 13б в реестре нет вообще)
«Малышева 30» → «д. 30-б» (обратное направление, живой прод-кейс)
Оба результата возвращались с `confidence="exact"` и оседали в
`geocode_cache` на 90 дней — пользователь получал оценку ЧУЖОГО здания,
помеченную как точная.
Почему так: номер дома теперь сравнивается РАВЕНСТВОМ нормализованных
форм. Обе стороны приводятся к одному канону (`13б` / `13 б` / `13-б` /
`13Б` → `13б`): запрос — существующим `_norm_house`, реестр — тем же
выражением на стороне Postgres. Дешёвый regex-anchor по цифрам оставлен
prefilter'ом (пушится в FDW, режет «(1-83)»-диапазоны), но корректность
теперь на равенстве, а не на нём.
Побочно закрыты той же логикой:
• «58» больше не матчит «58/3», «13» не матчит «130» — угловой номер
это часть номера дома, а не мусор;
• маркер дома ищется только с начала слова (`\m`), иначе «проезд 8
Марта, д 5» давал дом «8» — старый `д\.?` ловил «д» внутри «проезд»
(20 таких строк реестра были достижимы ТОЛЬКО через этот баг);
• номер дома больше не конкатенируется в regex — regex-injection
поверхность сузилась до цифр prefilter'а.
Промаха не превращаем в тихую подстановку соседа: нет дома с нужной
литерой → None, дальше отрабатывают следующие тиры (forward → DaData →
Nominatim). Понижать confidence здесь нечем — `GeocodeSuggestion` его не
несёт, все локальные тиры хардкодят "exact"; честный фолбэк дешевле, чем
протаскивать новый канал уверенности через три тира.
DaData-тир, который должен подхватывать такие адреса, был мёртв: в
`locations` уходило `region="Свердловская область"`, тогда как DaData
хранит имя региона БЕЗ типа (`region="Свердловская"`, `region_type="обл"`).
Hard-filter не совпадал ни с чем и молча схлопывал выдачу в 0 подсказок
(замер на проде: 0 хитов против 5 с «Свердловская», первый — искомый
«д 13б» с fias_id). Добавлен warning на пустую выдачу под region-
констрейнтом, чтобы следующая такая регрессия не была невидимой.
Миграция 203 инвалидирует уже отравленные записи `geocode_cache` —
точечно (запрос с литерой, либо запрос без литеры с закэшированным
домом С литерой), а не весь кэш: полная очистка сожгла бы квоту DaData
на ре-резолв заведомо корректных адресов.
Проверено: SQL-выражение прогнано против живого реестра (47k строк) —
«13б»→∅, «13»→дом 13, «30»→сооружение 30 (не «30-б»), «23б»→«д. 23-б»;
предикат миграции — против таблицы кейсов (4 delete / 8 keep).
Полный pytest: 1 failed, 3123 passed — падение
`test_search_api.py::test_search_cache_hit` (401) воспроизводится и на
чистом main (baseline 1 failed, 3081 passed), к этому изменению
отношения не имеет.
НЕ проверено вживую: ответ DaData с новым region-констрейнтом (нет
доступа к прод-токену из этой сессии) — опираюсь на замер из отчёта об
issue и на модель данных DaData.
855 lines
38 KiB
Python
855 lines
38 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 re
|
||
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
|
||
_SQL_HOUSE_TOKEN_RE,
|
||
SVERDLOVSK_OBLAST_REGION,
|
||
GeocodeResult,
|
||
GeocodeSuggestion,
|
||
_cadastral_house_match,
|
||
_dadata_suggest,
|
||
_norm_house,
|
||
_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 _house_match_params(house: str, street: str = "космонавтов") -> dict:
|
||
"""Вызывает матчер с mock-сессией и возвращает bound-params запроса."""
|
||
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, street, house)
|
||
|
||
# second positional arg to execute() is the bound-params dict
|
||
return db.execute.call_args.args[1]
|
||
|
||
|
||
def test_house_match_passes_full_normalized_house_not_just_digits() -> None:
|
||
"""house='26а' → в запрос уходит ПОЛНЫЙ номер '26а', а не только цифры '26'.
|
||
|
||
Регрессия-гард на исходный баг: раньше литера отрезалась (`house_digits`
|
||
= '26') и в WHERE была опциональна, поэтому «26а» матчился на дом «26».
|
||
Цифры остаются отдельным параметром — но только как дешёвый prefilter.
|
||
"""
|
||
params = _house_match_params("26а")
|
||
|
||
assert params["house_norm"] == "26а"
|
||
assert params["house_digits"] == "26" # prefilter only
|
||
assert params["street"] == "космонавтов"
|
||
# Литера больше не «подсказка для сортировки» — старый параметр ушёл.
|
||
assert "house_full" not in params
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("raw_house", "expected_norm"),
|
||
[
|
||
("13б", "13б"),
|
||
("13 б", "13б"),
|
||
("13-б", "13б"),
|
||
("13Б", "13б"),
|
||
("13 Б", "13б"),
|
||
("13", "13"),
|
||
],
|
||
)
|
||
def test_house_match_normalizes_letter_spellings(raw_house: str, expected_norm: str) -> None:
|
||
"""«13б» / «13 б» / «13-б» / «13Б» — одна и та же литера, один канон."""
|
||
assert _house_match_params(raw_house, street="новгородцевой")["house_norm"] == expected_norm
|
||
|
||
|
||
def test_house_match_sql_compares_house_by_equality() -> None:
|
||
"""SQL сравнивает нормализованный номер РАВЕНСТВОМ, а не «литера опциональна».
|
||
|
||
Структурный гард: если кто-то вернёт матч по цифрам с опциональной литерой
|
||
(`[а-яё]?` в WHERE как единственная проверка дома), тест упадёт.
|
||
"""
|
||
db = MagicMock()
|
||
db.execute.return_value.first.return_value = None
|
||
_cadastral_house_match(db, "новгородцевой", "13б")
|
||
|
||
sql = str(db.execute.call_args.args[0])
|
||
assert "= CAST(:house_norm AS text)" in sql
|
||
# tie-break по литере в ORDER BY больше не решает корректность
|
||
assert "house_full" not in sql
|
||
|
||
|
||
# ── 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"),
|
||
):
|
||
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()
|
||
|
||
|
||
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"),
|
||
):
|
||
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()
|
||
|
||
|
||
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._nominatim_lookup",
|
||
new_callable=AsyncMock,
|
||
return_value=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._nominatim_lookup",
|
||
new_callable=AsyncMock,
|
||
return_value=nominatim_result,
|
||
) as mock_nominatim,
|
||
):
|
||
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._nominatim_lookup",
|
||
new_callable=AsyncMock,
|
||
return_value=nominatim_result,
|
||
) as mock_nominatim,
|
||
):
|
||
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._nominatim_lookup",
|
||
new_callable=AsyncMock,
|
||
return_value=rezh_result,
|
||
),
|
||
):
|
||
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,
|
||
):
|
||
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()
|
||
|
||
|
||
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,
|
||
):
|
||
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()
|
||
|
||
|
||
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
|
||
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
|
||
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()
|
||
|
||
|
||
# ── House-letter matching semantics ─────────────────────────────────────────
|
||
# Само сравнение дома выполняет Postgres, поэтому здесь — зеркало SQL-выражения
|
||
# на Python. Паттерн НЕ дублируется: он выводится из той же константы
|
||
# `_SQL_HOUSE_TOKEN_RE`, что уходит в запрос (Postgres `\m` = «начало слова»
|
||
# ≡ Python `\b` перед словесным символом). Правка SQL-регекспа автоматически
|
||
# меняет и эти проверки — рассинхрон невозможен.
|
||
# Строки-адреса — реальные формы `readable_address` из gendesign_cad_buildings.
|
||
|
||
|
||
def _sql_house_token(readable_address: str) -> str:
|
||
"""Зеркало `_SQL_HOUSE_TOKEN_NORM`: извлечь номер дома и привести к канону."""
|
||
py_pattern = _SQL_HOUSE_TOKEN_RE.replace("\\m", "\\b")
|
||
m = re.search(py_pattern, readable_address, re.IGNORECASE)
|
||
token = (m.group(1) if m else "").lower()
|
||
token = re.sub(r"\s", "", token)
|
||
return re.sub(r"-([а-яё])", r"\1", token)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("readable_address", "expected"),
|
||
[
|
||
# Литера в трёх написаниях + регистр → один канон
|
||
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7б", "7б"),
|
||
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 23-б", "23б"),
|
||
("Свердловская область, г. Екатеринбург, ул. X, д. 18 б", "18б"),
|
||
("Свердловская область, г. Екатеринбург, ул. X, д. 13Б", "13б"),
|
||
# Без литеры
|
||
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 13", "13"),
|
||
("Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13", "13"),
|
||
("Российская Федерация, город Екатеринбург, улица Малышева, сооружение 30", "30"),
|
||
("Российская Федерация, город Екатеринбург, улица Малышева, строение 30 в", "30в"),
|
||
# Хвосты, которые литерой НЕ являются
|
||
("Свердловская область, г. Екатеринбург, ул. X, д. 11 (кв. 1-150)", "11"),
|
||
("Свердловская область, г. Екатеринбург, ул. X, д. 25, корп. 1", "25"),
|
||
("Российская Федерация, город Екатеринбург, улица X, дом 102 корпус 1", "102"),
|
||
("Свердловская область, г. Екатеринбург, ул. X, д. 16 угол улица Титова", "16"),
|
||
# Угловые/корпусные номера — ЧАСТЬ номера, не отбрасываются
|
||
("Свердловская область, г. Екатеринбург, ул. X, д. 58/3", "58/3"),
|
||
("Свердловская область, г. Екатеринбург, ул. X, д. 36/24а", "36/24а"),
|
||
# Дефис перед ЦИФРОЙ не схлопывается (иначе «64-2» стало бы домом «642»)
|
||
("Свердловская область, г. Екатеринбург, ул. X, д. 64-2", "64-2"),
|
||
("Свердловская область, г. Екатеринбург, ул. X, д. 642", "642"),
|
||
# Маркер только с начала слова: «проезд» не даёт дом «8»
|
||
("Свердловская область, г Екатеринбург, проезд 8 Марта, д 5", "5"),
|
||
("Свердловская область, г Екатеринбург, ул Привокзальная, д 22", "22"),
|
||
# Нет дом-маркера → номер не извлекаем (адрес недостижим этим тиром)
|
||
("Свердловская область, город Екатеринбург, проезд 4-й ЕКАД Южный", ""),
|
||
],
|
||
)
|
||
def test_sql_house_token_extraction(readable_address: str, expected: str) -> None:
|
||
assert _sql_house_token(readable_address) == expected
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("query_house", "readable_address", "should_match", "label"),
|
||
[
|
||
# ── Прод-баг #1: «Новгородцевой 13б» отдавал дом 13 как exact ──────
|
||
(
|
||
"13б",
|
||
"Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13",
|
||
False,
|
||
"запрос С литерой не берёт дом БЕЗ литеры",
|
||
),
|
||
# ── Прод-баг #2 (обратный): «Малышева 30» отдавал «д. 30-б» ────────
|
||
(
|
||
"30",
|
||
"Свердловская область, г. Екатеринбург, ул. Малышева, д. 30-б",
|
||
False,
|
||
"запрос БЕЗ литеры не берёт дом С литерой",
|
||
),
|
||
(
|
||
"7б",
|
||
"Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7в",
|
||
False,
|
||
"другая литера не матчится",
|
||
),
|
||
# ── Позитив: литера совпала во всех написаниях реестра ─────────────
|
||
("7б", "Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7б", True, "«13б»"),
|
||
(
|
||
"23б",
|
||
"Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 23-б",
|
||
True,
|
||
"«13-б»",
|
||
),
|
||
("18б", "Свердловская область, г. Екатеринбург, ул. X, д. 18 б", True, "«13 б»"),
|
||
("13б", "Свердловская область, г. Екатеринбург, ул. X, д. 13Б", True, "регистр"),
|
||
# ── Позитив: без литеры ────────────────────────────────────────────
|
||
(
|
||
"13",
|
||
"Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13",
|
||
True,
|
||
"«дом N»",
|
||
),
|
||
(
|
||
"30",
|
||
"Российская Федерация, город Екатеринбург, улица Малышева, сооружение 30",
|
||
True,
|
||
"«сооружение N»",
|
||
),
|
||
# Префикс числа не считается совпадением
|
||
("13", "Свердловская область, г. Екатеринбург, ул. X, д. 130", False, "13 ≠ 130"),
|
||
# Угловой номер не подменяет простой
|
||
("58", "Свердловская область, г. Екатеринбург, ул. X, д. 58/3", False, "58 ≠ 58/3"),
|
||
],
|
||
)
|
||
def test_house_letter_match_semantics(
|
||
query_house: str, readable_address: str, should_match: bool, label: str
|
||
) -> None:
|
||
"""Равенство нормализованных номеров — обе стороны приводятся к одному канону."""
|
||
matched = _sql_house_token(readable_address) == _norm_house(query_house)
|
||
assert matched is should_match, label
|
||
|
||
|
||
def test_query_letter_house_falls_through_instead_of_returning_neighbour() -> None:
|
||
"""Нет дома с литерой → None (не «похожий» дом) → работают следующие тиры.
|
||
|
||
Ключевое свойство фикса: молчаливая подмена соседнего здания здесь
|
||
помечалась бы `confidence="exact"` и кэшировалась на 90 дней.
|
||
"""
|
||
db = MagicMock()
|
||
db.execute.return_value.first.return_value = None # дома «13б» в реестре нет
|
||
|
||
assert _cadastral_house_match(db, "новгородцевой", "13б") is None
|
||
assert db.execute.call_args.args[1]["house_norm"] == "13б"
|
||
|
||
|
||
async def test_geocode_letter_house_miss_reaches_nominatim() -> None:
|
||
"""«Новгородцевой 13б» без хита в реестре доходит до Nominatim, а не
|
||
возвращает дом 13 с `confidence="exact"`."""
|
||
db = MagicMock()
|
||
nominatim_result = GeocodeResult(
|
||
lat=56.82,
|
||
lon=60.68,
|
||
full_address="ул. Новгородцевой, 13б, Екатеринбург",
|
||
provider="nominatim",
|
||
confidence="approximate",
|
||
)
|
||
|
||
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=[]),
|
||
patch("app.services.geocoder._cache_put"),
|
||
patch(
|
||
"app.services.geocoder._nominatim_lookup",
|
||
new_callable=AsyncMock,
|
||
return_value=nominatim_result,
|
||
) as mock_nominatim,
|
||
):
|
||
result = await geocode("Екатеринбург, Новгородцевой 13б", db)
|
||
|
||
assert result is not None
|
||
assert result.confidence == "approximate"
|
||
assert result.lat == pytest.approx(56.82)
|
||
mock_house.assert_called_once()
|
||
# в матчер ушёл ПОЛНЫЙ номер с литерой
|
||
assert mock_house.call_args.args[2] == "13б"
|
||
mock_nominatim.assert_called_once()
|
||
|
||
|
||
def test_parse_street_house_keeps_letter_in_all_spellings() -> None:
|
||
"""Парсер отдаёт литеру матчеру в каноне — иначе строгий матч бесполезен."""
|
||
assert _parse_street_house("Новгородцевой 13б") == ("новгородцевой", "13б")
|
||
assert _parse_street_house("Новгородцевой 13-б") == ("новгородцевой", "13б")
|
||
assert _parse_street_house("Новгородцевой 13 Б") == ("новгородцевой", "13б")
|
||
assert _parse_street_house("ул. Новгородцевой, д. 13б") == ("новгородцевой", "13б")
|
||
assert _parse_street_house("Новгородцевой 13") == ("новгородцевой", "13")
|
||
|
||
|
||
# ── DaData region constraint ────────────────────────────────────────────────
|
||
|
||
|
||
def test_dadata_region_constant_has_no_region_type() -> None:
|
||
"""DaData `locations.region` сравнивается с именем БЕЗ типа.
|
||
|
||
«Свердловская область» — hard-filter, который не совпадает ни с чем и молча
|
||
даёт 0 подсказок (прод-баг). Тип живёт в `region_type`/`region_with_type`.
|
||
"""
|
||
assert SVERDLOVSK_OBLAST_REGION == "Свердловская"
|
||
lowered = SVERDLOVSK_OBLAST_REGION.lower()
|
||
for type_word in ("область", "обл", "край", "респ"):
|
||
assert type_word not in lowered, f"тип региона {type_word!r} ломает locations-фильтр"
|
||
|
||
|
||
async def test_dadata_suggest_passes_region_without_type() -> None:
|
||
"""`_dadata_suggest` отдаёт в DaData именно region-константу (не город)."""
|
||
with patch(
|
||
"app.services.geocoder.dadata.suggest_addresses",
|
||
new_callable=AsyncMock,
|
||
return_value=[],
|
||
) as mock_suggest:
|
||
assert await _dadata_suggest("Новгородцевой 13б", limit=5) == []
|
||
|
||
kwargs = mock_suggest.call_args.kwargs
|
||
assert kwargs["region"] == "Свердловская"
|
||
assert kwargs["city"] is None
|