gendesign/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py
bot-backend 272abac4d2
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m6s
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 2m3s
fix(tradein/matching): город развёртки доезжает до стража Tier-2a — межгородские склейки домов (#2777) (#2808)
2026-08-10 08:51:14 +00:00

133 lines
8.3 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
), "в ключ дома вернулся фолбэк на кадастр квартиры"
def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None:
"""Страж города бесполезен, пока признак не доезжает до него с настоящего вызова (#2777).
Тот же класс ошибки, что у `house_fias_id`: параметр в сигнатуре есть, передать его
некому. Здесь граница — `scraper_kit.base._link_listing_to_house`, единственный боевой
вызывающий пути создания домов; город он получает из `save_listings`, который его же
пишет в `listings.city`.
"""
from scraper_kit.base import _link_listing_to_house, save_listings
assert "city" in _params(match_or_create_house)
hook_src = inspect.getsource(_link_listing_to_house)
assert "city=city" in hook_src, (
"_link_listing_to_house перестал передавать город в матчер — страж #2777 снова "
"недостижим, а межгородская склейка молча вернётся"
)
# save_listings считает lot_city (город batch'а после гео-guard'а) и обязан отдать
# именно его, а не сырой city-аргумент: лот вне city_radius_km города НЕ помечен.
assert "city=lot_city" in inspect.getsource(
save_listings
), "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён"