"""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="

x

").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=" & 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 # ── 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