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