gendesign/backend/tests/services/exporters/test_report_docx.py
Light1YT 3a89287c6b
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (push) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m49s
Deploy / build-worker (push) Successful in 3m27s
Deploy / deploy (push) Successful in 1m13s
CI / backend-tests (pull_request) Successful in 6m4s
feat(export): add DOCX export of §22 forecast report (#959)
render_report_docx (python-docx) mirrors report_md/report_pdf section order &
content, reuses report_pdf pure helpers (DRY), graceful on thin/empty report.
Widen /forecast/export format Literal to include docx → Word attachment.
Add python-docx dep + regenerate uv.lock (uv sync --frozen passes). Part of #959.
2026-06-07 13:58:04 +05:00

202 lines
8.6 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 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