REOPENED. PDF + Excel exporters read non-existent dict keys, so demand/supply/ scenario columns silently rendered "—". Tests passed only because the fixtures were stale (hand-typed the same wrong keys → fixture agreed with buggy exporter). - future_market: demand/supply → projected_demand_units/projected_supply_units - scenarios: drop non-existent per-scenario "overall"; show primary-horizon deficit_index from ScenarioForecast.forecasts (scoring.overall was NOT broken) - Excel #991: add missing future_supply (index + breakdown) + confidence.factors sections; add future_supply to PDF for parity - tests: rebuild forecast/scenario fixtures from real DemandSupplyForecast / ScenarioForecast as_dict(); contract-key regression guards fail on key-drift (verified: reintroducing old keys fails the new tests). 28 passed. Refs #989 #991
401 lines
19 KiB
Python
401 lines
19 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,
|
||
_fmt,
|
||
_normalize,
|
||
_scenario_deficit_index,
|
||
export_report_pdf,
|
||
)
|
||
from app.services.forecasting.demand_supply_forecast import DemandSupplyForecast
|
||
from app.services.forecasting.report import (
|
||
ReportConfidence,
|
||
ReportExecSummary,
|
||
ReportFutureMarket,
|
||
ReportMarketNow,
|
||
ReportMeta,
|
||
ReportProductTz,
|
||
ReportScenarios,
|
||
ReportScoring,
|
||
SiteFinderReport,
|
||
)
|
||
from app.services.forecasting.scenarios import ScenarioForecast
|
||
|
||
|
||
def _forecast(horizon: int, demand: float, supply: float, deficit: float) -> dict[str, object]:
|
||
"""Реальный `DemandSupplyForecast.as_dict()` — ключи гарантированно из контракта #952.
|
||
|
||
Строим frozen-dataclass БЕЗ БД и сериализуем через его собственный `as_dict()`: так
|
||
тест не может разойтись с реальной формой (projected_demand_units / projected_supply_
|
||
units), как это случилось со старой hand-typed фикстурой (demand/supply — #989/#991).
|
||
"""
|
||
return DemandSupplyForecast(
|
||
segment={"obj_class": "комфорт"},
|
||
horizon_months=horizon,
|
||
base_pace_units_per_mo=8.0,
|
||
demand_norm_coefficient=1.0,
|
||
macro_coefficient=1.0,
|
||
projected_demand_units=demand,
|
||
open_units=300,
|
||
hidden_release_units=80.0,
|
||
future_online_units=20.0,
|
||
projected_supply_units=supply,
|
||
balance_units=demand - supply,
|
||
balance_ratio=(demand / supply if supply else None),
|
||
deficit_index=deficit,
|
||
rate_future=18.0,
|
||
rate_sensitivity_phrase="спрос эластичен к ставке",
|
||
future_competitors=[{"obj_id": 3, "comm_name": "ЖК Гамма"}],
|
||
advisory=True,
|
||
confidence="medium",
|
||
).as_dict()
|
||
|
||
|
||
def _scenario(name: str, deficit_12mo: float) -> dict[str, object]:
|
||
"""Реальный `ScenarioForecast.as_dict()` — ключи из контракта #984 (НЕ hand-typed).
|
||
|
||
У сценария НЕТ скалярного «overall»: есть `forecasts` (по горизонтам) с deficit_index.
|
||
Экспортёр тянет сводный дефицит 12-мес горизонта — здесь даём именно его.
|
||
"""
|
||
return ScenarioForecast(
|
||
scenario=name, # type: ignore[arg-type] # Literal в проде; в тесте — str-имя
|
||
rate_path={6: 18.0, 12: 18.0},
|
||
forecasts=[
|
||
DemandSupplyForecast(
|
||
segment={"obj_class": "комфорт"},
|
||
horizon_months=12,
|
||
base_pace_units_per_mo=8.0,
|
||
demand_norm_coefficient=1.0,
|
||
macro_coefficient=1.0,
|
||
projected_demand_units=920.0,
|
||
open_units=300,
|
||
hidden_release_units=80.0,
|
||
future_online_units=20.0,
|
||
projected_supply_units=700.0,
|
||
balance_units=220.0,
|
||
balance_ratio=1.314,
|
||
deficit_index=deficit_12mo,
|
||
rate_future=18.0,
|
||
rate_sensitivity_phrase=None,
|
||
future_competitors=[],
|
||
advisory=True,
|
||
confidence="medium",
|
||
)
|
||
],
|
||
advisory=True,
|
||
).as_dict()
|
||
|
||
|
||
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=[
|
||
_forecast(6, demand=410.0, supply=380.0, deficit=0.2),
|
||
_forecast(12, demand=920.0, supply=700.0, deficit=0.34),
|
||
],
|
||
future_supply={
|
||
"district": "Верх-Исетский",
|
||
"horizon_months": 12,
|
||
"premise_kind": "квартира",
|
||
"confidence": "low",
|
||
"index": 0.42,
|
||
"breakdown": {
|
||
"open_units": 1200,
|
||
"hidden_units": 800,
|
||
"future_units_by_horizon": 450.0,
|
||
"monthly_absorption_units": 8.2,
|
||
"months_of_pressure": 9.0,
|
||
"index": 0.42,
|
||
},
|
||
},
|
||
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": _scenario("conservative", deficit_12mo=0.18),
|
||
"base": _scenario("base", deficit_12mo=0.34),
|
||
"aggressive": _scenario("aggressive", deficit_12mo=0.49),
|
||
},
|
||
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
|
||
|
||
|
||
# ── Regression #989: реальные ключи контракта попадают в вывод (НЕ "—") ─────────
|
||
# Эти тесты ловят key-drift экспортёра: они тянут значения из РЕАЛЬНОГО
|
||
# `SiteFinderReport.as_dict()` (а не hand-typed строк), поэтому если экспортёр снова
|
||
# начнёт читать несуществующий ключ (как demand/supply / per-scenario overall до фикса),
|
||
# значение пропадёт из HTML → тест упадёт. Так контракт и тест не разойдутся молча.
|
||
|
||
|
||
class TestContractKeysRendered:
|
||
def test_demand_supply_units_rendered_not_dash(self) -> None:
|
||
# Берём числа спроса/предложения из самого as_dict() (форма #952), затем
|
||
# проверяем, что они ЕСТЬ в HTML — старый bug читал demand/supply → было "—".
|
||
data = _full_report().as_dict()
|
||
forecasts = data["future_market"]["forecasts_by_horizon"]
|
||
assert forecasts, "фикстура должна содержать прогнозы по горизонтам"
|
||
html_str = _build_html(_normalize(_full_report()))
|
||
for f in forecasts:
|
||
demand = f["projected_demand_units"]
|
||
supply = f["projected_supply_units"]
|
||
assert demand is not None and supply is not None
|
||
assert str(_fmt(demand)) in html_str, f"спрос {demand} не отрисован (читается ключ?)"
|
||
assert str(_fmt(supply)) in html_str, f"предложение {supply} не отрисован"
|
||
|
||
def test_scenario_deficit_rendered_not_dash(self) -> None:
|
||
# Сводный дефицит 12-мес горизонта каждого сценария должен попасть в HTML.
|
||
# Старый bug читал per-scenario data.get("overall") (нет такого ключа) → "—".
|
||
data = _full_report().as_dict()
|
||
by_scenario = data["scenarios"]["by_scenario"]
|
||
assert by_scenario, "фикстура должна содержать сценарии"
|
||
html_str = _build_html(_normalize(_full_report()))
|
||
for payload in by_scenario.values():
|
||
di = _scenario_deficit_index(payload)
|
||
assert di is not None
|
||
assert str(_fmt(di)) in html_str, f"дефицит сценария {di} не отрисован"
|
||
|
||
def test_overall_score_rendered(self) -> None:
|
||
# scoring.overall (ReportScoring) — корректный ключ; убеждаемся, что он в HTML.
|
||
data = _full_report().as_dict()
|
||
overall = data["scoring"]["overall"]
|
||
assert overall is not None
|
||
html_str = _build_html(_normalize(_full_report()))
|
||
assert str(_fmt(overall)) in html_str
|
||
|
||
def test_future_supply_section_rendered(self) -> None:
|
||
# #991/parity: давление будущего предложения (index + breakdown) должно
|
||
# попасть в HTML — раньше PDF не разворачивал breakdown по слоям.
|
||
data = _full_report().as_dict()
|
||
fs = data["future_market"]["future_supply"]
|
||
assert fs is not None
|
||
html_str = _build_html(_normalize(_full_report()))
|
||
assert "Давление будущего предложения" in html_str
|
||
assert str(_fmt(fs["index"])) in html_str
|
||
assert str(_fmt(fs["breakdown"]["open_units"])) in html_str
|
||
|
||
def test_confidence_factors_rendered(self) -> None:
|
||
# confidence.factors должны попасть в HTML (ключ→значение).
|
||
html_str = _build_html(_normalize(_full_report()))
|
||
assert "Факторы уверенности" in html_str
|
||
assert "data_coverage" 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
|