gendesign/backend/tests/services/exporters/test_report_telegram.py
Light1YT 579ad54202
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m30s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Has been cancelled
feat(export): compact Telegram summary export for §22 forecast (#959)
render_report_telegram_summary (pure, no new deps, DRY-reuses report_pdf str
helpers) + `tg` format on GET /{cad}/forecast/export → inline text/plain snippet
(no attachment, copy-paste-ready). md/json unchanged; no-run 404, bad format 422.
Graceful on thin/empty reports. Part of EPIC #959.
2026-06-06 18:55:47 +00:00

206 lines
9.5 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-тесты §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