gendesign/backend/tests/test_gas_grs_loader.py
bot-backend 0a6281b6e2
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m45s
Deploy / build-worker (push) Successful in 3m15s
Deploy / deploy (push) Successful in 1m30s
fix(site-finder): ГРС-парсер переписан под реальную структуру таблицы ГТЕ (#2145)
2026-07-02 14:45:42 +00:00

323 lines
15 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 datetime import date
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: РЕАЛЬНАЯ структура таблицы ГТЕ ───────────────────────────
#
# Структура (снята с прода 2026-07-02):
# row0: caption (1 ячейка);
# row1: «по состоянию на DD.MM.YYYY» (asof);
# row2: РЕАЛЬНЫЕ заголовки (маркеры «Наименование» + «Проектная»);
# row3: суб-заголовки (Основание/Срок/Параметры) — n=3, скип;
# row4: строка нумерации «0..7» — n=8, скип (регион ещё не Свердловский);
# регион-разделители (1 ячейка) → current_region;
# FULL-строка (n>=10): idx 2=имя … 6=свободная, 7=%, 8+=уведомление;
# CONTINUATION-строка (n=7, доп. выход): idx 0=имя(с выходом) … 4=свободная, 5=%.
_GTE_HTML = """
<html><body>
<table>
<tr><td>Информация о наличии (отсутствии) технической возможности…</td></tr>
<tr><td></td><td>по состоянию на 01.06.2026</td><td></td></tr>
<tr>
<th>№ п/п</th><th>Субъект Российской Федерации</th>
<th>Наименование газораспределительной станции</th>
<th>Проектная мощность (производительность)</th>
<th>Загрузка газораспределительной станции</th>
<th>Суммарный объём газа по действующим ТУ</th>
<th>Наличие (дефицит) пропускной способности</th>
<th>Наличие (дефицит) пропускной способности, %</th>
<th>Наличие уведомления ГРО</th>
</tr>
<tr><th>Основание</th><th>Срок мероприятий</th><th>Параметры увеличения</th></tr>
<tr>
<td>0</td><td>1</td><td>2</td><td>3</td><td>4</td>
<td>5</td><td>6</td><td>7</td>
</tr>
<tr><td>СВЕРДЛОВСКАЯ ОБЛАСТЬ</td></tr>
<tr>
<td>114</td><td>Свердловская область</td><td>ГРС Арамиль (5)</td>
<td>50,000</td><td>31,350</td><td>17,445</td><td>1,205</td><td>(2 %)</td>
<td>—</td><td>—</td><td>—</td><td>—</td><td>—</td>
</tr>
<tr>
<td>115</td><td>Свердловская область</td><td>ГРС Арти (выход №1)</td>
<td>44,000</td><td>2,000</td><td>1,800</td><td>42,200</td><td>(96 %)</td>
<td>ТУ №77</td><td>2028</td><td>увеличение до 60</td><td>—</td><td>—</td>
</tr>
<tr>
<td>ГРС Арти (выход №2)</td><td>22,000</td><td>—</td><td>—</td>
<td>—</td><td>—</td><td>—</td>
</tr>
<tr><td>КУРГАНСКАЯ ОБЛАСТЬ</td></tr>
<tr>
<td>200</td><td>Курганская область</td><td>ГРС Кетово</td>
<td>30,000</td><td>10,000</td><td>5,000</td><td>20,000</td><td>(66 %)</td>
<td>—</td><td>—</td><td>—</td><td>—</td><td>—</td>
</tr>
</table>
</body></html>
"""
def test_parse_asof_extracted() -> None:
"""«по состоянию на 01.06.2026» из первых строк → asof на каждой записи."""
recs = gg.parse_grs_table(_GTE_HTML)
assert recs, "ожидались записи по Свердловской области"
assert all(r["asof"] == date(2026, 6, 1) for r in recs)
def test_parse_region_filter_kurgan_excluded() -> None:
"""Регион-разделитель «КУРГАНСКАЯ ОБЛАСТЬ» отсекает не-свердловские строки."""
recs = gg.parse_grs_table(_GTE_HTML)
names = [r["grs_name"] for r in recs]
assert "ГРС Кетово" not in names # Курганская отсечена регион-гейтом
assert all(r["region"] == "Свердловская Область" for r in recs) # title-case
def test_parse_full_row_mapping_and_pct() -> None:
"""FULL-строка (n>=10): idx-маппинг + процент «(2 %)» → 2.0; объём ТУ → raw."""
recs = gg.parse_grs_table(_GTE_HTML)
aramil = next(r for r in recs if r["grs_name"] == "ГРС Арамиль (5)")
# «(5)» — НЕ выход, остаётся в имени; output_name пуст.
assert aramil["output_name"] is None
assert aramil["design_capacity_th_m3_h"] == 50.0 # idx3
assert aramil["current_load_th_m3_h"] == 31.35 # idx4
assert aramil["free_capacity_th_m3_h"] == 1.205 # idx6 (свободная)
assert aramil["free_capacity_pct"] == 2.0 # idx7 «(2 %)» → 2.0
assert aramil["upgrade_due"] is None # все ячейки уведомления «—»
# Объём газа по ТУ (idx5) — ТОЛЬКО в raw, без колонки в схеме.
assert aramil["raw"]["volume_by_tu"] == "17,445"
assert "cells" in aramil["raw"]
def test_parse_output_suffix_extracted() -> None:
"""«(выход №1)»/«(выход №2)» → output_name отдельно, grs_name без суффикса."""
recs = gg.parse_grs_table(_GTE_HTML)
arti = [r for r in recs if r["grs_name"] == "ГРС Арти"]
assert len(arti) == 2 # FULL «выход №1» + CONTINUATION «выход №2»
assert {r["output_name"] for r in arti} == {"выход №1", "выход №2"}
def test_parse_continuation_row_shifted_and_pct96() -> None:
"""CONTINUATION-строка (n=7): сдвиг индексов; «(96 %)» из FULL «выход №1»."""
recs = gg.parse_grs_table(_GTE_HTML)
out1 = next(r for r in recs if r["output_name"] == "выход №1")
assert out1["free_capacity_pct"] == 96.0 # «(96 %)» → 96.0
assert out1["design_capacity_th_m3_h"] == 44.0
# Уведомление собрано join(« · ») из непустых Основание/Срок/Параметры.
assert out1["upgrade_due"] == "ТУ №77 · 2028 · увеличение до 60"
out2 = next(r for r in recs if r["output_name"] == "выход №2")
# CONTINUATION: имя=idx0, проектная=idx1 → 22.0; остальное «—» → None.
assert out2["grs_name"] == "ГРС Арти"
assert out2["design_capacity_th_m3_h"] == 22.0
assert out2["current_load_th_m3_h"] is None # «—» → None
assert out2["free_capacity_th_m3_h"] is None
assert out2["free_capacity_pct"] is None
assert out2["upgrade_due"] is None
def test_parse_no_table_returns_empty() -> None:
assert gg.parse_grs_table("<html><body><p>нет таблиц</p></body></html>") == []
def test_parse_no_header_row_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(_GTE_HTML)
assert len(recs) == 3 # Свердловская: Арамиль + Арти(вых1) + Арти(вых2)
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; asof проброшен в upsert.
assert db.calls[0][1]["grs_name_norm"] == "грс арамиль (5)"
assert db.calls[0][1]["asof"] == date(2026, 6, 1)
# «(выход №N)» ушёл в отдельный output_name (не в grs_name_norm).
assert {c[1]["output_name"] for c in db.calls} == {None, "выход №1", "выход №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