Files
ewoooc/tests/test_ppt_auto_generation_service.py

433 lines
15 KiB
Python
Raw Permalink 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.
from datetime import datetime
import json
import os
def test_build_defined_ppt_jobs_uses_latest_date():
from services.ppt_auto_generation_service import build_defined_ppt_jobs
jobs = build_defined_ppt_jobs(latest_date="2026-05-11")
by_type = {job.report_type: job for job in jobs}
assert list(by_type) == [
"daily", "weekly", "monthly", "quarterly", "half_yearly", "annual", "ttm",
"strategy", "competitor", "competitor_v4", "promo", "promo_compare",
"forecast_pre_event", "vendor", "category", "customer", "new_product",
"market_intel", "price_elasticity",
]
assert by_type["daily"].sub_arg == "2026/05/11"
assert by_type["monthly"].sub_arg == "2026/05"
assert by_type["quarterly"].sub_arg == "2026/Q2"
assert by_type["half_yearly"].sub_arg == "2026/H1"
assert by_type["annual"].sub_arg == "2026"
assert by_type["strategy"].sub_arg == "2026/05/01-2026/05/11"
assert by_type["market_intel"].sub_arg == "2026/05/11 起一週"
assert by_type["competitor"].sub_arg == "monthly"
assert by_type["promo"].sub_arg == "2026/05/05-2026/05/11"
assert by_type["promo"].expected_params["label"] == "2026/05/05~2026/05/11"
assert by_type["strategy"].expected_params == {
"report_type": "strategy",
"start": "2026/05/01",
"end": "2026/05/11",
"label": "2026/05 月策略(截至 05/11",
}
def test_auto_generation_respects_disabled_flag(monkeypatch):
monkeypatch.setenv("PPT_AUTO_GENERATION_ENABLED", "false")
from services.ppt_auto_generation_service import generate_defined_ppt_reports
result = generate_defined_ppt_reports(report_types=["daily"])
assert result["ok"] is False
assert result["status"] == "disabled"
def test_dry_run_does_not_generate(monkeypatch):
monkeypatch.setenv("PPT_AUTO_GENERATION_ENABLED", "true")
from services import ppt_auto_generation_service as svc
monkeypatch.setattr(svc, "_latest_sales_date", lambda: "2026-05-11")
result = svc.generate_defined_ppt_reports(
report_types=["daily", "monthly"],
dry_run=True,
)
assert result["ok"] is True
assert result["status"] == "planned"
assert [job["report_type"] for job in result["jobs"]] == ["daily", "monthly"]
def test_coverage_marks_ready_from_database(monkeypatch):
from services import ppt_auto_generation_service as svc
class _Rows:
def fetchall(self):
return [
(
"daily",
json.dumps({"report_type": "daily", "date": "2026/05/11"}),
"/tmp/ocbot_daily_ok.pptx",
datetime(2026, 5, 11, 20, 30),
),
(
"monthly",
json.dumps({"report_type": "monthly", "month": "2026/05"}),
"/tmp/ocbot_monthly_ok.pptx",
datetime(2026, 5, 11, 20, 30),
),
]
class _Session:
def execute(self, *_args, **_kwargs):
return _Rows()
def close(self):
return None
monkeypatch.setattr(svc, "get_session", lambda: _Session())
monkeypatch.setattr(svc, "_latest_sales_date", lambda: "2026-05-11")
monkeypatch.setenv("PPT_AUTO_GENERATION_ENABLED", "true")
result = svc.get_defined_report_coverage(
month_start=datetime(2026, 5, 1),
month_end=datetime(2026, 6, 1),
reports_dir="/tmp/does-not-exist-for-test",
report_types=["daily", "monthly", "weekly"],
)
by_key = {item["key"]: item for item in result["items"]}
assert by_key["daily"]["ready"] is True
assert by_key["daily"]["status"] == "ready"
assert by_key["daily"]["status_label"] == "已產出"
assert by_key["monthly"]["ready"] is True
assert by_key["weekly"]["ready"] is False
assert by_key["weekly"]["status"] == "missing"
assert by_key["weekly"]["status_label"] == "待排程補齊"
assert result["missing_count"] == 1
def test_due_schedule_kinds_include_periodic_boundaries():
from services.ppt_auto_generation_service import get_due_schedule_kinds
assert get_due_schedule_kinds(datetime(2026, 5, 18)) == ["daily", "weekly"]
assert get_due_schedule_kinds(datetime(2026, 7, 1)) == [
"daily",
"monthly",
"quarterly",
"half_yearly",
]
assert get_due_schedule_kinds(datetime(2026, 1, 1)) == [
"daily",
"monthly",
"quarterly",
"half_yearly",
"annual",
]
def test_latest_schedule_occurrence_replays_missed_slots():
from services.ppt_auto_generation_service import get_latest_schedule_occurrence
assert get_latest_schedule_occurrence("daily", datetime(2026, 6, 6, 14, 0)) == datetime(2026, 6, 5, 20, 30)
assert get_latest_schedule_occurrence("daily", datetime(2026, 6, 6, 21, 0)) == datetime(2026, 6, 6, 20, 30)
assert get_latest_schedule_occurrence("weekly", datetime(2026, 6, 6, 14, 0)) == datetime(2026, 6, 1, 20, 40)
assert get_latest_schedule_occurrence("monthly", datetime(2026, 6, 6, 14, 0)) == datetime(2026, 6, 1, 20, 50)
assert get_latest_schedule_occurrence("quarterly", datetime(2026, 6, 6, 14, 0)) == datetime(2026, 4, 1, 21, 0)
assert get_latest_schedule_occurrence("half_yearly", datetime(2026, 6, 6, 14, 0)) == datetime(2026, 1, 1, 21, 10)
assert get_latest_schedule_occurrence("annual", datetime(2026, 6, 6, 14, 0)) == datetime(2026, 1, 1, 21, 20)
def test_ppt_catchup_plan_marks_missing_after_missed_slot(monkeypatch):
from services import ppt_auto_generation_service as svc
class _Rows:
def fetchall(self):
return []
class _Session:
def execute(self, *_args, **_kwargs):
return _Rows()
def close(self):
return None
monkeypatch.setattr(svc, "get_session", lambda: _Session())
monkeypatch.setattr(svc, "_latest_sales_date", lambda: "2026-06-04")
plan = svc.get_scheduled_ppt_catchup_plan(
now=datetime(2026, 6, 6, 14, 0),
schedule_kinds=["daily"],
)
assert plan[0]["scheduled_at"] == "2026-06-05 20:30"
assert plan[0]["missing_report_types"] == ["daily"]
assert plan[0]["ready"] is False
def test_ppt_catchup_plan_uses_existing_exact_report(monkeypatch, tmp_path):
from services import ppt_auto_generation_service as svc
pptx = tmp_path / "ocbot_daily_ok.pptx"
pptx.write_bytes(b"pptx")
class _Rows:
def fetchall(self):
return [
(
"daily",
json.dumps({"report_type": "daily", "date": "2026/06/04"}),
str(pptx),
)
]
class _Session:
def execute(self, *_args, **_kwargs):
return _Rows()
def close(self):
return None
monkeypatch.setattr(svc, "get_session", lambda: _Session())
monkeypatch.setattr(svc, "_latest_sales_date", lambda: "2026-06-04")
plan = svc.get_scheduled_ppt_catchup_plan(
now=datetime(2026, 6, 6, 14, 0),
schedule_kinds=["daily"],
)
assert plan[0]["ready_report_types"] == ["daily"]
assert plan[0]["missing_report_types"] == []
assert plan[0]["ready"] is True
def test_ppt_catchup_generates_only_missing_types(monkeypatch):
from services import ppt_auto_generation_service as svc
calls = []
monkeypatch.setattr(
svc,
"get_scheduled_ppt_catchup_plan",
lambda **_kwargs: [{
"schedule_kind": "weekly",
"scheduled_at": "2026-06-01 20:40",
"missing_report_types": ["market_intel"],
}],
)
def fake_generate_defined_ppt_reports(**kwargs):
calls.append(kwargs)
return {"ok": True, "ready": 1, "errors": 0, "jobs": [{"report_type": "market_intel"}]}
monkeypatch.setattr(svc, "generate_defined_ppt_reports", fake_generate_defined_ppt_reports)
result = svc.catch_up_scheduled_ppt_reports()
assert result["status"] == "completed"
assert result["generated_kinds"] == ["weekly"]
assert calls == [{
"report_types": ["market_intel"],
"schedule_kind": "weekly",
"force": False,
}]
def test_ppt_catchup_background_queues_without_main_loop_block(monkeypatch):
from services import ppt_auto_generation_service as svc
calls = []
threads = []
class _Thread:
def __init__(self, *, target, name, daemon):
self.target = target
self.name = name
self.daemon = daemon
threads.append(self)
def start(self):
self.target()
def fake_catchup(**kwargs):
calls.append(kwargs)
return {"ok": True, "status": "skipped", "runs": []}
monkeypatch.setattr(svc.threading, "Thread", _Thread)
monkeypatch.setattr(svc, "catch_up_scheduled_ppt_reports", fake_catchup)
result = svc.start_scheduled_ppt_catchup_background(schedule_kinds=["daily", "weekly"])
assert result["status"] == "queued"
assert result["schedule_kinds"] == ["daily", "weekly"]
assert threads[0].name == "ppt-auto-generation-catchup"
assert threads[0].daemon is True
assert calls == [{
"now": None,
"force": False,
"schedule_kinds": ["daily", "weekly"],
}]
def test_scheduled_generation_sets_fast_fallback_env(monkeypatch, tmp_path):
from routes import openclaw_bot_routes as bot_routes
from services import ppt_auto_generation_service as svc
output = tmp_path / "ocbot_market_intel_ok.pptx"
output.write_bytes(b"pptx")
observed = {}
job = svc.build_defined_ppt_jobs(
latest_date="2026-05-11",
report_types=["market_intel"],
)[0]
monkeypatch.delenv("PPT_SCHEDULED_FAST_FALLBACK", raising=False)
monkeypatch.delenv("MCP_FAST_STATIC_FALLBACK", raising=False)
monkeypatch.setattr(svc, "_expire_matching_ppt_cache", lambda _job: 0)
monkeypatch.setattr(bot_routes, "send_message", lambda *_args, **_kwargs: None, raising=False)
def fake_generate_ppt_cmd(*_args, **_kwargs):
observed["ppt_fast"] = os.getenv("PPT_SCHEDULED_FAST_FALLBACK")
observed["mcp_fast"] = os.getenv("MCP_FAST_STATIC_FALLBACK")
return str(output)
monkeypatch.setattr(bot_routes, "_generate_ppt_cmd", fake_generate_ppt_cmd)
path, invalidated = svc._generate_job(job, schedule_kind="weekly")
assert path == str(output)
assert invalidated == 0
assert observed == {"ppt_fast": "true", "mcp_fast": "true"}
assert os.getenv("PPT_SCHEDULED_FAST_FALLBACK") is None
assert os.getenv("MCP_FAST_STATIC_FALLBACK") is None
def test_schedule_cadence_status_exposes_all_periodic_contracts():
from services.ppt_auto_generation_service import get_schedule_cadence_status
cadences = get_schedule_cadence_status([
{"key": "daily", "ready": True},
{"key": "weekly", "ready": False},
{"key": "market_intel", "ready": True},
])
by_key = {cadence["key"]: cadence for cadence in cadences}
assert [cadence["schedule_text"] for cadence in cadences] == [
"每日 20:30",
"每週一 20:40",
"每月 1 日 20:50",
"每季首日 21:00",
"每半年首日 21:10",
"每年 1/1 21:20",
]
assert by_key["weekly"]["report_types"] == ["weekly", "market_intel"]
assert by_key["weekly"]["ready_count"] == 1
assert by_key["weekly"]["missing_report_types"] == ["weekly"]
assert by_key["weekly"]["missing_report_labels"] == ["週報"]
assert by_key["weekly"]["status_label"] == "已完成 1/2"
assert by_key["weekly"]["coverage_text"] == "1/2"
assert "TTM 滾動 12 月" in by_key["monthly"]["report_labels"]
def test_scheduled_generation_uses_profile_without_generating(monkeypatch):
from services import ppt_auto_generation_service as svc
calls = []
def fake_generate_defined_ppt_reports(**kwargs):
calls.append(kwargs)
return {"ok": True, "ready": 2, "errors": 0, "jobs": [{"report_type": "weekly"}]}
monkeypatch.setattr(svc, "generate_defined_ppt_reports", fake_generate_defined_ppt_reports)
result = svc.generate_scheduled_ppt_reports(schedule_kind="weekly", force=True)
assert result["ok"] is True
assert result["schedule_kinds"] == ["weekly"]
assert calls == [{
"report_types": ("weekly", "market_intel"),
"schedule_kind": "weekly",
"force": True,
}]
def test_force_regeneration_expires_only_matching_cache(monkeypatch):
from services import ppt_auto_generation_service as svc
captured = {}
class _Result:
rowcount = 1
class _Session:
def execute(self, sql, params):
captured["sql"] = str(sql)
captured["params"] = dict(params)
return _Result()
def commit(self):
captured["committed"] = True
def close(self):
captured["closed"] = True
monkeypatch.setattr(svc, "get_session", lambda: _Session())
monkeypatch.setattr(svc, "_normalize_ppt_cache_parameters", lambda params: "normalized-cache-key")
job = svc.build_defined_ppt_jobs(latest_date="2026-05-11", report_types=["daily"])[0]
affected = svc._expire_matching_ppt_cache(job)
assert affected == 1
assert "UPDATE ppt_reports" in captured["sql"]
assert "report_type = :report_type" in captured["sql"]
assert "parameters = :parameters" in captured["sql"]
assert captured["params"]["report_type"] == "daily"
assert captured["params"]["parameters"] == "normalized-cache-key"
assert captured["committed"] is True
assert captured["closed"] is True
def test_ppt_observability_single_regeneration_forces_cache_refresh():
from pathlib import Path
js = Path("web/static/js/observability-charts.js").read_text(encoding="utf-8")
assert "force: Boolean(force)" in js
assert "triggerGeneration(false, [reportType], singleButton, label, true)" in js
def test_ppt_preview_reports_missing_converter(monkeypatch, tmp_path):
from services import ppt_preview_service as svc
pptx = tmp_path / "demo.pptx"
pptx.write_bytes(b"fake pptx")
monkeypatch.setattr(svc.shutil, "which", lambda _name: None)
result = svc.build_ppt_preview(pptx, cache_dir=tmp_path / "cache")
assert result.ok is False
assert "LibreOffice" in (result.error or "")
def test_ppt_preview_cache_info_is_read_only(tmp_path):
from pathlib import Path
from services import ppt_preview_service as svc
pptx = tmp_path / "demo.pptx"
pptx.write_bytes(b"fake pptx")
cache_dir = tmp_path / "cache"
miss = svc.get_ppt_preview_cache_info(pptx, cache_dir=cache_dir)
assert miss.pdf_path
assert miss.cache_exists is False
Path(miss.pdf_path).parent.mkdir(parents=True, exist_ok=True)
Path(miss.pdf_path).write_bytes(b"%PDF-1.4\n")
hit = svc.get_ppt_preview_cache_info(pptx, cache_dir=cache_dir)
assert hit.cache_exists is True
assert hit.cache_size_kb is not None