- scheduler.py:apscheduler 薄封装(统一 Asia/Shanghai、misfire_grace_time、起止/异常日志) - task_slots.py:长任务任务槽(进度上报 / 同槽去重 / SSE 推送 / 取消), video_analysis temp 清理等长任务接入 - outbound_media:新增 HEXI_MEDIA_MODE 显式发送方式(http/base64/file,/hub 可改), 修复 alconna 对 base64 载荷补 https:// 前缀导致的 fetch failed(repair_mangled_url) - learning_chat 定时任务迁入 hexi.core.scheduler - 测试:新增 test_scheduler / test_task_slots,扩充 test_outbound_media Co-Authored-By: Claude Code <noreply@anthropic.com>
162 lines
5.5 KiB
Python
162 lines
5.5 KiB
Python
"""定时任务薄封装(hexi/core/scheduler.py)单元测试
|
||
|
||
只测"补默认值 + 日志包装"这层:不启动 APScheduler、不初始化 NoneBot。
|
||
做法是往 sys.modules 塞一个假的 `nonebot_plugin_apscheduler`(记录调用参数),
|
||
再把 `nonebot.require` 换成 no-op(模块 import 时会 require 那个插件),
|
||
然后按文件路径加载目标模块(与本仓其它 hexi/core 测试同法)。
|
||
|
||
默认值本身是这次改动的核心:
|
||
- `timezone` 不设 → 跟随服务器本地时区,`cron(hour=4)` 会静默错位
|
||
- `misfire_grace_time` APScheduler 原生只有 1 秒 → 事件循环卡一下任务就被丢
|
||
"""
|
||
|
||
import importlib.util
|
||
import sys
|
||
import types
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
_MODULE_PATH = (
|
||
Path(__file__).resolve().parents[1] / "hexi" / "core" / "scheduler.py"
|
||
)
|
||
_MOD_NAME = "hexi_core_scheduler_under_test"
|
||
|
||
|
||
class _FakeScheduler:
|
||
"""记录注册参数;装饰器语义与 APScheduler 一致(返回被装饰的函数)。"""
|
||
|
||
def __init__(self) -> None:
|
||
self.calls: list[tuple[str, tuple, dict, object]] = []
|
||
|
||
def scheduled_job(self, *args, **kwargs):
|
||
def deco(func):
|
||
self.calls.append(("scheduled_job", args, kwargs, func))
|
||
return func
|
||
|
||
return deco
|
||
|
||
def add_job(self, func, *args, **kwargs):
|
||
self.calls.append(("add_job", args, kwargs, func))
|
||
return func
|
||
|
||
|
||
class _FakeLogger:
|
||
"""替代 loguru logger:记录 debug/exception 调用。"""
|
||
|
||
def __init__(self) -> None:
|
||
self.debugs: list[str] = []
|
||
self.exceptions: list[str] = []
|
||
|
||
def debug(self, msg: str, *a, **kw) -> None:
|
||
self.debugs.append(msg)
|
||
|
||
def exception(self, msg: str, *a, **kw) -> None:
|
||
self.exceptions.append(msg)
|
||
|
||
|
||
@pytest.fixture
|
||
def sched(monkeypatch):
|
||
"""加载 scheduler.py:假 apscheduler + no-op require。"""
|
||
fake_scheduler = _FakeScheduler()
|
||
fake_plugin = types.ModuleType("nonebot_plugin_apscheduler")
|
||
fake_plugin.scheduler = fake_scheduler
|
||
monkeypatch.setitem(sys.modules, "nonebot_plugin_apscheduler", fake_plugin)
|
||
|
||
import nonebot
|
||
|
||
monkeypatch.setattr(nonebot, "require", lambda name: None, raising=False)
|
||
|
||
spec = importlib.util.spec_from_file_location(_MOD_NAME, _MODULE_PATH)
|
||
module = importlib.util.module_from_spec(spec)
|
||
sys.modules[_MOD_NAME] = module
|
||
spec.loader.exec_module(module)
|
||
|
||
module.logger = _FakeLogger() # 日志也换成可观察的
|
||
module._fake = fake_scheduler
|
||
|
||
yield module
|
||
sys.modules.pop(_MOD_NAME, None)
|
||
|
||
|
||
# ───────────────────────── 默认值 ─────────────────────────
|
||
|
||
|
||
def test_scheduled_job_fills_defaults(sched):
|
||
@sched.scheduled_job("cron", hour=4, id="cleanup")
|
||
async def cleanup(): # pragma: no cover - 不执行
|
||
pass
|
||
|
||
_, args, kwargs, _ = sched._fake.calls[-1]
|
||
assert args == ("cron",)
|
||
assert kwargs["hour"] == 4
|
||
assert kwargs["id"] == "cleanup"
|
||
assert kwargs["timezone"] == sched.DEFAULT_TIMEZONE == "Asia/Shanghai"
|
||
assert kwargs["misfire_grace_time"] == sched.DEFAULT_MISFIRE_GRACE_TIME == 60
|
||
assert kwargs["coalesce"] is True
|
||
|
||
|
||
def test_scheduled_job_keeps_explicit_values(sched):
|
||
"""调用方显式给的一律优先(如 learning_chat 的 5 秒宽限)。"""
|
||
|
||
@sched.scheduled_job("interval", minutes=3, misfire_grace_time=5, timezone="UTC")
|
||
async def job(): # pragma: no cover
|
||
pass
|
||
|
||
_, _, kwargs, _ = sched._fake.calls[-1]
|
||
assert kwargs["misfire_grace_time"] == 5
|
||
assert kwargs["timezone"] == "UTC"
|
||
assert kwargs["minutes"] == 3
|
||
|
||
|
||
def test_add_job_fills_defaults(sched):
|
||
"""动态注册(video_analysis 的每日 cron 走这条)同样补默认值。"""
|
||
|
||
async def cleanup(): # pragma: no cover
|
||
pass
|
||
|
||
sched.add_job(
|
||
cleanup,
|
||
"cron",
|
||
hour=4,
|
||
id="video_analysis_temp_cleanup",
|
||
misfire_grace_time=3600,
|
||
replace_existing=True,
|
||
)
|
||
|
||
kind, args, kwargs, registered = sched._fake.calls[-1]
|
||
assert kind == "add_job"
|
||
assert args == ("cron",)
|
||
assert kwargs["timezone"] == "Asia/Shanghai" # 显式没给 → 补上(这次改动的重点)
|
||
assert kwargs["misfire_grace_time"] == 3600 # 显式给了 → 不覆盖
|
||
assert kwargs["replace_existing"] is True
|
||
# 包了一层,但函数名保住(不传 id 时 APScheduler 用 __name__ 作任务 id)
|
||
assert registered is not cleanup
|
||
assert registered.__name__ == "cleanup"
|
||
|
||
|
||
# ───────────────────────── 包装行为 ─────────────────────────
|
||
|
||
|
||
async def test_wrapper_runs_and_returns_value(sched):
|
||
async def job(x): # noqa: ANN001
|
||
return x * 2
|
||
|
||
registered = sched.scheduled_job("interval", minutes=1)(job)
|
||
assert registered.__name__ == "job"
|
||
assert await registered(21) == 42
|
||
assert any("定时任务开始: job" in m for m in sched.logger.debugs)
|
||
assert any("定时任务完成: job" in m for m in sched.logger.debugs)
|
||
|
||
|
||
async def test_wrapper_swallows_exception(sched):
|
||
"""任务抛异常 → 记日志并吞掉(不让坏任务外抛给调度器)。"""
|
||
|
||
async def boom():
|
||
raise RuntimeError("炸了")
|
||
|
||
registered = sched.scheduled_job("interval", minutes=1)(boom)
|
||
assert await registered() is None
|
||
assert sched.logger.exceptions and "定时任务异常: boom" in sched.logger.exceptions[0]
|
||
assert not any("完成" in m for m in sched.logger.debugs)
|