"""Unit-тесты §13 PDF-экспортёра (#989, 955-A3) — `export_report_pdf`.
Чистые тесты БЕЗ БД/сети (экспортёр только ПОТРЕБЛЯЕТ уже-собранный отчёт):
• полный `SiteFinderReport` → байты начинаются с `b"%PDF"` + нетривиальная длина;
• ожидаемый текст секций присутствует в HTML-источнике (заголовок вердикта, класс
продукта, RU-заголовки блоков) — рендерим тот же HTML, что уходит в WeasyPrint;
• экспортёр принимает КАК dataclass-инстанс, ТАК и его `as_dict()`-словарь;
• частичный / пустой / мусорный отчёт → всё равно валидный PDF (graceful, без падения);
• ADVISORY-маркер присутствует.
Детерминированно, без LLM. DATABASE_URL выставляем до импорта app-модулей (зеркало
test_excel.py / test_report.py) — на случай side-effect'ов импорта пакета.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import datetime as dt
from app.services.exporters.report_pdf import (
_ADVISORY_MARKER,
_TITLE_FUTURE_MARKET,
_TITLE_MARKET_NOW,
_TITLE_PRODUCT_TZ,
_TITLE_SCENARIOS,
_TITLE_SCORING,
_TITLE_SUMMARY,
_build_html,
_normalize,
export_report_pdf,
)
from app.services.forecasting.report import (
ReportConfidence,
ReportExecSummary,
ReportFutureMarket,
ReportMarketNow,
ReportMeta,
ReportProductTz,
ReportScenarios,
ReportScoring,
SiteFinderReport,
)
# Шесть ожидаемых заголовков блоков (по одному на содержательную секцию §13).
_EXPECTED_TITLES: tuple[str, ...] = (
_TITLE_SUMMARY,
_TITLE_MARKET_NOW,
_TITLE_FUTURE_MARKET,
_TITLE_PRODUCT_TZ,
_TITLE_SCENARIOS,
_TITLE_SCORING,
)
def _full_report() -> SiteFinderReport:
"""Полный SiteFinderReport из реалистичных sample-данных (форма #987 `as_dict()`)."""
return SiteFinderReport(
exec_summary=ReportExecSummary(
headline="Строить комфорт-класс, акцент на студии и 1-к.",
verdict="Дефицит малых форматов на горизонте 12 мес; рынок поглощает быстро.",
key_numbers={"overall_score": 0.71, "deficit_index": 0.34, "horizon_months": 12},
overall_confidence="medium",
),
market_now=ReportMarketNow(
market_metrics={"unit_velocity": 8.2, "overstock_index": 0.21, "confidence": "medium"},
competitors=[
{"obj_id": 1, "comm_name": "ЖК Альфа", "relevance_weight": 0.8},
{"obj_id": 2, "comm_name": "ЖК Бета", "relevance_weight": 0.5},
],
supply_layers={"open_units": 1200, "hidden_units": 800},
summary="Текущий рынок: умеренная абсорбция, средняя плотность конкурентов.",
),
future_market=ReportFutureMarket(
forecasts_by_horizon=[
{"horizon_months": 6, "demand": 410, "supply": 380, "deficit_index": 0.2},
{"horizon_months": 12, "demand": 920, "supply": 700, "deficit_index": 0.34},
],
future_supply={"index": 0.42, "horizon_months": 12, "confidence": "low"},
future_competitors=[{"obj_id": 3, "stage": "котлован"}],
scenarios_summary={"base": 0.34, "conservative": 0.18, "aggressive": 0.49},
summary="Будущий рынок: дефицит сохраняется на 12 мес.",
),
product_tz=ReportProductTz(
obj_class="комфорт",
mix=[
{"bucket": "1-Студия", "pct": 35},
{"bucket": "2-1-к", "pct": 40},
{"bucket": "3-2-к", "pct": 25},
],
commercial={"available": False, "caveat": "коммерция: нет достаточных данных"},
usp=[{"segment": "1-Студия", "usp_text": "Дефицит студий — стройте их."}],
reasons=[{"why": "deficit_index +0.34 на горизонте 12 мес", "advisory": True}],
summary="Рекомендован комфорт-класс с акцентом на малые форматы.",
),
scenarios=ReportScenarios(
by_scenario={
"conservative": {"overall": 0.18, "advisory": True},
"base": {"overall": 0.34, "advisory": True},
"aggressive": {"overall": 0.49, "advisory": True},
},
summary="Разброс сценариев умеренный.",
),
scoring=ReportScoring(
product_scores={
"overall": 0.71,
"scores": {"market_fit": {"value": 0.67}, "demand": {"value": 0.51}},
"advisory": True,
},
special_indices={
"indices": {"launch_window": {"value": 0.6, "label": "12 мес"}},
"advisory": True,
},
overall=0.71,
),
confidence=ReportConfidence(
level="medium",
rationale="Источники advisory-capped; данные средней плотности.",
factors={"data_coverage": 0.6, "engine_validated": False},
),
meta=ReportMeta(
cad_num="66:41:0000000:1",
district="Верх-Исетский",
segment={"obj_class": "комфорт", "room_bucket": "1-к 30-45"},
horizons=[6, 12, 18, 24],
generated_at=dt.date(2026, 6, 3),
),
)
# ── Полный отчёт: PDF-байты (магия %PDF), нетривиальная длина ───────────────────
class TestFullReportExport:
def test_returns_pdf_magic_bytes(self) -> None:
payload = export_report_pdf(_full_report())
assert isinstance(payload, bytes)
assert payload.startswith(b"%PDF")
def test_returns_non_trivial_length(self) -> None:
# Полный отчёт с таблицами — заметно больше пустого «скелета».
payload = export_report_pdf(_full_report())
assert len(payload) > 2000
def test_accepts_as_dict_input(self) -> None:
# Экспортёр принимает уже-словарь `as_dict()` (не только dataclass).
payload = export_report_pdf(_full_report().as_dict())
assert payload.startswith(b"%PDF")
# ── Содержимое HTML-источника: ожидаемый текст секций присутствует ─────────────
# Рендерим ТОТ ЖЕ HTML, что уходит в WeasyPrint (быстрее и точнее, чем парсить PDF).
class TestRenderedHtmlContent:
def test_all_section_titles_present(self) -> None:
html_str = _build_html(_normalize(_full_report()))
for title in _EXPECTED_TITLES:
assert title in html_str, f"отсутствует заголовок блока {title}"
def test_advisory_marker_present(self) -> None:
html_str = _build_html(_normalize(_full_report()))
assert _ADVISORY_MARKER in html_str
assert "ADVISORY" in html_str
def test_exec_headline_and_context_present(self) -> None:
html_str = _build_html(_normalize(_full_report()))
assert "Строить комфорт" in html_str
assert "66:41:0000000:1" in html_str
def test_product_class_and_mix_present(self) -> None:
html_str = _build_html(_normalize(_full_report()))
assert "комфорт" in html_str
assert "1-Студия" in html_str
def test_scoring_values_present(self) -> None:
html_str = _build_html(_normalize(_full_report()))
assert "market_fit" in html_str
assert "0.71" in html_str
def test_dynamic_fields_are_escaped(self) -> None:
# html.escape применён ко всем динамическим строкам: вредоносный ввод обезврежен.
report = SiteFinderReport(
exec_summary=ReportExecSummary(headline=" & co"),
)
html_str = _build_html(_normalize(report))
assert "" not in html_str
assert "<script>alert(1)</script>" in html_str
assert "& co" in html_str
# ── Graceful: частичный / пустой / мусорный вход → валидный PDF без падения ─────
class TestGracefulPartialReport:
def test_empty_report_still_valid_pdf(self) -> None:
# Полностью дефолтный (пустой) отчёт — валидный PDF, не падает.
payload = export_report_pdf(SiteFinderReport())
assert payload.startswith(b"%PDF")
assert len(payload) > 0
def test_empty_report_shows_no_data_marker(self) -> None:
# Пустые секции рисуют заглушку «нет данных» (graceful), а не падают.
html_str = _build_html(_normalize(SiteFinderReport()))
assert "нет данных" in html_str
# ADVISORY-маркер остаётся даже у пустого отчёта.
assert "ADVISORY" in html_str
def test_partial_report_some_sections(self) -> None:
# Заполнены только meta + exec_summary — остальные пусты, PDF валиден.
report = SiteFinderReport(
exec_summary=ReportExecSummary(headline="Тонкий анализ — только заголовок."),
meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]),
)
payload = export_report_pdf(report)
assert payload.startswith(b"%PDF")
html_str = _build_html(_normalize(report))
assert "Тонкий анализ" in html_str
assert "66:41:0000000:2" in html_str
def test_garbage_input_does_not_crash(self) -> None:
# Мусор (None / не-отчёт) → пустой, но валидный PDF (нормализация в {}).
for junk in (None, 123, "not a report"):
payload = export_report_pdf(junk)
assert payload.startswith(b"%PDF")
assert len(payload) > 0