181 lines
6.9 KiB
Python
181 lines
6.9 KiB
Python
"""Tests for Fix 4: clamp expected_sold <= asking (ratio cap 1.0).
|
||
|
||
Smoke-диагноз: 3к/27.0М -> expected_sold 31.4М > asking.
|
||
Причина: _get_asking_sold_ratio для high-price tier возвращает ratio > 1.0
|
||
(product artefact) -> expected_sold = headline x ratio > headline.
|
||
|
||
Проверяем:
|
||
1. ratio=1.15 + флаг on -> expected_sold == headline (не выше); лог clamp.
|
||
2. ratio=0.8 -> expected_sold = headline x 0.8 (норм, без clamp).
|
||
3. ratio None -> expected_sold не выводится (graceful).
|
||
4. Флаг off + ratio > 1 -> expected_sold > headline (старое поведение).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import anyio
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
|
||
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": "ЕКБ, ул. Малышева, 30",
|
||
"lat": 56.838,
|
||
"lon": 60.595,
|
||
"rooms": 3,
|
||
"area_m2": area_m2,
|
||
"floor": 10,
|
||
"total_floors": 20,
|
||
"price_rub": price_per_m2 * area_m2,
|
||
"price_per_m2": price_per_m2,
|
||
"listing_date": datetime(2026, 5, 1),
|
||
"days_on_market": 5,
|
||
"photo_urls": [],
|
||
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||
"distance_m": 100.0,
|
||
"relevance_score": 0.1,
|
||
}
|
||
|
||
|
||
_ANALOGS: list[dict[str, Any]] = [
|
||
_make_listing(price_per_m2=260_000.0),
|
||
_make_listing(price_per_m2=270_000.0),
|
||
_make_listing(price_per_m2=280_000.0),
|
||
]
|
||
|
||
|
||
def _make_geo():
|
||
from app.services.geocoder import GeocodeResult
|
||
|
||
return GeocodeResult(
|
||
lat=56.838,
|
||
lon=60.595,
|
||
full_address="Свердловская обл., Екатеринбург, ул. Малышева, 30",
|
||
provider="nominatim",
|
||
)
|
||
|
||
|
||
def _make_payload(rooms: int = 3):
|
||
from app.schemas.trade_in import TradeInEstimateInput
|
||
|
||
return TradeInEstimateInput(
|
||
address="ЕКБ, ул. Малышева, 30", area_m2=50.0, rooms=rooms, floor=10, total_floors=20
|
||
)
|
||
|
||
|
||
def _run_estimate(
|
||
ratio_tuple: tuple[float | None, str | None],
|
||
*,
|
||
clamp_enabled: bool = True,
|
||
) -> Any:
|
||
from app.services.estimator import estimate_quality
|
||
|
||
db = MagicMock()
|
||
payload = _make_payload()
|
||
|
||
async def _run() -> Any:
|
||
with (
|
||
patch("app.core.config.settings.estimate_expected_sold_le_asking", new=clamp_enabled),
|
||
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=None),
|
||
patch("app.services.estimator._get_asking_sold_ratio", return_value=ratio_tuple),
|
||
):
|
||
return await estimate_quality(payload, db)
|
||
|
||
return anyio.run(_run)
|
||
|
||
|
||
def test_expected_sold_clamped_to_headline_when_ratio_above_1(
|
||
caplog: pytest.LogCaptureFixture,
|
||
) -> None:
|
||
ratio = 1.15
|
||
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True)
|
||
assert est.median_price_rub > 0, "headline должен быть задан аналогами"
|
||
assert est.expected_sold_price_rub is not None
|
||
assert (
|
||
est.expected_sold_price_rub <= est.median_price_rub
|
||
), f"expected_sold {est.expected_sold_price_rub} > asking {est.median_price_rub}"
|
||
assert est.expected_sold_per_m2 is not None
|
||
assert (
|
||
est.expected_sold_per_m2 <= est.median_price_per_m2
|
||
), "expected_sold_per_m2 превышает median_price_per_m2"
|
||
assert est.expected_sold_range_high_rub is not None
|
||
assert (
|
||
est.expected_sold_range_high_rub <= est.range_high_rub
|
||
), "expected_sold_range_high > range_high"
|
||
assert est.expected_sold_range_low_rub is not None
|
||
assert (
|
||
est.expected_sold_range_low_rub <= est.range_low_rub
|
||
), "expected_sold_range_low > range_low"
|
||
assert est.expected_sold_price_rub == est.median_price_rub
|
||
assert est.expected_sold_per_m2 == est.median_price_per_m2
|
||
clamp_logs = [r for r in caplog.records if "clamped" in r.getMessage()]
|
||
assert clamp_logs, "Ожидался log-message о clamp ratio"
|
||
assert "1.150" in clamp_logs[0].getMessage() or "1.15" in clamp_logs[0].getMessage()
|
||
|
||
|
||
def test_expected_sold_not_clamped_when_ratio_below_1() -> None:
|
||
ratio = 0.8
|
||
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True)
|
||
assert est.median_price_rub > 0
|
||
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_low_rub == round(est.range_low_rub * ratio)
|
||
assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio)
|
||
assert est.expected_sold_price_rub < est.median_price_rub
|
||
|
||
|
||
def test_expected_sold_none_when_ratio_none() -> None:
|
||
est = _run_estimate((None, None), clamp_enabled=True)
|
||
assert est.expected_sold_price_rub is None
|
||
assert est.expected_sold_per_m2 is None
|
||
assert est.expected_sold_range_low_rub is None
|
||
assert est.expected_sold_range_high_rub is None
|
||
assert est.asking_to_sold_ratio is None
|
||
assert est.median_price_rub > 0
|
||
|
||
|
||
def test_expected_sold_exceeds_asking_when_clamp_flag_off() -> None:
|
||
ratio = 1.15
|
||
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=False)
|
||
assert est.median_price_rub > 0
|
||
assert est.expected_sold_price_rub is not None
|
||
assert est.expected_sold_price_rub > est.median_price_rub, (
|
||
f"Ожидалось expected_sold > asking при clamp=False, "
|
||
f"но {est.expected_sold_price_rub} <= {est.median_price_rub}"
|
||
)
|
||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|