Compare commits
No commits in common. "bbed7437738be2d67058b9514058e039c01954d5" and "3f87873d7baf0fd30565289d432ae6a605f6589c" have entirely different histories.
bbed743773
...
3f87873d7b
6 changed files with 15 additions and 368 deletions
|
|
@ -556,22 +556,11 @@ def get_estimate(
|
||||||
_qc_geo_to_precision,
|
_qc_geo_to_precision,
|
||||||
_resolve_target_city,
|
_resolve_target_city,
|
||||||
_source_counts,
|
_source_counts,
|
||||||
rehydrate_search_radius_m,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
||||||
actual_deals = [AnalogLot(**a) for a in (row.actual_deals 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 из
|
# #2043 (BE-1): CV / счётчики источников на rehydrate — best-effort из
|
||||||
# сохранённых analogs (top-N, усечённо: полная выборка не персистится). На
|
# сохранённых analogs (top-N, усечённо: полная выборка не персистится). На
|
||||||
# свежей оценке (POST) считаются по полной выборке; здесь — по тому, что есть
|
# свежей оценке (POST) считаются по полной выборке; здесь — по тому, что есть
|
||||||
|
|
@ -700,12 +689,8 @@ def get_estimate(
|
||||||
# getattr defensive: старые in-memory test doubles / любая строка без
|
# getattr defensive: старые in-memory test doubles / любая строка без
|
||||||
# этих колонок (не должно случаться после миграции) деградируют в
|
# этих колонок (не должно случаться после миграции) деградируют в
|
||||||
# дефолт схемы (ok / []), а не падают AttributeError.
|
# дефолт схемы (ok / []), а не падают AttributeError.
|
||||||
relaxations=persisted_relaxations,
|
relaxations=list(getattr(row, "relaxations", None) or []),
|
||||||
reliability=getattr(row, "reliability", None) or "ok",
|
reliability=getattr(row, "reliability", None) or "ok",
|
||||||
# #2632: фактический радиус подбора (реконструкция выше). requested_radius_m
|
|
||||||
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
|
||||||
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
|
||||||
search_radius_m=search_radius_m,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -275,26 +275,15 @@ class AggregatedEstimate(BaseModel):
|
||||||
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
||||||
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
||||||
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
||||||
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
|
# listings-аналоги (estimator.py: base_radius_m/fallback_radius_m, #2632). Может
|
||||||
# при нехватке аналогов сервер расширяет поиск сам (1 км → 2 км, дальше каскад
|
# ОТЛИЧАТЬСЯ от TradeInEstimateInput.radius_m (выбор пользователя в дропдауне):
|
||||||
# #oblast-F до 3/5 км — только когда пользователь НЕ зафиксировал радиус явно,
|
# сервер молча расширяет 1 км → 2 км при нехватке аналогов (см.
|
||||||
# контракт #2044). Фронт рисует круг на карте по ЭТОМУ полю (не по своему
|
# confidence_explanation "расширили радиус до 2 км"). Фронт рисует круг на карте
|
||||||
# выбору) — иначе карта врёт о реально использованном радиусе.
|
# по ЭТОМУ полю (не по своему выбору) — иначе карта врёт о реально
|
||||||
# На GET-rehydrate колонки под него нет, поэтому значение ВОССТАНАВЛИВАЕТСЯ
|
# использованном радиусе. None на GET-rehydrate (не персистится, старые записи)
|
||||||
# (estimator.rehydrate_search_radius_m): из persisted-подписи каскада
|
# и у _empty_estimate (поиск аналогов не выполнялся) — фронт в этом случае
|
||||||
# «радиус расширен до N м» (точное значение, строки с 2026-08-10), иначе из
|
# fallback'ает на выбор пользователя.
|
||||||
# размаха сохранённых аналогов, но не меньше DEFAULT_RADIUS_M. None — у
|
|
||||||
# _empty_estimate (поиск не выполнялся) и у старых строк без расстояний;
|
|
||||||
# фронт тогда fallback'ает на выбор пользователя, как раньше.
|
|
||||||
search_radius_m: int | None = None
|
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: премиальный дом (флаг, НЕ ценовой сигнал) ──
|
# ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ──
|
||||||
# premium_building — целевой дом признан премиальным. Источник — curated overlay
|
# premium_building — целевой дом признан премиальным. Источник — curated overlay
|
||||||
# `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false-
|
# `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false-
|
||||||
|
|
|
||||||
|
|
@ -205,57 +205,6 @@ 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_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000)
|
||||||
RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум
|
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.
|
# #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals.
|
||||||
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
|
# 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 = ЕКБ).
|
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
|
||||||
|
|
@ -2699,10 +2648,6 @@ def _price_from_inputs(
|
||||||
dadata_coarse: bool,
|
dadata_coarse: bool,
|
||||||
geo: GeocodeResult,
|
geo: GeocodeResult,
|
||||||
dadata_qc_geo: int | None,
|
dadata_qc_geo: int | None,
|
||||||
# #2632: фактический радиус подбора (после каскада #oblast-F) — чтобы
|
|
||||||
# объяснение называло тот радиус, по которому реально искали. None →
|
|
||||||
# прежнее поведение (FALLBACK_RADIUS_M) для оффлайн-вызывающих (бэктест).
|
|
||||||
search_radius_m: int | None = None,
|
|
||||||
) -> PricingResult:
|
) -> PricingResult:
|
||||||
"""Deterministic pricing orchestration — pure, synchronous, zero I/O.
|
"""Deterministic pricing orchestration — pure, synchronous, zero I/O.
|
||||||
|
|
||||||
|
|
@ -2868,7 +2813,6 @@ def _price_from_inputs(
|
||||||
fallback_used,
|
fallback_used,
|
||||||
area_widened,
|
area_widened,
|
||||||
listings=listings_clean,
|
listings=listings_clean,
|
||||||
radius_used_m=search_radius_m,
|
|
||||||
)
|
)
|
||||||
# #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of
|
# #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
|
# the gate above (real thin median kept, no usable deals corridor to cede
|
||||||
|
|
@ -4096,7 +4040,7 @@ async def estimate_quality(
|
||||||
if area_widened:
|
if area_widened:
|
||||||
relaxations.append("площадь ±25%")
|
relaxations.append("площадь ±25%")
|
||||||
if search_radius_m > base_radius_m:
|
if search_radius_m > base_radius_m:
|
||||||
relaxations.append(radius_relaxation_label(search_radius_m))
|
relaxations.append(f"радиус расширен до {search_radius_m} м")
|
||||||
|
|
||||||
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
|
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
|
||||||
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
|
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
|
||||||
|
|
@ -4288,7 +4232,6 @@ async def estimate_quality(
|
||||||
dadata_coarse=dadata_coarse,
|
dadata_coarse=dadata_coarse,
|
||||||
geo=geo,
|
geo=geo,
|
||||||
dadata_qc_geo=dadata.qc_geo if dadata else None,
|
dadata_qc_geo=dadata.qc_geo if dadata else None,
|
||||||
search_radius_m=search_radius_m,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Unpack pricing result
|
# Unpack pricing result
|
||||||
|
|
@ -4728,11 +4671,10 @@ async def estimate_quality(
|
||||||
metro_nearest=(dadata.metro if dadata and dadata.metro else []),
|
metro_nearest=(dadata.metro if dadata and dadata.metro else []),
|
||||||
address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None),
|
address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None),
|
||||||
analog_tier=api_analog_tier, # type: ignore[arg-type]
|
analog_tier=api_analog_tier, # type: ignore[arg-type]
|
||||||
# #2632: обе величины рядом — с чего поиск начали (выбор пользователя или
|
# #2632: фактический радиус отбора listings-аналогов (см. search_radius_m
|
||||||
# DEFAULT_RADIUS_M) и на чём закончили. Расходятся, когда сервер сам
|
# def выше) — может отличаться от payload.radius_m (выбор пользователя),
|
||||||
# расширил поиск; подменять одно другим нельзя — это ровно тот дефект
|
# когда сервер сам расширил поиск. None только у _empty_estimate (поиск
|
||||||
# метки, что чинили в #2811. None у _empty_estimate (поиск не выполнялся).
|
# аналогов вообще не выполнялся).
|
||||||
requested_radius_m=base_radius_m,
|
|
||||||
search_radius_m=search_radius_m,
|
search_radius_m=search_radius_m,
|
||||||
premium_building=premium_building,
|
premium_building=premium_building,
|
||||||
premium_building_median_ppm2=premium_building_median_ppm2,
|
premium_building_median_ppm2=premium_building_median_ppm2,
|
||||||
|
|
@ -6766,11 +6708,6 @@ def _cap_confidence_by_reliability(confidence: str, reliability: str) -> str:
|
||||||
return confidence
|
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(
|
def _compute_confidence(
|
||||||
n_analogs: int,
|
n_analogs: int,
|
||||||
median_ppm2: float,
|
median_ppm2: float,
|
||||||
|
|
@ -6779,7 +6716,6 @@ def _compute_confidence(
|
||||||
fallback_radius_used: bool,
|
fallback_radius_used: bool,
|
||||||
area_widened: bool = False,
|
area_widened: bool = False,
|
||||||
listings: list[dict] | None = None,
|
listings: list[dict] | None = None,
|
||||||
radius_used_m: int | None = None,
|
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Confidence + explanation string.
|
"""Confidence + explanation string.
|
||||||
|
|
||||||
|
|
@ -6813,12 +6749,7 @@ def _compute_confidence(
|
||||||
iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0
|
iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0
|
||||||
notes = []
|
notes = []
|
||||||
if fallback_radius_used:
|
if fallback_radius_used:
|
||||||
# #2632: НАЗЫВАЕМ фактический радиус. До этого здесь было прибито «2 км»
|
notes.append("расширили радиус до 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:
|
if area_widened:
|
||||||
notes.append("расширили допуск по площади до ±25%")
|
notes.append("расширили допуск по площади до ±25%")
|
||||||
fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else ""
|
fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else ""
|
||||||
|
|
|
||||||
|
|
@ -163,10 +163,6 @@ def _stub_precision_and_pdf():
|
||||||
# #2087 (M1): GET-rehydrate derives canonical sources_used via the shared
|
# #2087 (M1): GET-rehydrate derives canonical sources_used via the shared
|
||||||
# helper. Empty analogs + no valuation flags → [] (real behaviour).
|
# helper. Empty analogs + no valuation flags → [] (real behaviour).
|
||||||
_canonical_sources=lambda *a, **k: [],
|
_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")
|
real_estimator = sys.modules.get("app.services.estimator")
|
||||||
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
|
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
|
||||||
|
|
|
||||||
|
|
@ -273,10 +273,6 @@ def _estimator_stub():
|
||||||
_cv_from_ppm2=lambda *a, **k: None,
|
_cv_from_ppm2=lambda *a, **k: None,
|
||||||
_source_counts=lambda *a, **k: {},
|
_source_counts=lambda *a, **k: {},
|
||||||
_canonical_sources=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,
|
estimate_quality=_default_estimate_quality,
|
||||||
)
|
)
|
||||||
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
||||||
|
|
|
||||||
|
|
@ -1,250 +0,0 @@
|
||||||
"""#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