gendesign/backend/tests/api/v1/test_concept_recompute.py
Light1YT 18d012da1b
Some checks failed
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / openapi-codegen-check (pull_request) Failing after 1m49s
CI / backend-tests (pull_request) Successful in 13m49s
fix(generative): forward genuine price_source through /recompute (#1965 Stage 2a)
Code-review follow-up: /recompute hardcoded price_source="objective_district_median"
for any body-supplied market_price_per_sqm, mislabeling the honesty-flag once
Stage 2b forwards a price whose genuine source differs (objective_geo_radius /
district_reference / class_norm from financial_estimate).

- schemas/concept.py: add optional price_source: str | None to MassingProgram.
- api/v1/concepts.py: on FAST path use payload.price_source if provided, else the
  default label (now a module-level constant _DEFAULT_PRERESOLVED_SOURCE). DB-fallback
  and class-norm paths keep their own resolved source unchanged.
- tests: assert body-provided price_source echoes through to financial.price_source
  (not overwritten), and the default label applies when the front omits it.
2026-06-28 00:33:59 +05:00

162 lines
7.3 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
# Без 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