"""Unit-тесты §22 Telegram-сводки (#959, EPIC export) — `render_report_telegram_summary`. Чистые тесты БЕЗ БД/сети (рендер только ПОТРЕБЛЯЕТ уже-собранный отчёт): • реальный fixture-отчёт (frontend mock `parcel-forecast.json` → `report`) сворачивается в КРАТКУЮ plain-text сводку с вердиктом «что строить», ключевыми числами (дефицит/уверенность) и advisory-caveat; • рендер принимает КАК `SiteFinderReport`-инстанс, ТАК и его `as_dict()`-словарь; • пустой / частичный / мусорный отчёт → короткая валидная сводка БЕЗ падения (graceful); • сводка КОРОТКАЯ (Telegram-friendly): ≤ ~1500 символов / ≤ ~20 строк, без MD-таблиц. Детерминированно, без LLM. DATABASE_URL выставляем до импорта app-модулей (зеркало test_report_md.py) — на случай side-effect'ов импорта пакета. """ from __future__ import annotations import json import os from pathlib import Path from typing import Any os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.services.exporters.report_md import ( _TG_ADVISORY_CAVEAT, render_report_telegram_summary, ) from app.services.forecasting.report import ( ReportExecSummary, ReportMeta, ReportProductTz, SiteFinderReport, ) # Реальный fixture: frontend mock §22-форсайта — берём вложенный `report` (форма #987 # `as_dict()`). Так тест не разойдётся с реальной формой отчёта. _FIXTURE_PATH = ( Path(__file__).resolve().parents[4] / "frontend" / "src" / "lib" / "mocks" / "parcel-forecast.json" ) # Telegram-границы «короткости» (мягкие пороги из ТЗ #959). _MAX_CHARS = 1500 _MAX_LINES = 20 def _fixture_report() -> dict[str, Any]: """`report`-словарь из frontend-мока §22 (форма SiteFinderReport.as_dict()).""" data = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8")) report = data["report"] assert isinstance(report, dict) return report # ── Реальный fixture-отчёт: содержание + краткость ────────────────────────────── class TestFixtureReportSummary: def test_returns_non_empty_str(self) -> None: tg = render_report_telegram_summary(_fixture_report()) assert isinstance(tg, str) assert tg.strip() def test_contains_verdict_headline(self) -> None: report = _fixture_report() headline = report["exec_summary"]["headline"] tg = render_report_telegram_summary(report) # Вердикт «что строить» присутствует в сводке. assert headline.split(".")[0] in tg def test_contains_deficit_value(self) -> None: report = _fixture_report() deficit = report["exec_summary"]["key_numbers"]["deficit_index"] tg = render_report_telegram_summary(report) # Сводный индекс дефицита (−1.0) попадает в сводку (через _fmt → "-1.0"). assert str(round(float(deficit), 3)) in tg def test_contains_confidence_label_ru(self) -> None: # Уверенность отрендерена RU-меткой ("low" → "низкая"). tg = render_report_telegram_summary(_fixture_report()) assert "низкая" in tg def test_contains_months_of_inventory(self) -> None: report = _fixture_report() moi = report["exec_summary"]["key_numbers"]["months_of_inventory"] tg = render_report_telegram_summary(report) assert str(round(float(moi), 3)) in tg def test_contains_recommended_class(self) -> None: report = _fixture_report() obj_class = report["product_tz"]["obj_class"] tg = render_report_telegram_summary(report) assert obj_class in tg def test_contains_cad_and_district(self) -> None: tg = render_report_telegram_summary(_fixture_report()) assert "66:41:0702048:27" in tg assert "Кировский" in tg def test_contains_scenario_deficit_line(self) -> None: # Дефицит по сценариям одной строкой: RU-метки сценариев присутствуют. tg = render_report_telegram_summary(_fixture_report()) assert "база" in tg assert "агр" in tg assert "конс" in tg def test_contains_advisory_caveat(self) -> None: tg = render_report_telegram_summary(_fixture_report()) assert _TG_ADVISORY_CAVEAT in tg # Caveat — последняя строка (финальный дисклеймер). assert tg.rstrip().endswith(_TG_ADVISORY_CAVEAT) def test_is_short_telegram_friendly(self) -> None: # Сводка должна быть КОРОТКОЙ (Telegram): мягкие пороги по символам и строкам. tg = render_report_telegram_summary(_fixture_report()) assert len(tg) <= _MAX_CHARS, f"сводка слишком длинная: {len(tg)} символов" assert len(tg.splitlines()) <= _MAX_LINES, ( f"сводка слишком много строк: {len(tg.splitlines())}" ) def test_no_markdown_tables(self) -> None: # Telegram-safe: без GFM-таблиц (разделитель шапки не должен встречаться). tg = render_report_telegram_summary(_fixture_report()) assert "| --- |" not in tg assert "---" not in tg # ── Вход: dataclass-инстанс ИЛИ as_dict()-словарь ─────────────────────────────── class TestInputForms: def test_accepts_dataclass_instance(self) -> None: report = SiteFinderReport( exec_summary=ReportExecSummary(headline="Строить комфорт-класс."), meta=ReportMeta(cad_num="66:41:0000000:1", horizons=[12]), product_tz=ReportProductTz(obj_class="комфорт"), ) tg = render_report_telegram_summary(report) assert "Строить комфорт" in tg assert "66:41:0000000:1" in tg assert "комфорт" in tg def test_dataclass_and_dict_equivalent(self) -> None: report = SiteFinderReport( exec_summary=ReportExecSummary(headline="Один заголовок."), meta=ReportMeta(cad_num="66:41:0000000:9"), ) assert render_report_telegram_summary(report) == render_report_telegram_summary( report.as_dict() ) # ── Graceful: пустой / частичный / мусорный вход → короткая сводка без падения ─── class TestGraceful: def test_empty_dict_does_not_crash(self) -> None: # Пустой отчёт {} → короткая валидная сводка (no KeyError) с caveat. tg = render_report_telegram_summary({}) assert isinstance(tg, str) assert _TG_ADVISORY_CAVEAT in tg assert len(tg) <= _MAX_CHARS assert len(tg.splitlines()) <= _MAX_LINES def test_none_does_not_crash(self) -> None: tg = render_report_telegram_summary(None) assert isinstance(tg, str) assert _TG_ADVISORY_CAVEAT in tg def test_default_report_does_not_crash(self) -> None: # Полностью дефолтный (пустой) SiteFinderReport — короткая валидная сводка. tg = render_report_telegram_summary(SiteFinderReport()) assert _TG_ADVISORY_CAVEAT in tg assert len(tg.splitlines()) <= _MAX_LINES def test_garbage_input_returns_valid_summary(self) -> None: # Мусор (int / строка / список) → нормализуется в {} → валидная сводка, не падает. for junk in (123, "not a report", []): tg = render_report_telegram_summary(junk) assert isinstance(tg, str) assert _TG_ADVISORY_CAVEAT in tg def test_partial_report_no_scenarios_skips_line(self) -> None: # Тонкий отчёт без сценариев → строки «дефицит по сценариям» нет, но не падает. report = SiteFinderReport( exec_summary=ReportExecSummary(headline="Тонкий анализ."), meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]), ) tg = render_report_telegram_summary(report) assert "Тонкий анализ" in tg assert "66:41:0000000:2" in tg assert "Дефицит по сценариям" not in tg assert _TG_ADVISORY_CAVEAT in tg def test_partial_report_no_obj_class_skips_line(self) -> None: # Нет obj_class → строки «Рекомендованный класс» нет (graceful). report = SiteFinderReport( exec_summary=ReportExecSummary(headline="Без класса."), meta=ReportMeta(cad_num="66:41:0000000:3"), ) tg = render_report_telegram_summary(report) assert "Рекомендованный класс" not in tg