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
Ревью 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
109 lines
6.7 KiB
Python
109 lines
6.7 KiB
Python
"""Достижимость тиров матчинга домов (#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
|
||
), "в ключ дома вернулся фолбэк на кадастр квартиры"
|