fix(tradein/geocoder): инвариант «чужой топоним + результат внутри ЕКБ = недостоверно» (#2590) (#2837)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m11s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 2m9s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m11s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 2m9s
This commit is contained in:
parent
ad190be233
commit
677fcb749c
2 changed files with 441 additions and 0 deletions
|
|
@ -372,6 +372,134 @@ def _names_unrecognized_locality(address: str) -> bool:
|
|||
return bool(_LOCALITY_MARKER_RE.search(normalized))
|
||||
|
||||
|
||||
# ── Постфактум-инвариант подмены города (#2590) ──────────────────────────────
|
||||
# Гейты выше (#2582/#2589) стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only тиры.
|
||||
# Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, и
|
||||
# тот, не найдя Режа, отдаёт улицу Ленина в Екатеринбурге. Отличить на входе
|
||||
# «Реж» (город) от «Малышева» (улица) без списка городов нельзя — оба «слово до
|
||||
# запятой». ПОСЛЕ ответа можно: провайдер сам пишет, какой населённый пункт он
|
||||
# использовал, и если названный топоним туда не дожил — топоним подменён.
|
||||
#
|
||||
# Инвариант (#2590): назван топоним ≠ Екатеринбург + его нет в ответе провайдера
|
||||
# + ответ лежит внутри ЕКБ ⇒ результат недостоверен. Ни одного имени города в
|
||||
# коде — только уровни РФ-адреса (страна → регион → район → НП → улица → дом) и
|
||||
# сам целевой город.
|
||||
_ADDRESS_SEGMENT_RE = re.compile(r"[,;·]")
|
||||
# Страна: сегмент выше уровня НП. Единственная константа-топоним помимо целевого
|
||||
# города — продукт РФ-only, новых значений у неё не появится (в отличие от
|
||||
# списка городов области, ради ухода от которого всё и делается).
|
||||
_COUNTRY_RE = re.compile(r"\b(?:росси[яи]|russia)\b")
|
||||
# Уровень «улица/дом»: дойдя до него, НП уже был бы назван (порядок РФ-адреса
|
||||
# big→small). Дальше идти нельзя — иначе второй уличный сегмент («малышева,
|
||||
# мопра» — перекрёсток) читается как топоним и ложно отбраковывается.
|
||||
_STREET_LEVEL_RE = re.compile(
|
||||
r"\b(?:ул|улица|пер|переулок|пр|пр-кт|пркт|проспект|б-р|бульвар|ш|шоссе|наб|набережная"
|
||||
r"|пл|площадь|проезд|тракт|аллея|тупик|туп|линия|кв-л|квартал|стр|строение|дом|корп"
|
||||
r"|корпус|лит|литера|снт|сад|гск)\b"
|
||||
)
|
||||
# Уровни ВЫШЕ и НИЖЕ населённого пункта — пропускаем и идём дальше по сегментам:
|
||||
# «свердловская обл., г.о. рефтинский» (регион → НП), «мкр-н широкая речка, ул.
|
||||
# …» (район ВНУТРИ города — его провайдер в ответе обычно не повторяет).
|
||||
_REGION_LEVEL_RE = re.compile(r"\b(?:обл\.?|область|края|край|республика|респ\.?|ао)\b")
|
||||
_DISTRICT_LEVEL_RE = re.compile(r"\b(?:р-н|р-он|район|мкр|мкр-н|микрорайон|жк|жилой)\b")
|
||||
# Слова-ТИПЫ НП (не имя): «пос. Кедровка» → имя «кедровка». Тип не сравнивается
|
||||
# с ответом — провайдер пишет свой («посёлок» vs «пос.»), имя же обязано дожить.
|
||||
_LOCALITY_TYPE_WORDS = frozenset(
|
||||
{
|
||||
"поселок",
|
||||
"пос",
|
||||
"село",
|
||||
"деревня",
|
||||
"дер",
|
||||
"город",
|
||||
"гор",
|
||||
"округ",
|
||||
"муниципальный",
|
||||
"городской",
|
||||
"сельское",
|
||||
"поселение",
|
||||
"тер",
|
||||
"территория",
|
||||
"станция",
|
||||
"пгт",
|
||||
"рп",
|
||||
}
|
||||
)
|
||||
_WORD_RE = re.compile(r"[а-я][а-я-]*")
|
||||
|
||||
|
||||
def _fold(value: str) -> str:
|
||||
"""lower + ё→е + схлопывание пробелов — общий канон для сравнения топонимов."""
|
||||
return " ".join(value.lower().replace("ё", "е").split())
|
||||
|
||||
|
||||
def _claimed_locality(address: str) -> str | None:
|
||||
"""Имя населённого пункта, названное в тексте адреса, или None.
|
||||
|
||||
Структурно, БЕЗ перечисления городов: идём по сегментам в порядке РФ-адреса
|
||||
(страна → регион → район → НП → улица → дом), пропускаем уровни выше/ниже
|
||||
НП, останавливаемся на уровне улицы/дома. Первый оставшийся сегмент — имя НП.
|
||||
|
||||
None означает «НП не назван» — это основной трафик формы («Малышева 30»), и
|
||||
для него инвариант не применяется вовсе.
|
||||
"""
|
||||
for segment in _ADDRESS_SEGMENT_RE.split(_fold(address)):
|
||||
segment = segment.strip()
|
||||
if not segment:
|
||||
continue
|
||||
if any(ch.isdigit() for ch in segment) or _STREET_LEVEL_RE.search(segment):
|
||||
return None # улица/дом: будь НП назван, он шёл бы раньше
|
||||
if (
|
||||
_COUNTRY_RE.search(segment)
|
||||
or _REGION_LEVEL_RE.search(segment)
|
||||
or _DISTRICT_LEVEL_RE.search(segment)
|
||||
):
|
||||
continue
|
||||
name = " ".join(
|
||||
w for w in _WORD_RE.findall(segment) if w not in _LOCALITY_TYPE_WORDS and len(w) >= 3
|
||||
)
|
||||
if name:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _city_substituted(address: str, result: GeocodeResult) -> bool:
|
||||
"""True если провайдер подменил названный в адресе НП Екатеринбургом (#2590).
|
||||
|
||||
Три условия вместе:
|
||||
1. в адресе назван НП и это не Екатеринбург (`_claimed_locality`);
|
||||
2. этого имени НЕТ в адресе, который вернул провайдер — то есть топоним не
|
||||
пережил геокодинг;
|
||||
3. результат лежит внутри ЕКБ: и по координатам (`EKB_BBOX_TIGHT`), и по
|
||||
собственному ответу провайдера — он называет Екатеринбург либо не
|
||||
называет НП вовсе (ЕКБ-only локальные реестры отдают «Улица, дом»;
|
||||
тогда «внутри ЕКБ» подтверждают координаты).
|
||||
|
||||
Условие 3 и разводит подмену с посёлками в городской черте. «пос. Кедровка,
|
||||
Советская ул., 5» → ответ «Екатеринбург, Советская улица, 5» — имя не дожило,
|
||||
и это ПРАВДА подмена: настоящая Кедровка в 20 км от улицы Советской. А
|
||||
корректный ответ по посёлку («Кедровка, Екатеринбург, …» — Nominatim и Yandex
|
||||
пишут НП всегда, когда действительно его нашли) имя сохраняет и через фильтр
|
||||
не проходит. Проверяется не география посёлка, а факт «топоним потерян».
|
||||
|
||||
Известный потолок: НП, чьё имя совпало с уличным токеном ответа («Ачит» ⊂
|
||||
«М-12 Ачит-Екатеринбург», «Лесной» ⊂ «Лесной переулок»), считается дожившим —
|
||||
пропуск, не ложная отбраковка. Обратный потолок: жилрайон ЕКБ, названный без
|
||||
приставки («пионерский, советская»), понижается до `locality` — честная
|
||||
деградация, координаты не теряются.
|
||||
"""
|
||||
claimed = _claimed_locality(address)
|
||||
if claimed is None or _EKATERINBURG_RE.search(claimed):
|
||||
return False
|
||||
answer = _fold(result.full_address or "")
|
||||
if any(word in answer for word in claimed.split()):
|
||||
return False # топоним дожил до ответа — провайдер искал там, где просили
|
||||
if not is_within_ekb_bbox(result.lat, result.lon):
|
||||
return False
|
||||
answer_locality = _claimed_locality(answer)
|
||||
return answer_locality is None or bool(_EKATERINBURG_RE.search(answer_locality))
|
||||
|
||||
|
||||
def _ekb_local_tiers_allowed(address: str, city_hint: str | None = None) -> bool:
|
||||
"""Fail-closed гейт локальных ЕКБ-тиров geocoder (`geocode()`/`suggest()`, #2582).
|
||||
|
||||
|
|
@ -1287,6 +1415,42 @@ async def suggest(
|
|||
|
||||
# ── Public API ───────────────────────────────────────────────────────────────
|
||||
async def geocode(address: str, db: Session, city_hint: str | None = None) -> GeocodeResult | None:
|
||||
"""Геокодинг с кэшем + постфактум-проверка подмены города (#2590).
|
||||
|
||||
Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант
|
||||
применяется ОДНОЙ точкой на выходе — поэтому покрывает разом все источники,
|
||||
включая попадание в кэш: отравленная запись, записанная до этого фикса,
|
||||
больше не отдаётся как точная, хотя строка в `geocode_cache` не тронута
|
||||
(обратимо: откат кода возвращает прежнее поведение, чистить БД не требуется).
|
||||
|
||||
Сработал инвариант → `confidence="locality"` + `city_ambiguous=True`.
|
||||
`locality` — не косметика: `estimator._geocode_is_coarse` уже трактует его
|
||||
как «геокодер дошёл только до центра НП» и (а) включает #693 coarse-downgrade
|
||||
оценки, (б) через `tasks.geocode_missing` проставляет листингу
|
||||
`geo_precision='city'`, а этот признак исключает листинг из пула аналогов
|
||||
(`estimator`/`location_index`: `geo_precision IS DISTINCT FROM 'city'`).
|
||||
То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть
|
||||
за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и
|
||||
видимая, а не отказ.
|
||||
"""
|
||||
result = await _geocode_resolve(address, db, city_hint)
|
||||
if result is None or not _city_substituted(address, result):
|
||||
return result
|
||||
logger.warning(
|
||||
"geocode city substitution (#2590): %r → %r (%.5f, %.5f) provider=%s — "
|
||||
"названный НП не дожил до ответа, результат внутри ЕКБ; confidence→locality",
|
||||
address[:80],
|
||||
(result.full_address or "")[:80],
|
||||
result.lat,
|
||||
result.lon,
|
||||
result.provider,
|
||||
)
|
||||
return replace(result, confidence="locality", city_ambiguous=True)
|
||||
|
||||
|
||||
async def _geocode_resolve(
|
||||
address: str, db: Session, city_hint: str | None = None
|
||||
) -> GeocodeResult | None:
|
||||
"""Геокодинг с кэшем. Cadastral FDW → Nominatim → None.
|
||||
|
||||
Args:
|
||||
|
|
|
|||
277
tradein-mvp/backend/tests/test_geocoder_city_substitution.py
Normal file
277
tradein-mvp/backend/tests/test_geocoder_city_substitution.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
"""Тесты #2590 — постфактум-инвариант «назван чужой топоним + результат внутри ЕКБ».
|
||||
|
||||
Гейты #2582/#2589 стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only локальные реестры.
|
||||
Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, тот не
|
||||
находит Режа и отдаёт улицу Ленина в Екатеринбурге — с координатами ЕКБ, пометкой
|
||||
«точно» и записью в кэш на 90 дней. Замер на проде (`geocode_cache`, 9514 строк,
|
||||
2026-08-12): **1251 запись** подпадает под инвариант — 1245 `provider=yandex`,
|
||||
6 `nominatim`, 0 у локальных тиров; 365 разных населённых пунктов.
|
||||
|
||||
Инвариант проверяется ПОСЛЕ ответа провайдера, потому что на входе «Реж» (город)
|
||||
и «Малышева» (улица) структурно неразличимы — оба «слово до запятой». После
|
||||
ответа различимы: провайдер сам пишет, какой НП он использовал.
|
||||
|
||||
Адреса и full_address в тестах — живые строки из прод-кэша (не выдуманные).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.geocoder import (
|
||||
GeocodeResult,
|
||||
GeocodeSuggestion,
|
||||
_city_substituted,
|
||||
_claimed_locality,
|
||||
geocode,
|
||||
)
|
||||
|
||||
# ── _claimed_locality: какой НП назван в тексте ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,expected",
|
||||
[
|
||||
# НП назван явно
|
||||
("реж, ленина", "реж"),
|
||||
("арамиль, красноармейская ул, 118дк1", "арамиль"),
|
||||
("пос. кедровка, советская ул.,5", "кедровка"), # тип «пос.» отброшен, имя осталось
|
||||
# Уровни выше НП пропускаются, НП находится дальше
|
||||
("свердловская обл., г.о. рефтинский", "рефтинский"),
|
||||
("россия, свердловская область, реж, ленина 5", "реж"),
|
||||
# НП НЕ назван — основной трафик формы оценки, инвариант не применяется
|
||||
("малышева 30", None),
|
||||
("ул. титова/монтерская/смоленская, стр. 2", None),
|
||||
("улица декабристов, 16-18литж · р-н октябрьский", None),
|
||||
("мкр-н широкая речка, ул. анатолия муранова,18", None),
|
||||
# Перекрёсток: второй уличный сегмент НЕ должен читаться как топоним
|
||||
("малышева, мопра", "малышева"),
|
||||
],
|
||||
)
|
||||
def test_claimed_locality(address: str, expected: str | None) -> None:
|
||||
assert _claimed_locality(address) == expected
|
||||
|
||||
|
||||
# ── _city_substituted: подмена vs честный ответ ──────────────────────────────
|
||||
|
||||
|
||||
def _res(full_address: str, lat: float, lon: float) -> GeocodeResult:
|
||||
return GeocodeResult(
|
||||
lat=lat, lon=lon, full_address=full_address, provider="nominatim", confidence="exact"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,full_address,lat,lon",
|
||||
[
|
||||
# Живые записи прод-кэша из тела #2590 (все provider=yandex)
|
||||
(
|
||||
"реж, ленина",
|
||||
"Россия, Свердловская область, Екатеринбург, улица Ленина",
|
||||
56.83953,
|
||||
60.61244,
|
||||
),
|
||||
(
|
||||
"реж, краснофлотцев",
|
||||
"Россия, Свердловская область, Екатеринбург, улица Краснофлотцев",
|
||||
56.88996,
|
||||
60.63184,
|
||||
),
|
||||
(
|
||||
"арамиль, заводская",
|
||||
"Россия, Свердловская область, Екатеринбург, Заводская улица",
|
||||
56.83043,
|
||||
60.55170,
|
||||
),
|
||||
(
|
||||
"свердловская обл., г.о. рефтинский",
|
||||
"Россия, Свердловская область, Екатеринбург",
|
||||
56.83744,
|
||||
60.59764,
|
||||
),
|
||||
# Города вне списка 37 и без структурной приставки — дыра, оставленная #2589
|
||||
(
|
||||
"талица, кузнецова",
|
||||
"Россия, Свердловская область, Екатеринбург, улица Кузнецова",
|
||||
56.84226,
|
||||
60.64557,
|
||||
),
|
||||
(
|
||||
"верхотурье, герцена",
|
||||
"Россия, Свердловская область, Екатеринбург, улица Герцена",
|
||||
56.744397,
|
||||
60.677299,
|
||||
),
|
||||
# Посёлок В ЧЕРТЕ ЕКБ, но улица подменена: настоящая Кедровка в 20 км от
|
||||
# улицы Советской, куда провайдер поставил точку (живая запись прода).
|
||||
(
|
||||
"пос. кедровка, советская ул.,5",
|
||||
"Россия, Свердловская область, Екатеринбург, Советская улица, 5",
|
||||
56.8506,
|
||||
60.6282,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_substitution_detected(address: str, full_address: str, lat: float, lon: float) -> None:
|
||||
assert _city_substituted(address, _res(full_address, lat, lon)) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,full_address,lat,lon",
|
||||
[
|
||||
# Посёлок в черте ЕКБ, ответ ЧЕСТНЫЙ — имя дожило до full_address.
|
||||
# Тот же вход, что и в подмене выше: разделяет их не география, а
|
||||
# сохранность топонима (граница из #2777 — посёлки не ловятся
|
||||
# «городским» признаком, и здесь ловить их и не нужно).
|
||||
(
|
||||
"пос. кедровка, советская ул.,5",
|
||||
"5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург",
|
||||
56.9902,
|
||||
60.7166,
|
||||
),
|
||||
(
|
||||
"шувакиш, ленина 4",
|
||||
"4, улица Ленина, Шувакиш, Железнодорожный район, Екатеринбург",
|
||||
56.9295,
|
||||
60.4938,
|
||||
),
|
||||
# НП не назван вообще — основной трафик формы, ЕКБ как рабочая гипотеза
|
||||
(
|
||||
"малышева 30",
|
||||
"Россия, Свердловская область, Екатеринбург, улица Малышева, 30",
|
||||
56.8389,
|
||||
60.6057,
|
||||
),
|
||||
# Явный Екатеринбург в адресе — не подмена по определению
|
||||
("екатеринбург, ул. репина, 99", "Репина, 99", 56.8207, 60.5686),
|
||||
("г екатеринбург, ул сурикова, д 47", "Сурикова, 47", 56.8156, 60.5943),
|
||||
# Честный ответ по другому городу области: провайдер назвал НП, координаты
|
||||
# в его собственном bbox — под инвариант не подпадает
|
||||
("нижний тагил, ленина 1", "1, проспект Ленина, Нижний Тагил", 57.9101, 59.9813),
|
||||
# Ответ внутри ЕКБ-bbox, но провайдер назвал ДРУГОЙ НП (Берёзовский лежит
|
||||
# внутри щедрого EKB_BBOX_TIGHT) — «внутри Екатеринбурга» не подтверждено
|
||||
(
|
||||
"режевской, ленина",
|
||||
"Россия, Свердловская область, Берёзовский, улица Ленина",
|
||||
56.9088,
|
||||
60.8107,
|
||||
),
|
||||
# Перекрёсток двух улиц: провайдер взял первую — потеря второй улицы не
|
||||
# является подменой города
|
||||
(
|
||||
"малышева, мопра",
|
||||
"Россия, Свердловская область, Екатеринбург, улица Малышева",
|
||||
56.8389,
|
||||
60.6057,
|
||||
),
|
||||
# Тот же НП, но за пределами ЕКБ — инвариант молчит
|
||||
("реж, ленина", "Россия, Свердловская область, Реж, улица Ленина", 57.3722, 61.3897),
|
||||
],
|
||||
)
|
||||
def test_no_substitution(address: str, full_address: str, lat: float, lon: float) -> None:
|
||||
assert _city_substituted(address, _res(full_address, lat, lon)) is False
|
||||
|
||||
|
||||
# ── geocode(): сквозной эффект ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _tiers(*, cached=None, nominatim=None, geoportal=None):
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(patch("app.services.geocoder._cache_get", return_value=cached))
|
||||
stack.enter_context(patch("app.services.geocoder._cache_put"))
|
||||
stack.enter_context(
|
||||
patch("app.services.geocoder._geoportal_house_match", return_value=geoportal)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch("app.services.geocoder._cadastral_house_match", return_value=None)
|
||||
)
|
||||
stack.enter_context(patch("app.services.geocoder._cadastral_forward_sync", return_value=[]))
|
||||
stack.enter_context(
|
||||
patch("app.services.geocoder._nominatim_lookup", new=AsyncMock(return_value=nominatim))
|
||||
)
|
||||
stack.enter_context(patch("app.services.geocoder.asyncio.sleep", new=AsyncMock()))
|
||||
yield
|
||||
|
||||
|
||||
async def test_geocode_downgrades_substituted_result() -> None:
|
||||
"""«Реж, Ленина 5» → провайдер отдал улицу Ленина в ЕКБ с confidence=exact.
|
||||
|
||||
Координаты остаются (честная деградация, не отказ), но confidence падает до
|
||||
`locality` — а это уже рабочий контракт: `estimator._geocode_is_coarse`
|
||||
включает #693 coarse-downgrade, `tasks.geocode_missing` ставит листингу
|
||||
`geo_precision='city'`, что исключает его из пула аналогов.
|
||||
"""
|
||||
db = MagicMock()
|
||||
substituted = GeocodeResult(
|
||||
lat=56.83953,
|
||||
lon=60.61244,
|
||||
full_address="Россия, Свердловская область, Екатеринбург, улица Ленина, 5",
|
||||
provider="nominatim",
|
||||
confidence="exact",
|
||||
)
|
||||
with _tiers(nominatim=substituted):
|
||||
result = await geocode("Реж, Ленина 5", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.confidence == "locality"
|
||||
assert result.city_ambiguous is True
|
||||
assert result.lat == pytest.approx(56.83953) # координаты НЕ выброшены
|
||||
|
||||
|
||||
async def test_geocode_poisoned_cache_hit_downgraded() -> None:
|
||||
"""Отравленная запись кэша (их 1251 на проде) продолжает отдаваться, но уже
|
||||
НЕ как точная — без единого DELETE в `geocode_cache`."""
|
||||
db = MagicMock()
|
||||
poisoned = GeocodeResult(
|
||||
lat=56.83744,
|
||||
lon=60.59764,
|
||||
full_address="Россия, Свердловская область, Екатеринбург",
|
||||
provider="cache",
|
||||
confidence="exact",
|
||||
)
|
||||
with _tiers(cached=poisoned):
|
||||
result = await geocode("Арамиль, Красноармейская ул, 118дк1", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.confidence == "locality"
|
||||
assert result.city_ambiguous is True
|
||||
|
||||
|
||||
async def test_geocode_ekb_address_unaffected() -> None:
|
||||
"""Контроль: реальный ЕКБ-адрес через локальный тир (full_address без города)
|
||||
остаётся `exact` — инвариант не трогает основной трафик."""
|
||||
db = MagicMock()
|
||||
hit = GeocodeSuggestion(
|
||||
label="Малышева, 30", full_address="Малышева, 30", lat=56.8389, lon=60.6057, kind="house"
|
||||
)
|
||||
with _tiers(geoportal=hit):
|
||||
result = await geocode("Екатеринбург, Малышева 30", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.confidence == "exact"
|
||||
assert result.city_ambiguous is False
|
||||
|
||||
|
||||
async def test_geocode_settlement_inside_ekb_kept_exact() -> None:
|
||||
"""Посёлок в городской черте (Кедровка) с ЧЕСТНЫМ ответом провайдера не
|
||||
отбраковывается — граница, о которой предупреждает #2777."""
|
||||
db = MagicMock()
|
||||
honest = GeocodeResult(
|
||||
lat=56.9902,
|
||||
lon=60.7166,
|
||||
full_address="5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург",
|
||||
provider="nominatim",
|
||||
confidence="exact",
|
||||
)
|
||||
with _tiers(nominatim=honest):
|
||||
result = await geocode("пос. Кедровка, Советская ул., 5", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.confidence == "exact"
|
||||
Loading…
Add table
Reference in a new issue