gendesign/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py
lekss361 0af80b4395
Some checks failed
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m48s
Deploy Trade-In / build-backend (push) Successful in 1m18s
Deploy Trade-In / deploy (push) Successful in 1m28s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Has been cancelled
Коэффициент перехода от цены предложения к цене сделки считается по каждому региону (#3518)
2026-09-13 11:38:26 +00:00

341 lines
14 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,
}
# #oblast-E: 5 items each (>= HEADLINE_LISTINGS_MIN_N) so the new headline
# sufficiency gate doesn't suppress the median before this test's target
# mechanic (ratio-tier resolution) runs.
_ANALOGS_LOW: list[dict[str, Any]] = [
_make_listing(price_per_m2=95_000.0),
_make_listing(price_per_m2=100_000.0),
_make_listing(price_per_m2=105_000.0),
_make_listing(price_per_m2=110_000.0),
_make_listing(price_per_m2=115_000.0),
]
_ANALOGS_HIGH: list[dict[str, Any]] = [
_make_listing(price_per_m2=295_000.0),
_make_listing(price_per_m2=300_000.0),
_make_listing(price_per_m2=305_000.0),
_make_listing(price_per_m2=310_000.0),
_make_listing(price_per_m2=315_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,
area_m2: Any = None,
anchor_ppm2: Any = None,
region_code: Any = None,
) -> Any:
# #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2.
# #3512: region_code — регион цели, теперь тоже часть call-site сигнатуры.
ratio_calls.append(
{
"rooms": rooms,
"area_m2": area_m2,
"anchor_ppm2": anchor_ppm2,
"region_code": region_code,
}
)
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,
area_m2: Any = None,
anchor_ppm2: Any = None,
region_code: Any = None,
) -> tuple:
# #2620-2: signature now carries area_m2 (rekey to area-bucket) before anchor_ppm2.
# #3512: region_code — регион цели, теперь тоже часть call-site сигнатуры.
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