"""Regression tests for the repair-state coefficient (issue #7). Verifies the `_REPAIR_COEF` heuristic multiplier in `estimate_quality`: - excellent / needs_repair median ratio == coef ratio (within int rounding) - standard (baseline 1.0) is a true no-op vs the unadjusted market median - repair_state=None is a no-op (coef 1.0) - coef != 1.0 emits the repair note into confidence_explanation Coefficient values are read DYNAMICALLY from `_REPAIR_COEF` so the assertions survive future re-calibration. The full `estimate_quality` flow is exercised with all I/O stubbed (geocode / house-meta / _fetch_analogs / _fetch_deals / IMV / Yandex / Cian / DB) so the test isolates the multiplier — no DB, no network. """ from __future__ import annotations import os from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import anyio # Settings requires DATABASE_URL at init time. Set dummy DSN before any app import. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") # ── Fixtures / helpers ────────────────────────────────────────────────────── def _make_listing(*, price_per_m2: float, area_m2: float = 40.0) -> dict[str, Any]: """Minimal listing dict matching the keys the aggregation block reads.""" price_rub = price_per_m2 * area_m2 return { "source": "cian", "source_url": "https://cian.ru/offer/1", "address": "ЕКБ, ул. Учителей, 18", "lat": 56.838, "lon": 60.595, "rooms": 1, "area_m2": area_m2, "floor": 4, "total_floors": 16, "price_rub": price_rub, "price_per_m2": price_per_m2, "listing_date": datetime(2026, 5, 1), "days_on_market": 10, "photo_urls": [], "scraped_at": datetime(2026, 5, 20, tzinfo=UTC), "distance_m": 100.0, "relevance_score": 0.1, } # Three fixed analogs → deterministic median ppm2 = 150_000 (< 5 ⇒ no outlier drop). _ANALOGS: list[dict[str, Any]] = [ _make_listing(price_per_m2=140_000.0), _make_listing(price_per_m2=150_000.0), _make_listing(price_per_m2=160_000.0), ] def _make_fake_geo(): from app.services.geocoder import GeocodeResult return GeocodeResult( lat=56.838, lon=60.595, full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18", provider="nominatim", ) def _make_payload(repair_state: str | None): from app.schemas.trade_in import TradeInEstimateInput return TradeInEstimateInput( address="ЕКБ, ул. Учителей, 18", area_m2=40.0, rooms=1, floor=4, total_floors=16, repair_state=repair_state, ) def _run_estimate(repair_state: str | None): """Invoke estimate_quality with every external dependency stubbed.""" from app.services.estimator import estimate_quality db = MagicMock() payload = _make_payload(repair_state) async def _run(): with ( patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), patch("app.services.estimator.match_house_readonly", return_value=None), patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), # 3-tuple: (listings, fallback_used, analog_tier). Same analogs every call so # all fallback tiers are equivalent and the median is stable. patch("app.services.estimator._fetch_analogs", return_value=(list(_ANALOGS), False, "S")), patch("app.services.estimator._fetch_deals", return_value=[]), patch("app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)), patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", new=AsyncMock(return_value=None)), patch("app.services.estimator.estimate_via_cian_valuation", new=AsyncMock(return_value=None)), # #648 S3: stub asking→sold lookup off so this test isolates the # repair coefficient (no sold-correction, no DB). patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), ): return await estimate_quality(payload, db) return anyio.run(_run) # ── Tests ─────────────────────────────────────────────────────────────────── def test_excellent_to_needs_repair_ratio_matches_coef() -> None: """excellent ÷ needs_repair median ≈ _REPAIR_COEF["excellent"] / [...]["needs_repair"].""" from app.services.estimator import _REPAIR_COEF excellent = _run_estimate("excellent") needs_repair = _run_estimate("needs_repair") expected_ratio = _REPAIR_COEF["excellent"] / _REPAIR_COEF["needs_repair"] actual_ratio = excellent.median_price_rub / needs_repair.median_price_rub # int() truncation on both medians ⇒ allow a small tolerance. assert abs(actual_ratio - expected_ratio) < 0.005, ( f"excellent/needs_repair ratio {actual_ratio:.5f} != coef ratio {expected_ratio:.5f}" ) def test_standard_is_baseline_noop() -> None: """repair_state='standard' → median equals the unadjusted market median (coef 1.0).""" from app.services.estimator import _REPAIR_COEF assert _REPAIR_COEF["standard"] == 1.0, "baseline invariant: standard must be 1.0" standard = _run_estimate("standard") baseline = _run_estimate(None) # Unadjusted median: median ppm2 (150_000) × area (40.0) = 6_000_000. expected_median = int(150_000.0 * 40.0) assert standard.median_price_rub == expected_median assert standard.median_price_rub == baseline.median_price_rub def test_none_repair_state_is_noop() -> None: """repair_state=None → coef 1.0, no adjustment, no repair note.""" baseline = _run_estimate(None) expected_median = int(150_000.0 * 40.0) assert baseline.median_price_rub == expected_median assert "скорректирована на состояние ремонта" not in ( baseline.confidence_explanation or "" ) def test_repair_note_present_when_coef_differs() -> None: """coef != 1.0 (excellent) → confidence_explanation contains the repair note.""" from app.services.estimator import _REPAIR_COEF assert _REPAIR_COEF["excellent"] != 1.0, "fixture invariant: excellent must adjust" excellent = _run_estimate("excellent") explanation = excellent.confidence_explanation or "" assert "скорректирована на состояние ремонта" in explanation # Note reports the signed percentage derived from the coefficient. pct = round((_REPAIR_COEF["excellent"] - 1.0) * 100) assert f"{pct:+d}%" in explanation def test_standard_emits_no_repair_note() -> None: """Baseline standard (1.0) is a true no-op — no repair note appended.""" standard = _run_estimate("standard") assert "скорректирована на состояние ремонта" not in ( standard.confidence_explanation or "" )