Финальная часть эпика #1953: пользователь выбирает типовые дома (тип × этажность × число секций) вместо авто max-FAR раскладки, формируя building_program из Stage 3a. Бэкенд: - GET /api/v1/concepts/house-types — read-only каталог HOUSE_TYPES (section_type, label_ru, footprint w×d + sqm, default_floors, housing_class) как single source of truth; фронт ничего не хардкодит. - Схема HouseTypeCatalog / HouseTypeCatalogItem в schemas/concept.py. - Тесты эндпоинта: полнота каталога + совпадение ключей с available_section_types. Кодген: api-types.ts перегенерён (dump OpenAPI → openapi-typescript → project-local prettier 3.9.0); 2-й прогон без диффа. Фронтенд: - useHouseTypes() (TanStack useQuery, staleTime Infinity) в concept-api.ts; building_program в ConceptInput, placed_count/requested_count в ConceptVariant. - HouseProgramPicker: toggle «Авто (max-FAR)» (default, program omit → greedy) vs «Выбрать дома» (список каталожных типов, count 1-50 / floors 1-40, дефолт из каталога; габариты/этажность/класс как подсказка). Смонтирован в Section7Concept и на странице /concept. - Partial-fit заметка в ConceptVariantsResult: при placed<requested честное «Разместилось N из M секций — участок вмещает меньше» (нейтрально, не ошибка).
291 lines
10 KiB
Python
291 lines
10 KiB
Python
"""End-to-end API test — POST /api/v1/concepts returns 3 filled variants.
|
||
|
||
Goes through the FastAPI route (TestClient) and asserts the contract is populated
|
||
with *real* numbers (non-zero ТЭП + financial), valid building GeoJSON, and that a
|
||
degenerate parcel yields 422 rather than empty variants.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Iterator
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.core.db import get_db
|
||
from app.main import app
|
||
|
||
|
||
class _NoDbSession:
|
||
"""Заглушка Session: любой execute падает → market-price lookup деградирует в
|
||
class_norm без реальной БД. Тесты API не должны зависеть от живого Postgres."""
|
||
|
||
def execute(self, *args: object, **kwargs: object) -> object:
|
||
raise RuntimeError("no DB in unit test")
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _override_db() -> Iterator[None]:
|
||
"""get_db → заглушка без БД на время API-тестов концепции (conftest чистит overrides)."""
|
||
|
||
def _fake_get_db() -> Iterator[_NoDbSession]:
|
||
yield _NoDbSession()
|
||
|
||
app.dependency_overrides[get_db] = _fake_get_db
|
||
yield
|
||
app.dependency_overrides.pop(get_db, None)
|
||
|
||
|
||
_PARCEL = {
|
||
"type": "Polygon",
|
||
"coordinates": [
|
||
[
|
||
[60.60, 56.830],
|
||
[60.6045, 56.830],
|
||
[60.6045, 56.8328],
|
||
[60.60, 56.8328],
|
||
[60.60, 56.830],
|
||
]
|
||
],
|
||
}
|
||
|
||
|
||
def _post(payload: dict[str, object]) -> object:
|
||
client = TestClient(app)
|
||
return client.post("/api/v1/concepts", json=payload)
|
||
|
||
|
||
def test_concepts_returns_three_filled_variants() -> None:
|
||
response = _post(
|
||
{
|
||
"parcel_geojson": _PARCEL,
|
||
"housing_class": "comfort",
|
||
"target_floors": 9,
|
||
"development_type": "mid_rise",
|
||
"land_cost_rub": 150_000_000,
|
||
}
|
||
)
|
||
assert response.status_code == 200, response.text
|
||
variants = response.json()["variants"]
|
||
assert len(variants) == 3
|
||
assert {v["strategy"] for v in variants} == {"max_area", "max_insolation", "balanced"}
|
||
|
||
for v in variants:
|
||
teap = v["teap"]
|
||
fin = v["financial"]
|
||
# Реальные, ненулевые числа (не stub-нули).
|
||
assert teap["built_area_sqm"] > 0
|
||
assert teap["total_floor_area_sqm"] > 0
|
||
assert teap["residential_area_sqm"] > 0
|
||
assert teap["apartments_count"] > 0
|
||
assert teap["density"] > 0
|
||
assert teap["parking_spaces"] > 0
|
||
assert fin["revenue_rub"] > 0
|
||
assert fin["cost_rub"] > 0
|
||
# PR-2: без БД market-price lookup честно падает в норматив класса.
|
||
assert fin["price_is_calibrated"] is False
|
||
assert fin["price_source"] == "class_norm"
|
||
assert fin["price_per_sqm_used"] > 0
|
||
# GeoJSON застройки непустой.
|
||
fc = v["buildings_geojson"]
|
||
assert fc["type"] == "FeatureCollection"
|
||
assert len(fc["features"]) > 0
|
||
|
||
|
||
def test_concepts_degenerate_parcel_returns_422() -> None:
|
||
tiny = {
|
||
"type": "Polygon",
|
||
"coordinates": [
|
||
[
|
||
[60.60, 56.830],
|
||
[60.60015, 56.830],
|
||
[60.60015, 56.83015],
|
||
[60.60, 56.83015],
|
||
[60.60, 56.830],
|
||
]
|
||
],
|
||
}
|
||
response = _post(
|
||
{
|
||
"parcel_geojson": tiny,
|
||
"housing_class": "comfort",
|
||
"target_floors": 9,
|
||
"development_type": "mid_rise",
|
||
}
|
||
)
|
||
assert response.status_code == 422
|
||
|
||
|
||
def test_concepts_response_matches_contract_keys() -> None:
|
||
response = _post(
|
||
{
|
||
"parcel_geojson": _PARCEL,
|
||
"housing_class": "business",
|
||
"target_floors": 16,
|
||
"development_type": "high_rise",
|
||
}
|
||
)
|
||
assert response.status_code == 200
|
||
variant = response.json()["variants"][0]
|
||
assert set(variant.keys()) == {
|
||
"strategy",
|
||
"buildings_geojson",
|
||
"teap",
|
||
"financial",
|
||
# Stage 3a (#1965): partial-fit signal (None в greedy-режиме, число в program-режиме).
|
||
"placed_count",
|
||
"requested_count",
|
||
}
|
||
# Greedy-режим (программа не задана) → сигнал частичного размещения пуст (backward-compat).
|
||
assert variant["placed_count"] is None
|
||
assert variant["requested_count"] is None
|
||
assert set(variant["teap"].keys()) == {
|
||
"built_area_sqm",
|
||
"total_floor_area_sqm",
|
||
"office_area_sqm",
|
||
"residential_area_sqm",
|
||
"apartments_count",
|
||
"density",
|
||
"parking_spaces",
|
||
}
|
||
# Stage 3a partial-fit signal присутствует в контракте (None в greedy-режиме).
|
||
assert variant["placed_count"] is None
|
||
assert variant["requested_count"] is None
|
||
assert set(variant["financial"].keys()) == {
|
||
# legacy summary (backward-compat)
|
||
"revenue_rub",
|
||
"cost_rub",
|
||
"gross_margin_rub",
|
||
"irr",
|
||
# revenue breakdown
|
||
"revenue_residential_rub",
|
||
"revenue_parking_rub",
|
||
"revenue_office_rub",
|
||
# cost cascade
|
||
"construction_rub",
|
||
"pir_rub",
|
||
"networks_rub",
|
||
"developer_services_rub",
|
||
"contingency_rub",
|
||
"marketing_rub",
|
||
"land_rub",
|
||
# БДР / taxes
|
||
"vat_rub",
|
||
"profit_before_tax_rub",
|
||
"profit_tax_rub",
|
||
"net_profit_rub",
|
||
# metrics
|
||
"roi",
|
||
"margin_pct",
|
||
"irr_is_proxy",
|
||
# DCF (PR-3)
|
||
"npv_rub",
|
||
"payback_months",
|
||
"discount_rate_used",
|
||
"schedule_is_default",
|
||
"sales_duration_months",
|
||
# financing overlay (PR-5)
|
||
"financing_enabled",
|
||
"annual_rate_used",
|
||
"peak_debt_rub",
|
||
"total_interest_rub",
|
||
"net_profit_after_financing_rub",
|
||
"financing_is_simplified",
|
||
# levered (equity) IRR
|
||
"levered_irr",
|
||
"levered_irr_is_proxy",
|
||
# price calibration (PR-2)
|
||
"price_per_sqm_used",
|
||
"price_is_calibrated",
|
||
"price_source",
|
||
}
|
||
|
||
|
||
# ── Stage 3a (#1965): program-driven placement через API ────────────────────────────
|
||
|
||
|
||
def test_concepts_program_places_single_variant_with_signal() -> None:
|
||
# building_program задан → один вариант (не три), partial-fit сигнал заполнен.
|
||
response = _post(
|
||
{
|
||
"parcel_geojson": _PARCEL,
|
||
"housing_class": "comfort",
|
||
"target_floors": 9,
|
||
"development_type": "mid_rise",
|
||
"building_program": [
|
||
{"section_type": "monolith_comfort", "floors": 14, "count": 3},
|
||
{"section_type": "panel_econom", "floors": 9, "count": 2},
|
||
],
|
||
}
|
||
)
|
||
assert response.status_code == 200, response.text
|
||
variants = response.json()["variants"]
|
||
assert len(variants) == 1
|
||
v = variants[0]
|
||
assert v["requested_count"] == 5
|
||
assert v["placed_count"] == 5
|
||
assert v["teap"]["built_area_sqm"] > 0
|
||
assert v["teap"]["apartments_count"] > 0
|
||
# GeoJSON-фичи помечены program-режимом и несут тип секции.
|
||
feats = v["buildings_geojson"]["features"]
|
||
assert len(feats) == 5
|
||
assert {f["properties"]["strategy"] for f in feats} == {"program"}
|
||
|
||
|
||
def test_concepts_program_unknown_type_returns_422() -> None:
|
||
response = _post(
|
||
{
|
||
"parcel_geojson": _PARCEL,
|
||
"housing_class": "comfort",
|
||
"target_floors": 9,
|
||
"development_type": "mid_rise",
|
||
"building_program": [
|
||
{"section_type": "does_not_exist", "floors": 10, "count": 1},
|
||
],
|
||
}
|
||
)
|
||
assert response.status_code == 422
|
||
assert "unknown house type" in response.json()["detail"].lower()
|
||
|
||
|
||
# ── Stage 3b (#1965): GET /api/v1/concepts/house-types catalog ──────────────────────
|
||
|
||
|
||
def test_house_types_catalog_returns_full_catalog() -> None:
|
||
# Каталог отдаётся read-only (GET, без БД) и зеркалит catalog.HOUSE_TYPES —
|
||
# single source of truth для пикера Stage 3b (фронт ничего не хардкодит).
|
||
from app.services.generative.catalog import HOUSE_TYPES
|
||
|
||
client = TestClient(app)
|
||
resp = client.get("/api/v1/concepts/house-types")
|
||
assert resp.status_code == 200, resp.text
|
||
items = resp.json()["house_types"]
|
||
# Полный каталог: один пункт на каждый HouseType, ключи совпадают с каталогом.
|
||
assert len(items) == len(HOUSE_TYPES)
|
||
assert {it["section_type"] for it in items} == {ht.section_type for ht in HOUSE_TYPES}
|
||
|
||
# Каждый пункт несёт всё, что нужно пикеру: лейбл, габариты, дефолт-этажность, класс.
|
||
by_key = {it["section_type"]: it for it in items}
|
||
for ht in HOUSE_TYPES:
|
||
item = by_key[ht.section_type]
|
||
assert item["label_ru"] == ht.label_ru
|
||
assert item["footprint_w_m"] == ht.footprint_w_m
|
||
assert item["footprint_d_m"] == ht.footprint_d_m
|
||
# footprint_sqm — производный helper для UI (ширина × глубина).
|
||
assert item["footprint_sqm"] == ht.footprint_w_m * ht.footprint_d_m
|
||
assert item["default_floors"] == ht.default_floors
|
||
assert item["housing_class"] == ht.housing_class
|
||
# default_floors в пределах контракта BuildingProgramItem [1, 40].
|
||
assert 1 <= item["default_floors"] <= 40
|
||
|
||
|
||
def test_house_types_catalog_keys_match_available_section_types() -> None:
|
||
# Ключи каталога == множество, валидируемое в create_concept (program-режим).
|
||
# Гарантирует: всё, что фронт может выбрать, бэк примет (нет рассинхрона 422).
|
||
from app.services.generative.catalog import available_section_types
|
||
|
||
client = TestClient(app)
|
||
resp = client.get("/api/v1/concepts/house-types")
|
||
assert resp.status_code == 200, resp.text
|
||
catalog_keys = {it["section_type"] for it in resp.json()["house_types"]}
|
||
assert catalog_keys == set(available_section_types())
|