283 lines
10 KiB
Python
283 lines
10 KiB
Python
"""Тесты для 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 _make_response(
|
||
power_points: list[dict[str, Any]] | None = None,
|
||
water: list[dict[str, Any]] | None = None,
|
||
heat: 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,
|
||
"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
|