gendesign/backend/tests/services/exporters/test_report_md.py
Light1YT 9ca8c9bf26
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Successful in 6m9s
CI / backend-tests (push) Successful in 5m58s
feat(export): Markdown + JSON download of §22 forecast report (#959)
render_report_markdown (pure, no new runtime deps) reuses report_pdf's str
helpers (DRY), + GET /{cad_num}/forecast/export?format=md|json. No forecast run
→ 404; graceful on thin/empty reports; GFM-safe table escaping. PDF/XLSX already
existed; this adds the cheapest no-dep formats. Part of EPIC #959.
2026-06-06 23:39:17 +05:00

195 lines
8.3 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-тесты §13/§22 Markdown-экспортёра (#959, EPIC export) — `render_report_markdown`.
Чистые тесты БЕЗ БД/сети (экспортёр только ПОТРЕБЛЯЕТ уже-собранный отчёт):
• реальный fixture-отчёт (frontend mock `parcel-forecast.json` → `report`) рендерится
в Markdown с заголовками всех секций, advisory-дисклеймером и значением дефицита;
• экспортёр принимает КАК `SiteFinderReport`-инстанс, ТАК и его `as_dict()`-словарь;
• пустой / частичный / мусорный отчёт → валидный Markdown БЕЗ падения (graceful);
• pipe в данных экранируется (не ломает GFM-таблицу).
Детерминированно, без LLM. DATABASE_URL выставляем до импорта app-модулей (зеркало
test_report_pdf.py) — на случай side-effect'ов импорта пакета.
"""
from __future__ import annotations
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 app.services.exporters.report_md import (
_ADVISORY_DISCLAIMER,
_TITLE_CONFIDENCE,
_TITLE_FUTURE_MARKET,
_TITLE_MARKET_NOW,
_TITLE_PRODUCT_TZ,
_TITLE_SCENARIOS,
_TITLE_SCORING,
_TITLE_SUMMARY,
render_report_markdown,
)
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"
)
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
_EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_SUMMARY,
_TITLE_MARKET_NOW,
_TITLE_FUTURE_MARKET,
_TITLE_SCENARIOS,
_TITLE_PRODUCT_TZ,
_TITLE_SCORING,
_TITLE_CONFIDENCE,
)
# ── Реальный fixture-отчёт: структура + ключевые значения ───────────────────────
class TestFixtureReportRender:
def test_returns_non_empty_str(self) -> None:
md = render_report_markdown(_fixture_report())
assert isinstance(md, str)
assert len(md) > 200
def test_h1_and_advisory_disclaimer_present(self) -> None:
md = render_report_markdown(_fixture_report())
# H1 заголовок документа.
assert md.lstrip().startswith("# ")
# Advisory-дисклеймер обязателен (отчёт советующий).
assert _ADVISORY_DISCLAIMER in md
def test_all_section_headers_present(self) -> None:
md = render_report_markdown(_fixture_report())
for title in _EXPECTED_TITLES:
assert f"## {title}" in md, f"отсутствует заголовок секции «{title}»"
def test_meta_cad_num_present(self) -> None:
md = render_report_markdown(_fixture_report())
# Кадастр из meta фикстуры попадает в карточку.
assert "66:41:0702048:27" in md
def test_deficit_value_present(self) -> None:
# Сводный индекс дефицита (1.0) из exec_summary/forecasts должен быть в MD.
report = _fixture_report()
deficit = report["exec_summary"]["key_numbers"]["deficit_index"]
assert deficit is not None
# _fmt округляет float до 3 знаков → "-1.0".
assert str(round(float(deficit), 3)) in md_value(report)
def test_headline_present(self) -> None:
report = _fixture_report()
headline = report["exec_summary"]["headline"]
md = render_report_markdown(report)
# Заголовок-вердикт присутствует (bold).
assert headline.split(".")[0] in md
def test_gfm_table_separator_present(self) -> None:
# Хотя бы одна GFM-таблица (разделитель шапки) есть в выводе.
md = render_report_markdown(_fixture_report())
assert "| --- |" in md
def md_value(report: dict[str, Any]) -> str:
"""Helper: отрендерить отчёт (сахар для inline-ассертов)."""
return render_report_markdown(report)
# ── Вход: 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]),
)
md = render_report_markdown(report)
assert "Строить комфорт" in md
assert "66:41:0000000:1" in md
def test_dataclass_and_dict_equivalent(self) -> None:
report = SiteFinderReport(
exec_summary=ReportExecSummary(headline="Один заголовок."),
meta=ReportMeta(cad_num="66:41:0000000:9"),
)
assert render_report_markdown(report) == render_report_markdown(report.as_dict())
# ── Graceful: пустой / частичный / мусорный вход → валидный MD без падения ───────
class TestGraceful:
def test_empty_dict_does_not_crash(self) -> None:
# Пустой отчёт {} → валидный MD (no KeyError), с дисклеймером и «нет данных».
md = render_report_markdown({})
assert isinstance(md, str)
assert _ADVISORY_DISCLAIMER in md
assert "нет данных" in md
# Все секции всё равно отрисованы (стабильный контракт).
for title in _EXPECTED_TITLES:
assert f"## {title}" in md
def test_default_report_does_not_crash(self) -> None:
# Полностью дефолтный (пустой) SiteFinderReport — валидный MD.
md = render_report_markdown(SiteFinderReport())
assert _ADVISORY_DISCLAIMER in md
assert "нет данных" in md
def test_garbage_input_returns_valid_md(self) -> None:
# Мусор (None / int / строка) → нормализуется в {} → валидный MD, не падает.
for junk in (None, 123, "not a report", []):
md = render_report_markdown(junk)
assert isinstance(md, str)
assert _ADVISORY_DISCLAIMER in md
def test_partial_report_some_sections(self) -> None:
report = SiteFinderReport(
exec_summary=ReportExecSummary(headline="Тонкий анализ — только заголовок."),
meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]),
)
md = render_report_markdown(report)
assert "Тонкий анализ" in md
assert "66:41:0000000:2" in md
# Незаполненные секции рисуют «нет данных», но MD валиден.
assert "нет данных" in md
# ── Безопасность таблиц: pipe в данных не ломает GFM-разметку ───────────────────
class TestPipeEscaping:
def test_pipe_in_value_escaped(self) -> None:
report = SiteFinderReport(
exec_summary=ReportExecSummary(
key_numbers={"weird|key": "value|with|pipes"},
),
)
md = render_report_markdown(report)
# Сырой неэкранированный pipe из значения не должен утечь — он экранирован.
assert "value\\|with\\|pipes" in md