"""Unit-тесты загрузчика тепло-резервов ФАС (ri.eias.ru) (#2119 Phase B2). Pure / mock-based — без реальной сети и БД. Покрывает: - extract_publications: (guid, id, период) из синтетического HTML грида + выбор свежайшего квартала (несколько публикаций одного квартала → все); - extract_tpl_xlsx: base64 → ZIP → член 'tpl' (генерим zip в памяти с xlsx-членом); - parse_heat_sheet: детект «СТ №54 … 3.26», скип заголовков, правейшее число; - _upsert_heat_rows: счётчики parsed/inserted/updated (mock-DB как в eesk-тестах). """ from __future__ import annotations import base64 import io import zipfile from contextlib import contextmanager from typing import Any import openpyxl from app.services.site_finder import eias_heat_loader as eh # ── extract_publications: (guid, id, период) + свежайший квартал ────────────── def _pub_row(guid: str, pub_id: str, period_text: str) -> str: """ строки грида: ссылка на TemplatePrinter (guid+id) + текст периода.""" href = f"/Discl/TemplatePrinter.aspx?reg=RU.5.66&guid={guid}&id={pub_id}" return f'выгрузить{period_text}' def test_extract_publications_real_onclick_form() -> None: """РЕАЛЬНАЯ разметка прода: ссылка в onclick=openTemplateDialog(...), href="#", амперсанды HTML-энкожены (&), период «1 кв. 2026 года» в тексте строки.""" url = ( "https://ri-loader.eias.ru/TemplatePrinter.aspx?reg=RU.5.66" "&guid=40665f4f-c7e7-4121-ac15-0bff24c39f81&id=9125184878" ) html = ( "" '" "" "
' f"' "выгрузить1 кв. 2026 года
" ) pubs = eh.extract_publications(html) assert len(pubs) == 1 assert pubs[0]["guid"] == "40665f4f-c7e7-4121-ac15-0bff24c39f81" assert pubs[0]["id"] == "9125184878" assert pubs[0]["year"] == 2026 assert pubs[0]["quarter"] == 1 def test_extract_publications_freshest_quarter_all_pubs() -> None: """Берём ВСЕ публикации свежайшего квартала; старее — отбрасываем.""" html = ( "" + "".join( [ _pub_row("g-old", "101", "1 квартал 2026"), _pub_row("g-new-a", "201", "2 квартал 2026"), # свежайший, ЦС A _pub_row("g-new-b", "202", "2 квартала 2026"), # свежайший, ЦС B ] ) + "
" ) pubs = eh.extract_publications(html) assert len(pubs) == 2 guids = {p["guid"] for p in pubs} assert guids == {"g-new-a", "g-new-b"} assert all((p["year"], p["quarter"]) == (2026, 2) for p in pubs) by_guid = {p["guid"]: p for p in pubs} assert by_guid["g-new-a"]["id"] == "201" assert by_guid["g-new-b"]["id"] == "202" def test_extract_publications_dedupes_repeated_guid_id() -> None: """Одна публикация в нескольких строках грида (передача/сбыт) → одна запись. На проде каждый (guid, id) повторяется ~3 раза — без дедупа лоадер качал бы один и тот же ZIP многократно. """ html = ( "" + "".join( [ _pub_row("g-dup", "301", "1 кв. 2026 года"), # передача _pub_row("g-dup", "301", "1 кв. 2026 года"), # сбыт (та же публикация) _pub_row("g-other", "302", "1 кв. 2026 года"), ] ) + "
" ) pubs = eh.extract_publications(html) assert len(pubs) == 2 assert [(p["guid"], p["id"]) for p in pubs] == [("g-dup", "301"), ("g-other", "302")] def test_extract_publications_year_beats_quarter() -> None: """4 квартал 2025 старее 1 квартала 2026 — берём 2026.""" html = ( "" + "".join( [ _pub_row("g-2025q4", "10", "4 квартал 2025"), _pub_row("g-2026q1", "20", "1 квартал 2026"), ] ) + "
" ) pubs = eh.extract_publications(html) assert len(pubs) == 1 assert pubs[0]["guid"] == "g-2026q1" assert (pubs[0]["year"], pubs[0]["quarter"]) == (2026, 1) def test_extract_publications_skips_rows_without_period_or_link() -> None: """Строка без ссылки ИЛИ без квартала игнорируется; нет валидных → [].""" html = ( "" "" + _pub_row("g-noperiod", "99", "без указания периода") + "
шапка без ссылки 2 квартал 2026
" ) assert eh.extract_publications(html) == [] def test_quarter_from_text_helper() -> None: assert eh._quarter_from_text("… 3 квартал 2025 …") == (2025, 3) assert eh._quarter_from_text("… 2 квартала 2026 …") == (2026, 2) assert eh._quarter_from_text("1 кв. 2026 года") == (2026, 1) # реальная форма прода assert eh._quarter_from_text("1 кв 2026") == (2026, 1) # без точки assert eh._quarter_from_text("5 квартал 2026") is None # квартал вне 1..4 assert eh._quarter_from_text("без квартала 2026") is None # ── extract_tpl_xlsx: base64 → ZIP → член 'tpl' ────────────────────────────── def _make_tpl_zip_html(xlsx_bytes: bytes, member: str = "tpl") -> str: """Строит HTML TemplatePrinter: zip с членом =xlsx → base64 в data-file.""" zbuf = io.BytesIO() with zipfile.ZipFile(zbuf, "w", zipfile.ZIP_DEFLATED) as zf: zf.writestr(member, xlsx_bytes) zf.writestr("meta.xml", b"") # посторонний член — должен игнорироваться b64 = base64.b64encode(zbuf.getvalue()).decode("ascii") return ( "" f'
' "" ) def _heat_xlsx_bytes() -> bytes: """XLSX с листом 'ТП': строка-заголовок + СТ №54 → 3.26 Гкал/час.""" wb = openpyxl.Workbook() ws = wb.active ws.title = "ТП" ws.append(["Резерв мощности источников тепловой энергии", None, None]) # заголовок, без числа ws.append(["№ п/п", "Наименование", "Гкал/час"]) # шапка — без системного маркера ws.append(["СТ №54", "источник", 3.26]) # система + правейшее число 3.26 buf = io.BytesIO() wb.save(buf) return buf.getvalue() def test_extract_tpl_xlsx_roundtrip() -> None: """base64 → zip → член 'tpl' извлекается и открывается openpyxl.""" html = _make_tpl_zip_html(_heat_xlsx_bytes()) out = eh.extract_tpl_xlsx(html) assert out is not None wb = openpyxl.load_workbook(io.BytesIO(out)) assert "ТП" in wb.sheetnames def test_extract_tpl_xlsx_missing_member_returns_none() -> None: """ZIP без члена 'tpl' → None (graceful, не падаем).""" html = _make_tpl_zip_html(_heat_xlsx_bytes(), member="other") assert eh.extract_tpl_xlsx(html) is None def test_extract_tpl_xlsx_no_datafile_div_returns_none() -> None: """Нет
→ None.""" assert eh.extract_tpl_xlsx("пусто") is None def test_extract_tpl_xlsx_bad_zip_returns_none() -> None: """data-file — валидный base64, но не ZIP → None (graceful).""" b64 = base64.b64encode(b"not a zip at all").decode("ascii") html = f'
' assert eh.extract_tpl_xlsx(html) is None # ── parse_heat_sheet: СТ №54 → 3.26, скип заголовков, правейшее число ───────── def test_parse_heat_sheet_detects_system_and_rightmost_number() -> None: """«СТ №54 … 3.26» распознан; строки-заголовки без чисел скипнуты.""" systems, all_cells = eh.parse_heat_sheet(_heat_xlsx_bytes()) assert len(systems) == 1 assert systems[0]["system_name"] == "СТ №54" assert systems[0]["reserve_gcal_h"] == 3.26 # all_cells содержит ВЕСЬ лист (3 строки) для raw. assert len(all_cells) == 3 def test_parse_heat_sheet_rightmost_number_wins() -> None: """Если в строке несколько чисел — берётся правейшее.""" wb = openpyxl.Workbook() ws = wb.active ws.title = "ТП" ws.append(["зона Центральная", 100.0, 12.5]) # правейшее = 12.5 (резерв) buf = io.BytesIO() wb.save(buf) systems, _ = eh.parse_heat_sheet(buf.getvalue()) assert len(systems) == 1 assert systems[0]["system_name"] == "зона Центральная" assert systems[0]["reserve_gcal_h"] == 12.5 def test_parse_heat_sheet_zero_systems() -> None: """Лист без системных строк → systems пуст, но all_cells собран.""" wb = openpyxl.Workbook() ws = wb.active ws.title = "ТП" ws.append(["Совершенно посторонний заголовок", None]) ws.append(["ещё строка", 5.0]) # число есть, но нет системного маркера → скип buf = io.BytesIO() wb.save(buf) systems, all_cells = eh.parse_heat_sheet(buf.getvalue()) assert systems == [] assert len(all_cells) == 2 # ── Fake DB (SAVEPOINT + execute), как в test_eesk_reserve_loader.py ────────── class _FakeResult: def __init__(self, scalar_value: Any = True) -> None: self._scalar = scalar_value def scalar(self) -> Any: return self._scalar class _FakeSession: """Мини-заглушка Session: собирает execute-параметры, поддерживает begin_nested.""" def __init__(self, scalar_value: Any = True) -> None: self.calls: list[tuple[str, Any]] = [] self._scalar = scalar_value 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) def commit(self) -> None: self.committed = True def rollback(self) -> None: pass def test_upsert_heat_rows_counters_and_params() -> None: """parsed/inserted счётчики; params корректны; raw содержит cells; CAST jsonb.""" db = _FakeSession(scalar_value=True) # xmax=0 → insert systems = [ {"system_name": "СТ №54", "reserve_gcal_h": 3.26}, {"system_name": "зона Юг", "reserve_gcal_h": -1.2}, # дефицит: знак сохраняется ] all_cells = [["СТ №54", "источник", 3.26], ["зона Юг", None, -1.2]] counts = eh._upsert_heat_rows(db, "ЕТК", "2026-Q2", "guid-xyz", systems, all_cells) assert counts == {"parsed": 2, "inserted": 2, "updated": 0, "skipped": 0} assert len(db.calls) == 2 sql, first = db.calls[0] assert first["org"] == "ЕТК" assert first["system_name"] == "СТ №54" assert first["reserve"] == 3.26 assert first["period"] == "2026-Q2" assert first["source_guid"] == "guid-xyz" # raw — JSON-строка с ключом cells (весь лист), CAST в jsonb в SQL. assert '"cells"' in first["raw"] assert "CAST(:raw AS jsonb)" in sql assert "ON CONFLICT (org, sphere, system_name, period)" in sql # negative reserve (дефицит) прокинут знаком. assert db.calls[1][1]["reserve"] == -1.2 def test_upsert_heat_rows_update_counter() -> None: """scalar()=False (xmax≠0) → строка учтена как update, не insert.""" db = _FakeSession(scalar_value=False) systems = [{"system_name": "СТ №1", "reserve_gcal_h": 1.0}] counts = eh._upsert_heat_rows( db, "Екатеринбургэнерго", "2026-Q2", "g1", systems, [["СТ №1", 1.0]] ) assert counts == {"parsed": 1, "inserted": 0, "updated": 1, "skipped": 0} def test_upsert_heat_rows_skip_on_error() -> None: """execute бросает → строка skipped, счётчик не растёт в inserted/updated.""" class _RaisingSession(_FakeSession): def execute(self, stmt: Any, params: Any = None) -> _FakeResult: raise RuntimeError("db boom") db = _RaisingSession() systems = [{"system_name": "СТ №9", "reserve_gcal_h": 2.0}] counts = eh._upsert_heat_rows(db, "ЕТК", "2026-Q2", "g", systems, [["СТ №9", 2.0]]) assert counts == {"parsed": 1, "inserted": 0, "updated": 0, "skipped": 1}