gendesign/tradein-mvp/backend/tests/test_geocoder_bbox.py
bot-backend 3d075632a9
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
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 2m28s
chore(tradein/geocoder): удалить Яндекс-геокодер (#2593)
Убирает ядро Yandex Geocoder (forward/reverse/suggest lookups + region-check
+ bias-хелперы + EKB_BBOX dict) из app/services/geocoder.py — Yandex demo-key
исчерпан, Nominatim/DaData/локальные ЕКБ-тиры (geoportal/cadastral) остаются
единственными живыми провайдерами. Цепочка тиров после удаления: кэш →
геопортал ЕКБ → кадастр (house-match) → кадастр (raw) → Nominatim; в
подсказках дополнительно DaData.

НЕ затронуто (намеренно): Yandex.Недвижимость как источник объявлений
(source='yandex', yandex_city_sweep*, providers/yandex/serp.py:geocoderAddress),
Avito geocoder (providers/avito/imv.py:_geocode), EKB_BBOX_TIGHT/WIDE,
_nominatim_region_ok, scripts/*_yandex_reverse.py и их тесты, tests/fixtures/
yandex_geocode_sample.json (всё ещё используется test_audit_address_mismatch.py).

_SNAP_PRECISIONS оставлен с "exact" (недостижимо без Yandex-tier, но дёшево
хранить — parity с frontend MapPicker.tsx SNAP_PRECISIONS и не ломает
test_snap_precision_useful_exact_and_number).
2026-07-31 21:22:09 +03:00

418 lines
24 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.

"""Тесты для ЕКБ/oblast66 bbox-хелперов geocoder (#1871, oblast-66 expansion).
Покрывают:
- is_within_ekb_bbox_wide — ingest-guard (avito_detail координаты);
- is_within_ekb_bbox — geocoder tight-фильтр (поведение НЕ изменилось после DRY-рефактора);
- инвариант WIDE ⊇ TIGHT;
- OBLAST66_BBOX / is_within_oblast66_bbox — генеральный bbox всей области (region 66),
инвариант OBLAST66 ⊇ TIGHT, дальние города области, известное ограничение (Тюмень
внутри bbox координатно — компенсируется region cross-check на accept-сайтах);
- _has_oblast_marker — word-boundary matching (не substring);
- _nominatim_query accept-сайт — two-pass tie-break (tight ЕКБ приоритетнее) +
region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
"""
import httpx
import pytest
from app.services.geocoder import (
_LOCALITY_MARKER_RE,
EKB_BBOX_TIGHT,
EKB_BBOX_WIDE,
OBLAST66_BBOX,
_ekb_local_tiers_allowed,
_has_oblast_marker,
_names_non_ekb_city,
_names_unrecognized_locality,
_nominatim_query,
is_within_ekb_bbox,
is_within_ekb_bbox_wide,
is_within_oblast66_bbox,
)
# Центр ЕКБ (Плотинка) — внутри обоих bbox.
EKB_CENTER = (56.838, 60.605)
@pytest.mark.parametrize(
"lat,lon,expected",
[
(*EKB_CENTER, True), # центр ЕКБ
(56.797713, 60.609135, True), # реальная EKB-карточка из avito-fixture
(59.9386, 30.3141, False), # Питер (#1871)
(57.1530, 65.5343, False), # Тюмень (#1871)
(54.7388, 55.9721, False), # Уфа (#1871)
(57.0, 60.5, True), # приграничье севернее tight, но в wide (В.Пышма зона)
],
)
def test_is_within_ekb_bbox_wide(lat: float, lon: float, expected: bool) -> None:
assert is_within_ekb_bbox_wide(lat, lon) is expected
@pytest.mark.parametrize(
"lat,lon",
[
(56.6, 60.3), # юго-западный угол wide
(57.1, 60.9), # северо-восточный угол wide
(56.6, 60.9), # юго-восточный угол
(57.1, 60.3), # северо-западный угол
],
)
def test_wide_bbox_boundary_inclusive(lat: float, lon: float) -> None:
"""Границы wide-bbox включаются (inclusive)."""
assert is_within_ekb_bbox_wide(lat, lon) is True
def test_wide_bbox_just_outside() -> None:
"""Чуть за границей wide-bbox → False."""
assert is_within_ekb_bbox_wide(56.59, 60.605) is False # южнее
assert is_within_ekb_bbox_wide(57.11, 60.605) is False # севернее
assert is_within_ekb_bbox_wide(56.838, 60.29) is False # западнее
assert is_within_ekb_bbox_wide(56.838, 60.91) is False # восточнее
def test_tight_bbox_behavior_unchanged() -> None:
"""geocoder tight-фильтр: те же числа, что и до DRY-рефактора (60.40-60.85 / 56.65-56.95)."""
# внутри
assert is_within_ekb_bbox(56.838, 60.605) is True
# границы tight inclusive
assert is_within_ekb_bbox(56.65, 60.40) is True
assert is_within_ekb_bbox(56.95, 60.85) is True
# чуть за tight → False (но в wide)
assert is_within_ekb_bbox(56.64, 60.605) is False
assert is_within_ekb_bbox(56.96, 60.605) is False
assert is_within_ekb_bbox(56.838, 60.39) is False
assert is_within_ekb_bbox(56.838, 60.86) is False
def test_wide_strictly_contains_tight() -> None:
"""Инвариант: всё, что прошло бы tight, проходит и wide.
Гарантирует, что ingest-guard не режет ничего, что geocoder сам бы пропустил.
"""
t_lat_min, t_lat_max, t_lon_min, t_lon_max = EKB_BBOX_TIGHT
w_lat_min, w_lat_max, w_lon_min, w_lon_max = EKB_BBOX_WIDE
assert w_lat_min <= t_lat_min
assert w_lat_max >= t_lat_max
assert w_lon_min <= t_lon_min
assert w_lon_max >= t_lon_max
# точечная проверка на углах tight
for lat in (t_lat_min, t_lat_max):
for lon in (t_lon_min, t_lon_max):
assert is_within_ekb_bbox(lat, lon) is True
assert is_within_ekb_bbox_wide(lat, lon) is True
# ── OBLAST66_BBOX (region 66 expansion) ─────────────────────────────────────
def test_oblast66_strictly_contains_tight() -> None:
"""Инвариант: OBLAST66_BBOX ⊇ EKB_BBOX_TIGHT — генерализация не режет ЕКБ.
Мирроор test_wide_strictly_contains_tight, но для областного bbox.
"""
t_lat_min, t_lat_max, t_lon_min, t_lon_max = EKB_BBOX_TIGHT
o_lat_min, o_lat_max, o_lon_min, o_lon_max = OBLAST66_BBOX
assert o_lat_min <= t_lat_min
assert o_lat_max >= t_lat_max
assert o_lon_min <= t_lon_min
assert o_lon_max >= t_lon_max
for lat in (t_lat_min, t_lat_max):
for lon in (t_lon_min, t_lon_max):
assert is_within_ekb_bbox(lat, lon) is True
assert is_within_oblast66_bbox(lat, lon) is True
@pytest.mark.parametrize(
"lat,lon,label",
[
(60.6935, 60.4235, "Ивдель"),
(59.6297, 60.5541, "Серов"),
(58.0378, 65.2753, "Тавда"),
(56.6152, 57.7666, "Красноуфимск"),
],
)
def test_oblast66_accepts_far_towns(lat: float, lon: float, label: str) -> None:
"""Дальние города области (вне EKB tight/wide) — приняты oblast66-bbox."""
assert is_within_oblast66_bbox(lat, lon) is True, f"{label} должен быть внутри OBLAST66_BBOX"
def test_oblast66_bbox_admits_tyumen_by_design() -> None:
"""OBLAST66_BBOX — генеральный (щедрый) bbox, координатно admits Тюмень.
Это ЗНАЕМОЕ и намеренное ограничение самого bbox-хелпера (дешевле
false-positive у границы, чем false-negative на корректном адресе области).
Реальное отсечение Тюмени происходит на accept-сайте через region
cross-check — см. test_nominatim_query_rejects_out_of_region_only_candidate
ниже.
"""
assert is_within_oblast66_bbox(57.1530, 65.5343) is True
# ── _has_oblast_marker — word/phrase-boundary matching (не substring) ───────
@pytest.mark.parametrize(
"text,expected",
[
("екатеринбург, малышева 30", True),
("нижний тагил, ленина 10", True),
("свердловская область, серова 27", True),
("каменск-уральский, мира 1", True),
("заречный, мира 5", True), # ЗАТО-город (без district-префикса)
# false-positives, которые substring-match ловил ошибочно (#oblast66 bug):
("серова 27", False), # НЕ город "серов"
("ирбитская 5", False), # НЕ город "ирбит"
("асбестовский пер., 3", False), # НЕ город "асбест"
("невьянский пер., 10", False), # НЕ город "невьянск"
("богдановича, 22", False), # НЕ город "богданович"
("мкр заречный, мира 5", False), # район внутри города, не город-ЗАТО
("микрорайон заречный, мира 5", False),
("малышева 30", False), # обычный ЕКБ bare-адрес
],
)
def test_has_oblast_marker(text: str, expected: bool) -> None:
assert _has_oblast_marker(text) is expected
# ── _names_non_ekb_city — gate для EKB-only локальных tier'ов (#11) ─────────
@pytest.mark.parametrize(
"address,expected",
[
("Нижний Тагил, проспект Ленина, 1", True),
("г. Екатеринбург, проспект Ленина, 1", False),
("проспект Ленина, 1", False),
("Серова 27", False), # word-boundary — НЕ город "Серов"
("мкр Заречный, ул. Ленина 5", False), # район внутри города, не ЗАТО Заречный
("Каменск-Уральский, ул. Ленина 1", True),
# явное "Екатеринбург" перевешивает омоним соседнего города в названии
# ЖК/района внутри города (реальный false-positive из прод-кэша, #11):
("24, опалихинская улица, заречный, Верх-Исетский район, Екатеринбург", False),
# word-boundary на "екатеринбург" — "Екатеринбургское шоссе" НЕ город
# Екатеринбург (реальная улица в городах-спутниках, напр. Первоуральске):
("Первоуральск, Екатеринбургское шоссе, 5", True),
# расширенный gazetteer (deep-review fast-follow):
("Верхняя Пышма, ул. Ленина 5", True),
("Сухой Лог, ул. Мира 3", True),
],
)
def test_names_non_ekb_city(address: str, expected: bool) -> None:
assert _names_non_ekb_city(address) is expected
# ── _names_unrecognized_locality / _ekb_local_tiers_allowed (#2582) ─────────
# Fail-closed гейт: EKB-only локальные тиры (geoportal/cad_buildings) больше
# НЕ держатся на списке из 37 городов ("нет в списке → считаем ЕКБ"). Живые
# прод-случаи из geocode_cache (все получили координаты ЕКБ по ошибке до
# фикса): Ялта (другой регион), Трёхгорный (другой регион), Лёвиха/Реж
# (реальные города Свердловской области ВНЕ списка 37).
@pytest.mark.parametrize(
"address,expected",
[
# Живые прод-случаи (#2582) — региональный/settlement маркер есть,
# но это не Екатеринбург и не город из известного списка:
("республика крым, ялта, ул. репина, 18с2", True),
("челябинская обл., трёхгорный, ул. ленина, 15", True),
("свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8", True),
("свердловская обл., реж, ул. ленина, 5", True),
("ульяновская обл., димитровград, ул. менделеева, 11", True),
# известный город списка ловится отдельно (_names_non_ekb_city),
# эта функция на него не обязана реагировать без маркера:
("нижний тагил, ленина 10", False),
# города/региона вообще не названо — основной трафик, не топоним:
("малышева 30", False),
("серова 27", False), # word-boundary — не город "серов"
("проспект ленина, 1", False),
# явный Екатеринбург побеждает даже при наличии маркера "г":
("г. екатеринбург, ул. малышева, 30", False),
("г екатеринбург, ленинский р-н, ул серова, д 35", False),
# ── Изолирующие кейсы (#2589 M3) — параметризация выше зелёная и на
# маркер, и на «обл./республика/край» одновременно (все реальные
# прод-адреса их несут вместе); ниже — маркер БЕЗ регионального
# префикса, чтобы поймать регрессию именно сигнала-маркера (это и
# проехало у H1: мёртвый «с\.» был бы незамечен без таких кейсов).
(
"с. новоалексеевское, ул. 8 марта,37",
True,
), # живой инцидент (#2589 H1) — «с.» БЕЗ доп. маркера
("с. кашино, ул. ленина, 5", True), # изолированный «с.» (M3)
("д. родина, ул. мира, 1", True), # изолированный «д.» (M3, lookahead-фикс)
("пгт. шаля, ул. советская, 2", True), # изолированный «пгт.» (M3)
(
"пос. мичуринский, широкореченская ул., 45",
True,
), # пригородный посёлок ЕКБ — маркер закрывает гейт (M3, честно
# закреплено: даже для формально «своих» посёлков без «Екатеринбург»
# в тексте фолбэк на oblast-aware внешние провайдеры — не регрессия
# координат, просто не через fast-path geoportal)
("реж, ленина 5", False), # ИЗВЕСТНАЯ ДЫРА (#2589 M3): голый город вне
# списка 37 БЕЗ маркера — не ловится, уходит в ЕКБ-дефолт. Честно
# зафиксировано, не «фиксим» здесь (см. docstring — architectural
# follow-up отдельно, не через список городов).
# house-маркер НЕ должен матчить как топоним (регрессия #2582→#2589):
("ул. ленина, д. 5", False),
("д. 5", False),
("д. 5 стр. 2", False),
],
)
def test_names_unrecognized_locality(address: str, expected: bool) -> None:
assert _names_unrecognized_locality(address) is expected
@pytest.mark.parametrize(
"text,expected",
[
# Ровно набор из deep-review (#2589 H1) — «Проверь сам на живом Python».
("д. 5", False),
("д. 5 стр. 2", False),
("ул. ленина, д. 5", False),
("с. кашино", True),
("д. родина", True),
("пгт. шаля", True),
],
)
def test_locality_marker_re_isolated(text: str, expected: bool) -> None:
"""Regex-уровень, изолированно от `_names_unrecognized_locality`'s
Екатеринбург-early-return — прямая проверка `_LOCALITY_MARKER_RE` (#2589 M3/H1)."""
assert bool(_LOCALITY_MARKER_RE.search(text)) is expected
@pytest.mark.parametrize(
"address,expected",
[
# Живые прод-случаи (#2582) — гейт обязан закрыться:
("республика крым, ялта, ул. репина, 18с2", False),
("челябинская обл., трёхгорный, ул. ленина, 15", False),
("свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8", False),
("свердловская обл., реж, ул. ленина, 5", False),
# известный город региона 66 (список) — тоже закрыт:
("Нижний Тагил, проспект Ленина, 1", False),
("Каменск-Уральский, ул. Ленина 1", False),
# ЕКБ явно назван — гейт открыт (regardless маркера "г"):
("г. Екатеринбург, ул. Малышева, 30", True),
("620144, Свердловская обл, г Екатеринбург, Ленинский р-н, ул Серова, д 35", True),
# города/региона вообще не названо — ОСНОВНОЙ ТРАФИК (форма оценки без
# города) — дефолт ЕКБ должен остаться рабочим, иначе деградация #1:
("Малышева 30", True),
("Серова 27", True),
("Космонавтов 7б", True),
("проспект Ленина, 1", True),
# мкр-исключение (район внутри ЕКБ, не ЗАТО Заречный) — гейт открыт:
("мкр Заречный, ул. Ленина 5", True),
# изолирующий кейс (#2589 H1/M3) — «с.»-маркер БЕЗ доп. «обл.»/«республика»:
("с. Новоалексеевское, ул. 8 Марта,37", False),
# house-маркер «д.» НЕ закрывает гейт (регрессия H1-фикса):
("ул. Ленина, д. 5", True),
],
)
def test_ekb_local_tiers_allowed(address: str, expected: bool) -> None:
assert _ekb_local_tiers_allowed(address) is expected
# ── _ekb_local_tiers_allowed × city_hint (#2580 merge, #2582, #2589 M2) ─────
# Обе логики независимо закрывают гейт (OR трёх сигналов) — проверяем, что
# ни одна из веток не стала недостижимой после слияния #2580/#2582/#2589-M2:
# A. city_hint называет известный город региона 66 (не ЕКБ) → закрыто.
# A2. city_hint — ЛЮБАЯ непустая строка, отличная от "Екатеринбург" (в т.ч.
# НЕ из списка 37 — #2589 M2, было главной дырой: `city_hint="Реж"`
# раньше держал гейт открытым, geoportal отдавал точный ЕКБ-дом с
# `city_ambiguous=False` — уверенно-неверный результат) → закрыто.
# B. незнакомый топоним в ТЕКСТЕ адреса (маркер) → закрыто.
# C. ни того ни другого → открыто (дефолт ЕКБ).
# D. явный city_hint="Екатеринбург" (без конфликта в тексте) → открыто.
@pytest.mark.parametrize(
"address,city_hint,expected,label",
[
# A: hint называет известный не-ЕКБ город региона 66 → закрыто, даже
# если сам текст адреса города вообще не называет (#2580 C1).
("Ленина, 1", "Нижний Тагил", False, "A: hint=известный не-ЕКБ город"),
("Серова 27", "Каменск-Уральский", False, "A: hint=известный не-ЕКБ город (2)"),
# A2 (#2589 M2 — ключевой регрессионный кейс фикса): hint называет
# город, КОТОРОГО НЕТ в списке 37 (произвольный текст через API, не
# UI-selector) → тоже закрыто. До фикса `_names_non_ekb_city(city_hint)`
# давал False (Реж не в списке) → гейт был открыт.
("Ленина, 1", "Реж", False, "A2: hint=не-ЕКБ город ВНЕ списка 37"),
("Серова 27", "Ялта", False, "A2: hint=город другого региона"),
# B: незнакомый топоним в тексте (маркер) закрывает гейт независимо
# от hint (#2582) — hint здесь не задан, чистая проверка B.
("свердловская обл., реж, ул. ленина, 5", None, False, "B: маркер в тексте"),
# C: ни маркера в тексте, ни (не-ЕКБ) hint'а → открыто, основной трафик.
("Малышева 30", None, True, "C: ничего не названо"),
("Серова 27", "", True, "C: пустой hint == отсутствию hint'а"),
# D: явный city_hint="Екатеринбург" без конфликта в тексте → открыто —
# ключевая проверка, что #2580 C1 / #2589 M2 не сломали дефолтный
# ЕКБ-путь через hint.
("Ленина, 1", "Екатеринбург", True, "D: hint=Екатеринбург"),
("Серова 27", "екатеринбург", True, "D: hint=екатеринбург (регистр)"),
],
)
def test_ekb_local_tiers_allowed_with_city_hint(
address: str, city_hint: str | None, expected: bool, label: str
) -> None:
assert _ekb_local_tiers_allowed(address, city_hint) is expected, label
# ── Accept-site two-pass tie-break + region cross-check ─────────────────────
async def test_nominatim_query_prefers_tight_ekb_over_oblast_rank0() -> None:
"""rank0 — Серов (внутри OBLAST66, вне tight), rank1 — ЕКБ (tight).
Two-pass tie-break должен выбрать rank1 (tight), а не rank0, несмотря на то
что Nominatim ранжировал его вторым.
"""
payload = [
{"lat": "59.6297", "lon": "60.5541", "address": {"state": "Свердловская область"}},
{"lat": "56.838", "lon": "60.605", "address": {"state": "Свердловская область"}},
]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
item = await _nominatim_query(client, "Серова 27")
assert item is not None
assert float(item["lat"]) == pytest.approx(56.838)
assert float(item["lon"]) == pytest.approx(60.605)
async def test_nominatim_query_rejects_out_of_region_only_candidate() -> None:
"""Единственный кандидат — Тюмень (координатно внутри OBLAST66, но регион другой)
→ region cross-check отсекает его → None (не просто "первый как есть")."""
payload = [{"lat": "57.1522", "lon": "65.5272", "address": {"state": "Тюменская область"}}]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
item = await _nominatim_query(client, "какой-то мусорный адрес")
assert item is None
async def test_nominatim_query_accepts_oblast_wide_when_region_absent() -> None:
"""Нет tight-совпадения, `address.state` отсутствует (Nominatim не всегда его
отдаёт) — fallback на oblast-bbox (Серов), region cross-check не блокирует."""
payload = [{"lat": "59.6297", "lon": "60.5541"}]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport) as client:
item = await _nominatim_query(client, "Серов, Ленина 1")
assert item is not None
assert float(item["lat"]) == pytest.approx(59.6297)
_REAL_ASYNC_CLIENT = httpx.AsyncClient