gendesign/tradein-mvp/backend/tests/test_estimator_requires_analogs.py
lekss361 c63c48eea2
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
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 3m52s
Deploy Trade-In / build-backend (push) Successful in 1m14s
Deploy Trade-In / deploy (push) Successful in 1m41s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m40s
Не выдавать цену, когда рядом нет ни одного аналога (#3537)
2026-09-16 17:16:25 +00:00

173 lines
7.4 KiB
Python
Raw Permalink 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 #estimate-zero-analogs: honest refusal instead of a point price
when n_analogs == 0 and the engine itself already trusts the number no more
than "почти нет сигнала" (reliability == 'very_low').
Repro this guards against (prod, 2026-09-16): "Красногорск, Янтарная" returned
a confident 236 766 руб/м² with n_analogs=0 — 3.8% of ЕКБ estimates and 4.8% of
region 77/50 estimates hit this shape.
Same isolation harness as test_estimator_expected_sold.py: `estimate_quality`
with every I/O dependency stubbed (no DB, no network).
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
def _make_fake_geo():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=55.831,
lon=37.330,
full_address="Московская обл., Красногорск, ул. Янтарная, 5",
provider="nominatim",
)
def _make_payload():
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="Красногорск, ул. Янтарная, 5",
area_m2=40.0,
rooms=1,
floor=4,
total_floors=16,
)
def _run_estimate(
*,
require_analogs: bool,
dkp_raw: dict[str, Any] | None = None,
):
"""estimate_quality: пустой радиус (listings=[]), нет anchor — только
опциональный ДКП-коридор (`dkp_raw`) через `_fetch_dkp_corridor`."""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload()
async def _run():
with (
patch(
"app.services.estimator.settings.estimate_require_local_evidence", require_analogs
),
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)),
# Ничего не нашли в радиусе — listings_clean=[] → median_price=0 до фолбэков.
patch("app.services.estimator._fetch_analogs", return_value=([], False, None)),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
# Ни same-building, ни micro-radius anchor.
patch("app.services.estimator._fetch_anchor_comps", return_value=([], None)),
patch("app.services.estimator._fetch_house_imv_anchor", 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=(None, None)),
):
return await estimate_quality(payload, db)
return anyio.run(_run)
def test_true_zero_data_suppresses_price_when_flag_on() -> None:
"""Ни listings, ни anchor, ни ДКП-коридора вообще → n_analogs=0,
reliability='very_low' изначально (без ДКП-фолбэка median остаётся 0
и без флага) — но проверяем именно flag=True ветку на honest-explanation."""
est = _run_estimate(require_analogs=True, dkp_raw=None)
assert est.n_analogs == 0
assert est.median_price_rub == 0
assert est.median_price_per_m2 == 0
assert est.range_low_rub == 0
assert est.range_high_rub == 0
assert est.expected_sold_price_rub is None
assert est.confidence_explanation is not None
assert "Недостаточно данных" in est.confidence_explanation
def test_city_wide_corridor_never_becomes_headline() -> None:
"""Прод-репро «Красногорск, Янтарная»: у улицы одна сделка, поэтому коридор
расширился до всего города (scope='city_wide', 4770 сделок, медиана
200 676 ₽/м²) и выдал её как оценку конкретной квартиры. Средняя цена
города — не оценка адреса, поэтому цены быть не должно."""
dkp_raw = {
"count": 4770,
"median_ppm2": 200_676.0,
"low_ppm2": 150_000.0,
"high_ppm2": 260_000.0,
"period_months": 12,
"scope": "city_wide",
}
est = _run_estimate(require_analogs=True, dkp_raw=dkp_raw)
assert est.n_analogs == 0
assert est.median_price_rub == 0, "медиана по городу не может быть ценой квартиры"
assert est.median_price_per_m2 == 0
assert est.range_low_rub == 0
assert est.range_high_rub == 0
assert est.expected_sold_price_rub is None
assert est.expected_sold_range_low_rub is None
assert est.expected_sold_range_high_rub is None
assert est.insufficient_data is True
assert "Недостаточно данных" in (est.confidence_explanation or "")
# Адрес/геоНЕ ценовые поля — сохраняются, чтобы фронт показал адрес.
assert est.target_address is not None
def test_street_scoped_corridor_still_headlines() -> None:
"""Обратная сторона того же правила: уличный коридор — настоящий сигнал и
цену даёт. Это защита #oblast-D (Нижний Тагил), без которой туда протекал
екатеринбургский asking вшестеро выше."""
dkp_raw = {
"count": 12,
"median_ppm2": 85_911.0,
"low_ppm2": 70_000.0,
"high_ppm2": 100_000.0,
"period_months": 12,
"scope": "street",
}
est = _run_estimate(require_analogs=True, dkp_raw=dkp_raw)
assert est.n_analogs == 0
assert est.median_price_per_m2 == 85_911, "уличный коридор обязан остаться headline"
assert est.insufficient_data is False
def test_city_wide_corridor_kept_when_flag_off() -> None:
"""Флаг False → прежнее поведение целиком: общегородской коридор снова
становится headline. Откат без релиза."""
dkp_raw = {
"count": 4770,
"median_ppm2": 200_676.0,
"low_ppm2": 150_000.0,
"high_ppm2": 260_000.0,
"period_months": 12,
"scope": "city_wide",
}
est = _run_estimate(require_analogs=False, dkp_raw=dkp_raw)
assert est.n_analogs == 0
assert est.median_price_rub > 0, "flag=False должен возвращать прежнее поведение"
assert est.median_price_per_m2 == 200_676