gendesign/tradein-mvp/backend/tests/test_geocoder_city_substitution.py
bot-backend 677fcb749c
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
fix(tradein/geocoder): инвариант «чужой топоним + результат внутри ЕКБ = недостоверно» (#2590) (#2837)
2026-08-12 14:26:58 +00:00

277 lines
14 KiB
Python
Raw Permalink 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.

"""Тесты #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"