gendesign/backend/tests/test_gas_grs_loader.py
bot-backend 5839680998
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m2s
Deploy / build-worker (push) Successful in 3m17s
Deploy / build-frontend (push) Successful in 3m57s
Deploy / deploy (push) Successful in 1m31s
feat(site-finder): газ — свободная мощность ГРС + пометка ЕЭСК в §3 (#2119 Фаза B1) (#2123)
2026-07-02 12:54:05 +00:00

274 lines
11 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.

"""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 = """
<html><body>
<table>
<tr>
<th>Наименование ГРС</th><th>Выход</th><th>Проектная, тыс. м³/ч</th>
<th>Фактическая загрузка</th><th>Свободная мощность</th>
<th>Свободная, %</th><th>Срок расшивки</th>
</tr>
<tr>
<td>ГРС Свердловская</td><td>выход 1</td><td>100,5</td>
<td>60,0</td><td>40,5</td><td>40,3</td><td>2027</td>
</tr>
</table>
</body></html>
"""
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 = """
<html><body>
<table>
<tr>
<th>Наименование</th><th>Выход</th><th>Проектная</th>
<th>Загрузка</th><th>Свободная</th><th>%</th><th>Срок</th>
</tr>
<tr>
<td rowspan="3">ГРС Екатеринбург</td><td>выход 1</td><td>50,0</td>
<td>30,0</td><td>20,0</td><td>40,0</td><td>2028</td>
</tr>
<tr>
<td>выход 2</td><td>60,0</td><td>40,0</td><td>20,0</td><td>33,3</td><td>н/д</td>
</tr>
<tr>
<td>выход 3</td><td>70,0</td><td>70,0</td><td>0,0</td><td>0,0</td><td>после 2030</td>
</tr>
</table>
</body></html>
"""
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("<html><body><p>нет таблиц</p></body></html>") == []
def test_parse_unrecognized_name_column_returns_empty() -> None:
"""Заголовок без имени ГРС → пустой результат (defensive)."""
html = (
"<table><tr><th>Колонка А</th><th>Колонка Б</th></tr>"
"<tr><td>1</td><td>2</td></tr></table>"
)
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