gendesign/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py
bot-backend 1a577fe748
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m54s
fix(tradein/matching): снять слияние по ГАР-GUID, починить приёмник кадастра и keeper (#2674)
Ревью PR #2688 нашло, что расширение ключа дедупа было неверным. Снимаю его
полностью и добавляю три правки, которых не хватало.

СНЯТО: слияние 781 дома по COALESCE(house_fias_id, gar_house_guid).
Аргумент «общий UUID здания есть независимая идентичность» оказался круговым.
gar_flats_loader проставляет gar_house_guid предикатом
  WHERE tradein_canon_addr(COALESCE(h.short_address, h.full_address, h.address)) = gp.canon
— левая часть побайтово равна ключу канон-прохода, то есть guid является
детерминированной функцией канон-адреса, а не вторым наблюдением. Проход шёл
с выключенным гео-стражем, значит #2187 обходился боковой дверью: канон-проход
отказывается слить два дома в 6 км, а этот сливал их же за «общий UUID»,
выданный за тот же адрес. Плюс gar_pick берёт DISTINCT ON (canon) — одна
ГАР-строка на канон, а 20.3% канонов накрывают несколько зданий, и ЕКБ-фильтр
стоит только на стороне ГАР. Кедровка/Советская 17 уехала бы в ЕКБ. Нужен
ключ, независимый от канона, либо включённый гео-страж — это другая задача.

Приёмник кадастра сужен до кадастра ЗДАНИЯ. Параметр cadastral_number (кадастр
КВАРТИРЫ) убран из match_or_create_house, Protocol HouseMatcher,
RealMatcherAdapter и обоих вызывающих; `cad` больше не падает на него фолбэком.
Мина была отложенной: начни Циан отдавать offer["cadastralNumber"], который
парсер уже читает, — у каждой квартиры свой номер, Tier 0 не сматчил бы
никогда, New-house INSERT записал бы номер квартиры в houses.cadastral_number
и попутно снял P1-страж «безномерный адрес без кадастра не создаём». Две
квартиры одного дома дали бы два дома — то самое дробление. В listings оба
поля пишутся как раньше.

Keeper: listing_cnt DESC NULLS LAST. Счётчик приходит из LEFT JOIN, у дома без
объявлений он NULL, а DESC в Postgres — NULLS FIRST, поэтому пустая запись
обгоняла запись со 192 объявлениями вопреки задокументированному правилу.
Дефект предсуществующий и живой для канон-прохода.

Сторож границы вызова для живого ФИАС-тира. Прежние проверки были
структурными — видели имя параметра в сигнатуре. Уберут аргумент на настоящей
границе (estimator.estimate_quality -> match_house_readonly) — сигнатура цела,
тесты зелёные, тир снова мёртв. Новый тест смотрит на сам вызов. Заявление
«тест ловит неуловимый класс» из прошлого описания снято как преувеличение:
структурная проверка ловит подслучай, и building_cadastral_number её проходит
при нуле срабатываний из 49 502.

Остаётся из первого захода: снятый фильтр поиска has_kadastr, разделение
ФИАС-тира (удалён в пути создания, оставлен в read-only), поправка ложного
утверждения в шапке cadastral_geo_match.py.

Refs #2674
2026-08-06 05:11:47 +05:00

109 lines
6.7 KiB
Python
Raw Permalink 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.

"""Достижимость тиров матчинга домов (#2674).
Эпик #2674 — «код написан, отревьюен, смержен и ни разу не сработал». Два верхних тира
`match_or_create_house` были именно такими: за всю историю 0 из 49 502 строк house_sources
имеют matched_method 'cadastr_exact' или 'fias_exact'.
Причина у fias-тира структурная: параметр `house_fias_id` существовал только в сигнатуре
самой функции — ни Protocol `scraper_kit.contracts.HouseMatcher`, ни продуктовый адаптер
`RealMatcherAdapter`, ни прямые вызывающие его не имели, т.е. передать значение было НЕКОМУ.
Обычный тест этого не ловит: он вызывает функцию напрямую и передаёт параметр сам.
Ловит — сверка сигнатуры с границей вызова. Здесь она и живёт: каждый keyword-параметр
матчера обязан быть выразим у вызывающего, иначе ветка недостижима в проде.
"""
from __future__ import annotations
import inspect
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.contracts import HouseMatcher
from app.services.matching.houses import match_house_readonly, match_or_create_house
from app.services.scraper_adapters import RealMatcherAdapter
def _params(func: object) -> set[str]:
return set(inspect.signature(func).parameters) - {"self", "db"}
def test_create_path_matcher_params_are_all_reachable_from_the_boundary() -> None:
"""Каждый параметр `match_or_create_house` должен быть передаваем через границу.
Границы две, и обе обязаны выражать полный набор:
* Protocol `HouseMatcher` — контракт, который видит scraper-kit;
* `RealMatcherAdapter` — единственная боевая реализация этого контракта.
Параметр, которого нет ни там ни там, — недостижимая ветка (случай `house_fias_id`,
#2674). RED до фикса: {'house_fias_id'}.
"""
impl = _params(match_or_create_house)
unreachable_via_protocol = impl - _params(HouseMatcher.match_or_create_house)
unreachable_via_adapter = impl - _params(RealMatcherAdapter.match_or_create_house)
assert not unreachable_via_protocol, (
f"параметры матчера, недостижимые через Protocol HouseMatcher: "
f"{sorted(unreachable_via_protocol)} — ветка не сработает в проде"
)
assert not unreachable_via_adapter, (
f"параметры матчера, недостижимые через RealMatcherAdapter: "
f"{sorted(unreachable_via_adapter)} — ветка не сработает в проде"
)
def test_fias_tier_is_gone_from_create_path_but_alive_in_readonly() -> None:
"""Fias-тир удалён там, где источника ФИАС нет, и сохранён там, где он есть.
`match_house_readonly` зовёт estimator.resolve_target_house и передаёт ФИАС из
payload.target_fias_id / DaData — источник реальный, тир оставляем.
"""
assert "house_fias_id" not in _params(match_or_create_house)
assert "house_fias_id" in _params(match_house_readonly)
# Ищем именно литерал method-значения ('"fias_exact"'), а не слово в комментарии:
# комментарий-надгробие про удалённый тир остаться должен, ветка — нет.
assert '"fias_exact"' not in inspect.getsource(
match_or_create_house
), "мёртвая fias-ветка вернулась в путь создания домов"
assert '"fias_exact"' in inspect.getsource(match_house_readonly)
def test_readonly_fias_tier_is_actually_fed_by_its_caller() -> None:
"""Единственный живой ФИАС-тир должен ПОЛУЧАТЬ аргумент, а не только объявлять параметр.
Проверки выше — структурные: они видят имя в сигнатуре. Этого мало. Уберут аргумент на
настоящей границе вызова (`estimator.estimate_quality` → `match_house_readonly`) — сигнатура
останется прежней, оба теста выше зелёные, а тир снова мёртв, и мы снова здесь.
Доказательство, что структурной проверки недостаточно, лежит рядом: `building_cadastral_number`
её проходит и при этом сработал 0 раз из 49 502.
"""
from app.services.estimator import estimate_quality
src = inspect.getsource(estimate_quality)
assert "match_house_readonly" in src, "estimate_quality больше не зовёт read-only матчер"
assert "house_fias_id=" in src, (
"estimate_quality перестал передавать house_fias_id — единственный живой fias-тир "
"снова недостижим (это и есть класс ошибки #2674)"
)
def test_house_key_never_accepts_flat_cadastre() -> None:
"""Ключ ДОМА строится только на кадастре здания — не на кадастре квартиры (#2674).
Ловушка «оставленного рабочего приёмника»: у квартиры свой кадастр, поэтому Tier 0 по нему
не сматчит НИКОГДА, упадёт в New-house INSERT и проштампует номер квартиры в
houses.cadastral_number; плюс `cad` снимает P1-страж «безномерный адрес без кадастра не
создаём». Две квартиры одного дома → два дома. RED до фикса: параметр был в сигнатуре, а
`cad = building_cadastral_number or cadastral_number`.
"""
assert "cadastral_number" not in _params(match_or_create_house)
assert "building_cadastral_number" in _params(match_or_create_house)
src = inspect.getsource(match_or_create_house)
assert (
"cad = building_cadastral_number\n" in src
), "в ключ дома вернулся фолбэк на кадастр квартиры"