gendesign/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py
bot-backend fa84705ec7
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m0s
fix(tradein/geocoder): stop apt number leaking into house + houses bbox/sibling guard
Review round 2 on #2626 (local houses fallback) found two HIGH-severity bugs
verified live against prod data:

1. _extract_local_house_token took the LAST digit-like token in the raw
   address, so "...Педагогическая, д 15, кв 11" resolved house=11 (apartment
   number) instead of 15 -- confidently returning a stranger's building with
   confidence='exact', written to geocode_cache. Fixed by stripping the
   apartment/office/floor/entrance tail (кв/оф/пом/подъезд/этаж -- NOT
   корп/к, which is part of the house number) before extracting the token.
   Fixes the exact prod case from the review plus the corpus+apartment
   combo ("д 26 к 1, кв 41" -> 26к1, not 41).

2. houses is not an EKB-only table (21% of rows with coords are outside the
   metro, some as far as another city) -- "улица Маяковского, 7" in houses
   resolves to Серов, not Екатеринбург, and use_local_ekb only gates the
   user's query text, not the source row. Added an is_within_ekb_bbox_wide
   check on every candidate row before it can become a match.

Also addressed two MEDIUM findings from the same review:

3. The "<номер> -> <номер>к1" corpus guess only checked uniqueness among
   к1-labelled rows, so real multi-building addresses (Онуфриева 24: к1/к2/к3,
   250-400m apart) resolved confidently to к1 anyway. Guess is now skipped
   when any other corpus/slash variant of the same base number exists among
   the street's candidates.

4. Houses-fallback results are no longer cached in geocode_cache -- the
   source (scraped listings) is less reliable than geoportal/cadastral/
   Nominatim, and the lookup is cheap/local, so caching only extended the
   lifetime of a possible bad match. Side benefit: address_refined now
   survives every repeat request of the same raw address, not just the
   first.

Also added ORDER BY address, id to the underlying query so the coordinate
dedup picks a deterministic row (LOW finding #5).

14 new/updated tests in test_geocoder_local_houses_fallback.py cover all
five findings against real prod address/houses-row fixtures. Full geocoder
+ dadata + estimator/pdf regression suite (402 tests) green.
2026-08-15 18:50:17 +03:00

510 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()