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