gendesign/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py
bot-backend 1fe383f935
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 20s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 2m25s
CI Trade-In / backend-tests (pull_request) Successful in 7m2s
fix(tradein): коридор ДКП при якоре того же дома помечен как не вошедший в цену (#3466)
Кламп headline к коридору ДКП выключается не только малым числом сделок
(advisory_only, #3452), но и якорем Tier A: _apply_corridor_clamp его exempt,
radius-floor требует anchor_tier is None. Ревьюер #3462 воспроизвёл n=20,
advisory_only=False, headline 202 100 против потолка 140 000 — подписи нет.

- Признак — analog_tier == "same_building" (уже структурный в POST).
  corridorAdvisoryNote принимает тир и при same_building говорит
  «справочно: цена посчитана по аналогам в этом же доме — коридор её не
  ограничивает»; оба вызова (v1 HeroSummary, v2 mappers) передают тир.
- GET-rehydrate терял analog_tier (колонки нет) — якорный тир теперь
  восстанавливается из подписи якорного блока в confidence_explanation
  через общую константу (analog_tier_from_explanation), как радиус в #2632.
  Разбор по всей фразе: радиусный тир S пишет «(аналоги из того же дома)»,
  на проде таких строк 4.
- Тесты floor не доходили до floor: три лота уводили в #oblast-E, headline
  брался из медианы коридора. На main с полностью выключенным floor файл
  зелёный. Лотов шесть, ожидания точные, добавлен кейс n = min_n − 1.
- Полоса маркера corridor_advisory_zone — n = 1..9, не 3..9: уличный коридор
  отдаётся с одной сделки. Формулировки поправлены, тест на n=2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:38:35 +05:00

253 lines
10 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.

"""#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,
analog_tier_from_explanation,
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,
# #3466: радиусная строка («Найдено 42 аналогов») → None, разбор настоящий.
analog_tier_from_explanation=analog_tier_from_explanation,
)
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