gendesign/tradein-mvp/backend/tests/test_geocoder_bbox.py
bot-backend 8fdcd8df62
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
fix(tradein/geocoder): word-boundary EKB short-circuit + gate suggest() + gazetteer (#11)
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, Сухой Лог).
2026-07-13 00:06:19 +03:00

325 lines
15 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 / _yandex_lookup accept-сайты — two-pass tie-break (tight ЕКБ
приоритетнее) + region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
"""
from unittest.mock import patch
import httpx
import pytest
from app.services.geocoder import (
EKB_BBOX_TIGHT,
EKB_BBOX_WIDE,
OBLAST66_BBOX,
_has_oblast_marker,
_names_non_ekb_city,
_nominatim_query,
_yandex_lookup,
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 /
test_yandex_lookup_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
# ── 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
def _yandex_client_factory(transport: httpx.MockTransport):
"""Drop-in replacement для httpx.AsyncClient внутри geocoder._yandex_lookup."""
def factory(*_: object, **__: object) -> httpx.AsyncClient:
return _REAL_ASYNC_CLIENT(transport=transport)
return factory
def _yandex_geo_object(lat: str, lon: str, admin_area: str | None) -> dict:
meta: dict = {"precision": "exact", "text": "тест"}
if admin_area is not None:
meta["AddressDetails"] = {
"Country": {"AdministrativeArea": {"AdministrativeAreaName": admin_area}}
}
return {
"GeoObject": {
"Point": {"pos": f"{lon} {lat}"},
"metaDataProperty": {"GeocoderMetaData": meta},
}
}
def _yandex_payload(members: list[dict]) -> dict:
return {"response": {"GeoObjectCollection": {"featureMember": members}}}
async def test_yandex_lookup_prefers_tight_ekb_over_oblast_rank0() -> None:
"""Тот же tie-break сценарий, что и для Nominatim, но для Yandex top-5."""
members = [
_yandex_geo_object("59.6297", "60.5541", "Свердловская область"), # Серов, rank0
_yandex_geo_object("56.838", "60.605", "Свердловская область"), # ЕКБ tight, rank1
]
payload = _yandex_payload(members)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
result = await _yandex_lookup("Серова 27", "fake-key")
assert result is not None
assert result.lat == pytest.approx(56.838)
assert result.lon == pytest.approx(60.605)
async def test_yandex_lookup_rejects_out_of_region_only_candidate() -> None:
"""Единственный кандидат — Тюмень (bbox admits координатно, регион другой) →
region cross-check отсекает даже в ultimate as-is fallback → None."""
members = [_yandex_geo_object("57.1522", "65.5272", "Тюменская область")]
payload = _yandex_payload(members)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=payload)
transport = httpx.MockTransport(handler)
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
result = await _yandex_lookup("какой-то мусорный адрес", "fake-key")
assert result is None