228 lines
11 KiB
Python
228 lines
11 KiB
Python
"""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="<script>alert(1)</script> & co"),
|
||
)
|
||
html_str = _build_html(_normalize(report))
|
||
assert "<script>alert(1)</script>" 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
|