186 lines
7.4 KiB
Python
186 lines
7.4 KiB
Python
"""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 ""
|
||
)
|