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
173 lines
7.4 KiB
Python
173 lines
7.4 KiB
Python
"""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
|