"""Тесты для GET /{cad_num}/connection-capacity (Forgejo #2119 Phase A). FastAPI TestClient + mock сервиса get_connection_capacity — без Postgres. """ from __future__ import annotations from typing import Any from unittest.mock import patch import pytest from fastapi.testclient import TestClient from app.main import app _VALID_CAD = "66:41:0204016:10" def _power_point( name: str = "ПС 110/10 Уктус", reserve: float | None = 15.0, load_index: str | None = "open", distance_m: float = 850.0, reserve_unit: str | None = "МВА", district: str | None = None, reserve_note: str | None = None, ) -> dict[str, Any]: return { "name": name, "dzo_name": "Россети Урал", "voltage_class": "110/10", "load_index": load_index, "installed_capacity_mva": 40.0, "current_load_mva": 25.0, "reserve_mva": reserve, "reserve_unit": reserve_unit, "district": district, "reserve_asof": "2026-06-30", "reserve_note": reserve_note, "distance_m": distance_m, "lat": 56.78, "lon": 60.61, } def _heat_block( systems: list[dict[str, Any]] | None = None, total: float | None = 3.26, ) -> dict[str, Any]: if systems is None: systems = [ { "org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.26, "period": "2026-Q2", } ] return {"systems": systems, "total_reserve_gcal_h": total} def _gas_block( outlets_total: int = 1037, outlets_deficit: int = 218, outlets_needs_calc: int = 144, ) -> dict[str, Any]: return { "city_grs": [ { "grs_name": "ГРС Свердловская", "design_capacity_th_m3_h": 210.0, "free_capacity_th_m3_h": 40.5, "free_capacity_pct": 20.2, "upgrade_due": "2028", "outputs_count": 3, } ], "total_free_th_m3_h": 40.5, "outlets_total": outlets_total, "outlets_deficit": outlets_deficit, "outlets_needs_calc": outlets_needs_calc, } def _gas_outlet_point( outlet_name: str = "Котельная (Складской проезд,4а)", free: float | None = 0.512, needs_calc: bool = False, distance_m: float = 145.3, ) -> dict[str, Any]: return { "outlet_name": outlet_name, "consumer_type": "котельная", "free_capacity_mln_m3": free, "needs_calc": needs_calc, "distance_m": distance_m, "lat": 56.812345, "lon": 60.611111, } def _make_response( power_points: list[dict[str, Any]] | None = None, water: list[dict[str, Any]] | None = None, heat: dict[str, Any] | None = None, gas: dict[str, Any] | None = None, gas_outlet_points: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: if power_points is None: power_points = [_power_point()] if water is None: water = [ { "system_kind": "water", "system_name": "ЗФС, ГСВ", "reserve_thousand_m3_day": -168.5, "note": "дефицит", "period": "2026-Q1", } ] by_load_index: dict[str, int] = {} for p in power_points: li = p["load_index"] or "unknown" by_load_index[li] = by_load_index.get(li, 0) + 1 nearest = next((p for p in power_points if p["reserve_mva"] and p["reserve_mva"] > 0), None) return { "power_points": power_points, "power_summary": { "total_power_points": len(power_points), "by_load_index": by_load_index, "nearest_with_reserve": nearest, }, "water": water, "gas": gas if gas is not None else _gas_block(), "gas_outlet_points": ( gas_outlet_points if gas_outlet_points is not None else [_gas_outlet_point()] ), "heat": heat if heat is not None else _heat_block(), } def test_connection_capacity_returns_power_and_water() -> None: full = _make_response() with patch( "app.api.v1.parcels.get_connection_capacity", return_value=full, ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text body = r.json() assert len(body["power_points"]) == 1 assert body["power_points"][0]["voltage_class"] == "110/10" assert body["power_summary"]["total_power_points"] == 1 assert body["power_summary"]["by_load_index"]["open"] == 1 assert body["power_summary"]["nearest_with_reserve"]["reserve_mva"] == pytest.approx(15.0) # water: отрицательный резерв (дефицит) сохраняет знак. assert body["water"][0]["reserve_thousand_m3_day"] == pytest.approx(-168.5) assert body["water"][0]["system_name"] == "ЗФС, ГСВ" def test_connection_capacity_empty() -> None: empty = _make_response(power_points=[], water=[]) with patch( "app.api.v1.parcels.get_connection_capacity", return_value=empty, ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text body = r.json() assert body["power_points"] == [] assert body["power_summary"]["total_power_points"] == 0 assert body["power_summary"]["nearest_with_reserve"] is None assert body["water"] == [] def test_connection_capacity_no_reserve_nearest_null() -> None: """Все ЦП без положительного резерва → nearest_with_reserve=null.""" pts = [_power_point(reserve=None, load_index="closed")] with patch( "app.api.v1.parcels.get_connection_capacity", return_value=_make_response(power_points=pts), ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text body = r.json() assert body["power_summary"]["nearest_with_reserve"] is None assert body["power_summary"]["by_load_index"]["closed"] == 1 def test_connection_capacity_parcel_not_found_404() -> None: with patch( "app.api.v1.parcels.get_connection_capacity", side_effect=ValueError("Участок '66:41:9999999:1' не найден в БД"), ): client = TestClient(app) r = client.get("/api/v1/parcels/66:41:9999999:1/connection-capacity") assert r.status_code == 404 assert "не найден" in r.json()["detail"] def test_connection_capacity_passes_radius() -> None: with patch("app.api.v1.parcels.get_connection_capacity") as mock_svc: mock_svc.return_value = _make_response() client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=5000") assert r.status_code == 200 call = mock_svc.call_args assert call[0][2] == 5000 or call[1].get("radius_m") == 5000 def test_connection_capacity_radius_too_small_422() -> None: client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=50") assert r.status_code == 422 def test_connection_capacity_radius_too_large_422() -> None: client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity?radius_m=20000") assert r.status_code == 422 # ── Phase B2: heat-блок + reserve_unit/district + reserve_note ──────────────── def test_connection_capacity_heat_block_per_org() -> None: """Heat-блок: строки за последний период per-org + суммарный резерв. #2119 B2.""" heat = _heat_block( systems=[ {"org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.26, "period": "2026-Q2"}, # Другая организация может публиковать свежий квартал раньше/позже — # per-org MAX(period) сервиса это учитывает (тут просто передаём готовое). { "org": "Екатеринбургэнерго", "system_name": "ТЭЦ Зона-1", "reserve_gcal_h": -5.0, # дефицит — знак сохраняется "period": "2026-Q1", }, ], total=-1.74, ) with patch( "app.api.v1.parcels.get_connection_capacity", return_value=_make_response(heat=heat), ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text body = r.json() assert len(body["heat"]["systems"]) == 2 assert body["heat"]["systems"][0]["org"] == "ЕТК" assert body["heat"]["systems"][0]["reserve_gcal_h"] == pytest.approx(3.26) # дефицит (отрицательный резерв) сохраняет знак. assert body["heat"]["systems"][1]["reserve_gcal_h"] == pytest.approx(-5.0) assert body["heat"]["systems"][1]["period"] == "2026-Q1" assert body["heat"]["total_reserve_gcal_h"] == pytest.approx(-1.74) def test_connection_capacity_heat_graceful_absent() -> None: """Ключ 'heat' отсутствует (таблица не наполнена / миграция не применена) → null.""" resp = _make_response() del resp["heat"] with patch( "app.api.v1.parcels.get_connection_capacity", return_value=resp, ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text assert r.json()["heat"] is None def test_connection_capacity_heat_empty_systems() -> None: """Таблица есть, но систем нет → пустой блок, total=null (graceful degrade).""" with patch( "app.api.v1.parcels.get_connection_capacity", return_value=_make_response(heat=_heat_block(systems=[], total=None)), ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text body = r.json() assert body["heat"]["systems"] == [] assert body["heat"]["total_reserve_gcal_h"] is None def test_connection_capacity_power_reserve_unit_and_district() -> None: """power_points пробрасывает reserve_unit ('МВт' у ЕЭСК) + district. #2119 B2.""" pt = _power_point( name="ПС 110/10 Свердловск", reserve_unit="МВт", district="Октябрьский", ) with patch( "app.api.v1.parcels.get_connection_capacity", return_value=_make_response(power_points=[pt]), ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text p0 = r.json()["power_points"][0] assert p0["reserve_unit"] == "МВт" assert p0["district"] == "Октябрьский" def test_connection_capacity_reserve_note_null_when_reserve_filled() -> None: """ЕЭСК теперь с числами → reserve_note NULL когда reserve_mva заполнен. #2119 B2.""" # Заполненный резерв → сервис отдаёт reserve_note=None (даже для ЕЭСК-строк). pt = _power_point(reserve=12.5, reserve_note=None) with patch( "app.api.v1.parcels.get_connection_capacity", return_value=_make_response(power_points=[pt]), ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text assert r.json()["power_points"][0]["reserve_note"] is None # ── Phase B2 PR-4: газовые точки выхода (счётчики в gas + радиус-слой) ───────── def test_connection_capacity_gas_outlet_counters() -> None: """gas-блок несёт счётчики точек выхода последнего среза (city-level). #2119 B2 PR-4.""" with patch( "app.api.v1.parcels.get_connection_capacity", return_value=_make_response(), ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text gas = r.json()["gas"] assert gas["outlets_total"] == 1037 assert gas["outlets_deficit"] == 218 assert gas["outlets_needs_calc"] == 144 # ГРС-часть B1 не сломана. assert gas["total_free_th_m3_h"] == pytest.approx(40.5) def test_connection_capacity_gas_outlet_points_layer() -> None: """gas_outlet_points — гео-слой точек выхода: дистанция/needs_calc/дефицит. #2119 B2 PR-4.""" pts = [ _gas_outlet_point(distance_m=145.3, free=0.512, needs_calc=False), # дефицит со знаком. _gas_outlet_point( outlet_name="Промплощадка №2", distance_m=980.4, free=-0.084, needs_calc=False ), # «0.000*» → NULL мощность + needs_calc=True. _gas_outlet_point(outlet_name="ТЭЦ узел", distance_m=1500.0, free=None, needs_calc=True), ] with patch( "app.api.v1.parcels.get_connection_capacity", return_value=_make_response(gas_outlet_points=pts), ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text outlets = r.json()["gas_outlet_points"] assert len(outlets) == 3 assert outlets[0]["distance_m"] == pytest.approx(145.3) assert outlets[0]["free_capacity_mln_m3"] == pytest.approx(0.512) assert outlets[1]["free_capacity_mln_m3"] == pytest.approx(-0.084) # дефицит-знак assert outlets[2]["free_capacity_mln_m3"] is None assert outlets[2]["needs_calc"] is True def test_connection_capacity_gas_outlet_points_empty_default() -> None: """gas_outlet_points пуст (нет geom в радиусе) → []; ключ может отсутствовать → [].""" resp = _make_response(gas_outlet_points=[]) del resp["gas_outlet_points"] # сервис может не собрать ключ (миграция 184 не применена) with patch( "app.api.v1.parcels.get_connection_capacity", return_value=resp, ): client = TestClient(app) r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity") assert r.status_code == 200, r.text assert r.json()["gas_outlet_points"] == []