gendesign/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py
lekss361 50684ce87e
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m25s
Deploy Trade-In / deploy (push) Successful in 49s
Deploy Trade-In / build-backend (push) Successful in 53s
fix(tradein/estimator): remove dead tier-aware-ratio path — truncation artifact + footgun (#2002)
2026-06-27 17:34:54 +00:00

311 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for Fix 1: asking_to_sold ratio tier resolved from FINAL headline ppm².
Проверяем, что _get_asking_sold_ratio вызывается ПОСЛЕ всех headline-мутаций
(anchor/IMV-blend/quarter-index/corridor-clamp) с финальным median_ppm2, а не с
pre-anchor радиусной медианой. Это устраняет tier-несовпадение (#928 audit fix).
Два слоя:
1. Стохастический: _get_asking_sold_ratio не вызывается в ранней части estimate_quality
(только после corridor-clamp/radius-floor).
2. Функциональный: когда anchor поднял headline в другой tier, ratio берётся
по финальному ppm² (из нового tier), а не по исходной радиусной медиане.
Graceful: нет ratio → expected_sold_* == None, headline не изменён.
"""
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,
}
_ANALOGS_LOW: list[dict[str, Any]] = [
_make_listing(price_per_m2=100_000.0),
_make_listing(price_per_m2=110_000.0),
_make_listing(price_per_m2=105_000.0),
]
_ANALOGS_HIGH: list[dict[str, Any]] = [
_make_listing(price_per_m2=300_000.0),
_make_listing(price_per_m2=310_000.0),
_make_listing(price_per_m2=305_000.0),
]
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(rooms: int = 2):
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="ЕКБ, ул. Учителей, 18",
area_m2=50.0,
rooms=rooms,
floor=5,
total_floors=16,
)
def _run_estimate_with_ratio_spy(
analogs: list[dict[str, Any]],
ratio_return: tuple[float | None, str | None],
) -> tuple[Any, list[Any]]:
"""Запускает estimate_quality с отслеживанием вызовов _get_asking_sold_ratio.
Возвращает (estimate_result, list_of_call_args).
"""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload()
ratio_calls: list[Any] = []
def _spy_ratio(db_inner: Any, rooms: Any, anchor_ppm2: Any = None) -> Any:
ratio_calls.append({"rooms": rooms, "anchor_ppm2": anchor_ppm2})
return ratio_return
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=None),
patch("app.services.estimator._get_asking_sold_ratio", side_effect=_spy_ratio),
):
return await estimate_quality(payload, db)
est = anyio.run(_run)
return est, ratio_calls
# ── тест 1: ratio вызывается РОВНО ОДИН РАЗ (не дважды — до и после headline) ──
def test_ratio_called_exactly_once() -> None:
"""_get_asking_sold_ratio должен вызываться ровно один раз — после headline."""
_est, calls = _run_estimate_with_ratio_spy(_ANALOGS_LOW, (0.80, "per_rooms"))
assert (
len(calls) == 1
), f"_get_asking_sold_ratio должен вызываться 1 раз, вызван {len(calls)} раз"
# ── тест 2: anchor поднял headline → ratio вызван с финальным (high) ppm² ────
def test_ratio_tier_uses_final_headline_after_anchor() -> None:
"""Когда anchor поднял median_ppm2 с ~105k до ~300k, ratio вызывается с ~300k."""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload()
# Симулируем: radius median = 105k, anchor поднимает до 300k.
# _get_asking_sold_ratio должен получить anchor_ppm2 ≈ 300k (не 105k).
captured_anchor_ppm2: list[float | None] = []
def _spy(db_inner: Any, rooms: Any, anchor_ppm2: Any = None) -> tuple:
captured_anchor_ppm2.append(anchor_ppm2)
return (0.78, "per_rooms")
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)),
# Radius analogs дают медиану ~105k.
patch(
"app.services.estimator._fetch_analogs",
return_value=(list(_ANALOGS_LOW), 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),
# Same-building anchor поднимает headline до ~300k.
patch(
"app.services.estimator._fetch_anchor_comps",
return_value=(
[
{
"price_per_m2": 300_000,
"area_m2": 50.0,
"rooms": 2,
"source": "cian",
"source_url": "u",
"address": "a",
"lat": 56.838,
"lon": 60.595,
"floor": 5,
"total_floors": 16,
"listing_date": datetime(2026, 5, 1),
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 5.0,
"relevance_score": 0.9,
"price_rub": 15_000_000,
"building_cadastral_number": None,
"days_on_market": 5,
},
{
"price_per_m2": 305_000,
"area_m2": 50.0,
"rooms": 2,
"source": "cian",
"source_url": "u2",
"address": "a",
"lat": 56.838,
"lon": 60.595,
"floor": 6,
"total_floors": 16,
"listing_date": datetime(2026, 5, 1),
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 6.0,
"relevance_score": 0.9,
"price_rub": 15_250_000,
"building_cadastral_number": None,
"days_on_market": 5,
},
{
"price_per_m2": 298_000,
"area_m2": 50.0,
"rooms": 2,
"source": "cian",
"source_url": "u3",
"address": "a",
"lat": 56.838,
"lon": 60.595,
"floor": 4,
"total_floors": 16,
"listing_date": datetime(2026, 5, 1),
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 7.0,
"relevance_score": 0.9,
"price_rub": 14_900_000,
"building_cadastral_number": None,
"days_on_market": 5,
},
{
"price_per_m2": 302_000,
"area_m2": 50.0,
"rooms": 2,
"source": "cian",
"source_url": "u4",
"address": "a",
"lat": 56.838,
"lon": 60.595,
"floor": 7,
"total_floors": 16,
"listing_date": datetime(2026, 5, 1),
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 8.0,
"relevance_score": 0.9,
"price_rub": 15_100_000,
"building_cadastral_number": None,
"days_on_market": 5,
},
],
"A",
),
),
patch("app.services.estimator._get_asking_sold_ratio", side_effect=_spy),
):
return await estimate_quality(payload, db)
anyio.run(_run)
assert len(captured_anchor_ppm2) == 1
ppm2_used = captured_anchor_ppm2[0]
assert ppm2_used is not None
# Финальный headline должен быть в зоне anchor (~300k), а НЕ в зоне radius (~105k).
assert (
ppm2_used > 200_000
), f"ratio должен вызываться с anchor ppm2 (~300k), получено {ppm2_used}"
# ── тест 3: graceful — нет ratio → expected_sold_* = None, headline не изменён ──
def test_ratio_graceful_none_no_expected_sold() -> None:
"""Если ratio = None → все expected_sold_* == None, headline в норме."""
est, _calls = _run_estimate_with_ratio_spy(_ANALOGS_LOW, (None, None))
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.ratio_basis is None
# Headline не изменён.
assert est.median_price_rub > 0