fix(tradein/estimate): отдавать фактический радиус подбора, а не выбор пользователя #2835
6 changed files with 368 additions and 15 deletions
|
|
@ -556,11 +556,22 @@ def get_estimate(
|
|||
_qc_geo_to_precision,
|
||||
_resolve_target_city,
|
||||
_source_counts,
|
||||
rehydrate_search_radius_m,
|
||||
)
|
||||
|
||||
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
||||
actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])]
|
||||
|
||||
# #2632: search_radius_m колонкой не персистится — восстанавливаем его из
|
||||
# того, что персистится (подпись каскада «радиус расширен до N м», иначе
|
||||
# размах сохранённых аналогов). Без этого GET отдавал null, фронт падал на
|
||||
# превью-радиус 1 км и рисовал круг, за которым лежат его же пины (прод
|
||||
# 2026-08-11: 10 из 10 аналогов вне круга, самый дальний — 4381 м).
|
||||
persisted_relaxations = list(getattr(row, "relaxations", None) or [])
|
||||
search_radius_m = rehydrate_search_radius_m(
|
||||
persisted_relaxations, [a.distance_m for a in analogs]
|
||||
)
|
||||
|
||||
# #2043 (BE-1): CV / счётчики источников на rehydrate — best-effort из
|
||||
# сохранённых analogs (top-N, усечённо: полная выборка не персистится). На
|
||||
# свежей оценке (POST) считаются по полной выборке; здесь — по тому, что есть
|
||||
|
|
@ -689,8 +700,12 @@ def get_estimate(
|
|||
# getattr defensive: старые in-memory test doubles / любая строка без
|
||||
# этих колонок (не должно случаться после миграции) деградируют в
|
||||
# дефолт схемы (ok / []), а не падают AttributeError.
|
||||
relaxations=list(getattr(row, "relaxations", None) or []),
|
||||
relaxations=persisted_relaxations,
|
||||
reliability=getattr(row, "reliability", None) or "ok",
|
||||
# #2632: фактический радиус подбора (реконструкция выше). requested_radius_m
|
||||
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
||||
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
||||
search_radius_m=search_radius_m,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -275,15 +275,26 @@ class AggregatedEstimate(BaseModel):
|
|||
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
||||
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
||||
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
||||
# listings-аналоги (estimator.py: base_radius_m/fallback_radius_m, #2632). Может
|
||||
# ОТЛИЧАТЬСЯ от TradeInEstimateInput.radius_m (выбор пользователя в дропдауне):
|
||||
# сервер молча расширяет 1 км → 2 км при нехватке аналогов (см.
|
||||
# confidence_explanation "расширили радиус до 2 км"). Фронт рисует круг на карте
|
||||
# по ЭТОМУ полю (не по своему выбору) — иначе карта врёт о реально
|
||||
# использованном радиусе. None на GET-rehydrate (не персистится, старые записи)
|
||||
# и у _empty_estimate (поиск аналогов не выполнялся) — фронт в этом случае
|
||||
# fallback'ает на выбор пользователя.
|
||||
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
|
||||
# при нехватке аналогов сервер расширяет поиск сам (1 км → 2 км, дальше каскад
|
||||
# #oblast-F до 3/5 км — только когда пользователь НЕ зафиксировал радиус явно,
|
||||
# контракт #2044). Фронт рисует круг на карте по ЭТОМУ полю (не по своему
|
||||
# выбору) — иначе карта врёт о реально использованном радиусе.
|
||||
# На GET-rehydrate колонки под него нет, поэтому значение ВОССТАНАВЛИВАЕТСЯ
|
||||
# (estimator.rehydrate_search_radius_m): из persisted-подписи каскада
|
||||
# «радиус расширен до N м» (точное значение, строки с 2026-08-10), иначе из
|
||||
# размаха сохранённых аналогов, но не меньше DEFAULT_RADIUS_M. None — у
|
||||
# _empty_estimate (поиск не выполнялся) и у старых строк без расстояний;
|
||||
# фронт тогда fallback'ает на выбор пользователя, как раньше.
|
||||
search_radius_m: int | None = None
|
||||
# requested_radius_m — радиус, с которого поиск НАЧАЛСЯ: явный выбор
|
||||
# пользователя (TradeInEstimateInput.radius_m) либо DEFAULT_RADIUS_M, если он
|
||||
# выбрал «Авто». Отдаётся рядом с фактическим, чтобы ответ нёс ОБЕ величины —
|
||||
# что просили и что получилось — и потребителю не приходилось выводить
|
||||
# расхождение из своего локального состояния. None на GET-rehydrate:
|
||||
# radius_m не персистится, а угадывать «просили 1 км» за пользователя —
|
||||
# ровно та подмена входа результатом, которую чинит это поле.
|
||||
requested_radius_m: int | None = None
|
||||
# ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ──
|
||||
# premium_building — целевой дом признан премиальным. Источник — curated overlay
|
||||
# `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false-
|
||||
|
|
|
|||
|
|
@ -203,6 +203,57 @@ LISTINGS_FRESH_DAYS_RELAXED = 60 # #oblast-F (b): LISTINGS_FRESH_DAYS 14 → 60
|
|||
RELAX_RADIUS_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000)
|
||||
RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум
|
||||
|
||||
# #2632: подпись радиусного ослабления — ЕДИНСТВЕННОЕ место, где число
|
||||
# фактического радиуса попадает в persisted-состояние (колонка relaxations).
|
||||
# Отсюда же его читает GET-rehydrate (radius_from_relaxations) — иначе строка и
|
||||
# парсер разъедутся, и карта опять начнёт рисовать не тот круг.
|
||||
RADIUS_RELAXATION_PREFIX = "радиус расширен до "
|
||||
|
||||
|
||||
def radius_relaxation_label(radius_m: int) -> str:
|
||||
"""RU-подпись шага (d) каскада #oblast-F для AggregatedEstimate.relaxations."""
|
||||
return f"{RADIUS_RELAXATION_PREFIX}{radius_m} м"
|
||||
|
||||
|
||||
def radius_from_relaxations(relaxations: list[str] | None) -> int | None:
|
||||
"""Обратный разбор подписи: фактический радиус (м) или None, если его нет.
|
||||
|
||||
Нужен на GET-rehydrate: сам search_radius_m колонкой не персистится, но
|
||||
подпись каскада — да (миграция 255), и в ней лежит ровно то число, по
|
||||
которому реально отбирались аналоги.
|
||||
"""
|
||||
for label in relaxations or []:
|
||||
if label.startswith(RADIUS_RELAXATION_PREFIX):
|
||||
digits = label[len(RADIUS_RELAXATION_PREFIX) :].removesuffix(" м").strip()
|
||||
if digits.isdigit():
|
||||
return int(digits)
|
||||
return None
|
||||
|
||||
|
||||
def rehydrate_search_radius_m(
|
||||
relaxations: list[str] | None, analog_distances_m: list[int | None]
|
||||
) -> int | None:
|
||||
"""Фактический радиус подбора для СОХРАНЁННОЙ оценки (#2632).
|
||||
|
||||
Колонки под search_radius_m нет, поэтому восстанавливаем из того, что
|
||||
персистится:
|
||||
1) подпись каскада «радиус расширен до N м» — точное значение, то же,
|
||||
что вернул POST (строки, созданные с 2026-08-10, миграция 255);
|
||||
2) иначе — размах сохранённых аналогов, но не меньше DEFAULT_RADIUS_M:
|
||||
круг обязан вмещать пины, которые сам же и показывает (тот же приём
|
||||
уже применён во v2 SourcesMap — там радиус круга = max distance_m).
|
||||
None — расстояний нет вовсе (старые/anchor-строки): вызывающий честно
|
||||
падает обратно на выбор пользователя, как и до этой правки.
|
||||
"""
|
||||
labelled = radius_from_relaxations(relaxations)
|
||||
if labelled is not None:
|
||||
return labelled
|
||||
known = [d for d in analog_distances_m if d is not None]
|
||||
if not known:
|
||||
return None
|
||||
return max(DEFAULT_RADIUS_M, max(known))
|
||||
|
||||
|
||||
# #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals.
|
||||
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
|
||||
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
|
||||
|
|
@ -2630,6 +2681,10 @@ def _price_from_inputs(
|
|||
dadata_coarse: bool,
|
||||
geo: GeocodeResult,
|
||||
dadata_qc_geo: int | None,
|
||||
# #2632: фактический радиус подбора (после каскада #oblast-F) — чтобы
|
||||
# объяснение называло тот радиус, по которому реально искали. None →
|
||||
# прежнее поведение (FALLBACK_RADIUS_M) для оффлайн-вызывающих (бэктест).
|
||||
search_radius_m: int | None = None,
|
||||
) -> PricingResult:
|
||||
"""Deterministic pricing orchestration — pure, synchronous, zero I/O.
|
||||
|
||||
|
|
@ -2795,6 +2850,7 @@ def _price_from_inputs(
|
|||
fallback_used,
|
||||
area_widened,
|
||||
listings=listings_clean,
|
||||
radius_used_m=search_radius_m,
|
||||
)
|
||||
# #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of
|
||||
# the gate above (real thin median kept, no usable deals corridor to cede
|
||||
|
|
@ -4000,7 +4056,7 @@ async def estimate_quality(
|
|||
if area_widened:
|
||||
relaxations.append("площадь ±25%")
|
||||
if search_radius_m > base_radius_m:
|
||||
relaxations.append(f"радиус расширен до {search_radius_m} м")
|
||||
relaxations.append(radius_relaxation_label(search_radius_m))
|
||||
|
||||
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
|
||||
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
|
||||
|
|
@ -4192,6 +4248,7 @@ async def estimate_quality(
|
|||
dadata_coarse=dadata_coarse,
|
||||
geo=geo,
|
||||
dadata_qc_geo=dadata.qc_geo if dadata else None,
|
||||
search_radius_m=search_radius_m,
|
||||
)
|
||||
|
||||
# Unpack pricing result
|
||||
|
|
@ -4631,10 +4688,11 @@ async def estimate_quality(
|
|||
metro_nearest=(dadata.metro if dadata and dadata.metro else []),
|
||||
address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None),
|
||||
analog_tier=api_analog_tier, # type: ignore[arg-type]
|
||||
# #2632: фактический радиус отбора listings-аналогов (см. search_radius_m
|
||||
# def выше) — может отличаться от payload.radius_m (выбор пользователя),
|
||||
# когда сервер сам расширил поиск. None только у _empty_estimate (поиск
|
||||
# аналогов вообще не выполнялся).
|
||||
# #2632: обе величины рядом — с чего поиск начали (выбор пользователя или
|
||||
# DEFAULT_RADIUS_M) и на чём закончили. Расходятся, когда сервер сам
|
||||
# расширил поиск; подменять одно другим нельзя — это ровно тот дефект
|
||||
# метки, что чинили в #2811. None у _empty_estimate (поиск не выполнялся).
|
||||
requested_radius_m=base_radius_m,
|
||||
search_radius_m=search_radius_m,
|
||||
premium_building=premium_building,
|
||||
premium_building_median_ppm2=premium_building_median_ppm2,
|
||||
|
|
@ -6668,6 +6726,11 @@ def _cap_confidence_by_reliability(confidence: str, reliability: str) -> str:
|
|||
return confidence
|
||||
|
||||
|
||||
def _radius_ru(radius_m: int) -> str:
|
||||
"""«2 км» / «5 км» / «1500 м» — целые километры словом, остальное метрами."""
|
||||
return f"{radius_m // 1000} км" if radius_m % 1000 == 0 else f"{radius_m} м"
|
||||
|
||||
|
||||
def _compute_confidence(
|
||||
n_analogs: int,
|
||||
median_ppm2: float,
|
||||
|
|
@ -6676,6 +6739,7 @@ def _compute_confidence(
|
|||
fallback_radius_used: bool,
|
||||
area_widened: bool = False,
|
||||
listings: list[dict] | None = None,
|
||||
radius_used_m: int | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Confidence + explanation string.
|
||||
|
||||
|
|
@ -6709,7 +6773,12 @@ def _compute_confidence(
|
|||
iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0
|
||||
notes = []
|
||||
if fallback_radius_used:
|
||||
notes.append("расширили радиус до 2 км")
|
||||
# #2632: НАЗЫВАЕМ фактический радиус. До этого здесь было прибито «2 км»
|
||||
# — константа старого 1км→2км fallback'а, а каскад #oblast-F уходит до
|
||||
# 3/5 км: текст утверждал 2 км там, где поиск реально шёл по 5 км
|
||||
# (прод, 2026-08-11: relaxations «радиус расширен до 5000 м», самый
|
||||
# дальний аналог 4381 м, объяснение — «расширили радиус до 2 км»).
|
||||
notes.append(f"расширили радиус до {_radius_ru(radius_used_m or FALLBACK_RADIUS_M)}")
|
||||
if area_widened:
|
||||
notes.append("расширили допуск по площади до ±25%")
|
||||
fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else ""
|
||||
|
|
|
|||
|
|
@ -163,6 +163,10 @@ def _stub_precision_and_pdf():
|
|||
# #2087 (M1): GET-rehydrate derives canonical sources_used via the shared
|
||||
# helper. Empty analogs + no valuation flags → [] (real behaviour).
|
||||
_canonical_sources=lambda *a, **k: [],
|
||||
# #2632: GET-rehydrate реконструирует фактический радиус подбора. У этих
|
||||
# фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее
|
||||
# поведение (см. tests/test_estimator_search_radius_2632.py).
|
||||
rehydrate_search_radius_m=lambda *a, **k: None,
|
||||
)
|
||||
real_estimator = sys.modules.get("app.services.estimator")
|
||||
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
|
||||
|
|
|
|||
|
|
@ -273,6 +273,10 @@ def _estimator_stub():
|
|||
_cv_from_ppm2=lambda *a, **k: None,
|
||||
_source_counts=lambda *a, **k: {},
|
||||
_canonical_sources=lambda *a, **k: [],
|
||||
# #2632: GET-rehydrate реконструирует фактический радиус подбора. У строк
|
||||
# этой фикстуры analogs пусты → None и есть настоящее поведение
|
||||
# (см. tests/test_estimator_search_radius_2632.py).
|
||||
rehydrate_search_radius_m=lambda *a, **k: None,
|
||||
estimate_quality=_default_estimate_quality,
|
||||
)
|
||||
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
||||
|
|
|
|||
250
tradein-mvp/backend/tests/test_estimator_search_radius_2632.py
Normal file
250
tradein-mvp/backend/tests/test_estimator_search_radius_2632.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"""#2632 — ответ несёт ФАКТИЧЕСКИЙ радиус подбора, а не выбор пользователя.
|
||||
|
||||
Прод, 2026-08-11 (оценка 4c20518f, ул. Советская 51): сохранённая строка несёт
|
||||
`relaxations = ["радиус расширен до 5000 м"]`, все 10 сохранённых аналогов лежат
|
||||
дальше километра (самый дальний — 4381 м), а `GET /estimate/{id}` отдавал
|
||||
`search_radius_m: null` → фронт падал на превью-радиус 1 км и рисовал круг, за
|
||||
которым остаются его же пины. Там же вторая ложь: `confidence_explanation`
|
||||
утверждал «расширили радиус до 2 км» — константа старого 1км→2км fallback'а,
|
||||
хотя каскад #oblast-F ушёл до 5 км.
|
||||
|
||||
Тесты падают на коде до этой правки: helper'ов не существует (ImportError),
|
||||
а объяснение прибито к «2 км».
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
|
||||
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
|
||||
|
||||
import pytest # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.services.estimator import ( # noqa: E402
|
||||
DEFAULT_RADIUS_M,
|
||||
_compute_confidence,
|
||||
radius_from_relaxations,
|
||||
radius_relaxation_label,
|
||||
rehydrate_search_radius_m,
|
||||
)
|
||||
|
||||
_ESTIMATE_ID = "4c20518f-7d5d-4f9a-9dde-8f65d5ea2202"
|
||||
|
||||
|
||||
# ── подпись каскада ↔ разбор: единственный носитель числа в persisted-состоянии ──
|
||||
|
||||
|
||||
def test_radius_label_roundtrips() -> None:
|
||||
assert radius_from_relaxations([radius_relaxation_label(5000)]) == 5000
|
||||
assert radius_from_relaxations(["учтены студии", radius_relaxation_label(2000)]) == 2000
|
||||
# прод-строка дословно
|
||||
assert radius_from_relaxations(["радиус расширен до 5000 м"]) == 5000
|
||||
|
||||
|
||||
def test_radius_label_absent_or_malformed_gives_none() -> None:
|
||||
assert radius_from_relaxations(None) is None
|
||||
assert radius_from_relaxations([]) is None
|
||||
assert radius_from_relaxations(["учтены новостройки", "площадь ±25%"]) is None
|
||||
assert radius_from_relaxations(["радиус расширен до пяти км"]) is None
|
||||
|
||||
|
||||
# ── реконструкция радиуса для сохранённой оценки ──────────────────────────────
|
||||
|
||||
|
||||
def test_rehydrate_prefers_persisted_label_over_analog_extent() -> None:
|
||||
"""Подпись — точное значение (то же, что вернул POST); размах — лишь оценка снизу."""
|
||||
assert rehydrate_search_radius_m(["радиус расширен до 5000 м"], [4381, 1200, None]) == 5000
|
||||
|
||||
|
||||
def test_rehydrate_falls_back_to_analog_extent() -> None:
|
||||
"""Строки до миграции 255: подписи нет, но круг обязан вмещать свои же пины."""
|
||||
assert rehydrate_search_radius_m([], [800, 2428, None]) == 2428
|
||||
|
||||
|
||||
def test_rehydrate_never_below_default_radius() -> None:
|
||||
"""Все аналоги рядом → поиск всё равно шёл по базовому радиусу, не по 300 м."""
|
||||
assert rehydrate_search_radius_m([], [120, 300]) == DEFAULT_RADIUS_M
|
||||
|
||||
|
||||
def test_rehydrate_without_distances_stays_none() -> None:
|
||||
"""Нечего утверждать — фронт честно падает на выбор пользователя (как раньше)."""
|
||||
assert rehydrate_search_radius_m([], []) is None
|
||||
assert rehydrate_search_radius_m(None, [None, None]) is None
|
||||
|
||||
|
||||
# ── объяснение называет тот радиус, по которому реально искали ────────────────
|
||||
|
||||
|
||||
def _confidence(radius_used_m: int | None) -> str:
|
||||
_, explanation = _compute_confidence(
|
||||
12,
|
||||
100_000.0,
|
||||
95_000.0,
|
||||
105_000.0,
|
||||
True, # fallback_radius_used
|
||||
False,
|
||||
listings=[{"address": f"дом {i}"} for i in range(12)],
|
||||
radius_used_m=radius_used_m,
|
||||
)
|
||||
return explanation
|
||||
|
||||
|
||||
def test_explanation_names_actual_radius_not_hardcoded_2km() -> None:
|
||||
text = _confidence(5000)
|
||||
assert "5 км" in text
|
||||
assert "2 км" not in text
|
||||
|
||||
|
||||
def test_explanation_keeps_2km_wording_when_that_is_the_truth() -> None:
|
||||
assert "2 км" in _confidence(2000)
|
||||
# не-круглый радиус не притворяется километрами
|
||||
assert "1500 м" in _confidence(1500)
|
||||
|
||||
|
||||
# ── GET /estimate/{id}: сохранённая оценка отдаёт фактический радиус ──────────
|
||||
|
||||
|
||||
def _prod_shaped_row() -> SimpleNamespace:
|
||||
"""Форма прод-строки 4c20518f (расширение до 5 км, аналоги дальше километра)."""
|
||||
analog = {
|
||||
"address": "Екатеринбург, ул. Советская, 51",
|
||||
"area_m2": 44.0,
|
||||
"rooms": 2,
|
||||
"floor": 3,
|
||||
"total_floors": 9,
|
||||
"price_rub": 6_000_000,
|
||||
"price_per_m2": 136_000,
|
||||
"listing_date": None,
|
||||
"days_on_market": None,
|
||||
"source": "cian",
|
||||
"distance_m": 4381,
|
||||
}
|
||||
return SimpleNamespace(
|
||||
id=_ESTIMATE_ID,
|
||||
median_price=6_000_000,
|
||||
range_low=5_500_000,
|
||||
range_high=6_500_000,
|
||||
median_price_per_m2=136_000,
|
||||
confidence="low",
|
||||
confidence_explanation="Найдено 42 аналогов",
|
||||
n_analogs=42,
|
||||
analogs=[analog, {**analog, "distance_m": 1200}],
|
||||
actual_deals=[],
|
||||
sources_used=["cian"],
|
||||
data_freshness_minutes=15,
|
||||
expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
|
||||
retain_until=None,
|
||||
address="Свердловская область, г. Екатеринбург, ул. Советская, д. 51",
|
||||
lat=56.8519,
|
||||
lon=60.6122,
|
||||
area_m2=44.0,
|
||||
rooms=2,
|
||||
floor=3,
|
||||
total_floors=9,
|
||||
year_built=1975,
|
||||
house_type=None,
|
||||
repair_state=None,
|
||||
has_balcony=None,
|
||||
canonical_address=None,
|
||||
house_cadnum=None,
|
||||
house_fias_id=None,
|
||||
dadata_qc_geo=None,
|
||||
dadata_metro=[],
|
||||
expected_sold_price=None,
|
||||
expected_sold_range_low=None,
|
||||
expected_sold_range_high=None,
|
||||
expected_sold_per_m2=None,
|
||||
asking_to_sold_ratio=None,
|
||||
ratio_basis=None,
|
||||
created_by="praktika",
|
||||
created_at=datetime.now(tz=UTC),
|
||||
relaxations=["радиус расширен до 5000 м"],
|
||||
reliability="low",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _light_estimator_stub():
|
||||
"""Подменяет тяжёлые POST-only helper'ы estimator'а, оставляя РЕАЛЬНУЮ реконструкцию.
|
||||
|
||||
Тот же приём, что в test_estimate_idor / test_estimate_revival (не тащить
|
||||
геокодер/скрейперы в API-тест), но `rehydrate_search_radius_m` берётся
|
||||
настоящий — его поведение и проверяем.
|
||||
"""
|
||||
real_estimator = sys.modules.get("app.services.estimator")
|
||||
stub = SimpleNamespace(
|
||||
_qc_geo_to_precision=lambda _qc: None,
|
||||
_fetch_price_trend=lambda *a, **k: None,
|
||||
_fetch_dkp_corridor=lambda *a, **k: None,
|
||||
_fetch_house_imv_anchor=lambda *a, **k: None,
|
||||
_resolve_target_city=lambda *a, **k: None,
|
||||
_cv_from_ppm2=lambda *a, **k: None,
|
||||
_source_counts=lambda *a, **k: {},
|
||||
_canonical_sources=lambda *a, **k: ["cian"],
|
||||
rehydrate_search_radius_m=rehydrate_search_radius_m,
|
||||
)
|
||||
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
||||
yield stub
|
||||
if real_estimator is not None:
|
||||
sys.modules["app.services.estimator"] = real_estimator
|
||||
else:
|
||||
sys.modules.pop("app.services.estimator", None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_get_role():
|
||||
from app.core import auth as auth_mod
|
||||
|
||||
original = auth_mod.get_role
|
||||
yield
|
||||
auth_mod.get_role = original
|
||||
|
||||
|
||||
def test_get_estimate_returns_actual_search_radius(_light_estimator_stub: SimpleNamespace) -> None:
|
||||
"""Прод-репро: круг на реоткрытой оценке рисовался по 1 км при поиске в 5 км."""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
from app.core import auth as auth_mod
|
||||
from app.core.db import get_db
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
|
||||
row = _prod_shaped_row()
|
||||
db = MagicMock()
|
||||
|
||||
def _execute(clause, params=None, *_a, **_k):
|
||||
sql = getattr(clause, "text", str(clause))
|
||||
result = MagicMock()
|
||||
result.fetchone.return_value = row if "SELECT id, median_price" in sql else None
|
||||
return result
|
||||
|
||||
db.execute.side_effect = _execute
|
||||
|
||||
def _override_db():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment]
|
||||
|
||||
resp = TestClient(app).get(
|
||||
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||
headers={"X-Authenticated-User": "praktika"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["search_radius_m"] == 5000
|
||||
# круг обязан вмещать пины, которые сам же показывает
|
||||
assert body["search_radius_m"] >= max(a["distance_m"] for a in body["analogs"])
|
||||
# вход не выдаём за результат: radius_m не персистится — «просили» неизвестно
|
||||
assert body["requested_radius_m"] is None
|
||||
Loading…
Add table
Reference in a new issue