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