gendesign/backend/tests/test_eias_heat_loader.py
bot-backend e501dedf78
All checks were successful
Deploy / build-frontend (push) Successful in 4m38s
Deploy / deploy (push) Successful in 1m37s
Deploy / build-backend (push) Successful in 2m48s
Deploy / build-worker (push) Successful in 3m59s
Deploy / changes (push) Successful in 9s
feat(site-finder): ЕЭСК-резервы городских ПС/ТП + тепло из ФГИС ФАС (#2119 B2) (#2165)
2026-07-02 16:44:57 +00:00

278 lines
11 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-тесты загрузчика тепло-резервов ФАС (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:
"""<tr> строки грида: ссылка на TemplatePrinter (guid+id) + текст периода."""
href = f"/Discl/TemplatePrinter.aspx?reg=RU.5.66&guid={guid}&id={pub_id}"
return f'<tr><td><a href="{href}">выгрузить</a></td><td>{period_text}</td></tr>'
def test_extract_publications_freshest_quarter_all_pubs() -> None:
"""Берём ВСЕ публикации свежайшего квартала; старее — отбрасываем."""
html = (
"<table>"
+ "".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
]
)
+ "</table>"
)
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_year_beats_quarter() -> None:
"""4 квартал 2025 старее 1 квартала 2026 — берём 2026."""
html = (
"<table>"
+ "".join(
[
_pub_row("g-2025q4", "10", "4 квартал 2025"),
_pub_row("g-2026q1", "20", "1 квартал 2026"),
]
)
+ "</table>"
)
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 = (
"<table>"
"<tr><td>шапка без ссылки 2 квартал 2026</td></tr>"
+ _pub_row("g-noperiod", "99", "без указания периода")
+ "</table>"
)
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("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 с членом <member>=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"<meta/>") # посторонний член — должен игнорироваться
b64 = base64.b64encode(zbuf.getvalue()).decode("ascii")
return (
"<html><body>"
f'<div id="dataFile" data-filename="f.zip" data-file="{b64}"></div>'
"</body></html>"
)
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:
"""Нет <div id=dataFile> → None."""
assert eh.extract_tpl_xlsx("<html><body>пусто</body></html>") 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'<div id="dataFile" data-file="{b64}"></div>'
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}