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