gendesign/backend/tests/services/generative/test_api_concepts.py
Light1YT c55eb4f4d4
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m1s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI / backend-tests (pull_request) Successful in 14m9s
feat(concept): фронт-пикер типовых домов (#1965 Stage 3b)
Финальная часть эпика #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 секций — участок вмещает меньше» (нейтрально, не ошибка).
2026-06-28 02:49:44 +05:00

291 lines
10 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.

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