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
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, Сухой Лог).
325 lines
15 KiB
Python
325 lines
15 KiB
Python
"""Тесты для ЕКБ/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
|