"""Тесты для ЕКБ/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