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