"""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 = """
Наименование ГРСВыходПроектная, тыс. м³/ч Фактическая загрузкаСвободная мощность Свободная, %Срок расшивки
ГРС Свердловскаявыход 1100,5 60,040,540,32027
""" 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 = """
НаименованиеВыходПроектная ЗагрузкаСвободная%Срок
ГРС Екатеринбургвыход 150,0 30,020,040,02028
выход 260,040,020,033,3н/д
выход 370,070,00,00,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 = ( "" "
Колонка АКолонка Б
12
" ) 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