"""Достижимость тиров матчинга домов (#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 соседнего города обойдён" )