Compare commits
1 commit
main
...
feat/forec
| Author | SHA1 | Date | |
|---|---|---|---|
| d53b63f92a |
4 changed files with 398 additions and 11 deletions
|
|
@ -44,7 +44,10 @@ from app.services.analysis_runs.repository import (
|
||||||
persist_analysis_run,
|
persist_analysis_run,
|
||||||
)
|
)
|
||||||
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
|
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
|
||||||
from app.services.exporters.report_md import render_report_markdown
|
from app.services.exporters.report_md import (
|
||||||
|
render_report_markdown,
|
||||||
|
render_report_telegram_summary,
|
||||||
|
)
|
||||||
from app.services.exporters.snapshot_pdf import generate_snapshot_pdf
|
from app.services.exporters.snapshot_pdf import generate_snapshot_pdf
|
||||||
from app.services.site_finder.best_layouts import get_best_layouts
|
from app.services.site_finder.best_layouts import get_best_layouts
|
||||||
from app.services.site_finder.cadastre_fetch import (
|
from app.services.site_finder.cadastre_fetch import (
|
||||||
|
|
@ -1319,34 +1322,44 @@ def export_parcel_forecast(
|
||||||
cad_num: str,
|
cad_num: str,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
format: Annotated[
|
format: Annotated[
|
||||||
Literal["md", "json"],
|
Literal["md", "json", "tg"],
|
||||||
Query(description="Формат выгрузки: md (Markdown) | json (сырой отчёт)"),
|
Query(description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | tg (сводка)"),
|
||||||
] = "md",
|
] = "md",
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Скачать §22-форсайт участка файлом — Markdown (default) или JSON (#959, EPIC export).
|
"""Экспорт §22-форсайта участка — Markdown / JSON (файл) или TG-сводка (#959, EPIC export).
|
||||||
|
|
||||||
Самый дешёвый, БЕЗ-runtime-dep срез экспорта: читает ПОСЛЕДНИЙ §22-ран schema_version
|
Самый дешёвый, БЕЗ-runtime-dep срез экспорта: читает ПОСЛЕДНИЙ §22-ран schema_version
|
||||||
"1.0" (тот же блоб, что отдаёт GET /{cad}/forecast inline) и отдаёт его как attachment.
|
"1.0" (тот же блоб, что отдаёт GET /{cad}/forecast inline) и отдаёт его в нужной форме.
|
||||||
• format=md → `render_report_markdown(run.result)` (text/markdown, чистый Markdown).
|
• format=md → `render_report_markdown(run.result)` — attachment (.md, чистый Markdown).
|
||||||
• format=json → сырой `run.result` (application/json) — download-вариант inline-чтения.
|
• format=json → сырой `run.result` — attachment (.json, download-вариант inline-чтения).
|
||||||
|
• format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка
|
||||||
|
для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать
|
||||||
|
и отправить, а не файл-download).
|
||||||
|
|
||||||
В отличие от read-only поллинга GET /{cad}/forecast (нет рана → 202 pending), это
|
В отличие от read-only поллинга GET /{cad}/forecast (нет рана → 202 pending), это
|
||||||
download-эндпоинт: нет рана → 404 (graceful HTTPException «прогноз ещё не посчитан»,
|
export-эндпоинт: нет рана → 404 (graceful HTTPException «прогноз ещё не посчитан»,
|
||||||
НЕ 500). 3-сегментный путь (`/{cad}/forecast/export`) не конфликтует с 2-сегментными
|
НЕ 500). 3-сегментный путь (`/{cad}/forecast/export`) не конфликтует с 2-сегментными
|
||||||
`/{cad}/forecast`, `/{cad}/runs`, `/runs/{run_id}` и 1-сегментным `/{parcel_id}`.
|
`/{cad}/forecast`, `/{cad}/runs`, `/runs/{run_id}` и 1-сегментным `/{parcel_id}`.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cad_num: кадастровый номер участка (в имени файла `:` → `_`).
|
cad_num: кадастровый номер участка (в имени файла `:` → `_`).
|
||||||
format: "md" (default) | "json".
|
format: "md" (default) | "json" | "tg".
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response-attachment с Content-Disposition (имя
|
Response — для md/json attachment с Content-Disposition (имя
|
||||||
`gendesign_forecast_<cad>_<YYYY-MM-DD>.<ext>`).
|
`gendesign_forecast_<cad>_<YYYY-MM-DD>.<ext>`); для tg — inline text/plain сводка.
|
||||||
"""
|
"""
|
||||||
run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
|
run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
|
||||||
if run is None:
|
if run is None:
|
||||||
raise HTTPException(status_code=404, detail="прогноз ещё не посчитан")
|
raise HTTPException(status_code=404, detail="прогноз ещё не посчитан")
|
||||||
|
|
||||||
|
# tg — INLINE сниппет (не файл): краткая сводка для копипаста в Telegram, без attachment.
|
||||||
|
if format == "tg":
|
||||||
|
return Response(
|
||||||
|
content=render_report_telegram_summary(run.result),
|
||||||
|
media_type="text/plain; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
cad_safe = cad_num.replace(":", "_")
|
cad_safe = cad_num.replace(":", "_")
|
||||||
today = _dt.date.today().strftime("%Y-%m-%d")
|
today = _dt.date.today().strftime("%Y-%m-%d")
|
||||||
base_name = f"gendesign_forecast_{cad_safe}_{today}"
|
base_name = f"gendesign_forecast_{cad_safe}_{today}"
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,19 @@ _TITLE_CONFIDENCE: str = "Уверенность"
|
||||||
# Краткий RU-дисклеймер (формулировка из ТЗ #959) — после H1, виден сразу.
|
# Краткий RU-дисклеймер (формулировка из ТЗ #959) — после H1, виден сразу.
|
||||||
_ADVISORY_DISCLAIMER: str = "Оценка advisory — не основание для инвест-решения."
|
_ADVISORY_DISCLAIMER: str = "Оценка advisory — не основание для инвест-решения."
|
||||||
|
|
||||||
|
# Telegram-сводка (#959): сверхкраткий caveat в финальной строке (≤ одной строки чата).
|
||||||
|
_TG_ADVISORY_CAVEAT: str = "Оценка advisory, не основание для инвест-решения."
|
||||||
|
# Заголовок-бренд Telegram-сводки (1-я строка). Plain-text, без Markdown.
|
||||||
|
_TG_TITLE: str = "Site Finder v2 — сводка §22"
|
||||||
|
# Канонический порядок сценариев в однострочной сводке дефицита (зеркало report_assembler).
|
||||||
|
_TG_SCENARIO_ORDER: tuple[str, ...] = ("base", "aggressive", "conservative")
|
||||||
|
# RU-метки сценариев для компактной строки «дефицит по сценариям».
|
||||||
|
_TG_SCENARIO_RU: dict[str, str] = {
|
||||||
|
"base": "база",
|
||||||
|
"aggressive": "агр",
|
||||||
|
"conservative": "конс",
|
||||||
|
}
|
||||||
|
|
||||||
# Сколько конкурентов выводить в таблицу (top-N, чтобы не раздувать MD). Зеркало
|
# Сколько конкурентов выводить в таблицу (top-N, чтобы не раздувать MD). Зеркало
|
||||||
# competitors_limit=5 у snapshot-экспорта.
|
# competitors_limit=5 у snapshot-экспорта.
|
||||||
_COMPETITORS_TOP_N: int = 5
|
_COMPETITORS_TOP_N: int = 5
|
||||||
|
|
@ -401,3 +414,114 @@ def render_report_markdown(report: Any) -> str:
|
||||||
data.get("advisory"),
|
data.get("advisory"),
|
||||||
)
|
)
|
||||||
return markdown
|
return markdown
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# §22 Telegram-сводка — СВЕРХКРАТКИЙ копипаст-сниппет для чата (#959, EPIC export).
|
||||||
|
# Не файл-download, а текст-в-чат: длинный MD-отчёт не годится для Telegram, нужна
|
||||||
|
# выжимка ~8-15 строк. PURE, без новых зависимостей (только str), graceful, тот же
|
||||||
|
# вход/нормализация, что у render_report_markdown. DRY: переиспользует импортированные
|
||||||
|
# из report_pdf хелперы (`_normalize`/`_fmt`/`_level_ru`/`_as_dict`/`_as_list`/
|
||||||
|
# `_scenario_deficit_index`) — НЕ дублирует их. Telegram-safe: plain text + «•»-буллеты,
|
||||||
|
# без тяжёлых Markdown-таблиц (MarkdownV2 капризен) — копипаст-готово как есть.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _tg_scenario_deficit_line(report: dict[str, Any]) -> str | None:
|
||||||
|
"""Однострочная сводка дефицита по сценариям: «база -1.0 · агр -1.0 · конс -1.0».
|
||||||
|
|
||||||
|
PURE, graceful. Берёт `scenarios.by_scenario`, для каждого сценария — сводный
|
||||||
|
deficit_index основного горизонта через импортированный `_scenario_deficit_index`
|
||||||
|
(DRY — не считаем заново). Порядок канонический (`_TG_SCENARIO_ORDER`), но если
|
||||||
|
встречаются иные имена сценариев — добавляем их в конец. Нет данных → None
|
||||||
|
(вызывающий просто пропустит строку — graceful, без «нет данных»-шума в чате).
|
||||||
|
"""
|
||||||
|
by_scenario = _as_dict(_as_dict(report.get("scenarios")).get("by_scenario"))
|
||||||
|
if not by_scenario:
|
||||||
|
return None
|
||||||
|
ordered = [name for name in _TG_SCENARIO_ORDER if name in by_scenario]
|
||||||
|
ordered += [name for name in by_scenario if name not in _TG_SCENARIO_ORDER]
|
||||||
|
parts: list[str] = []
|
||||||
|
for name in ordered:
|
||||||
|
label = _TG_SCENARIO_RU.get(name, str(name))
|
||||||
|
deficit = _scenario_deficit_index(_as_dict(by_scenario.get(name)))
|
||||||
|
parts.append(f"{label} {_fmt(deficit)}")
|
||||||
|
return " · ".join(parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def render_report_telegram_summary(report: Any) -> str:
|
||||||
|
"""§22 Свернуть `SiteFinderReport` (#987) в КРАТКУЮ Telegram-сводку и вернуть str.
|
||||||
|
|
||||||
|
Копипаст-готовый сниппет ~8-15 строк для отправки в чат (длинный MD-отчёт для
|
||||||
|
Telegram непригоден — здесь только выжимка):
|
||||||
|
• 1-я строка — заголовок-бренд + кадастр + район;
|
||||||
|
• вердикт «что строить» (headline) + ключевые числа компактно (индекс дефицита,
|
||||||
|
мес. предложения, итоговый скор, уверенность RU);
|
||||||
|
• целевой горизонт + дефицит по сценариям (база/агр/конс) одной строкой, если есть;
|
||||||
|
• рекомендованный класс продукта (`product_tz.obj_class`), если есть;
|
||||||
|
• финальная строка — короткий advisory-caveat.
|
||||||
|
|
||||||
|
Telegram-safe: PLAIN TEXT с «•»-буллетами, БЕЗ Markdown-таблиц (MarkdownV2 капризен).
|
||||||
|
ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети, БЕЗ новых зависимостей (чистый str — рендер текста;
|
||||||
|
САМА отправка в Telegram — отдельная инфраструктура, вне scope). Принимает КАК
|
||||||
|
`SiteFinderReport`-инстанс, ТАК и его `as_dict()`-словарь (нормализуется `_normalize`).
|
||||||
|
GRACEFUL: пустой/тонкий/мусорный отчёт → короткая валидная сводка (минимум — заголовок
|
||||||
|
+ caveat), НИКОГДА не падает (нет KeyError на тонком отчёте).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report: `SiteFinderReport`-инстанс или его `as_dict()`-словарь (или мусор → минимум).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Короткую (≈8-15 строк) plain-text строку, готовую для
|
||||||
|
`Response(media_type="text/plain; charset=utf-8")` и копипаста в Telegram.
|
||||||
|
"""
|
||||||
|
data = _normalize(report)
|
||||||
|
meta = _as_dict(data.get("meta"))
|
||||||
|
exec_summary = _as_dict(data.get("exec_summary"))
|
||||||
|
key_numbers = _as_dict(exec_summary.get("key_numbers"))
|
||||||
|
product = _as_dict(data.get("product_tz"))
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
# 1-я строка: бренд + кадастр + район (компактно, plain text).
|
||||||
|
cad = _fmt(meta.get("cad_num"))
|
||||||
|
district = _fmt(meta.get("district"))
|
||||||
|
lines.append(f"{_TG_TITLE} · {cad} · {district}")
|
||||||
|
|
||||||
|
# Вердикт «что строить» (headline) — отдельной строкой, если есть.
|
||||||
|
headline = exec_summary.get("headline")
|
||||||
|
if headline:
|
||||||
|
lines.append(str(headline))
|
||||||
|
|
||||||
|
# Ключевые числа компактными буллетами (уверенность — RU; уровень читаем из
|
||||||
|
# key_numbers, иначе из exec_summary.overall_confidence — оба varianta встречаются).
|
||||||
|
confidence_level = key_numbers.get("confidence") or exec_summary.get("overall_confidence")
|
||||||
|
lines.append(f"• Индекс дефицита: {_fmt(key_numbers.get('deficit_index'))}")
|
||||||
|
lines.append(f"• Мес. предложения: {_fmt(key_numbers.get('months_of_inventory'))}")
|
||||||
|
lines.append(f"• Итоговый скор: {_fmt(key_numbers.get('overall_score'))}")
|
||||||
|
lines.append(f"• Уверенность: {_level_ru(confidence_level)}")
|
||||||
|
|
||||||
|
# Целевой горизонт + дефицит по сценариям одной строкой (если есть сценарии).
|
||||||
|
scenario_line = _tg_scenario_deficit_line(data)
|
||||||
|
if scenario_line is not None:
|
||||||
|
horizons = _as_list(meta.get("horizons"))
|
||||||
|
horizon_note = f" (гор. {_PRIMARY_HORIZON_MONTHS} мес)" if horizons else ""
|
||||||
|
lines.append(f"• Дефицит по сценариям{horizon_note}: {scenario_line}")
|
||||||
|
|
||||||
|
# Рекомендованный класс продукта (если задан).
|
||||||
|
obj_class = product.get("obj_class")
|
||||||
|
if obj_class:
|
||||||
|
lines.append(f"• Рекомендованный класс: {_fmt(obj_class)}")
|
||||||
|
|
||||||
|
# Финальная строка — короткий advisory-caveat (всегда).
|
||||||
|
lines.append(_TG_ADVISORY_CAVEAT)
|
||||||
|
|
||||||
|
summary = "\n".join(lines)
|
||||||
|
logger.info(
|
||||||
|
"render_report_telegram_summary: cad_num=%s lines=%d len=%d advisory=%s",
|
||||||
|
meta.get("cad_num"),
|
||||||
|
len(lines),
|
||||||
|
len(summary),
|
||||||
|
data.get("advisory"),
|
||||||
|
)
|
||||||
|
return summary
|
||||||
|
|
|
||||||
|
|
@ -427,6 +427,50 @@ def test_export_forecast_json_returns_dict() -> None:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_forecast_tg_returns_inline_plain_text() -> None:
|
||||||
|
"""format=tg → 200 text/plain; charset=utf-8, INLINE (НЕТ attachment Content-Disposition)."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
fake_run = MagicMock()
|
||||||
|
fake_run.result = _REPORT_FIXTURE
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=tg")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.headers["content-type"] == "text/plain; charset=utf-8"
|
||||||
|
# Сниппет для копипаста — НЕ файл-download: attachment-заголовка быть не должно.
|
||||||
|
assert "content-disposition" not in {k.lower() for k in resp.headers}
|
||||||
|
body = resp.text
|
||||||
|
# Краткая сводка несёт вердикт + advisory-caveat.
|
||||||
|
assert "комфорт" in body
|
||||||
|
assert "Оценка advisory" in body
|
||||||
|
# Читаем именно §22-схему "1.0".
|
||||||
|
_, kwargs = lrf.call_args
|
||||||
|
assert kwargs.get("schema_version") == "1.0"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_forecast_tg_no_run_returns_404() -> None:
|
||||||
|
"""format=tg + рана нет → 404 (как md/json export-вариант, НЕ 202/500)."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.parcels.latest_run_for", return_value=None):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=tg")
|
||||||
|
assert resp.status_code == 404, resp.text
|
||||||
|
assert "посчитан" in resp.json()["detail"]
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
def test_export_forecast_no_run_returns_404() -> None:
|
def test_export_forecast_no_run_returns_404() -> None:
|
||||||
"""Рана нет (latest_run_for → None) → 404 (download-вариант, НЕ 202/500)."""
|
"""Рана нет (latest_run_for → None) → 404 (download-вариант, НЕ 202/500)."""
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
|
|
||||||
206
backend/tests/services/exporters/test_report_telegram.py
Normal file
206
backend/tests/services/exporters/test_report_telegram.py
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
"""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
|
||||||
Loading…
Add table
Reference in a new issue