Наполнение карты точками подключения с ХАРАКТЕРИСТИКАМИ (свободная мощность) из верифицированных источников (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-комментарий).
157 lines
5.3 KiB
Python
157 lines
5.3 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,
|
||
) -> 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
|