fix(tradein/geocoder): строгий матч литеры дома + починка DaData region-фильтра (#2622)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
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 2m36s
Deploy Trade-In / build-backend (push) Successful in 59s
Deploy Trade-In / deploy (push) Successful in 1m26s

This commit is contained in:
lekss361 2026-08-02 12:06:44 +00:00
parent c1b407527b
commit 0abfe020df
4 changed files with 416 additions and 25 deletions

View file

@ -343,6 +343,10 @@ async def suggest_addresses(
жёсткий фильтр (не boost) на уровне указанного admin-поля доп.
параметров не требуется. По умолчанию не задан поведение (и body
запроса) для существующих вызовов не меняется.
ВАЖНО: значение сравнивается с полем DaData `region`, где имя лежит
БЕЗ типа («Свердловская», а тип отдельно в `region_type`="обл").
Передашь «Свердловская область» совпадений не будет, и запрос
вернёт ПУСТО без всякой ошибки (hard-filter, не boost).
Returns:
list[DadataSuggestion] пустой список если:

View file

@ -156,7 +156,15 @@ SVERDLOVSK_OBLAST_CITIES = frozenset(
# без district-префикса ложно ушёл бы в non-EKB gate.
}
)
SVERDLOVSK_OBLAST_REGION = "Свердловская область"
# Значение для DaData-констрейнта `locations: [{"region": ...}]`.
# ВАЖНО: DaData хранит имя региона БЕЗ типа — `region="Свердловская"`,
# `region_type="обл"` (тип лежит в отдельных полях `region_type` /
# `region_with_type`). `locations` сравнивает именно с `region`, поэтому
# «Свердловская область» не совпадает НИ С ЧЕМ и hard-фильтр молча схлопывал
# выдачу в 0 подсказок (замер на проде: «Свердловская область» → 0 хитов,
# «Свердловская» → 5 хитов, первый — искомый «д 13б» с fias_id).
# Тип региона сюда дописывать нельзя — см. `test_dadata_region_constant_*`.
SVERDLOVSK_OBLAST_REGION = "Свердловская"
# Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
@ -637,13 +645,24 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
типа город/район, для autocomplete с привязкой к карте они бесполезны).
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
Constraint вся область (region='Свердловская область', hard-filter внутри
`suggest_addresses`), а не один город ЕКБ иначе Нижний Тагил/Серов/etc
никогда не появились бы в подсказках.
Constraint вся область (region=`SVERDLOVSK_OBLAST_REGION`, hard-filter
внутри `suggest_addresses`), а не один город ЕКБ иначе Нижний Тагил/
Серов/etc никогда не появились бы в подсказках.
"""
raw = await dadata.suggest_addresses(
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
)
if not raw:
# Region-констрейнт — hard-filter: неверное значение схлопывает выдачу в
# 0 БЕЗ ошибки (так и жил баг «Свердловская область» → 0 подсказок).
# Отдельный warning, чтобы следующая такая регрессия была видна в логах,
# а не выглядела как «DaData ничего не знает про этот адрес».
logger.warning(
"dadata suggest: 0 кандидатов для %r при region=%r"
"проверь, что констрейнт совпадает с полем DaData `region` (без типа)",
query[:60],
SVERDLOVSK_OBLAST_REGION,
)
out: list[GeocodeSuggestion] = []
for s in raw:
if s.lat is None or s.lon is None:
@ -946,23 +965,85 @@ def _parse_street_house(address: str) -> tuple[str, str] | None:
return (street, house)
# Извлечение номера дома из `readable_address` реестра. Реальные формы в
# gendesign_cad_buildings (47k строк, замер 2026-08-02):
# «д. 13» / «дом 13» / «сооружение 30» — 21k
# «д. 13б» — 2.6k
# «д. 13-б» — 2.1k
# «д. 13 б» — 125
# «д. 58/3», «д. 64-2» — 0.9k (угловые/корпусные номера)
# «д. 11 (кв. 1-150)», «д. 102 корпус 1» — хвост, литерой НЕ является
# Разбор:
# \m… — маркер только с НАЧАЛА слова, иначе «проезд 8
# Марта, д 5» дало бы дом «8» (старый `д\.?` без
# границы слова ловил «д» внутри «проезд»)
# [0-9]+ — номер
# (\s*[-/]\s*[0-9]+)? — «58/3» / «64-2»: часть номера, а не мусор —
# иначе «58» ложно совпало бы с «58/3»
# (\s*-?\s*[а-яё](?![а-яё]))? — литера; lookahead отсекает начало слова
# («102 корпус 1» → «102», не «102к»)
_SQL_HOUSE_TOKEN_RE = (
r"\m(?:дом|д\.?|строение|стр\.?|сооружение|соор\.?)\s*"
r"([0-9]+(?:\s*[-/]\s*[0-9]+)?(?:\s*-?\s*[а-яё](?![а-яё]))?)"
)
# Нормализация извлечённого токена к канону `_norm_house`: убираем пробелы,
# затем дефис ТОЛЬКО перед литерой («23-б» → «23б», но «64-2» остаётся «64-2»,
# иначе он схлопнулся бы в реальный дом «642»).
_SQL_HOUSE_TOKEN_NORM = (
r"regexp_replace("
r" regexp_replace("
r" lower(COALESCE((regexp_match(readable_address, :house_token_re, 'i'))[1], '')),"
r" '\s', '', 'g'),"
r" '-([а-яё])', '\1', 'g')"
)
def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None:
"""Anchored cadastral match: ILIKE по улице + regex-anchor на дом-маркер.
"""Anchored cadastral match: ILIKE по улице + СТРОГОЕ равенство номера дома.
SQL validated на проде (11/16 hits, 0 false positives). Anchor на
«д./дом/строение» убивает ложный матч номера внутри «(1-83)»-диапазона.
Литера часть идентичности дома, а не украшение: «Новгородцевой 13б» и
«Новгородцевой 13» РАЗНЫЕ здания. Поэтому номер сравнивается равенством
нормализованных форм (обе стороны канон «13б»), а не «совпали цифры,
литера опциональна».
`street` идёт ТОЛЬКО в bound-param ILIKE (безопасно). Для regex берём
только ЦИФРЫ дома (regex-safe) конкатенируем bound-param внутри SQL.
Литеру (если есть) используем лишь для tie-break сортировки.
Раньше в regex шли только ЦИФРЫ дома, литера была опциональна в WHERE и
участвовала лишь как tie-break в ORDER BY из-за чего запрос с литерой
молча получал соседний дом БЕЗ неё (и наоборот: «Малышева 30» «д. 30-б»),
причём с `confidence="exact"` и записью в `geocode_cache` на 90 дней.
Regex-anchor на «д./дом/строение» (prefilter) сохранён: он дёшев, пушится
в FDW и убивает ложный матч номера внутри «(1-83)»-диапазона. Точность
даёт равенство токенов ниже.
`street` идёт ТОЛЬКО в bound-param ILIKE, номер дома в regex больше НЕ
конкатенируется (сравнивается как текст) regex-injection поверхность
сузилась до цифр prefilter'а.
Нет дома с нужной литерой возвращаем None, а НЕ «похожий» дом: пусть
отработают следующие тиры. Тихо подставленный соседний дом здесь
необратимо помечался бы `exact`.
ВНИМАНИЕ, цепочки различаются не путать:
* `geocode()` : geoportal cadastral `_cadastral_forward_sync`
Nominatim None. Тира DaData тут НЕТ.
* `suggest()` : cadastral DaData Nominatim (единственный вызов
`_dadata_suggest`).
То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`)
адрес с литерой, неизвестный ни геопорталу, ни Nominatim, даёт None
оценка не строится. Это сознательный выбор: честный отказ вместо
уверенно-неверной оценки чужого дома. Основной UI-путь этим не задет
координаты приходят из выбранной подсказки (`ParamsPanel.tsx:776`
`api/v1/trade_in.py:128` использует lat/lon напрямую, минуя `geocode()`).
"""
house_digits_m = re.match(r"\d+", house)
house_norm = _norm_house(house)
house_digits_m = re.match(r"\d+", house_norm)
if not house_digits_m:
return None
house_digits = house_digits_m.group(0)
try:
row = db.execute(
text(r"""
text(
r"""
SELECT readable_address, lat, lon
FROM gendesign_cad_buildings
WHERE readable_address ILIKE CAST('%' || :street || '%' AS text)
@ -976,14 +1057,19 @@ def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSugge
'(п\.\s|пос[. ]|посёлок|поселок|северка|шабровский'
|| '| км|снт|гараж|коллективный сад)'
)
ORDER BY
(CASE WHEN CAST(:house_full AS text) ~ '[а-яё]'
AND readable_address ~* (CAST(:house_full AS text) || '(\D|$)')
THEN 0 ELSE 1 END),
length(readable_address) ASC
AND """
+ _SQL_HOUSE_TOKEN_NORM
+ r""" = CAST(:house_norm AS text)
ORDER BY length(readable_address) ASC
LIMIT 1
"""),
{"street": street, "house_digits": house_digits, "house_full": house},
"""
),
{
"street": street,
"house_digits": house_digits,
"house_norm": house_norm,
"house_token_re": _SQL_HOUSE_TOKEN_RE,
},
).first()
except Exception:
logger.warning(

View file

@ -0,0 +1,52 @@
-- Инвалидация записей geocode_cache, отравленных багом матчинга литеры дома.
--
-- Контекст: `_cadastral_house_match` (app/services/geocoder.py) сравнивал дом
-- только по ЦИФРАМ — литера была опциональна в WHERE и участвовала лишь как
-- tie-break в ORDER BY. Итог, двусторонний:
-- • «Новгородцевой 13б» → «дом 13» (запрос с литерой → дом без неё)
-- • «Малышева 30» → «д. 30-б» (запрос без литеры → дом с литерой)
-- Оба результата писались с provider-тиром локального реестра и
-- `confidence='exact'`, TTL 90 дней → пользователь получал оценку ЧУЖОГО
-- здания, помеченную как точная, и она залипала в кэше.
--
-- Здесь удаляем только ПОДОЗРИТЕЛЬНЫЕ строки, а не весь кэш: полная очистка
-- сожгла бы квоту внешних геокодеров (DaData 10k/день) на ре-резолв заведомо
-- корректных адресов. Удалённое будет пересчитано лениво, при следующем
-- запросе, уже исправленным матчером.
--
-- Идемпотентность: чистый DELETE по предикату. Повторный прогон удалит 0 строк
-- (первый уже вычистил всё подходящее), новых строк с такой же патологией
-- исправленный код не создаёт. Безопасно для strict exit-1 авто-применения.
BEGIN;
DELETE FROM geocode_cache
WHERE
-- (a) В самом запросе была литера дома: под старым матчером такой адрес мог
-- уехать в дом без литеры / с чужой литерой. Смотрим на ХВОСТ адреса
-- (дом пишется последним) — иначе порядковые части улиц («1-я
-- Пятилетки», «4-й Кианитовый») ложно читались бы как литера.
-- `|city=` — суффикс ключа кэша (см. geocoder._cache_key), отрезаем.
split_part(address_normalized, '|city=', 1) ~* '[0-9]+\s*-?\s*[а-яё]\s*$'
-- (b) Обратное направление: в запросе литеры НЕ было, а закэширован адрес
-- реестра, у которого номер дома С литерой («Малышева 30» → «д. 30-б»).
-- Извлечение номера — то же выражение, что и в исправленном матчере
-- (geocoder._SQL_HOUSE_TOKEN_RE): маркер только с начала слова, литера
-- — одиночная кириллическая буква, «58/3»/«64-2» литерой не считаются.
OR (
split_part(address_normalized, '|city=', 1) !~* '[0-9]+\s*-?\s*[а-яё]\s*$'
AND full_address IS NOT NULL
AND regexp_replace(
regexp_replace(
lower(COALESCE((regexp_match(
full_address,
'\m(?:дом|д\.?|строение|стр\.?|сооружение|соор\.?)\s*'
|| '([0-9]+(?:\s*[-/]\s*[0-9]+)?(?:\s*-?\s*[а-яё](?![а-яё]))?)',
'i'))[1], '')),
'\s', '', 'g'),
'-([а-яё])', '\1', 'g'
) ~ '[а-яё]$'
);
COMMIT;

View file

@ -12,6 +12,7 @@ Covers:
from __future__ import annotations
import os
import re
import sys
from unittest.mock import AsyncMock, MagicMock, patch
@ -26,9 +27,13 @@ sys.modules.setdefault("weasyprint", _wp_mock)
from app.services.geocoder import ( # noqa: E402
_SQL_HOUSE_TOKEN_RE,
SVERDLOVSK_OBLAST_REGION,
GeocodeResult,
GeocodeSuggestion,
_cadastral_house_match,
_dadata_suggest,
_norm_house,
_parse_street_house,
geocode,
suggest,
@ -143,8 +148,8 @@ def test_house_match_returns_none_for_non_numeric_house() -> None:
db.execute.assert_not_called()
def test_house_match_passes_only_digits_as_regex_param() -> None:
"""house='26а' → :house_digits bound param is '26' (letter stripped for regex)."""
def _house_match_params(house: str, street: str = "космонавтов") -> dict:
"""Вызывает матчер с mock-сессией и возвращает bound-params запроса."""
row = MagicMock()
row.readable_address = "г. Екатеринбург, пр-кт Космонавтов, д. 26а"
row.lat = 56.9
@ -154,13 +159,58 @@ def test_house_match_passes_only_digits_as_regex_param() -> None:
result.first.return_value = row
db.execute.return_value = result
_cadastral_house_match(db, "космонавтов", "26а")
_cadastral_house_match(db, street, house)
# second positional arg to execute() is the bound-params dict
params = db.execute.call_args.args[1]
assert params["house_digits"] == "26"
assert params["house_full"] == "26а"
return db.execute.call_args.args[1]
def test_house_match_passes_full_normalized_house_not_just_digits() -> None:
"""house='26а' → в запрос уходит ПОЛНЫЙ номер '26а', а не только цифры '26'.
Регрессия-гард на исходный баг: раньше литера отрезалась (`house_digits`
= '26') и в WHERE была опциональна, поэтому «26а» матчился на дом «26».
Цифры остаются отдельным параметром но только как дешёвый prefilter.
"""
params = _house_match_params("26а")
assert params["house_norm"] == "26а"
assert params["house_digits"] == "26" # prefilter only
assert params["street"] == "космонавтов"
# Литера больше не «подсказка для сортировки» — старый параметр ушёл.
assert "house_full" not in params
@pytest.mark.parametrize(
("raw_house", "expected_norm"),
[
("13б", "13б"),
("13 б", "13б"),
("13-б", "13б"),
("13Б", "13б"),
("13 Б", "13б"),
("13", "13"),
],
)
def test_house_match_normalizes_letter_spellings(raw_house: str, expected_norm: str) -> None:
"""«13б» / «13 б» / «13-б» / «13Б» — одна и та же литера, один канон."""
assert _house_match_params(raw_house, street="новгородцевой")["house_norm"] == expected_norm
def test_house_match_sql_compares_house_by_equality() -> None:
"""SQL сравнивает нормализованный номер РАВЕНСТВОМ, а не «литера опциональна».
Структурный гард: если кто-то вернёт матч по цифрам с опциональной литерой
(`[а-яё]?` в WHERE как единственная проверка дома), тест упадёт.
"""
db = MagicMock()
db.execute.return_value.first.return_value = None
_cadastral_house_match(db, "новгородцевой", "13б")
sql = str(db.execute.call_args.args[0])
assert "= CAST(:house_norm AS text)" in sql
# tie-break по литере в ORDER BY больше не решает корректность
assert "house_full" not in sql
# ── geocode() wiring ─────────────────────────────────────────────────────────
@ -604,3 +654,202 @@ async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality(
mock_house.assert_not_called()
mock_forward.assert_not_called()
mock_nominatim.assert_called_once()
# ── House-letter matching semantics ─────────────────────────────────────────
# Само сравнение дома выполняет Postgres, поэтому здесь — зеркало SQL-выражения
# на Python. Паттерн НЕ дублируется: он выводится из той же константы
# `_SQL_HOUSE_TOKEN_RE`, что уходит в запрос (Postgres `\m` = «начало слова»
# ≡ Python `\b` перед словесным символом). Правка SQL-регекспа автоматически
# меняет и эти проверки — рассинхрон невозможен.
# Строки-адреса — реальные формы `readable_address` из gendesign_cad_buildings.
def _sql_house_token(readable_address: str) -> str:
"""Зеркало `_SQL_HOUSE_TOKEN_NORM`: извлечь номер дома и привести к канону."""
py_pattern = _SQL_HOUSE_TOKEN_RE.replace("\\m", "\\b")
m = re.search(py_pattern, readable_address, re.IGNORECASE)
token = (m.group(1) if m else "").lower()
token = re.sub(r"\s", "", token)
return re.sub(r"-([а-яё])", r"\1", token)
@pytest.mark.parametrize(
("readable_address", "expected"),
[
# Литера в трёх написаниях + регистр → один канон
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7б", "7б"),
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 23-б", "23б"),
("Свердловская область, г. Екатеринбург, ул. X, д. 18 б", "18б"),
("Свердловская область, г. Екатеринбург, ул. X, д. 13Б", "13б"),
# Без литеры
("Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 13", "13"),
("Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13", "13"),
("Российская Федерация, город Екатеринбург, улица Малышева, сооружение 30", "30"),
("Российская Федерация, город Екатеринбург, улица Малышева, строение 30 в", "30в"),
# Хвосты, которые литерой НЕ являются
("Свердловская область, г. Екатеринбург, ул. X, д. 11 (кв. 1-150)", "11"),
("Свердловская область, г. Екатеринбург, ул. X, д. 25, корп. 1", "25"),
("Российская Федерация, город Екатеринбург, улица X, дом 102 корпус 1", "102"),
("Свердловская область, г. Екатеринбург, ул. X, д. 16 угол улица Титова", "16"),
# Угловые/корпусные номера — ЧАСТЬ номера, не отбрасываются
("Свердловская область, г. Екатеринбург, ул. X, д. 58/3", "58/3"),
("Свердловская область, г. Екатеринбург, ул. X, д. 36/24а", "36/24а"),
# Дефис перед ЦИФРОЙ не схлопывается (иначе «64-2» стало бы домом «642»)
("Свердловская область, г. Екатеринбург, ул. X, д. 64-2", "64-2"),
("Свердловская область, г. Екатеринбург, ул. X, д. 642", "642"),
# Маркер только с начала слова: «проезд» не даёт дом «8»
("Свердловская область, г Екатеринбург, проезд 8 Марта, д 5", "5"),
("Свердловская область, г Екатеринбург, ул Привокзальная, д 22", "22"),
# Нет дом-маркера → номер не извлекаем (адрес недостижим этим тиром)
("Свердловская область, город Екатеринбург, проезд 4-й ЕКАД Южный", ""),
],
)
def test_sql_house_token_extraction(readable_address: str, expected: str) -> None:
assert _sql_house_token(readable_address) == expected
@pytest.mark.parametrize(
("query_house", "readable_address", "should_match", "label"),
[
# ── Прод-баг #1: «Новгородцевой 13б» отдавал дом 13 как exact ──────
(
"13б",
"Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13",
False,
"запрос С литерой не берёт дом БЕЗ литеры",
),
# ── Прод-баг #2 (обратный): «Малышева 30» отдавал «д. 30-б» ────────
(
"30",
"Свердловская область, г. Екатеринбург, ул. Малышева, д. 30-б",
False,
"запрос БЕЗ литеры не берёт дом С литерой",
),
(
"7б",
"Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7в",
False,
"другая литера не матчится",
),
# ── Позитив: литера совпала во всех написаниях реестра ─────────────
("7б", "Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 7б", True, "«13б»"),
(
"23б",
"Свердловская область, г. Екатеринбург, ул. Новгородцевой, д. 23-б",
True,
"«13-б»",
),
("18б", "Свердловская область, г. Екатеринбург, ул. X, д. 18 б", True, "«13 б»"),
("13б", "Свердловская область, г. Екатеринбург, ул. X, д. 13Б", True, "регистр"),
# ── Позитив: без литеры ────────────────────────────────────────────
(
"13",
"Российская Федерация, город Екатеринбург, улица Новгородцевой, дом 13",
True,
"«дом N»",
),
(
"30",
"Российская Федерация, город Екатеринбург, улица Малышева, сооружение 30",
True,
"«сооружение N»",
),
# Префикс числа не считается совпадением
("13", "Свердловская область, г. Екатеринбург, ул. X, д. 130", False, "13 ≠ 130"),
# Угловой номер не подменяет простой
("58", "Свердловская область, г. Екатеринбург, ул. X, д. 58/3", False, "58 ≠ 58/3"),
],
)
def test_house_letter_match_semantics(
query_house: str, readable_address: str, should_match: bool, label: str
) -> None:
"""Равенство нормализованных номеров — обе стороны приводятся к одному канону."""
matched = _sql_house_token(readable_address) == _norm_house(query_house)
assert matched is should_match, label
def test_query_letter_house_falls_through_instead_of_returning_neighbour() -> None:
"""Нет дома с литерой → None (не «похожий» дом) → работают следующие тиры.
Ключевое свойство фикса: молчаливая подмена соседнего здания здесь
помечалась бы `confidence="exact"` и кэшировалась на 90 дней.
"""
db = MagicMock()
db.execute.return_value.first.return_value = None # дома «13б» в реестре нет
assert _cadastral_house_match(db, "новгородцевой", "13б") is None
assert db.execute.call_args.args[1]["house_norm"] == "13б"
async def test_geocode_letter_house_miss_reaches_nominatim() -> None:
"""«Новгородцевой 13б» без хита в реестре доходит до Nominatim, а не
возвращает дом 13 с `confidence="exact"`."""
db = MagicMock()
nominatim_result = GeocodeResult(
lat=56.82,
lon=60.68,
full_address="ул. Новгородцевой, 13б, Екатеринбург",
provider="nominatim",
confidence="approximate",
)
with (
patch("app.services.geocoder._cache_get", return_value=None),
patch("app.services.geocoder._geoportal_house_match", return_value=None),
patch("app.services.geocoder._cadastral_house_match", return_value=None) as mock_house,
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]),
patch("app.services.geocoder._cache_put"),
patch(
"app.services.geocoder._nominatim_lookup",
new_callable=AsyncMock,
return_value=nominatim_result,
) as mock_nominatim,
):
result = await geocode("Екатеринбург, Новгородцевой 13б", db)
assert result is not None
assert result.confidence == "approximate"
assert result.lat == pytest.approx(56.82)
mock_house.assert_called_once()
# в матчер ушёл ПОЛНЫЙ номер с литерой
assert mock_house.call_args.args[2] == "13б"
mock_nominatim.assert_called_once()
def test_parse_street_house_keeps_letter_in_all_spellings() -> None:
"""Парсер отдаёт литеру матчеру в каноне — иначе строгий матч бесполезен."""
assert _parse_street_house("Новгородцевой 13б") == ("новгородцевой", "13б")
assert _parse_street_house("Новгородцевой 13-б") == ("новгородцевой", "13б")
assert _parse_street_house("Новгородцевой 13 Б") == ("новгородцевой", "13б")
assert _parse_street_house("ул. Новгородцевой, д. 13б") == ("новгородцевой", "13б")
assert _parse_street_house("Новгородцевой 13") == ("новгородцевой", "13")
# ── DaData region constraint ────────────────────────────────────────────────
def test_dadata_region_constant_has_no_region_type() -> None:
"""DaData `locations.region` сравнивается с именем БЕЗ типа.
«Свердловская область» hard-filter, который не совпадает ни с чем и молча
даёт 0 подсказок (прод-баг). Тип живёт в `region_type`/`region_with_type`.
"""
assert SVERDLOVSK_OBLAST_REGION == "Свердловская"
lowered = SVERDLOVSK_OBLAST_REGION.lower()
for type_word in ("область", "обл", "край", "респ"):
assert type_word not in lowered, f"тип региона {type_word!r} ломает locations-фильтр"
async def test_dadata_suggest_passes_region_without_type() -> None:
"""`_dadata_suggest` отдаёт в DaData именно region-константу (не город)."""
with patch(
"app.services.geocoder.dadata.suggest_addresses",
new_callable=AsyncMock,
return_value=[],
) as mock_suggest:
assert await _dadata_suggest("Новгородцевой 13б", limit=5) == []
kwargs = mock_suggest.call_args.kwargs
assert kwargs["region"] == "Свердловская"
assert kwargs["city"] is None