gendesign/backend/tests/test_gas_grs_loader.py
bot-backend 21608eced1
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Successful in 3m35s
Deploy / build-worker (push) Successful in 5m31s
Deploy / build-frontend (push) Successful in 7m23s
Deploy / deploy (push) Successful in 2m17s
fix(site-finder): три честности-фикса — B1 ГРС «выход №N» + околонуль + Росреестр-квартал (#2257)
2026-07-03 06:27:27 +00:00

581 lines
28 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) == []
# ── _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