All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
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 2m28s
Убирает ядро Yandex Geocoder (forward/reverse/suggest lookups + region-check + bias-хелперы + EKB_BBOX dict) из app/services/geocoder.py — Yandex demo-key исчерпан, Nominatim/DaData/локальные ЕКБ-тиры (geoportal/cadastral) остаются единственными живыми провайдерами. Цепочка тиров после удаления: кэш → геопортал ЕКБ → кадастр (house-match) → кадастр (raw) → Nominatim; в подсказках дополнительно DaData. НЕ затронуто (намеренно): Yandex.Недвижимость как источник объявлений (source='yandex', yandex_city_sweep*, providers/yandex/serp.py:geocoderAddress), Avito geocoder (providers/avito/imv.py:_geocode), EKB_BBOX_TIGHT/WIDE, _nominatim_region_ok, scripts/*_yandex_reverse.py и их тесты, tests/fixtures/ yandex_geocode_sample.json (всё ещё используется test_audit_address_mismatch.py). _SNAP_PRECISIONS оставлен с "exact" (недостижимо без Yandex-tier, но дёшево хранить — parity с frontend MapPicker.tsx SNAP_PRECISIONS и не ломает test_snap_precision_useful_exact_and_number).
418 lines
24 KiB
Python
418 lines
24 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 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
|