Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
410 lines
16 KiB
Python
410 lines
16 KiB
Python
"""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 ₽/м² по сделкам."""
|
||
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},
|
||
]
|
||
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"] == 100_000
|
||
assert res["median_ppm2"] == 120_000
|
||
assert res["high_ppm2"] == 140_000
|
||
|
||
|
||
# ── #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'а)."""
|
||
mock_db = MagicMock()
|
||
mock_db.execute.return_value.mappings.return_value.all.return_value = [
|
||
{"price_per_m2": 100_000},
|
||
{"price_per_m2": 130_000},
|
||
]
|
||
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"] == 100_000
|
||
assert res["high_ppm2"] == 130_000
|
||
|
||
|
||
# ── #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)
|