diff --git a/tradein-mvp/backend/app/services/dadata.py b/tradein-mvp/backend/app/services/dadata.py index a14626e5..8a5b9c9f 100644 --- a/tradein-mvp/backend/app/services/dadata.py +++ b/tradein-mvp/backend/app/services/dadata.py @@ -343,6 +343,10 @@ async def suggest_addresses( жёсткий фильтр (не boost) на уровне указанного admin-поля — доп. параметров не требуется. По умолчанию не задан — поведение (и body запроса) для существующих вызовов не меняется. + ВАЖНО: значение сравнивается с полем DaData `region`, где имя лежит + БЕЗ типа («Свердловская», а тип — отдельно в `region_type`="обл"). + Передашь «Свердловская область» — совпадений не будет, и запрос + вернёт ПУСТО без всякой ошибки (hard-filter, не boost). Returns: list[DadataSuggestion] — пустой список если: diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index ff832001..01c37ebe 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -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,73 @@ 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, а НЕ «похожий» дом: пусть + отработают следующие тиры (`_cadastral_forward_sync` → DaData → Nominatim). + Тихо подставленный соседний дом здесь необратимо помечался бы `exact`. """ - 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 +1045,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( diff --git a/tradein-mvp/backend/data/sql/203_purge_geocode_cache_house_letter.sql b/tradein-mvp/backend/data/sql/203_purge_geocode_cache_house_letter.sql new file mode 100644 index 00000000..d58586e2 --- /dev/null +++ b/tradein-mvp/backend/data/sql/203_purge_geocode_cache_house_letter.sql @@ -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; diff --git a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py index a2e78b57..919f9386 100644 --- a/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py +++ b/tradein-mvp/backend/tests/test_geocoder_cadastral_matcher.py @@ -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