All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m11s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 2m9s
277 lines
14 KiB
Python
277 lines
14 KiB
Python
"""Тесты #2590 — постфактум-инвариант «назван чужой топоним + результат внутри ЕКБ».
|
||
|
||
Гейты #2582/#2589 стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only локальные реестры.
|
||
Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, тот не
|
||
находит Режа и отдаёт улицу Ленина в Екатеринбурге — с координатами ЕКБ, пометкой
|
||
«точно» и записью в кэш на 90 дней. Замер на проде (`geocode_cache`, 9514 строк,
|
||
2026-08-12): **1251 запись** подпадает под инвариант — 1245 `provider=yandex`,
|
||
6 `nominatim`, 0 у локальных тиров; 365 разных населённых пунктов.
|
||
|
||
Инвариант проверяется ПОСЛЕ ответа провайдера, потому что на входе «Реж» (город)
|
||
и «Малышева» (улица) структурно неразличимы — оба «слово до запятой». После
|
||
ответа различимы: провайдер сам пишет, какой НП он использовал.
|
||
|
||
Адреса и full_address в тестах — живые строки из прод-кэша (не выдуманные).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import os
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||
|
||
import pytest
|
||
|
||
from app.services.geocoder import (
|
||
GeocodeResult,
|
||
GeocodeSuggestion,
|
||
_city_substituted,
|
||
_claimed_locality,
|
||
geocode,
|
||
)
|
||
|
||
# ── _claimed_locality: какой НП назван в тексте ──────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"address,expected",
|
||
[
|
||
# НП назван явно
|
||
("реж, ленина", "реж"),
|
||
("арамиль, красноармейская ул, 118дк1", "арамиль"),
|
||
("пос. кедровка, советская ул.,5", "кедровка"), # тип «пос.» отброшен, имя осталось
|
||
# Уровни выше НП пропускаются, НП находится дальше
|
||
("свердловская обл., г.о. рефтинский", "рефтинский"),
|
||
("россия, свердловская область, реж, ленина 5", "реж"),
|
||
# НП НЕ назван — основной трафик формы оценки, инвариант не применяется
|
||
("малышева 30", None),
|
||
("ул. титова/монтерская/смоленская, стр. 2", None),
|
||
("улица декабристов, 16-18литж · р-н октябрьский", None),
|
||
("мкр-н широкая речка, ул. анатолия муранова,18", None),
|
||
# Перекрёсток: второй уличный сегмент НЕ должен читаться как топоним
|
||
("малышева, мопра", "малышева"),
|
||
],
|
||
)
|
||
def test_claimed_locality(address: str, expected: str | None) -> None:
|
||
assert _claimed_locality(address) == expected
|
||
|
||
|
||
# ── _city_substituted: подмена vs честный ответ ──────────────────────────────
|
||
|
||
|
||
def _res(full_address: str, lat: float, lon: float) -> GeocodeResult:
|
||
return GeocodeResult(
|
||
lat=lat, lon=lon, full_address=full_address, provider="nominatim", confidence="exact"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"address,full_address,lat,lon",
|
||
[
|
||
# Живые записи прод-кэша из тела #2590 (все provider=yandex)
|
||
(
|
||
"реж, ленина",
|
||
"Россия, Свердловская область, Екатеринбург, улица Ленина",
|
||
56.83953,
|
||
60.61244,
|
||
),
|
||
(
|
||
"реж, краснофлотцев",
|
||
"Россия, Свердловская область, Екатеринбург, улица Краснофлотцев",
|
||
56.88996,
|
||
60.63184,
|
||
),
|
||
(
|
||
"арамиль, заводская",
|
||
"Россия, Свердловская область, Екатеринбург, Заводская улица",
|
||
56.83043,
|
||
60.55170,
|
||
),
|
||
(
|
||
"свердловская обл., г.о. рефтинский",
|
||
"Россия, Свердловская область, Екатеринбург",
|
||
56.83744,
|
||
60.59764,
|
||
),
|
||
# Города вне списка 37 и без структурной приставки — дыра, оставленная #2589
|
||
(
|
||
"талица, кузнецова",
|
||
"Россия, Свердловская область, Екатеринбург, улица Кузнецова",
|
||
56.84226,
|
||
60.64557,
|
||
),
|
||
(
|
||
"верхотурье, герцена",
|
||
"Россия, Свердловская область, Екатеринбург, улица Герцена",
|
||
56.744397,
|
||
60.677299,
|
||
),
|
||
# Посёлок В ЧЕРТЕ ЕКБ, но улица подменена: настоящая Кедровка в 20 км от
|
||
# улицы Советской, куда провайдер поставил точку (живая запись прода).
|
||
(
|
||
"пос. кедровка, советская ул.,5",
|
||
"Россия, Свердловская область, Екатеринбург, Советская улица, 5",
|
||
56.8506,
|
||
60.6282,
|
||
),
|
||
],
|
||
)
|
||
def test_substitution_detected(address: str, full_address: str, lat: float, lon: float) -> None:
|
||
assert _city_substituted(address, _res(full_address, lat, lon)) is True
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"address,full_address,lat,lon",
|
||
[
|
||
# Посёлок в черте ЕКБ, ответ ЧЕСТНЫЙ — имя дожило до full_address.
|
||
# Тот же вход, что и в подмене выше: разделяет их не география, а
|
||
# сохранность топонима (граница из #2777 — посёлки не ловятся
|
||
# «городским» признаком, и здесь ловить их и не нужно).
|
||
(
|
||
"пос. кедровка, советская ул.,5",
|
||
"5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург",
|
||
56.9902,
|
||
60.7166,
|
||
),
|
||
(
|
||
"шувакиш, ленина 4",
|
||
"4, улица Ленина, Шувакиш, Железнодорожный район, Екатеринбург",
|
||
56.9295,
|
||
60.4938,
|
||
),
|
||
# НП не назван вообще — основной трафик формы, ЕКБ как рабочая гипотеза
|
||
(
|
||
"малышева 30",
|
||
"Россия, Свердловская область, Екатеринбург, улица Малышева, 30",
|
||
56.8389,
|
||
60.6057,
|
||
),
|
||
# Явный Екатеринбург в адресе — не подмена по определению
|
||
("екатеринбург, ул. репина, 99", "Репина, 99", 56.8207, 60.5686),
|
||
("г екатеринбург, ул сурикова, д 47", "Сурикова, 47", 56.8156, 60.5943),
|
||
# Честный ответ по другому городу области: провайдер назвал НП, координаты
|
||
# в его собственном bbox — под инвариант не подпадает
|
||
("нижний тагил, ленина 1", "1, проспект Ленина, Нижний Тагил", 57.9101, 59.9813),
|
||
# Ответ внутри ЕКБ-bbox, но провайдер назвал ДРУГОЙ НП (Берёзовский лежит
|
||
# внутри щедрого EKB_BBOX_TIGHT) — «внутри Екатеринбурга» не подтверждено
|
||
(
|
||
"режевской, ленина",
|
||
"Россия, Свердловская область, Берёзовский, улица Ленина",
|
||
56.9088,
|
||
60.8107,
|
||
),
|
||
# Перекрёсток двух улиц: провайдер взял первую — потеря второй улицы не
|
||
# является подменой города
|
||
(
|
||
"малышева, мопра",
|
||
"Россия, Свердловская область, Екатеринбург, улица Малышева",
|
||
56.8389,
|
||
60.6057,
|
||
),
|
||
# Тот же НП, но за пределами ЕКБ — инвариант молчит
|
||
("реж, ленина", "Россия, Свердловская область, Реж, улица Ленина", 57.3722, 61.3897),
|
||
],
|
||
)
|
||
def test_no_substitution(address: str, full_address: str, lat: float, lon: float) -> None:
|
||
assert _city_substituted(address, _res(full_address, lat, lon)) is False
|
||
|
||
|
||
# ── geocode(): сквозной эффект ───────────────────────────────────────────────
|
||
|
||
|
||
@contextlib.contextmanager
|
||
def _tiers(*, cached=None, nominatim=None, geoportal=None):
|
||
with contextlib.ExitStack() as stack:
|
||
stack.enter_context(patch("app.services.geocoder._cache_get", return_value=cached))
|
||
stack.enter_context(patch("app.services.geocoder._cache_put"))
|
||
stack.enter_context(
|
||
patch("app.services.geocoder._geoportal_house_match", return_value=geoportal)
|
||
)
|
||
stack.enter_context(
|
||
patch("app.services.geocoder._cadastral_house_match", return_value=None)
|
||
)
|
||
stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
|
||
stack.enter_context(
|
||
patch("app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim))
|
||
)
|
||
stack.enter_context(patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()))
|
||
yield
|
||
|
||
|
||
async def test_geocode_downgrades_substituted_result() -> None:
|
||
"""«Реж, Ленина 5» → провайдер отдал улицу Ленина в ЕКБ с confidence=exact.
|
||
|
||
Координаты остаются (честная деградация, не отказ), но confidence падает до
|
||
`locality` — а это уже рабочий контракт: `estimator._geocode_is_coarse`
|
||
включает #693 coarse-downgrade, `tasks.geocode_missing` ставит листингу
|
||
`geo_precision='city'`, что исключает его из пула аналогов.
|
||
"""
|
||
db = MagicMock()
|
||
substituted = GeocodeResult(
|
||
lat=56.83953,
|
||
lon=60.61244,
|
||
full_address="Россия, Свердловская область, Екатеринбург, улица Ленина, 5",
|
||
provider="nominatim",
|
||
confidence="exact",
|
||
)
|
||
with _tiers(nominatim=substituted):
|
||
result = await geocode("Реж, Ленина 5", db)
|
||
|
||
assert result is not None
|
||
assert result.confidence == "locality"
|
||
assert result.city_ambiguous is True
|
||
assert result.lat == pytest.approx(56.83953) # координаты НЕ выброшены
|
||
|
||
|
||
async def test_geocode_poisoned_cache_hit_downgraded() -> None:
|
||
"""Отравленная запись кэша (их 1251 на проде) продолжает отдаваться, но уже
|
||
НЕ как точная — без единого DELETE в `geocode_cache`."""
|
||
db = MagicMock()
|
||
poisoned = GeocodeResult(
|
||
lat=56.83744,
|
||
lon=60.59764,
|
||
full_address="Россия, Свердловская область, Екатеринбург",
|
||
provider="cache",
|
||
confidence="exact",
|
||
)
|
||
with _tiers(cached=poisoned):
|
||
result = await geocode("Арамиль, Красноармейская ул, 118дк1", db)
|
||
|
||
assert result is not None
|
||
assert result.confidence == "locality"
|
||
assert result.city_ambiguous is True
|
||
|
||
|
||
async def test_geocode_ekb_address_unaffected() -> None:
|
||
"""Контроль: реальный ЕКБ-адрес через локальный тир (full_address без города)
|
||
остаётся `exact` — инвариант не трогает основной трафик."""
|
||
db = MagicMock()
|
||
hit = GeocodeSuggestion(
|
||
label="Малышева, 30", full_address="Малышева, 30", lat=56.8389, lon=60.6057, kind="house"
|
||
)
|
||
with _tiers(geoportal=hit):
|
||
result = await geocode("Екатеринбург, Малышева 30", db)
|
||
|
||
assert result is not None
|
||
assert result.confidence == "exact"
|
||
assert result.city_ambiguous is False
|
||
|
||
|
||
async def test_geocode_settlement_inside_ekb_kept_exact() -> None:
|
||
"""Посёлок в городской черте (Кедровка) с ЧЕСТНЫМ ответом провайдера не
|
||
отбраковывается — граница, о которой предупреждает #2777."""
|
||
db = MagicMock()
|
||
honest = GeocodeResult(
|
||
lat=56.9902,
|
||
lon=60.7166,
|
||
full_address="5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург",
|
||
provider="nominatim",
|
||
confidence="exact",
|
||
)
|
||
with _tiers(nominatim=honest):
|
||
result = await geocode("пос. Кедровка, Советская ул., 5", db)
|
||
|
||
assert result is not None
|
||
assert result.confidence == "exact"
|