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.
206 lines
9.5 KiB
Python
206 lines
9.5 KiB
Python
"""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
|