gendesign/backend/tests/services/exporters/test_report_pdf.py
Light1YT 01a74ade7a
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m21s
Deploy / build-worker (push) Successful in 2m17s
Deploy / deploy (push) Successful in 1m12s
feat(forecasting): add months-of-inventory (MOI) to §9.8 demand-supply forecast
deficit_index pins to -1.0 for every ЕКБ segment (12mo demand flow vs multi-year
supply stock → log-ratio clamps) → zero discriminating power, though the oversupply
is partly real. Add MOI (gross competing supply / demand_per_mo), the real-estate
absorption standard, as an additive non-saturating companion that DISCRIMINATES
(Уралмаш 42mo … Чермет 109mo) where deficit cannot. deficit_index math kept exactly
as-is (honest absolute: -1 = genuinely oversupplied); docstrings clarify -1 is common
and MOI is the discriminating companion (no recalibration). _gross_supply extract-method
(single source of truth; _project_supply behavior byte-identical, code-review-verified).
Surface MOI in §22 future_market (passthrough) + exec_summary key_numbers/verdict.
Guards: no demand → None, no supply → 0. Prod: MOI varies 42→109mo, deficit stays -1.
Discrimination test pins MOI separating two segments both at deficit -1. Refs #952.
2026-06-05 07:36:59 +05:00

403 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
months_of_inventory=(supply / (demand / horizon) if demand else None),
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,
months_of_inventory=9.1,
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 "&lt;script&gt;alert(1)&lt;/script&gt;" in html_str
assert "&amp; 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