gendesign/backend/tests/services/generative/test_api_concepts.py
bot-backend 1bbd4c99e2
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
fix(generative): отклонять спроецированные координаты до pyproj CRSError → 422 (#2445-B1) (#2453)
2026-07-05 19:20:19 +00:00

318 lines
11 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_projected_coordinates_returns_422_not_500() -> None:
# Регресс эпик #2445 B1: parcel_geojson в проекции (МСК-66/UTM-like метры) вместо
# WGS84 lon/lat раньше падал неперехваченным pyproj.CRSError -> opaque 500. Должен
# быть чистый 422 (ParcelGeometryError), как и для прочих невалидных геометрий.
projected = {
"type": "Polygon",
"coordinates": [
[
[500_000.0, 6_200_000.0],
[500_450.0, 6_200_000.0],
[500_450.0, 6_200_310.0],
[500_000.0, 6_200_310.0],
[500_000.0, 6_200_000.0],
]
],
}
response = _post(
{
"parcel_geojson": projected,
"housing_class": "comfort",
"target_floors": 9,
"development_type": "mid_rise",
}
)
assert response.status_code == 422, response.text
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())