Files
HeXi/tests/test_scheduler.py
T
sansenhoshiandClaude Code 0ac1958932 feat(core): 定时任务薄封装 scheduler / 跨插件任务槽 task_slots / 出站媒体显式发送方式
- 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>
2026-10-13 17:53:21 +08:00

162 lines
5.5 KiB
Python
Raw 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.
"""定时任务薄封装(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)