"""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 = """
Информация о наличии (отсутствии) технической возможности…
по состоянию на 01.06.2026
№ п/пСубъект Российской Федерации Наименование газораспределительной станции Проектная мощность (производительность) Загрузка газораспределительной станции Суммарный объём газа по действующим ТУ Наличие (дефицит) пропускной способности Наличие (дефицит) пропускной способности, % Наличие уведомления ГРО
ОснованиеСрок мероприятийПараметры увеличения
01234 567
СВЕРДЛОВСКАЯ ОБЛАСТЬ
114Свердловская областьГРС Арамиль (5) 50,00031,35017,4451,205(2 %)
115Свердловская областьГРС Арти (выход №1) 44,0002,0001,80042,200(96 %) ТУ №772028увеличение до 60
ГРС Арти (выход №2)22,000
КУРГАНСКАЯ ОБЛАСТЬ
200Курганская областьГРС Кетово 30,00010,0005,00020,000(66 %)
""" 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("

нет таблиц

") == [] def test_parse_no_header_row_returns_empty() -> None: """Нет строки-заголовка (маркеры «наименование»+«проектн») → пусто (defensive).""" html = ( "" "
Колонка АКолонка Б
12
" ) assert gg.parse_grs_table(html) == [] # ── _split_grs_name: «(выход №N)» в конце И в середине имени ─────────────────── def test_split_grs_name_suffix_at_end() -> None: """«(выход №N)» в конце → output отдельно, grs_name без суффикса.""" assert gg._split_grs_name("ГРС Арти (выход №2)") == ("ГРС Арти", "выход №2") def test_split_grs_name_digit_suffix_not_output() -> None: """«(5)» (только цифры) — НЕ выход, остаётся в имени.""" assert gg._split_grs_name("ГРС Арамиль (5)") == ("ГРС Арамиль (5)", None) def test_split_grs_name_output_in_middle() -> None: """Фрагмент «(выход №1)» В СЕРЕДИНЕ имени вырезается, остаток склеивается. Живые данные: «ГРС-1 Свердловск (выход №1) (5)» — «(выход №1)» стоит перед станционным «(5)». Ранее $-якорь матчил только конец → выход терялся, а «(5)» ошибочно попадал бы под выход-логику. Теперь search по всему имени. """ grs_name, output = gg._split_grs_name("ГРС-1 Свердловск (выход №1) (5)") assert output == "выход №1" # «(5)» остаётся; двойной пробел на месте выреза нормализован. assert grs_name == "ГРС-1 Свердловск (5)" def test_split_grs_name_no_output() -> None: """Имя без «(выход …)» → без изменений, output=None.""" assert gg._split_grs_name("ГРС Кольцово") == ("ГРС Кольцово", None) def test_split_grs_name_output_before_station_digit_real_rows() -> None: """B1-хвост: РЕАЛЬНЫЕ прод-строки «(выход №N) (M)» → output_name извлекается. Данные ГТЕ (asof 2026-06-01, снято 2026-07-02): FULL-строки, где «(выход №N)» стоит ПЕРЕД станционным «(M)». До search-фикса они оседали в grs_name с пустым output_name (5 строк). Локим на точных прод-именах — не регрессировать. """ cases = { "ГРС-1 Свердловск (выход №1) (5)": ("ГРС-1 Свердловск (5)", "выход №1"), "ГРС-1 Свердловск (выход №2) (5)": ("ГРС-1 Свердловск (5)", "выход №2"), "ГРС-3 Екатеринбург (выход №1) (3)": ("ГРС-3 Екатеринбург (3)", "выход №1"), "ГРС Реж (выход №1) (3)": ("ГРС Реж (3)", "выход №1"), "ГРС Сухой Лог (выход №1) (4)": ("ГРС Сухой Лог (4)", "выход №1"), } for raw, expected in cases.items(): assert gg._split_grs_name(raw) == expected, raw def test_split_grs_name_output_canonicalized() -> None: """output_name канонизируется в «выход №N» независимо от пробелов/регистра/«№». «Выход № 1» / «выход 1» / «выход №1» → все дают «выход №1» (иначе разный output_name → дубли по ON CONFLICT (grs_name_norm, output_name)). """ for raw in ("ГРС Х (Выход № 1)", "ГРС Х (выход 1)", "ГРС Х (выход №1)"): grs_name, output = gg._split_grs_name(raw) assert output == "выход №1", raw assert grs_name == "ГРС Х" # ── _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 def first(self) -> dict | None: return self._mappings_rows[0] if self._mappings_rows else None class _FakeSession: """Мини-заглушка Session: execute + begin_nested (как в loaders-тестах). Один набор строк на все execute (mappings_rows) ИЛИ скрипт по вызовам (results_script: список наборов строк, отдаётся по порядку execute-вызовов; после исчерпания — последний). results_script нужен для функций с НЕСКОЛЬКИМИ разными SELECT (напр. _query_gas_city_grs: счётчики точек выхода + агрегация ГРС). """ def __init__( self, scalar_value: Any = True, mappings_rows: list[dict] | None = None, execute_raises: Exception | None = None, results_script: list[list[dict]] | None = None, ) -> None: self.calls: list[tuple[str, Any]] = [] self._scalar = scalar_value self._mappings_rows = mappings_rows self._execute_raises = execute_raises self._results_script = results_script self._call_idx = 0 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 if self._results_script is not None: idx = min(self._call_idx, len(self._results_script) - 1) rows = self._results_script[idx] self._call_idx += 1 return _FakeResult(self._scalar, rows) 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: агрегация ──────────────────────────────────────────── # Счётчики точек выхода (первый execute в _query_gas_city_grs) — нейтральный дефолт для # тестов B1-агрегации, которым важна только ГРС-часть (второй execute). _OUTLET_COUNTS_ROW = {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0} 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, }, ] # execute #1 = счётчики точек выхода, #2 = агрегация ГРС. db = _FakeSession(results_script=[[_OUTLET_COUNTS_ROW], 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 # Счётчики точек выхода вплетены в тот же gas-блок (аддитивно). assert out["outlets_total"] == 0 assert out["outlets_deficit"] == 0 assert out["outlets_needs_calc"] == 0 def test_query_gas_city_grs_empty_table() -> None: db = _FakeSession(results_script=[[_OUTLET_COUNTS_ROW], []]) out = ccl._query_gas_city_grs(db) # type: ignore[arg-type] assert out == { "city_grs": [], "total_free_th_m3_h": None, "outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0, } def test_query_gas_city_grs_missing_table_degrades() -> None: """Таблица gas_grs_capacity не существует (миграция 181) → пустой блок, не падаем. _query_gas_outlet_counts (первый execute) тоже упадёт на той же raises-заглушке → счётчики деградируют в нули, блок цел. """ 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, "outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0, } # ── _query_gas_outlet_counts: счётчики точек выхода (city-level) ─────────────── def test_query_gas_outlet_counts_flags() -> None: """COUNT + FILTER (дефицит / needs_calc) маппятся в счётчики (по одной row-агрегату).""" row = {"outlets_total": 1037, "outlets_deficit": 218, "outlets_needs_calc": 144} db = _FakeSession(mappings_rows=[row]) out = ccl._query_gas_outlet_counts(db) # type: ignore[arg-type] assert out == {"outlets_total": 1037, "outlets_deficit": 218, "outlets_needs_calc": 144} def test_query_gas_outlet_counts_null_row_zeroes() -> None: """Пустая таблица (агрегат вернул NULL/нет строки) → все нули.""" db = _FakeSession(mappings_rows=[]) # .first() → None out = ccl._query_gas_outlet_counts(db) # type: ignore[arg-type] assert out == {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0} def test_query_gas_outlet_counts_missing_table_degrades() -> None: """Таблица не существует (миграция 184 не применена) → нули, не падаем.""" db = _FakeSession( execute_raises=RuntimeError('relation "gas_grs_outlet_points" does not exist') ) out = ccl._query_gas_outlet_counts(db) # type: ignore[arg-type] assert out == {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0} # ── _query_gas_outlet_points: гео-точки выхода в радиусе (слой карты §3) ─────── def test_query_gas_outlet_points_maps_and_flags() -> None: """Строки радиус-запроса → точки: дистанция округлена, needs_calc/дефицит, lat/lon.""" rows = [ { "outlet_name": "Котельная (г.Екатеринбург, Складской проезд,4а)", "consumer_type": "котельная", "free_capacity_mln_m3": 0.512, "needs_calc": False, "distance_m": 145.27, "lat": 56.812345, "lon": 60.611111, }, { # Дефицит со знаком сохраняется; needs_calc отдельным флагом. "outlet_name": "Промплощадка №2", "consumer_type": None, "free_capacity_mln_m3": -0.084, "needs_calc": False, "distance_m": 980.4, "lat": 56.80, "lon": 60.60, }, { # «0.000*» → free_capacity_mln_m3=NULL, needs_calc=True. "outlet_name": "ТЭЦ узел", "consumer_type": "ТЭЦ", "free_capacity_mln_m3": None, "needs_calc": True, "distance_m": 1500.0, "lat": None, "lon": None, }, ] db = _FakeSession(mappings_rows=rows) out = ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)") # type: ignore[arg-type] assert len(out) == 3 # Порядок сохранён как пришёл из SQL (ORDER BY distance ASC) — ближайший первым. assert out[0]["distance_m"] == 145.3 # round(145.27, 1) assert out[0]["free_capacity_mln_m3"] == 0.512 assert out[0]["needs_calc"] is False assert out[0]["lat"] == 56.812345 # дефицит сохраняет знак. assert out[1]["free_capacity_mln_m3"] == -0.084 # «0.000*»: NULL мощность + needs_calc=True; координаты None → пробрасываются null. assert out[2]["free_capacity_mln_m3"] is None assert out[2]["needs_calc"] is True assert out[2]["lat"] is None and out[2]["lon"] is None def test_query_gas_outlet_points_passes_radius_and_limit() -> None: """Радиус-запрос параметризован _GAS_OUTLET_RADIUS_M и _GAS_OUTLET_LIMIT.""" db = _FakeSession(mappings_rows=[]) ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)") # type: ignore[arg-type] assert db.calls, "ожидался хотя бы один execute" params = db.calls[0][1] assert params["radius_m"] == ccl._GAS_OUTLET_RADIUS_M assert params["limit"] == ccl._GAS_OUTLET_LIMIT assert params["wkt"] == "POINT(60.6 56.81)" def test_query_gas_outlet_points_empty() -> None: db = _FakeSession(mappings_rows=[]) out = ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)") # type: ignore[arg-type] assert out == [] def test_query_gas_outlet_points_missing_table_degrades() -> None: """Таблица не существует (миграция 184 не применена) → [], не падаем.""" db = _FakeSession( execute_raises=RuntimeError('relation "gas_grs_outlet_points" does not exist') ) out = ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)") # type: ignore[arg-type] assert out == [] # ── _query_heat_latest: per-org период + суммарный резерв + graceful ─────────── def test_query_heat_latest_aggregation_per_org() -> None: """Строки heat_system_reserves маппятся в systems + суммарный резерв (знак сохр.).""" rows = [ {"org": "ЕТК", "system_name": "СТ №54", "reserve_gcal_h": 3.26, "period": "2026-Q2"}, { "org": "Екатеринбургэнерго", "system_name": "ТЭЦ Зона-1", "reserve_gcal_h": -5.0, # дефицит "period": "2026-Q1", }, ] db = _FakeSession(mappings_rows=rows) out = ccl._query_heat_latest(db) # type: ignore[arg-type] assert len(out["systems"]) == 2 assert out["systems"][0]["org"] == "ЕТК" assert out["systems"][0]["reserve_gcal_h"] == 3.26 assert out["systems"][1]["reserve_gcal_h"] == -5.0 # знак дефицита сохранён assert out["total_reserve_gcal_h"] == -1.74 # 3.26 + (-5.0), round(…, 3) def test_query_heat_latest_null_reserve_excluded_from_total() -> None: """Система с reserve_gcal_h=NULL входит в systems, но не в total.""" rows = [ {"org": "ЕТК", "system_name": "СТ №1", "reserve_gcal_h": 2.0, "period": "2026-Q2"}, {"org": "ЕТК", "system_name": "СТ №2", "reserve_gcal_h": None, "period": "2026-Q2"}, ] db = _FakeSession(mappings_rows=rows) out = ccl._query_heat_latest(db) # type: ignore[arg-type] assert len(out["systems"]) == 2 assert out["systems"][1]["reserve_gcal_h"] is None assert out["total_reserve_gcal_h"] == 2.0 def test_query_heat_latest_empty_table() -> None: db = _FakeSession(mappings_rows=[]) out = ccl._query_heat_latest(db) # type: ignore[arg-type] assert out == {"systems": [], "total_reserve_gcal_h": None} def test_query_heat_latest_missing_table_degrades() -> None: """Таблица не существует (миграция 182 не применена) → пустой блок, не падаем.""" db = _FakeSession(execute_raises=RuntimeError('relation "heat_system_reserves" does not exist')) out = ccl._query_heat_latest(db) # type: ignore[arg-type] assert out == {"systems": [], "total_reserve_gcal_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