"""Unit tests for the anchored cadastral street+house matcher. Covers: - `_parse_street_house`: PURE parsing of DaData full form, bare «Street House», корпус/квартира stripping, digit-leading street («8 Марта 204»). Plus None cases. - `_cadastral_house_match`: mocked db session (FDW not in unit-test DB) → asserts GeocodeSuggestion built from row / None on no row / None on error. - geocode() + suggest() wiring: parse → house-match called first; parse-fail or no-hit → falls back to legacy `_cadastral_forward_sync`. """ from __future__ import annotations import os import re import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest # DATABASE_URL required by config before any app import. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") # WeasyPrint stub — not installed in CI without GTK. _wp_mock = MagicMock() 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, ) # ── _parse_street_house: pure unit tests ───────────────────────────────────── @pytest.mark.parametrize( ("address", "expected"), [ # DaData full form with district ( "620144, Свердловская обл, г Екатеринбург, Ленинский р-н, ул Серова, д 35", ("серова", "35"), ), # пр-кт + house with letter + квартира noise → drop кв, keep "26а" ( "620091, Свердловская обл, г Екатеринбург, пр-кт Космонавтов, д 26а, кв 335", ("космонавтов", "26а"), ), # корпус noise "к 3" dropped ("ул Щербакова, д 77 к 3", ("щербакова", "77")), # bare typed form ("Серова 27", ("серова", "27")), # street starting with a digit ("8 Марта 204", ("8 марта", "204")), # human-typed full form with abbreviations ( "Свердловская область, г. Екатеринбург, ул. Серова, д. 27", ("серова", "27"), ), # bare forms (validated prod addresses present in base) ("Малышева 30", ("малышева", "30")), ("Сурикова 31", ("сурикова", "31")), ("Шаумяна 28", ("шаумяна", "28")), ("Щербакова 77", ("щербакова", "77")), # not-in-base bare forms still must PARSE (matcher returns None, not parser) ("Космонавтов 7", ("космонавтов", "7")), ("Космонавтов 26", ("космонавтов", "26")), ("Педагогическая 15", ("педагогическая", "15")), ("Фрунзе 75", ("фрунзе", "75")), ("Белинского 83", ("белинского", "83")), ], ) def test_parse_street_house_valid_forms(address: str, expected: tuple[str, str]) -> None: assert _parse_street_house(address) == expected @pytest.mark.parametrize( "address", [ "", " ", "полный мусор без дома", "Екатеринбург", "Свердловская область", ], ) def test_parse_street_house_returns_none_for_garbage(address: str) -> None: assert _parse_street_house(address) is None # ── _cadastral_house_match: mocked FDW session ─────────────────────────────── def test_house_match_returns_suggestion_when_row_found() -> None: """DB returns a row → GeocodeSuggestion built from it.""" row = MagicMock() row.readable_address = "Свердловская область, г. Екатеринбург, ул. Серова, д. 27" row.lat = 56.81188 row.lon = 60.59739 db = MagicMock() result = MagicMock() result.first.return_value = row db.execute.return_value = result hit = _cadastral_house_match(db, "серова", "27") assert hit is not None assert isinstance(hit, GeocodeSuggestion) assert hit.kind == "house" assert hit.lat == 56.81188 assert hit.lon == 60.59739 assert hit.full_address == row.readable_address def test_house_match_returns_none_when_no_row() -> None: """No matching row → None.""" db = MagicMock() result = MagicMock() result.first.return_value = None db.execute.return_value = result assert _cadastral_house_match(db, "космонавтов", "7") is None def test_house_match_returns_none_on_db_error() -> None: """FDW raises → None, no exception bubbles out (graceful).""" db = MagicMock() db.execute.side_effect = RuntimeError("FDW connection failed") assert _cadastral_house_match(db, "серова", "27") is None def test_house_match_returns_none_for_non_numeric_house() -> None: """House with no leading digits → None before any DB call.""" db = MagicMock() assert _cadastral_house_match(db, "серова", "abc") is None db.execute.assert_not_called() def _house_match_params(house: str, street: str = "космонавтов") -> dict: """Вызывает матчер с mock-сессией и возвращает bound-params запроса.""" row = MagicMock() row.readable_address = "г. Екатеринбург, пр-кт Космонавтов, д. 26а" row.lat = 56.9 row.lon = 60.6 db = MagicMock() result = MagicMock() result.first.return_value = row db.execute.return_value = result _cadastral_house_match(db, street, house) # second positional arg to execute() is the bound-params dict 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 ───────────────────────────────────────────────────────── async def test_geocode_uses_house_match_before_legacy_forward() -> None: """Parse succeeds + house-match hits → returns result, legacy forward NOT called.""" db = MagicMock() hit = GeocodeSuggestion( label="ул. Серова, д. 27, Екатеринбург", full_address="ул. Серова, д. 27, Екатеринбург", lat=56.81188, lon=60.59739, kind="house", ) 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=hit, ) as mock_house, patch( "app.services.geocoder._cadastral_forward_sync", ) as mock_forward, patch("app.services.geocoder._cache_put"), ): result = await geocode("Серова 27", db) assert result is not None assert result.lat == 56.81188 assert result.lon == 60.59739 assert result.confidence == "exact" mock_house.assert_called_once() # house-match hit → legacy raw-ILIKE forward never invoked mock_forward.assert_not_called() async def test_geocode_falls_back_to_legacy_forward_when_house_match_misses() -> None: """Parse succeeds but house-match returns None → legacy forward IS called.""" db = MagicMock() legacy_hit = GeocodeSuggestion( label="ул. Дублёр, 1", full_address="ул. Дублёр, 1, Екатеринбург", lat=56.84, lon=60.61, kind="house", ) 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=[legacy_hit], ) as mock_forward, patch("app.services.geocoder._cache_put"), ): result = await geocode("Серова 27", db) assert result is not None assert result.lat == 56.84 mock_house.assert_called_once() mock_forward.assert_called_once() async def test_geocode_skips_house_match_when_parse_fails() -> None: """Unparseable address → house-match NOT called, legacy forward used.""" db = MagicMock() with ( patch("app.services.geocoder._cache_get", return_value=None), patch( "app.services.geocoder._cadastral_house_match", ) as mock_house, patch( "app.services.geocoder._cadastral_forward_sync", return_value=[], ) as mock_forward, patch("app.services.geocoder._cache_put"), patch( "app.services.geocoder._nominatim_lookup", new_callable=AsyncMock, return_value=None, ), ): result = await geocode("полный мусор без дома", db) assert result is None mock_house.assert_not_called() mock_forward.assert_called_once() # ── geocode() non-EKB city gate (#11) ──────────────────────────────────────── async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None: """Non-EKB oblast-адрес (Нижний Тагил) → EKB-only локальные тиры (geoportal/ cadastral) НЕ вызываются, результат идёт от oblast-aware Nominatim (#11). Без гейта «проспект Ленина, 1» коллизирует с одноимённым ЕКБ-домом через geoportal/cadastral и снапается в ЕКБ вместо Нижнего Тагила. """ db = MagicMock() nominatim_result = GeocodeResult( lat=57.905, lon=59.950, full_address="проспект Ленина, 1, Нижний Тагил", provider="nominatim", confidence="approximate", ) with ( patch("app.services.geocoder._cache_get", return_value=None), patch("app.services.geocoder._geoportal_house_match") as mock_geoportal, patch("app.services.geocoder._cadastral_house_match") as mock_house, patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, 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("Нижний Тагил, проспект Ленина, 1", db) assert result is not None assert result.lat == pytest.approx(57.905) assert result.lon == pytest.approx(59.950) mock_geoportal.assert_not_called() mock_house.assert_not_called() mock_forward.assert_not_called() mock_nominatim.assert_called_once() # ── geocode() fail-closed gate for UNRECOGNIZED locality (#2582) ──────────── # Живые прод-случаи из geocode_cache: адрес называет город/регион ВНЕ списка # из 37 (или другой регион РФ целиком) — до фикса `_names_non_ekb_city` # молчаливо считал это «город не назван» → EKB-only тиры отвечали первыми и # снапали улицу+дом в координаты ЕКБ. @pytest.mark.parametrize( ("address", "label"), [ ("республика крым, ялта, ул. репина, 18с2", "Ялта (другой регион)"), ("челябинская обл., трёхгорный, ул. ленина, 15", "Трёхгорный (другой регион)"), ( "свердловская обл., кировградский р-н, пос. лёвиха, ул. карла маркса, 8", "Лёвиха (город области ВНЕ списка 37)", ), ("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"), ( "с. Новоалексеевское, ул. 8 Марта,37", "Новоалексеевское (живой инцидент #2589 H1 — «с.»-маркер без доп. «обл.»)", ), ], ) async def test_geocode_skips_ekb_local_tiers_for_unrecognized_locality( address: str, label: str ) -> None: """EKB-only локальные тиры (geoportal/cadastral) НЕ вызываются для адреса, называющего незнакомый топоним — идём сразу к внешнему oblast/nation-aware провайдеру (#2582).""" db = MagicMock() nominatim_result = GeocodeResult( lat=45.0, lon=34.0, full_address=address, provider="nominatim", confidence="approximate", ) with ( patch("app.services.geocoder._cache_get", return_value=None), patch("app.services.geocoder._geoportal_house_match") as mock_geoportal, patch("app.services.geocoder._cadastral_house_match") as mock_house, patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, 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(address, db) assert result is not None, label mock_geoportal.assert_not_called() mock_house.assert_not_called() mock_forward.assert_not_called() mock_nominatim.assert_called_once() @pytest.mark.parametrize( ("address", "label"), [ ("Серова 27", "ЕКБ без города (основной трафик формы оценки)"), ("Малышева 30", "ЕКБ без города (основной трафик формы оценки)"), ("г. Екатеринбург, ул. Серова, д. 27", "ЕКБ с явным городом"), ("ул. Серова, д. 27", "house-маркер «д.» не закрывает гейт (#2589 H1 regression)"), ], ) async def test_geocode_still_uses_ekb_local_tiers_for_real_ekb_address( address: str, label: str ) -> None: """Регрессия: реальный ЕКБ-адрес (с городом и без) по-прежнему резолвится через geoportal (fast-path) — fail-closed гейт не должен деградировать основной трафик (#2582).""" db = MagicMock() hit = GeocodeSuggestion( label="ул. Серова, д. 27, Екатеринбург", full_address="ул. Серова, д. 27, Екатеринбург", lat=56.81188, lon=60.59739, kind="house", ) with ( patch("app.services.geocoder._cache_get", return_value=None), patch("app.services.geocoder._geoportal_house_match", return_value=hit) as mock_geoportal, patch("app.services.geocoder._cache_put"), ): result = await geocode(address, db) assert result is not None, label assert result.lat == pytest.approx(56.81188), label mock_geoportal.assert_called_once() async def test_geocode_explicit_ekaterinburg_city_hint_still_uses_local_tiers() -> None: """Слияние #2580/#2582: явный `city_hint="Екатеринбург"` (без города в самом тексте адреса, без конфликта) по-прежнему открывает ЕКБ-only локальные тиры — ветка D из объединённого гейта `_ekb_local_tiers_allowed` не должна была стать недостижимой после merge.""" db = MagicMock() hit = GeocodeSuggestion( label="ул. Серова, д. 27, Екатеринбург", full_address="ул. Серова, д. 27, Екатеринбург", lat=56.81188, lon=60.59739, kind="house", ) with ( patch("app.services.geocoder._cache_get", return_value=None), patch("app.services.geocoder._geoportal_house_match", return_value=hit) as mock_geoportal, patch("app.services.geocoder._cache_put"), ): result = await geocode("Серова 27", db, city_hint="Екатеринбург") assert result is not None assert result.lat == pytest.approx(56.81188) mock_geoportal.assert_called_once() async def test_geocode_city_hint_unknown_city_skips_local_ekb_tiers() -> None: """#2589 M2 (deep-review fix): `city_hint="Реж"` — город ВНЕ списка 37 и без структурного маркера в самом тексте адреса — обязан закрыть ЕКБ-only локальные тиры так же, как известный `city_hint`. До фикса `hint_names_non_ekb = _names_non_ekb_city(city_hint)` был list-based и держал гейт открытым для ЛЮБОГО города вне списка 37 (Реж, седьмой город после расширения UI-selector'а, произвольный текст через API) — geoportal отдавал точный ЕКБ-хит, а `city_ambiguous=False` (хинт ведь БЫЛ) делало это уверенно-неверным результатом (хуже, чем честный `city_ambiguous=True` без хинта вообще).""" db = MagicMock() ekb_hit = GeocodeSuggestion( label="Ленина, 1, Екатеринбург", full_address="Ленина, 1, Екатеринбург", lat=56.83788, lon=60.58018, kind="house", ) rezh_result = GeocodeResult( lat=57.371, lon=61.386, full_address="Ленина, 1, Реж", provider="nominatim" ) with ( patch("app.services.geocoder._cache_get", return_value=None), patch("app.services.geocoder._cache_put"), patch( "app.services.geocoder._geoportal_house_match", return_value=ekb_hit ) as mock_geoportal, patch("app.services.geocoder._cadastral_house_match", return_value=ekb_hit) as mock_house, patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), patch( "app.services.geocoder._nominatim_lookup", new_callable=AsyncMock, return_value=rezh_result, ), ): result = await geocode("Ленина, 1", db, city_hint="Реж") mock_geoportal.assert_not_called() mock_house.assert_not_called() assert result is not None assert result.lat == pytest.approx(57.371) # Реж, НЕ подставленный ЕКБ-хит (56.83788) assert result.lat != pytest.approx(56.83788) # ── suggest() wiring ───────────────────────────────────────────────────────── async def test_suggest_uses_house_match_before_legacy_forward() -> None: """Parse + house-match hit → returns single suggestion, legacy forward not called.""" db = MagicMock() hit = GeocodeSuggestion( label="ул. Серова, д. 27", full_address="ул. Серова, д. 27, Екатеринбург", lat=56.81188, lon=60.59739, kind="house", ) with ( patch( "app.services.geocoder._cadastral_house_match", return_value=hit, ) as mock_house, patch( "app.services.geocoder._cadastral_forward_sync", ) as mock_forward, ): results = await suggest("Серова 27", db=db, limit=8) assert len(results) == 1 assert results[0].lat == 56.81188 mock_house.assert_called_once() mock_forward.assert_not_called() async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() -> None: """Parse succeeds, house-match None → legacy forward IS called.""" db = MagicMock() legacy = [ GeocodeSuggestion( label="ул. Дублёр, 1", full_address="ул. Дублёр, 1, Екатеринбург", lat=56.84, lon=60.61, kind="house", ) ] with ( patch( "app.services.geocoder._cadastral_house_match", return_value=None, ) as mock_house, patch( "app.services.geocoder._cadastral_forward_sync", return_value=legacy, ) as mock_forward, ): results = await suggest("Серова 27", db=db, limit=8) assert len(results) == 1 assert results[0].lat == 56.84 mock_house.assert_called_once() mock_forward.assert_called_once() async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None: """Non-EKB oblast query (Нижний Тагил) → EKB-only cadastral Tier 1 (`_cadastral_house_match` / `_cadastral_forward_sync`) NOT called; falls through to external tiers, same root cause as `geocode()` (#11).""" db = MagicMock() nominatim_hit = [ GeocodeSuggestion( label="проспект Ленина, 1", full_address="проспект Ленина, 1, Нижний Тагил", lat=57.905, lon=59.950, kind="house", ) ] with ( patch("app.services.geocoder._cadastral_house_match") as mock_house, patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, patch("app.services.geocoder.settings") as mock_settings, patch( "app.services.geocoder._nominatim_suggest", new_callable=AsyncMock, return_value=nominatim_hit, ) as mock_nominatim, ): mock_settings.dadata_api_token = None results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8) assert len(results) == 1 assert results[0].lat == pytest.approx(57.905) mock_house.assert_not_called() mock_forward.assert_not_called() mock_nominatim.assert_called_once() @pytest.mark.parametrize( ("query", "label"), [ ("республика крым, ялта, ул. репина, 18с2", "Ялта (другой регион)"), ("свердловская обл., реж, ул. ленина, 5", "Реж (город области ВНЕ списка 37)"), ], ) async def test_suggest_skips_ekb_local_tier_for_unrecognized_locality( query: str, label: str ) -> None: """Незнакомый топоним (не в списке 37, не Екатеринбург) → EKB-only cadastral Tier 1 не вызывается; falls through к внешним тирам (#2582).""" db = MagicMock() nominatim_hit = [ GeocodeSuggestion( label="ул. Репина, 18с2", full_address=query, lat=44.5, lon=34.16, kind="house", ) ] with ( patch("app.services.geocoder._cadastral_house_match") as mock_house, patch("app.services.geocoder._cadastral_forward_sync") as mock_forward, patch("app.services.geocoder.settings") as mock_settings, patch( "app.services.geocoder._nominatim_suggest", new_callable=AsyncMock, return_value=nominatim_hit, ) as mock_nominatim, ): mock_settings.dadata_api_token = None results = await suggest(query, db=db, limit=8) assert len(results) == 1, label 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