"""Unit-тесты загрузчика резервов АО «ЕЭСК» (#2119 Phase B2).
Pure / mock-based — без реальной сети и БД. Покрывает:
- select_freshest_links: 2 серии (ПС 35-220 / ТП «ниже 35»), выбор свежайшего
квартала из HTML-фрагмента; классификация по тексту ссылки.
- _classify_series / _quarter_from_text / quarter_period / quarter_last_day.
- _pct_share_to_percent: доля 0.41 → 41.0.
- load_ps_35_220: шапка r4, данные r7+/r65+, строка-разделитель, доля→41.0,
матч ЕЭСК-only (dzo_name ILIKE %ЕЭСК%) / анматч.
- load_tp_rp_sub35: данные с r4, name «ТП/РП », municipality='Екатеринбург'.
"""
from __future__ import annotations
import io
from contextlib import contextmanager
from typing import Any
import openpyxl
from app.services.site_finder import eesk_reserve_loader as ee
from app.services.site_finder import rosseti_wfs_loader as rw
# ── select_freshest_links / классификация серий ──────────────────────────────
def _link(href: str, text: str) -> str:
return f'{text}'
def test_select_freshest_links_two_series_quarters() -> None:
"""Из HTML-хаба выбирается свежайший квартал каждой серии; серии различаются."""
base = "/upload/site1/document_file/"
html = "".join(
[
_link(
base + "aaa.xlsx",
"Резерв мощности по центрам питания ПС 35-220 кВ 1 квартал 2026",
),
_link(
base + "bbb.xlsx",
"Резерв мощности по центрам питания ПС 35-220 кВ 2 квартала 2026",
),
_link(
base + "ccc.xls",
"Наличие свободной мощности ТП и РП ниже 35 кВ 1 квартал 2026",
),
_link(
base + "ddd.xls",
"Наличие свободной мощности ТП и РП ниже 35 кВ 2 квартала 2026",
),
]
)
links = ee.select_freshest_links(html)
assert set(links) == {"ps_35_220", "tp_rp_sub35"}
# Свежайший ПС — 2 квартал 2026 (bbb), абсолютный URL достроен.
assert links["ps_35_220"]["url"].endswith("bbb.xlsx")
assert links["ps_35_220"]["url"].startswith("https://www.eesk.ru")
assert (links["ps_35_220"]["year"], links["ps_35_220"]["quarter"]) == (2026, 2)
# Свежайший ТП — 2 квартал 2026 (ddd).
assert links["tp_rp_sub35"]["url"].endswith("ddd.xls")
assert (links["tp_rp_sub35"]["year"], links["tp_rp_sub35"]["quarter"]) == (2026, 2)
def test_select_freshest_picks_latest_year_over_quarter() -> None:
"""4 квартал 2025 старее, чем 1 квартал 2026 — берём 2026."""
base = "/upload/site1/document_file/"
html = "".join(
[
_link(base + "old.xlsx", "Резерв по центрам питания 35-220 4 квартал 2025"),
_link(base + "new.xlsx", "Резерв по центрам питания 35-220 1 квартал 2026"),
]
)
links = ee.select_freshest_links(html)
assert links["ps_35_220"]["url"].endswith("new.xlsx")
assert (links["ps_35_220"]["year"], links["ps_35_220"]["quarter"]) == (2026, 1)
def test_classify_series_below_wins_over_cp_phrase() -> None:
"""«ниже 35» классифицирует как ТП даже если рядом «по центрам питания»."""
assert ee._classify_series("резерв по центрам питания ниже 35 кв") == "tp_rp_sub35"
assert ee._classify_series("резерв мощности по центрам питания 35-220") == "ps_35_220"
assert ee._classify_series("что-то постороннее") is None
def test_quarter_from_text_and_helpers() -> None:
assert ee._quarter_from_text("… 3 квартал 2025 …") == (2025, 3)
assert ee._quarter_from_text("… 2 квартала 2026 …") == (2026, 2)
assert ee._quarter_from_text("без квартала 2026") is None
assert ee.quarter_period(2026, 2) == "2026-Q2"
assert ee.quarter_last_day(2026, 2).isoformat() == "2026-06-30"
assert ee.quarter_last_day(2026, 1).isoformat() == "2026-03-31"
assert ee.quarter_last_day(2025, 4).isoformat() == "2025-12-31"
def test_pct_share_to_percent() -> None:
"""Доля 0.41 → 41.0; уже-проценты не трогаем; мусор → None."""
assert ee._pct_share_to_percent(0.41) == 41.0
assert ee._pct_share_to_percent("0,41") == 41.0
assert ee._pct_share_to_percent(1.0) == 100.0
assert ee._pct_share_to_percent(41.0) == 41.0 # >1 → уже проценты
assert ee._pct_share_to_percent("н/д") is None
# ── Fake DB session (SAVEPOINT + execute), как в test_connection_capacity ────
class _FakeResult:
def __init__(self, scalar_value: Any = True, rowcount: int = 1) -> None:
self._scalar = scalar_value
self.rowcount = rowcount
def scalar(self) -> Any:
return self._scalar
class _FakeSession:
"""Мини-заглушка Session: собирает execute-параметры, поддерживает begin_nested."""
def __init__(self, scalar_value: Any = True, rowcount: int = 1) -> None:
self.calls: list[tuple[str, Any]] = []
self._scalar = scalar_value
self._rowcount = rowcount
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))
return _FakeResult(self._scalar, self._rowcount)
def commit(self) -> None:
self.committed = True
def rollback(self) -> None:
pass
# ── load_ps_35_220: r4 шапка, данные r7+/r65+, разделитель, доля→41.0 ─────────
def _build_ps_workbook() -> bytes:
"""xlsx ПС 35-220: лист «Лист1», шапка r4, данные с r7, строка-разделитель.
Колонки 0-based: A=№, B=адм.район, C=имя ПС, D=макс полн. МВА, E=доля загрузки,
F=степень МУ250, G=свободная МВт.
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Лист1"
# Строки 1-6 — заголовки/шапка (r4 — реальная шапка), должны игнорироваться.
for r in range(1, 7):
ws.cell(row=r, column=1, value=f"header-{r}")
# Строка 7 — первая строка данных.
ws.append([1, "Ленинский", "ПС 110/10 Уктус", 40.0, 0.41, "МУ250", 15.0])
# Строка 8 — вторая ПС.
ws.append([2, "Кировский", "ПС 35/6 Изоплит", 16.0, 0.60, "МУ250", 4.0])
# Строка 9 — РАЗДЕЛИТЕЛЬ секций: C пусто И D не число → скип.
ws.append([None, None, None, None, None, None, None])
# Строка 10 — ещё одна ПС (эмулирует секцию r65+).
ws.append([3, "Октябрьский", "ПС 110/10 Сибирь", 25.0, 0.50, "МУ250", 8.0])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def test_load_ps_35_220_parse_and_match() -> None:
"""Данные с r7, разделитель пропущен, доля→%, матч по sc_name_norm."""
from datetime import date
db = _FakeSession(scalar_value=None, rowcount=1) # UPDATE matched 1 row
result = ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30))
# 3 строки данных (разделитель отсеян), все смэтчились.
assert result["rows"] == 3, result
assert result["matched"] == 3
assert result["unmatched"] == 0
first = db.calls[0][1]
assert first["name_norm"] == rw.normalize_sc_name("Уктус") == "уктус"
assert first["installed"] == 40.0
assert first["reserve"] == 15.0
assert first["district"] == "Ленинский"
assert first["load_pct"] == 41.0 # доля 0.41 → 41.0%
assert first["asof"] == date(2026, 6, 30)
def test_load_ps_35_220_unmatched_counted() -> None:
"""ПС без совпадения (rowcount=0 — напр. не ЕЭСК) → unmatched, не падаем."""
from datetime import date
db = _FakeSession(scalar_value=None, rowcount=0) # UPDATE matched 0 rows
result = ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30))
assert result["rows"] == 3
assert result["matched"] == 0
assert result["unmatched"] == 3
def test_load_ps_35_220_sql_scopes_to_eesk() -> None:
"""UPDATE ограничен dzo_name ILIKE '%ЕЭСК%' — не затирает Россети-строки."""
from datetime import date
db = _FakeSession(scalar_value=None, rowcount=1)
ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30))
sql = db.calls[0][0]
assert "ILIKE '%ЕЭСК%'" in sql
assert "capacity_source = 'eesk_35_220'" in sql
assert "reserve_unit = 'МВт'" in sql
# ── load_tp_rp_sub35: данные с r4, name «ТП/РП », municipality ────────────
def _build_tp_workbook() -> bytes:
"""xlsx ТП/РП «ниже 35»: лист «Публикация», r1 title, r3 шапка, данные r4+.
Колонки 0-based: A=номер, B=предельно доп. МВт, C=загрузка МВт, D=резерв МВт.
"""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Публикация"
ws.cell(row=1, column=1, value="Заголовок публикации") # r1 title
# r2 пустая, r3 — шапка.
ws.cell(row=3, column=1, value="№ ТП/РП")
ws.cell(row=3, column=2, value="предельно допустимая, МВт")
# Строка 4+ — данные.
ws.cell(row=4, column=1, value=10003) # int-номер (Excel часто float)
ws.cell(row=4, column=2, value=1.6)
ws.cell(row=4, column=3, value=1.0)
ws.cell(row=4, column=4, value=0.6)
ws.cell(row=5, column=1, value="10004") # уже строка
ws.cell(row=5, column=2, value=2.5)
ws.cell(row=5, column=3, value=2.0)
ws.cell(row=5, column=4, value=0.5)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def test_load_tp_rp_sub35_parse() -> None:
"""Данные с r4; name «ТП/РП 10003», name_norm='10003', municipality='Екатеринбург'."""
from datetime import date
db = _FakeSession(scalar_value=True, rowcount=1)
result = ee.load_tp_rp_sub35(db, _build_tp_workbook(), date(2026, 6, 30))
assert result["rows"] == 2, result
assert result["inserted"] == 2
first = db.calls[0][1]
# float-номер 10003.0 из Excel нормализован к «10003» (без .0).
assert first["name"] == "ТП/РП 10003"
assert first["name_norm"] == "10003"
assert first["municipality"] == "Екатеринбург"
assert first["installed"] == 1.6
assert first["current"] == 1.0
assert first["reserve"] == 0.6
assert first["asof"] == date(2026, 6, 30)
second = db.calls[1][1]
assert second["name"] == "ТП/РП 10004"
assert second["name_norm"] == "10004"
def test_load_tp_rp_sub35_voltage_null_no_kva_sanitize() -> None:
"""voltage_kv NULL в SQL; installed НЕ делится на 1000 (без кВА-санитайза)."""
from datetime import date
db = _FakeSession(scalar_value=True, rowcount=1)
ee.load_tp_rp_sub35(db, _build_tp_workbook(), date(2026, 6, 30))
sql = db.calls[0][0]
assert "voltage_kv" in sql and "NULL" in sql
# installed сохранён как есть (1.6), без деления.
assert db.calls[0][1]["installed"] == 1.6