"""Stage 2a (#1965, эпик #1953) — POST /api/v1/concepts/recompute endpoint tests. LIVE-пересчёт экономики из агрегированной массинг-программы (пятно × этажность) для дебаунс-слайдеров Stage 2b. Проверяем: 200 + связный вывод (ТЭП-математика, revenue>0, NPV посчитан), FAST path с предрезолвленной ценой (БЕЗ обращения к БД) и DB-fallback по центроиду (через dependency override get_db мок-сессией). compute_financial остаётся чистой — её арифметика покрыта в tests/services/generative; тут — контракт эндпоинта. """ from __future__ import annotations from typing import Any from fastapi.testclient import TestClient from app.core.db import get_db from app.main import app def _program(**overrides: Any) -> dict[str, Any]: base: dict[str, Any] = { "total_footprint_sqm": 3000.0, "floors": 12, "sections": 2, "site_area_sqm": 10_000.0, "housing_class": "comfort", "development_type": "mid_rise", } base.update(overrides) return base def test_recompute_returns_200_and_coherent_output() -> None: client = TestClient(app) resp = client.post( "/api/v1/concepts/recompute", json=_program(market_price_per_sqm=150_000.0), ) assert resp.status_code == 200, resp.text body = resp.json() teap = body["teap"] fin = body["financial"] # ТЭП-математика: GFA = пятно × этажность; пятно = total_footprint_sqm. assert teap["built_area_sqm"] == 3000.0 assert teap["total_floor_area_sqm"] == 3000.0 * 12 # Финмодель связна: выручка/затраты > 0, NPV посчитан (поле присутствует). assert fin["revenue_rub"] > 0 assert fin["cost_rub"] > 0 assert isinstance(fin["npv_rub"], float) def test_recompute_market_price_passthrough_skips_db() -> None: # Предрезолвленная цена в теле → используется как есть, БЕЗ обращения к БД. # get_db переопределён сессией, которая ВЗРЫВАЕТСЯ при любом execute — если бы # эндпоинт пошёл в БД, тест бы упал. Зелёный тест доказывает skip DB lookup. class _ExplodingSession: def execute(self, *_a: object, **_k: object) -> None: raise AssertionError("DB lookup must be skipped when market_price_per_sqm given") app.dependency_overrides[get_db] = lambda: _ExplodingSession() try: client = TestClient(app) resp = client.post( "/api/v1/concepts/recompute", json=_program(market_price_per_sqm=180_000.0), ) assert resp.status_code == 200, resp.text fin = resp.json()["financial"] # Цена прокинута в выручку жилья → price_per_sqm_used == переданная цена. assert fin["price_per_sqm_used"] == 180_000.0 assert fin["price_is_calibrated"] is True # Без body price_source → дефолтный лейбл (фронт не форвардил подлинный source). assert fin["price_source"] == "objective_district_median" finally: app.dependency_overrides.pop(get_db, None) def test_recompute_body_price_source_echoed_through() -> None: # Фронт форвардит ПОДЛИННЫЙ source цены (из financial_estimate, напр. geo_radius) — # эндпоинт НЕ подменяет его захардкоженным дефолтом; honest-флаг доходит до UI/PDF. # get_db взрывается → доказывает, что и source-passthrough идёт по FAST path без БД. class _ExplodingSession: def execute(self, *_a: object, **_k: object) -> None: raise AssertionError("DB lookup must be skipped when market_price_per_sqm given") app.dependency_overrides[get_db] = lambda: _ExplodingSession() try: client = TestClient(app) resp = client.post( "/api/v1/concepts/recompute", json=_program(market_price_per_sqm=160_000.0, price_source="objective_geo_radius"), ) assert resp.status_code == 200, resp.text fin = resp.json()["financial"] assert fin["price_per_sqm_used"] == 160_000.0 assert fin["price_is_calibrated"] is True # Подлинный source эхнут наружу, НЕ перетёрт дефолтом "objective_district_median". assert fin["price_source"] == "objective_geo_radius" finally: app.dependency_overrides.pop(get_db, None) def test_recompute_db_fallback_when_no_price_but_centroid() -> None: # Нет market_price_per_sqm, но передан centroid → эндпоинт делает _lookup_market_price. # Мок-сессия отдаёт район + Objective-медиану (n>=10) → цена калибруется из БД. class _Result: def __init__(self, row: dict[str, Any] | None) -> None: self._row = row def mappings(self) -> _Result: return self def first(self) -> dict[str, Any] | None: return self._row class _FakeSession: def __init__(self, rows: list[dict[str, Any] | None]) -> None: self._rows = rows self.calls = 0 def execute(self, *_a: object, **_k: object) -> _Result: row = self._rows[self.calls] if self.calls < len(self._rows) else None self.calls += 1 return _Result(row) rows = [ {"district_name": "Кировский", "median_price_per_m2": 130_000}, {"median_ppm2": 175_000.0, "sample_size": 42}, ] app.dependency_overrides[get_db] = lambda: _FakeSession(rows) try: client = TestClient(app) resp = client.post( "/api/v1/concepts/recompute", json=_program(parcel_centroid_wkt="POINT (60.6 56.83)"), ) assert resp.status_code == 200, resp.text fin = resp.json()["financial"] # Цена пришла из Objective-медианы района (DB-fallback). assert fin["price_per_sqm_used"] == 175_000.0 assert fin["price_source"] == "objective_district_median" assert fin["price_is_calibrated"] is True finally: app.dependency_overrides.pop(get_db, None) def test_recompute_no_price_no_centroid_uses_class_norm() -> None: # Ни цены, ни центроида → норматив класса comfort (145_000), не калибровано. client = TestClient(app) resp = client.post("/api/v1/concepts/recompute", json=_program()) assert resp.status_code == 200, resp.text fin = resp.json()["financial"] assert fin["price_per_sqm_used"] == 145_000.0 assert fin["price_is_calibrated"] is False assert fin["price_source"] == "class_norm" def test_recompute_validates_floors_bounds() -> None: # floors вне [1, 40] → 422 (Pydantic-валидация контракта). client = TestClient(app) resp = client.post("/api/v1/concepts/recompute", json=_program(floors=99)) assert resp.status_code == 422