Suite was uncollectable for a while → code evolved, mocks didn't (42 failed / 5 errors → 1687 passed / 0 failed / 0 errors). TEST-ONLY, no app code changed, no real bugs found (code-reviewed ✅ — no test weakened to fake-green). - best_layouts: sum_deals→deals_window rename; velocity-scaling direction for new SF-01 divisors (last_month=1.0/last_year=12.0) - quarter_dump_lookup: 7→10-tuple mock rows (risks/opportunity/red_lines cols); fix early-exit call-counts for the cad_zouit fallback (#232) - cadastre_bulk: add xmin/ymin/xmax/ymax to harvest-quarter db mock - nspd_client: add required QuarterDump.opportunity - TopLayoutRow: add required is_oversold - analyze_{market_price,recent_permits,inline_weights}: SQL-signature dispatch instead of fragile positional db.execute indices (SF-B5 query reorder; also fixes a double-POST geom-starvation false-negative) - custom_pois: move centroid >15km so center_bonus=0 isolates custom-POI delta - layout/report PDF: runtime WeasyPrint probe (skip macOS dev, RUN on CI) - mv_layout SQL: normalize +psycopg DSN + connectivity-probe skipif - admin token tests: skip (X-Admin-Token gate removed #437/#426; protection is Caddy basic_auth + RBAC, covered by test_rbac) Refs #944.
259 lines
12 KiB
Python
259 lines
12 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
|
||
|
||
import pytest
|
||
|
||
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,
|
||
)
|
||
|
||
|
||
def _weasyprint_native_libs_available() -> tuple[bool, str]:
|
||
"""Probe whether WeasyPrint can render (native GTK/Pango/GObject libs present).
|
||
|
||
WeasyPrint is lazy-imported inside export_report_pdf, so the missing-native-lib
|
||
OSError fires only at write_pdf() time (libpango/libgobject absent on macOS dev,
|
||
present in the Docker/CI image). We probe by rendering a trivial document; failure
|
||
⇒ skip the PDF-rendering tests on dev, while the HTML-only tests still run and the
|
||
PDF path still runs on CI. NB: the HTML-content tests (_build_html only) are NOT
|
||
skipped — they never touch WeasyPrint.
|
||
"""
|
||
try:
|
||
from weasyprint import HTML
|
||
|
||
HTML(string="<p>x</p>").write_pdf()
|
||
except (OSError, ImportError) as e:
|
||
return False, str(e)
|
||
return True, ""
|
||
|
||
|
||
_WP_OK, _WP_ERR = _weasyprint_native_libs_available()
|
||
_skip_if_no_weasyprint = pytest.mark.skipif(
|
||
not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
|
||
)
|
||
|
||
# Шесть ожидаемых заголовков блоков (по одному на содержательную секцию §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), нетривиальная длина ───────────────────
|
||
|
||
|
||
@_skip_if_no_weasyprint
|
||
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:
|
||
@_skip_if_no_weasyprint
|
||
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
|
||
|
||
@_skip_if_no_weasyprint
|
||
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
|
||
|
||
@_skip_if_no_weasyprint
|
||
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
|