gendesign/backend/tests/services/exporters/test_report_pptx.py
Light1YT 40b1f3ad69
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m58s
Deploy / build-worker (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m26s
CI / backend-tests (pull_request) Successful in 6m7s
feat(export): add PPTX presentation export of §22 forecast report (#959)
Completes EPIC #959 export formats (md/json/tg/docx/pptx; PDF/XLSX pre-existing).
render_report_pptx (python-pptx, pure + graceful, reuses report_pdf helpers,
local import) → 6-slide deck. Endpoint format=pptx (early-return, presentationml
media-type, .pptx attachment). Add python-pptx + regenerate uv.lock (python-pptx
+ xlsxwriter; uv sync --frozen passes).

Closes #959.
2026-06-07 14:09:35 +05:00

205 lines
8.8 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-тесты §22 PPTX-экспортёра (#959, EPIC export) — `render_report_pptx`.
Чистые тесты БЕЗ БД/сети (экспортёр только ПОТРЕБЛЯЕТ уже-собранный отчёт):
• реальный fixture-отчёт (frontend mock `parcel-forecast.json` → `report`) рендерится
в НЕПУСТЫЕ байты, начинающиеся с `b"PK"` (OOXML/.pptx — это zip-контейнер);
• презентация открываемая python-pptx и несёт заголовки слайдов + advisory-дисклеймер
+ кадастр из meta;
• экспортёр принимает КАК `SiteFinderReport`-инстанс, ТАК и его `as_dict()`-словарь;
• пустой / частичный / мусорный отчёт → валидная минимальная .pptx БЕЗ падения (graceful).
Детерминированно, без LLM. DATABASE_URL выставляем до импорта app-модулей (зеркало
test_report_docx.py) — на случай side-effect'ов импорта пакета.
"""
from __future__ import annotations
import io
import json
import os
from pathlib import Path
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from pptx import Presentation
from app.services.exporters.report_pptx import (
_ADVISORY_DISCLAIMER,
_TITLE_CONFIDENCE,
_TITLE_DECK,
_TITLE_FUTURE_MARKET,
_TITLE_PRODUCT_TZ,
_TITLE_SCENARIOS,
_TITLE_SUMMARY,
render_report_pptx,
)
from app.services.forecasting.report import (
ReportExecSummary,
ReportMeta,
SiteFinderReport,
)
# Реальный fixture: frontend mock §22-форсайта — берём вложенный `report` (форма #987
# `as_dict()`). Так тест не разойдётся с реальной формой отчёта.
_FIXTURE_PATH = (
Path(__file__).resolve().parents[4]
/ "frontend"
/ "src"
/ "lib"
/ "mocks"
/ "parcel-forecast.json"
)
# OOXML / .pptx — это zip-контейнер: первые два байта — сигнатура zip «PK».
_ZIP_SIGNATURE = b"PK"
_EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_DECK,
_TITLE_SUMMARY,
_TITLE_FUTURE_MARKET,
_TITLE_SCENARIOS,
_TITLE_PRODUCT_TZ,
_TITLE_CONFIDENCE,
)
def _fixture_report() -> dict[str, Any]:
"""`report`-словарь из frontend-мока §22 (форма SiteFinderReport.as_dict())."""
data = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8"))
report = data["report"]
assert isinstance(report, dict)
return report
def _pptx_text(pptx_bytes: bytes) -> str:
"""Весь текст презентации (текст всех shape'ов + ячеек таблиц) одной строкой."""
prs = Presentation(io.BytesIO(pptx_bytes))
parts: list[str] = []
for slide in prs.slides:
for shape in slide.shapes:
if shape.has_text_frame:
parts.append(shape.text_frame.text)
if shape.has_table:
for row in shape.table.rows:
parts.extend(cell.text for cell in row.cells)
return "\n".join(parts)
# ── Реальный fixture-отчёт: байты-сигнатура + структура + ключевые значения ──────
class TestFixtureReportRender:
def test_returns_non_empty_bytes(self) -> None:
out = render_report_pptx(_fixture_report())
assert isinstance(out, bytes)
# Нетривиальная презентация — далеко не пустая.
assert len(out) > 1000
def test_starts_with_zip_signature(self) -> None:
# OOXML/.pptx — zip: первые 2 байта обязаны быть «PK».
out = render_report_pptx(_fixture_report())
assert out[:2] == _ZIP_SIGNATURE
def test_openable_by_python_pptx(self) -> None:
# Результат — валидная .pptx (открывается python-pptx без ошибки).
out = render_report_pptx(_fixture_report())
prs = Presentation(io.BytesIO(out))
# Титул + пять содержательных слайдов = шесть.
assert len(prs.slides) == 6
def test_advisory_disclaimer_present(self) -> None:
text = _pptx_text(render_report_pptx(_fixture_report()))
assert _ADVISORY_DISCLAIMER in text
def test_all_slide_titles_present(self) -> None:
text = _pptx_text(render_report_pptx(_fixture_report()))
for title in _EXPECTED_TITLES:
assert title in text, f"отсутствует заголовок слайда «{title}»"
def test_meta_cad_num_present(self) -> None:
text = _pptx_text(render_report_pptx(_fixture_report()))
# Кадастр из meta фикстуры попадает на титульный слайд.
assert "66:41:0702048:27" in text
def test_headline_present(self) -> None:
report = _fixture_report()
headline = report["exec_summary"]["headline"]
text = _pptx_text(render_report_pptx(report))
assert headline.split(".")[0] in text
# ── Вход: dataclass-инстанс ИЛИ as_dict()-словарь ───────────────────────────────
class TestInputForms:
def test_accepts_dataclass_instance(self) -> None:
report = SiteFinderReport(
exec_summary=ReportExecSummary(headline="Строить комфорт-класс."),
meta=ReportMeta(cad_num="66:41:0000000:1", horizons=[12]),
)
text = _pptx_text(render_report_pptx(report))
assert "Строить комфорт" in text
assert "66:41:0000000:1" in text
def test_dataclass_and_dict_equivalent_text(self) -> None:
# Байты могут расходиться (zip-метаданные/порядок), но ТЕКСТ — идентичен.
report = SiteFinderReport(
exec_summary=ReportExecSummary(headline="Один заголовок."),
meta=ReportMeta(cad_num="66:41:0000000:9"),
)
assert _pptx_text(render_report_pptx(report)) == _pptx_text(
render_report_pptx(report.as_dict())
)
# ── Graceful: пустой / частичный / мусорный вход → валидная .pptx без падения ────
class TestGraceful:
def test_empty_dict_does_not_crash(self) -> None:
# Пустой отчёт {} → валидная .pptx (no KeyError), сигнатура PK, есть дисклеймер.
out = render_report_pptx({})
assert isinstance(out, bytes)
assert out[:2] == _ZIP_SIGNATURE
text = _pptx_text(out)
assert _ADVISORY_DISCLAIMER in text
assert "нет данных" in text
# Все слайды всё равно отрисованы (стабильный контракт).
for title in _EXPECTED_TITLES:
assert title in text
def test_none_does_not_crash(self) -> None:
# None → нормализуется в {} → валидная минимальная .pptx, не падает.
out = render_report_pptx(None)
assert isinstance(out, bytes)
assert out[:2] == _ZIP_SIGNATURE
assert _ADVISORY_DISCLAIMER in _pptx_text(out)
def test_default_report_does_not_crash(self) -> None:
# Полностью дефолтный (пустой) SiteFinderReport — валидная .pptx.
out = render_report_pptx(SiteFinderReport())
assert out[:2] == _ZIP_SIGNATURE
text = _pptx_text(out)
assert _ADVISORY_DISCLAIMER in text
assert "нет данных" in text
def test_garbage_input_returns_valid_pptx(self) -> None:
# Мусор (int / строка / list) → нормализуется в {} → валидная .pptx, не падает.
for junk in (123, "not a report", []):
out = render_report_pptx(junk)
assert isinstance(out, bytes)
assert out[:2] == _ZIP_SIGNATURE
def test_partial_report_some_sections(self) -> None:
report = SiteFinderReport(
exec_summary=ReportExecSummary(headline="Тонкий анализ — только заголовок."),
meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]),
)
out = render_report_pptx(report)
assert out[:2] == _ZIP_SIGNATURE
text = _pptx_text(out)
assert "Тонкий анализ" in text
assert "66:41:0000000:2" in text
# Незаполненные слайды рисуют «нет данных», но .pptx валидна.
assert "нет данных" in text