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
133 lines
8.3 KiB
Python
133 lines
8.3 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
|
||
), "в ключ дома вернулся фолбэк на кадастр квартиры"
|
||
|
||
|
||
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 соседнего города обойдён"
|