All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m46s
CI / openapi-codegen-check (pull_request) Successful in 2m38s
CI / backend-tests (pull_request) Successful in 14m58s
551 lines
26 KiB
Python
551 lines
26 KiB
Python
"""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)
|
||
|
||
|
||
# ── _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
|