gendesign/tradein-mvp/backend/tests/test_geocoder_bbox.py
bot-backend 720cee2c8e
All checks were successful
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 53s
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
fix(tradein/geocoder): gate EKB-local tiers for non-EKB oblast addresses (#11)
geocode() ran EKB-only local tiers (2a _geoportal_house_match on
ekb_geoportal_buildings, 2c _cadastral_house_match, 2d
_cadastral_forward_sync — all strictly EKB or EKB-dominated) BEFORE the
already oblast-aware external providers (Yandex/Nominatim via OBLAST66_BBOX
+ region cross-check, shipped in c0cbdc2f). _parse_street_house drops the
city, so any non-EKB oblast street+house that collides with an EKB building
(e.g. "проспект Ленина 1" exists in both Nizhny Tagil and EKB) short-circuited
to EKB coordinates.

Add _names_non_ekb_city() — reuses the existing SVERDLOVSK_OBLAST_CITIES
gazetteer (minus Ekaterinburg) and word-boundary matching from
_has_oblast_marker/_DISTRICT_PREFIXES — to detect when an address explicitly
names a different oblast city. Gate tiers 2a/2c/2d behind
`use_local_ekb = not _names_non_ekb_city(address)`; EKB/bare addresses keep
the exact same code path (byte-identical ordering/logic, only additive
gating). Non-EKB addresses fall straight through to Yandex/Nominatim, which
already handle oblast-wide geocoding correctly.

Verified gendesign_cad_buildings is 99.85% EKB-scoped (47043/47111 rows);
ekb_geoportal_buildings is 100% EKB by construction — gating loses no
meaningful local coverage for non-EKB cities.

Follow-up (not in this PR): geocode_cache has rows poisoned by the old
behavior (non-EKB addresses cached with EKB coordinates) — needs a purge.
2026-07-12 23:45:40 +03:00

316 lines
14 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),
],
)
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