"""#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