161 lines
5.5 KiB
Python
161 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)
|