"""Unit-тесты газовой вертикали ГРС «Газпром трансгаз Екатеринбург» (#2119 Фаза B1).
Pure / mock-based — без реальной сети и БД. Покрывает:
- normalize_grs_name: ё→е, схлопывание пробелов/дефисов, сохранение «ГРС»-префикса.
- parse_grs_table: заголовок-матчинг колонок, rowspan forward-fill имени ГРС на
строки-выходы, запятые/«н/д»-числа, multi-выход.
- _passes_region_filter: колонка региона → фильтр «свердлов»; нет → всё.
- _upsert_grs_rows: SAVEPOINT + счётчики на fake-DB.
- _query_gas_city_grs: агрегация по grs_name (SUM/AVG/MAX/COUNT), пустая таблица → [].
- _reserve_note: ЕЭСК + reserve_mva IS NULL → пометка; иначе None.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
from app.services.site_finder import connection_capacity_lookup as ccl
from app.services.site_finder import gazprom_grs_loader as gg
# ── normalize_grs_name ────────────────────────────────────────────────────────
def test_normalize_grs_yo_to_e() -> None:
assert gg.normalize_grs_name("ГРС Берёзовский") == "грс березовский"
def test_normalize_grs_keeps_prefix() -> None:
"""Префикс «ГРС»/«АРП» СОХРАНЯЕТСЯ (различает станции)."""
assert gg.normalize_grs_name("ГРС Кольцово") == "грс кольцово"
assert gg.normalize_grs_name("АРП Северная") == "арп северная"
def test_normalize_grs_dash_and_spaces_collapse() -> None:
assert gg.normalize_grs_name("ГРС Северная — 2") == "грс северная-2"
assert gg.normalize_grs_name("ГРС Западная") == "грс западная"
def test_normalize_grs_empty() -> None:
assert gg.normalize_grs_name(None) == ""
assert gg.normalize_grs_name("") == ""
# ── parse_grs_table: заголовок-матчинг + rowspan forward-fill ─────────────────
_SINGLE_GRS_HTML = """
| Наименование ГРС | Выход | Проектная, тыс. м³/ч |
Фактическая загрузка | Свободная мощность |
Свободная, % | Срок расшивки |
| ГРС Свердловская | выход 1 | 100,5 |
60,0 | 40,5 | 40,3 | 2027 |
"""
def test_parse_single_grs_header_matching_and_numbers() -> None:
"""Колонки мапятся по заголовкам; запятая-десятичный → float."""
recs = gg.parse_grs_table(_SINGLE_GRS_HTML)
assert len(recs) == 1
r = recs[0]
assert r["grs_name"] == "ГРС Свердловская"
assert r["output_name"] == "выход 1"
assert r["design_capacity_th_m3_h"] == 100.5
assert r["current_load_th_m3_h"] == 60.0
assert r["free_capacity_th_m3_h"] == 40.5
assert r["free_capacity_pct"] == 40.3
assert r["upgrade_due"] == "2027"
# raw сохраняет заголовки + ячейки (неизвестные колонки не теряются).
assert "headers" in r["raw"] and "cells" in r["raw"]
# rowspan имени ГРС: строка-выход 2/3 НЕ содержит ячейки имени (rowspan «съел» её).
_MULTI_OUTPUT_HTML = """
| Наименование | Выход | Проектная |
Загрузка | Свободная | % | Срок |
| ГРС Екатеринбург | выход 1 | 50,0 |
30,0 | 20,0 | 40,0 | 2028 |
| выход 2 | 60,0 | 40,0 | 20,0 | 33,3 | н/д |
| выход 3 | 70,0 | 70,0 | 0,0 | 0,0 | после 2030 |
"""
def test_parse_multi_output_rowspan_forward_fill() -> None:
"""rowspan имени ГРС → выходы 2/3 наследуют имя; «н/д» → None."""
recs = gg.parse_grs_table(_MULTI_OUTPUT_HTML)
assert len(recs) == 3
assert [r["grs_name"] for r in recs] == ["ГРС Екатеринбург"] * 3
assert [r["output_name"] for r in recs] == ["выход 1", "выход 2", "выход 3"]
# Числа корректно смещены (offset при rowspan-строках).
assert recs[0]["free_capacity_th_m3_h"] == 20.0
assert recs[1]["design_capacity_th_m3_h"] == 60.0
assert recs[1]["upgrade_due"] is None # «н/д» → None
assert recs[2]["free_capacity_th_m3_h"] == 0.0 # 0 при полной загрузке сохраняется
assert recs[2]["upgrade_due"] == "после 2030"
def test_parse_no_table_returns_empty() -> None:
assert gg.parse_grs_table("нет таблиц
") == []
def test_parse_unrecognized_name_column_returns_empty() -> None:
"""Заголовок без имени ГРС → пустой результат (defensive)."""
html = (
""
)
assert gg.parse_grs_table(html) == []
# ── _passes_region_filter ─────────────────────────────────────────────────────
def test_region_filter_no_region_keeps_all() -> None:
recs = [{"region": None, "grs_name": "ГРС А"}, {"region": None, "grs_name": "ГРС Б"}]
assert gg._passes_region_filter(recs) == recs
def test_region_filter_present_keeps_sverdlovsk() -> None:
recs = [
{"region": "Свердловская область", "grs_name": "ГРС А"},
{"region": "Челябинская область", "grs_name": "ГРС Б"},
]
out = gg._passes_region_filter(recs)
assert len(out) == 1
assert out[0]["grs_name"] == "ГРС А"
# ── Fake DB session для upsert- и query-тестов ────────────────────────────────
class _FakeResult:
def __init__(self, scalar_value: Any = True, mappings_rows: list[dict] | None = None) -> None:
self._scalar = scalar_value
self._mappings_rows = mappings_rows or []
def scalar(self) -> Any:
return self._scalar
def mappings(self) -> _FakeResult:
return self
def all(self) -> list[dict]:
return self._mappings_rows
class _FakeSession:
"""Мини-заглушка Session: execute + begin_nested (как в loaders-тестах)."""
def __init__(
self,
scalar_value: Any = True,
mappings_rows: list[dict] | None = None,
execute_raises: Exception | None = None,
) -> None:
self.calls: list[tuple[str, Any]] = []
self._scalar = scalar_value
self._mappings_rows = mappings_rows
self._execute_raises = execute_raises
self.committed = False
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
yield
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
self.calls.append((str(stmt), params))
if self._execute_raises is not None:
raise self._execute_raises
return _FakeResult(self._scalar, self._mappings_rows)
def commit(self) -> None:
self.committed = True
def rollback(self) -> None:
pass
# ── _upsert_grs_rows ──────────────────────────────────────────────────────────
def test_upsert_grs_rows_counts_and_savepoint() -> None:
db = _FakeSession(scalar_value=True)
recs = gg.parse_grs_table(_MULTI_OUTPUT_HTML)
counts = gg._upsert_grs_rows(db, recs) # type: ignore[arg-type]
assert counts == {"rows": 3, "inserted": 3, "updated": 0, "skipped": 0}
# grs_name_norm нормализован в params.
assert db.calls[0][1]["grs_name_norm"] == "грс екатеринбург"
assert db.calls[1][1]["output_name"] == "выход 2"
# ── _query_gas_city_grs: агрегация ────────────────────────────────────────────
def test_query_gas_city_grs_aggregation() -> None:
"""Агрегированные строки (per grs_name) маппятся в GasGrsRow-словари + total."""
rows = [
{
"grs_name": "ГРС Свердловская",
"design_sum": 210.0,
"free_sum": 40.5,
"pct_avg": 20.25,
"upgrade_due": "2028",
"outputs_count": 3,
},
{
"grs_name": "ГРС Екатеринбург Северная",
"design_sum": 100.0,
"free_sum": 10.0,
"pct_avg": 10.0,
"upgrade_due": None,
"outputs_count": 1,
},
]
db = _FakeSession(mappings_rows=rows)
out = ccl._query_gas_city_grs(db) # type: ignore[arg-type]
assert len(out["city_grs"]) == 2
assert out["city_grs"][0]["grs_name"] == "ГРС Свердловская"
assert out["city_grs"][0]["free_capacity_th_m3_h"] == 40.5
assert out["city_grs"][0]["free_capacity_pct"] == 20.2 # round(20.25, 1)
assert out["city_grs"][0]["outputs_count"] == 3
assert out["total_free_th_m3_h"] == 50.5 # 40.5 + 10.0
def test_query_gas_city_grs_empty_table() -> None:
db = _FakeSession(mappings_rows=[])
out = ccl._query_gas_city_grs(db) # type: ignore[arg-type]
assert out == {"city_grs": [], "total_free_th_m3_h": None}
def test_query_gas_city_grs_missing_table_degrades() -> None:
"""Таблица не существует (миграция 181 не применена) → пустой блок, не падаем."""
db = _FakeSession(execute_raises=RuntimeError('relation "gas_grs_capacity" does not exist'))
out = ccl._query_gas_city_grs(db) # type: ignore[arg-type]
assert out == {"city_grs": [], "total_free_th_m3_h": None}
# ── _reserve_note: пометка ЕЭСК ───────────────────────────────────────────────
def test_reserve_note_eesk_null_reserve() -> None:
note = ccl._reserve_note("АО «ЕЭСК»", None)
assert note == ccl._EESK_RESERVE_NOTE
assert "ЕЭСК" in note
def test_reserve_note_eesk_with_reserve_none() -> None:
"""ЕЭСК с известным резервом → без пометки."""
assert ccl._reserve_note("АО «ЕЭСК»", 12.0) is None
def test_reserve_note_other_dzo_none() -> None:
"""ПС не-ЕЭСК без резерва → без пометки."""
assert ccl._reserve_note("Россети Урал", None) is None
assert ccl._reserve_note(None, None) is None