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.
195 lines
8.3 KiB
Python
195 lines
8.3 KiB
Python
"""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
|