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
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.
205 lines
8.8 KiB
Python
205 lines
8.8 KiB
Python
"""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
|