gendesign/tradein-mvp/backend/tests/test_estimator_repair_coef.py
Light1YT 33ee00c065 feat(tradein): калибровка repair-коэффициентов с base 1.0 (#7)
_REPAIR_COEF → tunable market-эвристика: needs_repair=0.94, standard=1.00
(baseline no-op; было 0.98 — срезало 2% с каждой standard-оценки), good=1.05,
excellent=1.10. Деривация из данных отклонена: listings.repair_state покрытие
~2%, сырые un-normalized значения, медианы confounded площадью (issue #7).

+ regression-тест (читает _REPAIR_COEF динамически — переживёт рекалибровку):
ratio excellent/needs_repair, standard/None no-op, наличие/отсутствие note.
Поправлен mislabeled comment над _IMV_HOUSE_TYPE_MAP. _IMV_REPAIR_MAP не тронут.

code-reviewer:  APPROVE no critical/minor. Tests: 5 new + 7 cian-integration.
Ruff clean.

Closes #7
2026-05-28 16:15:54 +05:00

182 lines
7.1 KiB
Python
Raw 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.

"""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)),
):
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 ""
)