"""Unit tests for #651 IMV blend (killer accuracy fix) + #652 DKP corridor. Тестируем чистую blend-трансформацию `_apply_imv_blend` (без БД) и DB-helpers с замоканной Session — premium-blend поднимает медиану + расширяет диапазон, no-anchor / sub-threshold случаи — no-op. """ import os from datetime import UTC, datetime from typing import Any # Settings требует DATABASE_URL при инициализации (fail-fast, C-3). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from unittest.mock import AsyncMock, MagicMock, patch import anyio from app.services.estimator import ( _apply_imv_blend, _fetch_dkp_corridor, _fetch_house_imv_anchor, ) # ── #651: pure blend transform ──────────────────────────────────────────────── def test_blend_premium_raises_median_and_extends_range() -> None: """IMV ≫ median → median поднимается blend'ом, range_high расширяется.""" # median 50М, IMV recommended 100М (≈2x — premium/view unit underestimate), # IMV higher 110М. weight 0.5, threshold 1.15. area = 80.0 median_price = 50_000_000 range_high = 60_000_000 median_ppm2 = median_price / area new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( median_price=median_price, range_high=range_high, median_ppm2=median_ppm2, area=area, anchor_total=100_000_000, anchor_higher=110_000_000, weight=0.5, threshold=1.15, ) assert blended is True # blend = 50М*0.5 + 100М*0.5 = 75М assert new_median == 75_000_000 # range_high расширен до IMV higher (110М) assert new_range_high == 110_000_000 # ppm2 пересчитан консистентно assert new_ppm2 == 75_000_000 / area assert anchor_used == 100_000_000 def test_blend_no_op_when_anchor_below_median() -> None: """A < median → медиану НЕ понижаем (однонаправленность), но диапазон может расшириться.""" area = 50.0 median_price = 10_000_000 range_high = 11_000_000 median_ppm2 = median_price / area new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( median_price=median_price, range_high=range_high, median_ppm2=median_ppm2, area=area, anchor_total=8_000_000, # ниже медианы anchor_higher=8_500_000, weight=0.5, threshold=1.15, ) assert blended is False assert new_median == median_price # не понижаем assert new_ppm2 == median_ppm2 assert anchor_used == 8_000_000 # range_high не сужается (anchor ниже текущего high) assert new_range_high == range_high def test_blend_no_op_below_threshold() -> None: """A выше median, но в пределах threshold → blend не срабатывает (медиана та же).""" area = 40.0 median_price = 10_000_000 range_high = 11_000_000 median_ppm2 = median_price / area new_median, _, new_ppm2, blended, _ = _apply_imv_blend( median_price=median_price, range_high=range_high, median_ppm2=median_ppm2, area=area, anchor_total=11_000_000, # ×1.1 < threshold 1.15 anchor_higher=None, weight=0.5, threshold=1.15, ) assert blended is False assert new_median == median_price assert new_ppm2 == median_ppm2 def test_blend_no_op_when_no_anchor() -> None: """anchor_total=None → graceful no-op (common case без IMV-данных).""" new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend( median_price=12_000_000, range_high=14_000_000, median_ppm2=200_000.0, area=60.0, anchor_total=None, anchor_higher=None, weight=0.5, threshold=1.15, ) assert blended is False assert anchor_used is None assert new_median == 12_000_000 assert new_range_high == 14_000_000 assert new_ppm2 == 200_000.0 def test_blend_extends_range_to_anchor_when_no_higher() -> None: """Нет higher_price → диапазон расширяется до самого якоря.""" _, new_range_high, _, blended, _ = _apply_imv_blend( median_price=50_000_000, range_high=60_000_000, median_ppm2=625_000.0, area=80.0, anchor_total=100_000_000, anchor_higher=None, weight=0.5, threshold=1.15, ) assert blended is True assert new_range_high == 100_000_000 # до якоря, т.к. higher отсутствует # ── #651: house_imv anchor lookup (mocked Session) ───────────────────────────── def test_fetch_house_imv_anchor_none_when_no_house_id() -> None: """target_house_id=None → None без обращения к БД.""" mock_db = MagicMock() assert _fetch_house_imv_anchor(mock_db, target_house_id=None, rooms=2, area=50.0) is None mock_db.execute.assert_not_called() def test_fetch_house_imv_anchor_returns_row() -> None: """Возвращает dict из house_imv_evaluations при наличии строки.""" mock_db = MagicMock() mock_db.execute.return_value.mappings.return_value.first.return_value = { "recommended_price": 31_950_000, "lower_price": 30_352_500, "higher_price": 33_547_500, "market_count": 800, "rooms": 2, "area_m2": 80.0, } res = _fetch_house_imv_anchor(mock_db, target_house_id=11308, rooms=2, area=80.0) assert res is not None assert res["recommended_price"] == 31_950_000 assert res["higher_price"] == 33_547_500 # ── #652: DKP corridor (mocked Session) ──────────────────────────────────────── def test_dkp_corridor_none_without_street() -> None: """Адрес без распознаваемой улицы → None.""" mock_db = MagicMock() assert _fetch_dkp_corridor(mock_db, address="", rooms=2, area=50.0) is None def test_dkp_corridor_aggregates_ppm2() -> None: """Считает low/median/high ₽/м² по сделкам. #1520: low/high — P10/P90 (не min/max), устойчивы к выбросам. Для 3 значений [100k, 120k, 140k]: P10 = 100k + (120k-100k)*0.2 = 104k P50 = 120k P90 = 120k + (140k-120k)*0.8 = 136k """ mock_db = MagicMock() mock_db.execute.return_value.mappings.return_value.all.return_value = [ {"price_per_m2": 100_000}, {"price_per_m2": 120_000}, {"price_per_m2": 140_000}, ] with patch("app.services.estimator._load_sber_index_series", return_value={}): res = _fetch_dkp_corridor(mock_db, address="улица Ленина, 5", rooms=2, area=50.0) assert res is not None assert res["count"] == 3 assert res["low_ppm2"] == 104_000 # P10 assert res["median_ppm2"] == 120_000 assert res["high_ppm2"] == 136_000 # P90 # ── #651: anchor band-guard (WHERE clause is exercised via bind params) ───────── def test_fetch_house_imv_anchor_passes_band_bind_params() -> None: """rooms/area проброшены в bind — band-guard WHERE их использует (no :x::type).""" mock_db = MagicMock() mock_db.execute.return_value.mappings.return_value.first.return_value = None _fetch_house_imv_anchor(mock_db, target_house_id=11308, rooms=3, area=80.0) # Один execute с биндами {hid, rooms, area}; SQL содержит CAST(...) band-guard. args, _kwargs = mock_db.execute.call_args sql_text = str(args[0]) binds = args[1] assert binds == {"hid": 11308, "rooms": 3, "area": 80.0} # psycopg v3 convention — CAST(:x AS type), никогда :x::type. assert "::" not in sql_text assert "abs(rooms - CAST(:rooms AS integer)) <= 1" in sql_text assert "area_m2 BETWEEN CAST(:area AS double precision) * 0.7" in sql_text def test_fetch_house_imv_anchor_studio_not_used_for_3room() -> None: """Studio-only IMV запись отфильтрована band-guard'ом → anchor None (blend no-op). Эмулируем БД-семантику WHERE: для target rooms=3 строка rooms=0/area=22 не проходит abs(0-3)<=1 → SQL вернёт 0 строк → .first() = None. """ mock_db = MagicMock() # БД с band-guard вернула бы None (studio не матчит 3-комн.). mock_db.execute.return_value.mappings.return_value.first.return_value = None res = _fetch_house_imv_anchor(mock_db, target_house_id=11308, rooms=3, area=80.0) assert res is None # ── #651/#652: weight clamp + DKP corridor boundary ──────────────────────────── def test_blend_weight_clamped_above_one() -> None: """weight=1.5 → clamp к 1.0: blend = anchor (median*0 + anchor*1).""" area = 50.0 median_price = 10_000_000 new_median, _, new_ppm2, blended, _ = _apply_imv_blend( median_price=median_price, range_high=11_000_000, median_ppm2=median_price / area, area=area, anchor_total=20_000_000, anchor_higher=None, weight=1.5, # out-of-range → clamp 1.0 threshold=1.15, ) assert blended is True assert new_median == 20_000_000 # 10М*0 + 20М*1 assert new_ppm2 == 20_000_000 / area def test_blend_weight_clamped_below_zero() -> None: """weight=-0.2 → clamp к 0.0: blend = median (медиана не двигается).""" area = 50.0 median_price = 10_000_000 new_median, _, new_ppm2, blended, _ = _apply_imv_blend( median_price=median_price, range_high=11_000_000, median_ppm2=median_price / area, area=area, anchor_total=20_000_000, anchor_higher=None, weight=-0.2, # out-of-range → clamp 0.0 threshold=1.15, ) # blended=True (anchor > median×threshold), но w=0 → median неизменна. assert blended is True assert new_median == median_price assert new_ppm2 == median_price / area def test_dkp_corridor_count_below_three_still_aggregates() -> None: """count<3 → corridor всё равно вычисляется (sanity-bound branch уже у caller'а). #1520: P10/P90 при n=2 [100k, 130k] (интерполяция по диапазону 30k): P10 = 100k + 30k*0.1 = 103k P90 = 100k + 30k*0.9 = 127k """ mock_db = MagicMock() mock_db.execute.return_value.mappings.return_value.all.return_value = [ {"price_per_m2": 100_000}, {"price_per_m2": 130_000}, ] with patch("app.services.estimator._load_sber_index_series", return_value={}): res = _fetch_dkp_corridor(mock_db, address="улица Ленина, 5", rooms=2, area=50.0) assert res is not None assert res["count"] == 2 assert res["low_ppm2"] == 103_000 # P10 assert res["high_ppm2"] == 127_000 # P90 # ── #651: expected_sold ↔ blended-median consistency (full estimate path) ─────── def _make_listing(*, price_per_m2: float, area_m2: float = 40.0) -> dict[str, Any]: 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, } _BLEND_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(): from app.schemas.trade_in import TradeInEstimateInput return TradeInEstimateInput( address="ЕКБ, ул. Учителей, 18", area_m2=40.0, rooms=1, floor=4, total_floors=16, ) def _run_estimate_with_anchor( ratio_tuple: tuple[float | None, str | None], anchor: dict[str, Any] | None, ): """estimate_quality со всеми I/O застабленными; IMV-anchor форсирован.""" from app.services.estimator import estimate_quality db = MagicMock() payload = _make_payload() 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)), patch( "app.services.estimator._fetch_analogs", return_value=(list(_BLEND_ANALOGS), False, "S"), ), patch("app.services.estimator._fetch_deals", return_value=[]), patch("app.services.estimator._fetch_dkp_corridor", return_value=None), 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._get_asking_sold_ratio", return_value=ratio_tuple), patch("app.services.estimator._fetch_house_imv_anchor", return_value=anchor), ): return await estimate_quality(payload, db) return anyio.run(_run) def test_expected_sold_consistent_with_blended_median() -> None: """Когда blend срабатывает — expected_sold выводится из POST-blend median/range. asking-median pre-blend = 150_000×40 = 6_000_000. Anchor 18_000_000 ≫ median×1.15 → blend = 6М*0.5 + 18М*0.5 = 12_000_000. expected_sold должен браться от 12М. """ ratio = 0.74 anchor = { "recommended_price": 18_000_000, "lower_price": 16_000_000, "higher_price": 20_000_000, "market_count": 500, "rooms": 1, "area_m2": 40.0, } est = _run_estimate_with_anchor((ratio, "per_rooms"), anchor) # Blend сработал: headline median поднялся над pre-blend 6М. assert est.median_price_rub == 12_000_000 # expected_sold выведен из POST-blend значений — no stale. assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio) assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio) assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio) # Sanity: sold < asking (ratio<1), но НЕ абсурдная «скидка» от stale 6М-базы. assert est.expected_sold_price_rub == round(12_000_000 * ratio) def test_expected_sold_unblended_when_anchor_band_rejects() -> None: """anchor=None (band-guard отбросил) → blend no-op → expected_sold от чистой median.""" ratio = 0.74 est = _run_estimate_with_anchor((ratio, "per_rooms"), None) assert est.median_price_rub == 6_000_000 # pre-blend (no blend) assert est.expected_sold_price_rub == round(6_000_000 * ratio)