"""Unit-тесты §22 DOCX-экспортёра (#959, EPIC export) — `render_report_docx`. Чистые тесты БЕЗ БД/сети (экспортёр только ПОТРЕБЛЯЕТ уже-собранный отчёт): • реальный fixture-отчёт (frontend mock `parcel-forecast.json` → `report`) рендерится в НЕПУСТЫЕ байты, начинающиеся с `b"PK"` (OOXML/.docx — это zip-контейнер); • документ открываемый python-docx и несёт заголовки всех секций + advisory-дисклеймер + кадастр из meta; • экспортёр принимает КАК `SiteFinderReport`-инстанс, ТАК и его `as_dict()`-словарь; • пустой / частичный / мусорный отчёт → валидный минимальный .docx БЕЗ падения (graceful). Детерминированно, без LLM. DATABASE_URL выставляем до импорта app-модулей (зеркало test_report_md.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 docx import Document from app.services.exporters.report_docx import ( _ADVISORY_DISCLAIMER, _TITLE_CONFIDENCE, _TITLE_FUTURE_MARKET, _TITLE_MARKET_NOW, _TITLE_PRODUCT_TZ, _TITLE_SCENARIOS, _TITLE_SCORING, _TITLE_SUMMARY, render_report_docx, ) 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 / .docx — это zip-контейнер: первые два байта — сигнатура zip «PK». _ZIP_SIGNATURE = b"PK" _EXPECTED_TITLES: tuple[str, ...] = ( _TITLE_SUMMARY, _TITLE_MARKET_NOW, _TITLE_FUTURE_MARKET, _TITLE_SCENARIOS, _TITLE_PRODUCT_TZ, _TITLE_SCORING, _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 _docx_text(docx_bytes: bytes) -> str: """Весь текст документа (абзацы + ячейки таблиц) одной строкой — для ассертов.""" doc = Document(io.BytesIO(docx_bytes)) parts: list[str] = [p.text for p in doc.paragraphs] for table in doc.tables: for row in 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_docx(_fixture_report()) assert isinstance(out, bytes) # Нетривиальный документ — далеко не пустой. assert len(out) > 1000 def test_starts_with_zip_signature(self) -> None: # OOXML/.docx — zip: первые 2 байта обязаны быть «PK». out = render_report_docx(_fixture_report()) assert out[:2] == _ZIP_SIGNATURE def test_openable_by_python_docx(self) -> None: # Результат — валидный .docx (открывается python-docx без ошибки). out = render_report_docx(_fixture_report()) doc = Document(io.BytesIO(out)) assert len(doc.paragraphs) > 0 def test_advisory_disclaimer_present(self) -> None: text = _docx_text(render_report_docx(_fixture_report())) assert _ADVISORY_DISCLAIMER in text def test_all_section_headers_present(self) -> None: text = _docx_text(render_report_docx(_fixture_report())) for title in _EXPECTED_TITLES: assert title in text, f"отсутствует заголовок секции «{title}»" def test_meta_cad_num_present(self) -> None: text = _docx_text(render_report_docx(_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 = _docx_text(render_report_docx(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 = _docx_text(render_report_docx(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 _docx_text(render_report_docx(report)) == _docx_text( render_report_docx(report.as_dict()) ) # ── Graceful: пустой / частичный / мусорный вход → валидный .docx без падения ──── class TestGraceful: def test_empty_dict_does_not_crash(self) -> None: # Пустой отчёт {} → валидный .docx (no KeyError), сигнатура PK, есть дисклеймер. out = render_report_docx({}) assert isinstance(out, bytes) assert out[:2] == _ZIP_SIGNATURE text = _docx_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 → нормализуется в {} → валидный минимальный .docx, не падает. out = render_report_docx(None) assert isinstance(out, bytes) assert out[:2] == _ZIP_SIGNATURE assert _ADVISORY_DISCLAIMER in _docx_text(out) def test_default_report_does_not_crash(self) -> None: # Полностью дефолтный (пустой) SiteFinderReport — валидный .docx. out = render_report_docx(SiteFinderReport()) assert out[:2] == _ZIP_SIGNATURE text = _docx_text(out) assert _ADVISORY_DISCLAIMER in text assert "нет данных" in text def test_garbage_input_returns_valid_docx(self) -> None: # Мусор (int / строка / list) → нормализуется в {} → валидный .docx, не падает. for junk in (123, "not a report", []): out = render_report_docx(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_docx(report) assert out[:2] == _ZIP_SIGNATURE text = _docx_text(out) assert "Тонкий анализ" in text assert "66:41:0000000:2" in text # Незаполненные секции рисуют «нет данных», но .docx валиден. assert "нет данных" in text