"""Guard against implausible year_built poisoning the hedonic correction (Mera-audit 2026-08-02). house_metadata (OSM/кадастр, best-effort enrichment) и TradeInEstimateInput.year_built (payload, схема допускает ge=1800) могут отдать явно ошибочный год постройки МКД — прод-инцидент: house_metadata year_built=1829 для обычной вторички (см. vault fixes). Без валидации этот год уходит в хедонический year+area фактор (_price_from_inputs, #2002), который экстраполирует regression fit далеко за пределы обучающей выборки (COHORTS не определяет когорту раньше 1955 — см. estimator.py) и упирается в нижний кламп estimate_hedonic_factor_min=0.75 — выкупная цена режется на фиксированные −25% без физического смысла. _sanitize_build_year() отсекает год вне [MIN_PLAUSIBLE_BUILD_YEAR, текущий год + MAX_PLAUSIBLE_BUILD_YEAR_LEAD] на входе, трактуя его как «неизвестен» (None) — хедонический year-term становится нейтральным (эквивалент year=2000, см. test_estimator_hedonic.py ::test_target_year_none_is_neutral), а не клампится к произвольной границе. NOTE: importing app.services.estimator pulls app.core.config.Settings which requires DATABASE_URL. Set it BEFORE importing app modules (см. паттерн test_estimator_hedonic.py). """ from __future__ import annotations import os from datetime import UTC, datetime import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.services import estimator from app.services.geocoder import GeocodeResult # ── _sanitize_build_year: unit-level ──────────────────────────────────────── def test_implausible_low_year_dropped_to_none(caplog: pytest.LogCaptureFixture) -> None: """год=1829 (прод-инцидент house_metadata) → трактуется как отсутствующий.""" with caplog.at_level("WARNING"): result = estimator._sanitize_build_year(1829, house_id=42, address="ул. Тестовая, 1") assert result is None assert any("1829" in r.message for r in caplog.records) assert any("42" in r.message for r in caplog.records) def test_plausible_year_unchanged() -> None: """год=1960 (хрущёвка) — валиден, работает как раньше (без изменений).""" assert estimator._sanitize_build_year(1960) == 1960 def test_future_year_beyond_lead_dropped() -> None: """год = текущий+10 (далеко за допуском для строек) → отбрасывается.""" future_year = datetime.now(UTC).year + 10 assert estimator._sanitize_build_year(future_year) is None def test_near_future_year_within_lead_kept() -> None: """год = текущий + LEAD (граница допуска для строек) — остаётся валидным.""" near_future = datetime.now(UTC).year + estimator.MAX_PLAUSIBLE_BUILD_YEAR_LEAD assert estimator._sanitize_build_year(near_future) == near_future def test_none_year_unchanged() -> None: """Отсутствие года — поведение НЕ меняется (уже было честным «не знаем»).""" assert estimator._sanitize_build_year(None) is None def test_boundary_year_min_plausible_kept() -> None: """MIN_PLAUSIBLE_BUILD_YEAR сам — валиден (inclusive).""" year = estimator.MIN_PLAUSIBLE_BUILD_YEAR assert estimator._sanitize_build_year(year) == year def test_boundary_year_below_min_dropped() -> None: """MIN_PLAUSIBLE_BUILD_YEAR - 1 — уже невалиден.""" assert estimator._sanitize_build_year(estimator.MIN_PLAUSIBLE_BUILD_YEAR - 1) is None # ── price impact via _price_from_inputs (hermetic, no DB) — #1966-стиль ──── def _geo() -> GeocodeResult: return GeocodeResult( lat=56.838, lon=60.597, full_address="ул. Тестовая, 1", provider="nominatim", confidence="approximate", ) def _lots(ppm2: float, n: int = 7) -> list[dict]: """n unique-address lots all at the same ₽/m² → median_ppm2 == ppm2.""" return [ {"price_per_m2": ppm2, "address": f"ул. Тестовая, {i + 1}", "source": "avito"} for i in range(n) ] def _price(*, target_year: int | None, area_m2: float = 50.0) -> estimator.PricingResult: """Pure radius-only spine call (no anchor / dkp / imv) with a forced ratio. Зеркалит helper из test_estimator_hedonic.py — прогоняет ровно тот же вызов _price_from_inputs, который estimate_quality делает после _sanitize_build_year(target_year, ...). """ def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]: return 0.85, "per_rooms" return estimator._price_from_inputs( listings=_lots(100_000.0), area_m2=area_m2, rooms=2, repair_state=None, floor=5, total_floors=10, target_year=target_year, analog_tier="W", fallback_used=False, area_widened=False, anchor_comps=[], anchor_tier_fetched=None, dkp_raw=None, imv_anchor=None, imv_eval=None, yandex_val_present=False, cian_val_present=False, ratio_resolver=ratio_resolver, quarter_index_lookup=lambda q: None, quarter_indexes_lookup=lambda qs: {}, target_house_cadnum=None, dadata_coarse=False, geo=_geo(), dadata_qc_geo=None, ) def test_price_not_cut_after_sanitizing_1829(monkeypatch: pytest.MonkeyPatch) -> None: """Прод-репро (Mera-audit 2026-08-02): год=1829 без guard'а клампит хедонический фактор в пол estimate_hedonic_factor_min=0.75 (−25% к цене). После sanitize (estimate_quality прогоняет target_year через _sanitize_build_year ДО _price_from_inputs) год трактуется как отсутствующий — фактор нейтрален, цена НЕ порезана. """ monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) # "Было бы" без фикса: 1829 идёт в хедонику напрямую. unfixed = _price(target_year=1829) # "Стало" с фиксом: estimate_quality сначала санитайзит год. sanitized_year = estimator._sanitize_build_year(1829) assert sanitized_year is None fixed = _price(target_year=sanitized_year) assert unfixed.expected_sold_price is not None assert fixed.expected_sold_price is not None ratio_only = round(unfixed.median_price * 0.85) factor_before = unfixed.expected_sold_price / ratio_only factor_after = fixed.expected_sold_price / ratio_only # До фикса — кламп ровно в пол (фиксированная −25% недоплата). assert factor_before == pytest.approx(estimator.settings.estimate_hedonic_factor_min, abs=1e-3) # После фикса — год «неизвестен», фактор около нейтрали (НЕ 0.75). assert factor_after > 0.95 assert fixed.expected_sold_price > unfixed.expected_sold_price # Совпадает байт-в-байт с явным "год не указан" (test_target_year_none_is_neutral). none_year = _price(target_year=None) assert fixed.expected_sold_price == none_year.expected_sold_price assert fixed.expected_sold_per_m2 == none_year.expected_sold_per_m2 def test_year_1960_hedonic_unaffected_by_guard(monkeypatch: pytest.MonkeyPatch) -> None: """год=1960 (валидная хрущёвка) — guard не меняет хедоническую поправку.""" monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) sanitized_year = estimator._sanitize_build_year(1960) assert sanitized_year == 1960 before = _price(target_year=1960) after = _price(target_year=sanitized_year) assert before.expected_sold_price == after.expected_sold_price assert before.expected_sold_per_m2 == after.expected_sold_per_m2