gendesign/backend/tests/api/v1/test_parcel_connection_capacity.py
Light1YT e285cbe33a
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m17s
CI / openapi-codegen-check (pull_request) Successful in 2m6s
CI / backend-tests (pull_request) Successful in 14m28s
feat(site-finder): резервы мощности для ТП — электро+вода на карте §3 (#2119 Фаза A)
Наполнение карты точками подключения с ХАРАКТЕРИСТИКАМИ (свободная мощность)
из верифицированных источников (research #2119). Все источники гео-блокируют
не-RU IP — лоадеры выполняются на проде (Celery weekly + ручной docker exec).

Backend:
- Миграция 180: power_supply_centers (ПС 35-220кВ, точки + резерв МВА),
  power_tp_rp_reserves (10.7k ТП/РП 6-10кВ, геокод — Фаза B),
  water_supply_reserves (ЦСВ/ЦСК Водоканала). UNIQUE NULLS NOT DISTINCT на
  nullable-ключах (грабли #140).
- rosseti_wfs_loader: открытый WFS портал-тп.рф (punycode) → 488 ПС Свердл обл,
  индекс загрузки 256184/6/8 → open/limited/closed, voltage из имени.
- rosseti_reserve_loader: раскрытие ПП№24 (xlsx) — ЦП 35-110кВ (строка 8+)
  матчится к WFS-точкам по нормализованному имени; ТП/РП <35кВ (строка 9+),
  кВА-санити (>2.5 МВА для ТП → ÷1000), «н/д»→NULL, SAVEPOINT per-row.
- vodokanal_reserve_loader: DOCX-раскрытие ПП№6 (stdlib parse, vMerge
  forward-fill), отрицательные резервы (город: дефицит) сохраняются со знаком.
- Endpoint GET /{cad}/connection-capacity: ПС в радиусе (ST_DWithin geography)
  + summary + вода за последний период per-system_kind. Celery: вт 04:00/04:30.

Frontend (§3):
- Карточка «Ресурсные резервы»: ближайший ЦП с резервом МВА + статус-бейдж;
  вода/канализация с красным «дефицит» при отрицательном резерве + примечание.
- Слой «Центры питания (резерв)» на карте: цвет по индексу загрузки
  (зелёный/янтарь/красный), попап с напряжением/мощностью/загрузкой/резервом,
  легенда под картой. Числа через toFiniteNumber (Decimal→str coercion).

api-types.ts перегенерён офлайн (+131/-0, аддитивно). 39 тестов зелёные.
Deep-review: ⚠️ MINOR → все 3 замечания исправлены (NULLS NOT DISTINCT,
per-kind MAX(period), settlement-комментарий).
2026-07-02 12:45:55 +05:00

157 lines
5.3 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.

"""Тесты для 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,
) -> 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_asof": "2026-06-30",
"distance_m": distance_m,
"lat": 56.78,
"lon": 60.61,
}
def _make_response(
power_points: list[dict[str, Any]] | None = None,
water: 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,
}
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