"""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