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
Кламп 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>
253 lines
10 KiB
Python
253 lines
10 KiB
Python
"""#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
|