"""Tests for Fix 2: radius-path нижний floor от DKP-коридора (анти-undershoot). Симметрично corridor-clamp (верхний потолок): если median_ppm2 < dkp_low_ppm2 × factor и anchor_tier is None → поднимаем до floor. Только radius-путь. Без dkp_raw → no-op. Тесты: - radius median ниже dkp_low × factor → median поднята до floor - radius median выше dkp_low × factor → no-op (медиана не изменена) - dkp_raw is None → no-op (нет базы для floor) - anchor-путь (anchor_tier != None) → не затронут floor'ом """ from __future__ import annotations import os from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import anyio os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") # ── helpers ────────────────────────────────────────────────────────────────── def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]: return { "source": "cian", "source_url": "https://cian.ru/offer/1", "address": "ЕКБ, ул. Учителей, 18", "lat": 56.838, "lon": 60.595, "rooms": 2, "area_m2": area_m2, "floor": 5, "total_floors": 16, "price_rub": price_per_m2 * area_m2, "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": 150.0, "relevance_score": 0.1, } def _make_geo(): from app.services.geocoder import GeocodeResult return GeocodeResult( lat=56.838, lon=60.595, full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18", provider="nominatim", ) def _make_payload(): from app.schemas.trade_in import TradeInEstimateInput return TradeInEstimateInput( address="ЕКБ, ул. Учителей, 18", area_m2=50.0, rooms=2, floor=5, total_floors=16, ) def _run_estimate( analogs: list[dict[str, Any]], dkp_raw: dict[str, Any] | None, *, radius_floor_factor: float = 0.8, ) -> Any: from app.core.config import settings from app.services.estimator import estimate_quality db = MagicMock() payload = _make_payload() async def _run() -> Any: with ( patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_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)), 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), ), patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor), ): return await estimate_quality(payload, db) return anyio.run(_run) # ── тест 1: radius median ниже floor → поднята ─────────────────────────────── def test_radius_median_below_dkp_floor_is_lifted() -> None: """Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята.""" # analogs с median ~80k analogs = [ _make_listing(price_per_m2=75_000.0), _make_listing(price_per_m2=80_000.0), _make_listing(price_per_m2=85_000.0), ] dkp_raw = { "count": 15, "low_ppm2": 150_000, "median_ppm2": 180_000, "high_ppm2": 220_000, "period_months": 12, } est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) floor_ppm2 = 150_000 * 0.8 # 120_000 assert ( est.median_price_per_m2 >= floor_ppm2 ), f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}" # ── тест 2: radius median выше floor → no-op ───────────────────────────────── def test_radius_median_above_dkp_floor_unchanged() -> None: """Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op.""" analogs = [ _make_listing(price_per_m2=190_000.0), _make_listing(price_per_m2=200_000.0), _make_listing(price_per_m2=210_000.0), ] dkp_raw = { "count": 15, "low_ppm2": 150_000, "median_ppm2": 180_000, "high_ppm2": 220_000, "period_months": 12, } est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) # floor = 150k × 0.8 = 120k; median = 200k > floor → no-op, медиана не изменяется # Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor. floor_ppm2 = 150_000 * 0.8 # 120_000 assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)" # Медиана соответствует аналогам (~200k), а не floor assert ( 180_000 <= est.median_price_per_m2 <= 220_000 ), f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)" # ── тест 3: dkp_raw is None → no-op ───────────────────────────────────────── def test_no_dkp_raw_no_floor() -> None: """Без dkp_raw floor не применяется — median остаётся radius-значением.""" analogs = [ _make_listing(price_per_m2=75_000.0), _make_listing(price_per_m2=80_000.0), _make_listing(price_per_m2=85_000.0), ] est = _run_estimate(analogs, dkp_raw=None, radius_floor_factor=0.8) # median ~80k, без dkp_raw floor не поднимает assert ( est.median_price_per_m2 < 100_000 ), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"