Files

161 lines
5.5 KiB
Python
Raw Permalink Normal View History

"""定时任务薄封装(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)