gendesign/backend/tests/api/v1/test_concept_recompute.py
Light1YT d38d5d43c5 feat(generative): LIVE financial recompute from massing program (#1965 Stage 2a)
Stage 2a of epic #1953: backend service + endpoint for live economic recompute
driven by the interactive 3D massing (Stage 2b debounced sliders).

- teap.py: add pure synthesize_teap_from_program(total_footprint_sqm, floors,
  site_area_sqm, housing_class, sections) — builds a TEAP from the SCALAR
  aggregate footprint × floors, mirroring synthesize_teap_from_buildability and
  reusing the same shared norm constants (_OFFICE_SHARE_OF_GFA / _EFFICIENCY_BY_CLASS
  / _AVG_APARTMENT_SQM / _PARKING_PER_APARTMENT) — single source of truth.
- schemas/concept.py: add MassingProgram (program contract, optional pre-resolved
  market_price_per_sqm + parcel_centroid_wkt) and MassingRecomputeOutput (teap + financial).
- api/v1/concepts.py: add POST /api/v1/concepts/recompute — synthesize TEAP → run
  the existing pure compute_financial. FAST path uses body market_price_per_sqm
  (no DB); else _lookup_market_price by centroid via run_in_threadpool; else class norm.
- tests: synthesize_teap_from_program (gfa math, parity with compute_teap, class
  efficiency, sections no-op) + endpoint (200, coherent output, price passthrough
  skips DB, DB fallback, class-norm default, floors validation).
2026-06-28 00:27:25 +05:00

135 lines
5.8 KiB
Python
Raw 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.

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