"""Unit tests for the `houses` fallback tier of `geocode()` (#2626). Covers: - `_norm_local_house`: normalization of corpus/slash house-number forms («49 к 1» / «49-к1» / «49 корпус 1» → «49к1»; «88 / 2» → «88/2»). - `_extract_local_house_token`: pulling the house-number token out of a raw user address, WITH the corpus/slash suffix that `_parse_street_house`'s `_HOUSE_NUM` drops. - `_clean_local_house_street` / `_row_local_house`: extracting a comparable (street, house) pair out of the free-text `houses.address` column (multiple scraper source formats — avito/cian/derived/yandex_valuation). - `_street_tail_matches`: «Онуфриева» finds «Начдива Онуфриева» (ГАР canonical name), regardless of leading district/city noise. - `_local_houses_match`: full tier with a mocked DB session — exact number match, corpus-1 fallback guess («49» → «49к1»), and the defensive "ambiguous → None" invariant (no guessing on >1 distinct match). - `geocode()` wiring: local-houses tier is the LAST step, only reached when cache/geoportal/cadastral/Nominatim all miss, and marks `GeocodeResult.address_refined=True`. Real prod addresses (#2626, lat IS NULL in trade_in_estimates) are used as regression fixtures: «ул Онуфриева, д 24» → «Начдива Онуфриева, 24к1», «ул. Хрустальногорская, д. 88/2» → exact match, «ул Крестинского, д 49» → genuinely ambiguous in prod data (two DIFFERENT buildings both stored as «Крестинского, 49к1» — must NOT resolve, per the defensive "no guessing" rule). """ from __future__ import annotations import os import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) from app.services.geocoder import ( # noqa: E402 GeocodeSuggestion, _clean_local_house_street, _extract_local_house_token, _local_houses_match, _norm_local_house, _row_local_house, _street_tail_matches, geocode, ) # ── _norm_local_house ──────────────────────────────────────────────────────── @pytest.mark.parametrize( ("raw", "expected"), [ ("49 к 1", "49к1"), ("49-к1", "49к1"), ("49 корпус 1", "49к1"), ("49 корп. 1", "49к1"), ("88 / 2", "88/2"), ("88/2", "88/2"), ("35А", "35а"), ("13Б", "13б"), ("13-б", "13б"), ("44", "44"), ], ) def test_norm_local_house(raw: str, expected: str) -> None: assert _norm_local_house(raw) == expected # ── _extract_local_house_token ─────────────────────────────────────────────── @pytest.mark.parametrize( ("address", "expected"), [ ("ул Крестинского, д 49", "49"), ("ул. Хрустальногорская, д. 88/2", "88/2"), ("ул Онуфриева, д 24", "24"), ("Крестинского 49к1", "49к1"), ("8 Марта 204", "204"), # digit-leading street name doesn't confuse it ("Малышева 30", "30"), # #2626 review R2 #1 — прод-баг: квартира подменяла дом («д 15, кв 11» # → дом «11», чужое здание). Реальные строки из trade_in_estimates: ( "620078, Свердловская обл, г Екатеринбург, Кировский р-н, " "ул Педагогическая, д 15, кв 11", "15", ), ( "620078, Свердловская обл, г Екатеринбург, Кировский р-н, " "ул Педагогическая, д 15, кв 48", "15", ), # корпус ПЕРЕД квартирой — «26 к 1» обязан остаться частью номера дома, # «кв 41» — уйти: ( "620149, Свердловская обл, г Екатеринбург, Ленинский р-н, " "ул Начдива Онуфриева, д 26 к 1, кв 41", "26к1", ), # подъезд/этаж — тот же класс бага, что и квартира (последнее число в # строке — не дом): ( "Россия, Свердловская область, Екатеринбург, Трамвайный переулок, " "2к2, подъезд 1, этаж 25, кв. 205", "2к2", ), ], ) def test_extract_local_house_token(address: str, expected: str) -> None: assert _extract_local_house_token(address) == expected def test_extract_local_house_token_none_for_garbage() -> None: assert _extract_local_house_token("") is None assert _extract_local_house_token("Екатеринбург") is None # ── _clean_local_house_street / _street_tail_matches ──────────────────────── def test_clean_local_house_street_strips_type_regardless_of_position() -> None: """Тип улицы ДО имени («улица X») и ПОСЛЕ («X ул.») — оба зачищаются.""" assert _clean_local_house_street("улица Начдива Онуфриева") == "начдива онуфриева" assert _clean_local_house_street("Хрустальногорская ул.") == "хрустальногорская" def test_street_tail_matches_onufrieva_finds_nachdiva_onufrieva() -> None: """Ядро #2626: «Онуфриева» (как пишет пользователь) находит «Начдива Онуфриева» (каноничное имя ГАР, как в houses.address).""" assert _street_tail_matches("начдива онуфриева", "онуфриева") is True def test_street_tail_matches_exact_equality() -> None: assert _street_tail_matches("хрустальногорская", "хрустальногорская") is True def test_street_tail_matches_rejects_non_suffix_substring() -> None: """«Онуфриева» НЕ находит несвязанную улицу, где она — не хвостовое слово.""" assert _street_tail_matches("онуфриева южная", "онуфриева") is False # ── _row_local_house: разбор houses.address разных форматов источников ────── @pytest.mark.parametrize( ("row_address", "expected"), [ ( "р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1", ("р-н чкаловский мкр. ботанический крестинского", "49к1"), ), ("Хрустальногорская ул.,88/2", ("хрустальногорская", "88/2")), ("ул. Начдива Онуфриева,24к2", ("начдива онуфриева", "24к2")), ( "р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", ("р-н ленинский мкр. юго-западный начдива онуфриева", "24к1"), ), ("Крестинского, 44", ("крестинского", "44")), # house-then-district order («·» separator, no comma before house) — # match-from-start of the LAST comma-segment still finds the leading token. ("улица Хрустальногорская, 35к1 · р-н Академический", ("хрустальногорская", "35к1")), ], ) def test_row_local_house(row_address: str, expected: tuple[str, str]) -> None: assert _row_local_house(row_address) == expected def test_row_local_house_none_without_house_segment() -> None: """Нет запятой (номер дома не отделён сегментом) → None, не гадаем.""" assert _row_local_house("Крестинского") is None assert _row_local_house("") is None # ── _local_houses_match: full tier, mocked db ──────────────────────────────── def _make_row(address: str, lat: float, lon: float) -> MagicMock: row = MagicMock() row.address = address row.lat = lat row.lon = lon return row def _db_with_rows(rows: list[MagicMock]) -> MagicMock: db = MagicMock() db.execute.return_value.fetchall.return_value = rows return db def test_local_houses_match_exact_house_number() -> None: """«88/2» точно совпадает с единственной строкой houses — возвращает её координаты.""" db = _db_with_rows( [ _make_row("Хрустальногорская ул.,88", 56.79412, 60.498687), _make_row("Хрустальногорская ул.,88/2", 56.793218, 60.497106), ] ) hit = _local_houses_match(db, "хрустальногорская", "88/2") assert hit is not None assert isinstance(hit, GeocodeSuggestion) assert hit.lat == pytest.approx(56.793218) assert hit.lon == pytest.approx(60.497106) assert hit.kind == "house" def test_local_houses_match_street_tail_and_corpus1_guess() -> None: """«Онуфриева, 24» (без «Начдива», без корпуса), реестр — ЕДИНСТВЕННЫЙ корпус «24к1» → уверенная догадка (нет sibling-корпусов — не угадайка).""" db = _db_with_rows( [ _make_row( "р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", 56.802928, 60.551696, ), ] ) hit = _local_houses_match(db, "онуфриева", "24") assert hit is not None assert hit.lat == pytest.approx(56.802928) assert hit.lon == pytest.approx(60.551696) def test_local_houses_match_corpus1_guess_skipped_when_sibling_corpus_exists() -> None: """#2626 review R2 #3, прод-данные: «Начдива Онуфриева, 24» реально ТРИ разных здания (24к1/24к2/24к3, 250-400м друг от друга). Догадка «→24к1» не угадывает конкретное здание среди known-siblings — честный None, не «уверенный» результат с confidence='exact' на случайно выбранном доме.""" db = _db_with_rows( [ _make_row( "р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", 56.802928, 60.551696, ), _make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391), _make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283), ] ) assert _local_houses_match(db, "онуфриева", "24") is None def test_local_houses_match_corpus1_guess_skipped_when_slash_sibling_exists() -> None: """Sibling-guard ловит не только «кN», но и «/N» вариант того же номера.""" db = _db_with_rows( [ _make_row("улица X, 24к1", 56.80, 60.60), _make_row("улица X, 24/2", 56.81, 60.61), ] ) assert _local_houses_match(db, "x", "24") is None def test_local_houses_match_no_corpus1_candidate_returns_none() -> None: """Только «24к2»/«24к3» в реестре (нет «24к1») → фолбэк НЕ гадает, None.""" db = _db_with_rows( [ _make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391), _make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283), ] ) assert _local_houses_match(db, "онуфриева", "24") is None def test_local_houses_match_ambiguous_exact_number_returns_none() -> None: """Прод-кейс: «Крестинского, 49к1» встречается ДВАЖДЫ с РАЗНЫМИ координатами (две разные строки houses) — неоднозначность, фолбэк не угадывает, None.""" db = _db_with_rows( [ _make_row( "р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1", 56.789895, 60.632464, ), _make_row("Екатеринбург, улица Крестинского, 49к1", 56.7952695, 60.610079), ] ) assert _local_houses_match(db, "крестинского", "49к1") is None def test_local_houses_match_ambiguous_corpus1_guess_returns_none() -> None: """«49» → «49к1»-кандидатов больше одного (разные координаты) → None.""" db = _db_with_rows( [ _make_row("улица X, 49к1", 56.80, 60.60), _make_row("улица X, 49к1", 56.81, 60.61), ] ) assert _local_houses_match(db, "x", "49") is None def test_local_houses_match_deduplicates_same_building_different_sources() -> None: """Один и тот же дом, две source-строки (avito+cian) с ПОЧТИ идентичными координатами — НЕ считается неоднозначностью (дедуп по округлённым coords).""" db = _db_with_rows( [ _make_row("улица X, 49к1", 56.800001, 60.600001), _make_row("улица X, 49к1", 56.800002, 60.600002), # тот же дом, другой source ] ) hit = _local_houses_match(db, "x", "49к1") assert hit is not None assert hit.lat == pytest.approx(56.800001) def test_local_houses_match_no_guess_for_non_digit_house() -> None: """Запрос уже с литерой/корпусом («35к3»), точного совпадения нет — корпус-1 ДОГАДКА не пробуется (не «35к3к1»), результат None.""" db = _db_with_rows([_make_row("улица X, 35к4", 56.80, 60.60)]) assert _local_houses_match(db, "x", "35к3") is None def test_local_houses_match_returns_none_on_db_error() -> None: db = MagicMock() db.execute.side_effect = RuntimeError("connection lost") assert _local_houses_match(db, "онуфриева", "24") is None # ── bbox guard: `houses` is NOT EKB-only (#2626 review R2 #2) ─────────────── def test_local_houses_match_rejects_row_outside_ekb_bbox() -> None: """Прод-кейс: «улица Маяковского, 7» в `houses` — это Серов (56.6/60.66 — ~310км от ЕКБ), не Екатеринбург. `use_local_ekb` в `geocode()` гейтит только ЗАПРОС пользователя, не координаты строки-источника — bbox-фильтр внутри `_local_houses_match` обязан отбросить такую строку, а не вернуть её как confidence='exact' совпадение чужого города.""" db = _db_with_rows( [_make_row("улица Маяковского, 7", 59.652903, 60.659674)], # Серов, не ЕКБ ) assert _local_houses_match(db, "маяковского", "7") is None def test_local_houses_match_accepts_row_inside_ekb_bbox_wide() -> None: """Контроль: легитимная ЕКБ-строка (в т.ч. приграничье, в WIDE, не в TIGHT) по-прежнему проходит — bbox-фильтр не режет реальные ЕКБ-дома.""" db = _db_with_rows( [_make_row("Екатеринбург, улица Маяковского, 8", 56.862701, 60.620274)], ) hit = _local_houses_match(db, "маяковского", "8") assert hit is not None assert hit.lat == pytest.approx(56.862701) # ── deterministic ORDER BY (#2626 review R2 #5) ────────────────────────────── def test_local_houses_match_query_has_deterministic_order_by() -> None: """Без ORDER BY дедуп по округлённым координатам оставлял бы ПЕРВУЮ строку в порядке сканирования — недетерминированно между вызовами. SQL обязан сортировать явно.""" db = _db_with_rows([]) _local_houses_match(db, "x", "1") sql_text = str(db.execute.call_args[0][0]) assert "ORDER BY" in sql_text.upper() # ── geocode() wiring — last-resort tier, sets address_refined ─────────────── async def test_geocode_falls_back_to_local_houses_after_nominatim_miss() -> None: """Cache/geoportal/cadastral/Nominatim все промахнулись → local-houses тир вызывается ПОСЛЕДНИМ и помечает результат `address_refined=True`.""" db = MagicMock() hit = GeocodeSuggestion( label="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", full_address="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", lat=56.802928, lon=60.551696, 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), patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), patch("app.services.geocoder._cache_put") as mock_cache_put, patch( "app.services.geocoder._nominatim_lookup", new_callable=AsyncMock, return_value=None, ), patch( "app.services.geocoder._local_houses_match", return_value=hit, ) as mock_local, ): result = await geocode("ул Онуфриева, д 24", db) assert result is not None assert result.lat == pytest.approx(56.802928) assert result.confidence == "exact" assert result.address_refined is True mock_local.assert_called_once() # #2626 review R2 #4 — houses-фолбэк дешёвый и менее надёжный источник # координат, чем geoportal/cadastral/Nominatim — свой результат не кэширует. mock_cache_put.assert_not_called() async def test_geocode_address_refined_false_when_earlier_tier_hits() -> None: """geoportal-хит (обычный, точный ввод) НЕ помечается `address_refined` — флаг честно относится ТОЛЬКО к houses-фолбэку.""" 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), patch("app.services.geocoder._cache_put"), patch( "app.services.geocoder._local_houses_match", ) as mock_local, ): result = await geocode("Серова 27", db) assert result is not None assert result.address_refined is False mock_local.assert_not_called() async def test_geocode_returns_none_when_local_houses_also_misses() -> None: """Все тиры включая houses-фолбэк промахнулись → honest None (не выдумываем).""" db = MagicMock() 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), 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=None, ), patch("app.services.geocoder._local_houses_match", return_value=None) as mock_local, ): result = await geocode("ул Онуфриева, д 24", db) assert result is None mock_local.assert_called_once() async def test_geocode_local_houses_apartment_number_does_not_leak_into_house() -> None: """End-to-end regression, #2626 review R2 #1: реальный прод-адрес с хвостом «кв 11» должен резолвиться в дом 15 (`Педагогическая ул.,15`), а НЕ в дом 11 (`Педагогическая ул.,11` — чужое здание) — `_local_houses_match` не замокан, проверяем полную цепочку `geocode()` → `_extract_local_house_token` → SQL-lookup.""" db = _db_with_rows( [ _make_row("Педагогическая ул.,11", 56.835387, 60.654104), _make_row("Педагогическая ул.,15", 56.835284, 60.655829), ] ) 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), patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), patch("app.services.geocoder._cache_put") as mock_cache_put, patch( "app.services.geocoder._nominatim_lookup", new_callable=AsyncMock, return_value=None, ), ): result = await geocode( "620078, Свердловская обл, г Екатеринбург, Кировский р-н, " "ул Педагогическая, д 15, кв 11", db, ) assert result is not None assert result.lat == pytest.approx(56.835284) assert result.lon == pytest.approx(60.655829) assert result.address_refined is True mock_cache_put.assert_not_called()