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>
This commit is contained in:
sansenhoshiandClaude Code committed 2026-10-13 17:53:21 +08:00
1 parent a789a44f5d
commit 0ac1958932
8 files changed
+1353 -12

No files matched your search

+212
View File
@@ -113,6 +113,134 @@ def test_non_string_values_are_left_alone(om, value):
assert om.local_file_of(value) is None
# ------------------------------------------------------------ 可达地址选择
def _fake_psutil(monkeypatch, ips_by_iface: dict[str, list[str]]) -> None:
"""把 psutil.net_if_addrs 换成假网卡表(`_local_ips` 在函数内 import psutil)。"""
import socket as _socket
import types
fake = types.ModuleType("psutil")
fake.net_if_addrs = lambda: {
name: [
types.SimpleNamespace(family=_socket.AF_INET, address=ip) for ip in ips
]
for name, ips in ips_by_iface.items()
}
monkeypatch.setitem(sys.modules, "psutil", fake)
def test_route_ip_wins_over_virtual_adapter(om, monkeypatch):
"""回归 2026-10-08:EasyTier 的 10.0.0.129 按字符串排序赢过真实局域网,
协议端拉不到媒体(ActionFailed fetch failed)。默认路由网卡必须排最前。"""
_fake_psutil(
monkeypatch,
{
"EasyTier": ["10.0.0.129"],
"以太网": ["192.168.2.186"],
"WLAN": ["169.254.65.98"], # 链路本地,应被过滤
},
)
monkeypatch.setattr(om, "_route_ip", lambda: "192.168.2.186")
ips = om._local_ips()
assert ips[0] == "192.168.2.186"
assert ips == ["192.168.2.186", "10.0.0.129"]
def test_local_ips_without_route_falls_back_to_private_first(om, monkeypatch):
"""探不到默认路由(无网络/异常)时退回原排序:私有段优先、其余按字符串。"""
_fake_psutil(
monkeypatch,
{"Ethernet": ["192.168.2.186"], "VPN": ["10.0.0.129"], "eth1": ["203.0.113.9"]},
)
monkeypatch.setattr(om, "_route_ip", lambda: None)
assert om._local_ips() == ["10.0.0.129", "192.168.2.186", "203.0.113.9"]
def test_local_ips_filters_loopback_and_ipv6(om, monkeypatch):
_fake_psutil(
monkeypatch,
{"Loopback": ["127.0.0.1", "::1"], "Ethernet": ["192.168.2.186", "fe80::1"]},
)
monkeypatch.setattr(om, "_route_ip", lambda: None)
assert om._local_ips() == ["192.168.2.186"]
def test_route_ip_absent_from_candidates_is_ignored(om, monkeypatch):
"""路由出口不在网卡表里(异常环境)时不该崩,也不该把结果搞乱。"""
_fake_psutil(monkeypatch, {"Ethernet": ["192.168.2.186"]})
monkeypatch.setattr(om, "_route_ip", lambda: "10.9.9.9")
assert om._local_ips() == ["192.168.2.186"]
def test_route_ip_smoke(om):
"""真跑一次 UDP connect(不发包):有默认路由时应给出本机 IPv4。"""
ip = om._route_ip()
assert ip is None or (ip.count(".") == 3 and not ip.startswith("127."))
# ------------------------------------------------------------ 发送方式
def _clear_mode_env(monkeypatch) -> None:
for key in ("HEXI_MEDIA_MODE", "HEXI_INLINE_MEDIA", "HEXI_INLINE_MEDIA_URL"):
monkeypatch.delenv(key, raising=False)
@pytest.mark.parametrize(
("env", "expected"),
[
# 默认 http(协议端跨机来拉)
({}, "http"),
({"HEXI_MEDIA_MODE": "http"}, "http"),
({"HEXI_MEDIA_MODE": " BASE64 "}, "base64"), # 大小写/空白容忍
({"HEXI_MEDIA_MODE": "file"}, "file"),
# 新键没配时按旧开关推断(老 .env 行为不变)
({"HEXI_INLINE_MEDIA": "false"}, "file"),
({"HEXI_INLINE_MEDIA_URL": "0"}, "base64"),
({"HEXI_INLINE_MEDIA": "false", "HEXI_INLINE_MEDIA_URL": "false"}, "file"),
# 新键优先于旧开关
({"HEXI_MEDIA_MODE": "http", "HEXI_INLINE_MEDIA": "false"}, "http"),
# 非法值退回旧开关/默认
({"HEXI_MEDIA_MODE": "ftp"}, "http"),
({"HEXI_MEDIA_MODE": "ftp", "HEXI_INLINE_MEDIA_URL": "false"}, "base64"),
],
)
def test_mode_resolution(om, monkeypatch, env, expected):
_clear_mode_env(monkeypatch)
for key, value in env.items():
monkeypatch.setenv(key, value)
assert om._mode() == expected
async def test_file_mode_passes_through(om, media_file: Path, monkeypatch):
"""file 模式(协议端就在本机)完全不改写:消息与文件上传都原样交给适配器。"""
monkeypatch.setenv("HEXI_MEDIA_MODE", "file")
monkeypatch.setattr(om, "_mounted", True)
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.15:39697")
assert await om.rewrite_file(str(media_file)) is None
assert await om.rewrite_upload_file(str(media_file)) is None
async def test_base64_mode_skips_url(om, media_file: Path, monkeypatch):
"""base64 模式即使媒体服务可用也不发 URL(协议端拨不通本机的场合)。"""
monkeypatch.setenv("HEXI_MEDIA_MODE", "base64")
monkeypatch.setattr(om, "_mounted", True)
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.15:39697")
assert await om.rewrite_file(str(media_file)) == "base64://aGVsbG8="
# 文件上传不做 base64(太大)→ base64 模式下只能原样放行
assert await om.rewrite_upload_file(str(media_file)) is None
async def test_http_mode_still_falls_back_to_base64(om, media_file: Path, monkeypatch):
"""http 模式拿不到可达地址时仍回退 base64(没挂上媒体服务/无网卡)。"""
monkeypatch.setenv("HEXI_MEDIA_MODE", "http")
monkeypatch.setattr(om, "_mounted", False)
assert await om.rewrite_file(str(media_file)) == "base64://aGVsbG8="
# ------------------------------------------------------------ 改写目标
@@ -278,6 +406,67 @@ async def test_walk_respects_depth_limit(om, media_file: Path, monkeypatch):
assert deepest["data"]["file"] == str(media_file) # 没被改写
# ------------------------------------------------------------ 伪协议还原
@pytest.mark.parametrize(
("value", "expected"),
[
# JPEG 的 base64 载荷恒以 /9j/ 开头,alconna 构造 Image(url=...) 时
# 会因 netloc 为空补上 https:// 前缀(2026-10-10 实测 fetch failed 的根因)
("https://base64:///9j/4AAQSkZJRg", "base64:///9j/4AAQSkZJRg"),
("http://base64:///9j/4AAQSkZJRg", "base64:///9j/4AAQSkZJRg"),
("https://file:///D:/x.jpg", "file:///D:/x.jpg"),
],
)
def test_repair_mangled_pseudo_scheme(om, value: str, expected: str):
assert om.repair_mangled_url(value) == expected
@pytest.mark.parametrize(
"value",
[
# 裸域名补 https:// 是 alconna 的本意,正常链接一律不碰
"https://example.com/a.jpg",
# 主机名恰好是 base64 的正常链接不能误伤(只有 base64:// 才算伪协议)
"https://base64.com/a.png",
# 没被补头的原值无需还原
"base64:///9j/4AAQSkZJRg",
"file:///D:/x.jpg",
None,
123,
],
)
def test_repair_leaves_normal_values_alone(om, value):
assert om.repair_mangled_url(value) is None
async def test_walk_repairs_mangled_base64(om, monkeypatch):
"""补头后的 base64 值进了 file 字段,walk 必须还原回伪协议本身。"""
monkeypatch.setattr(om, "_mounted", False)
seg = {"type": "image", "data": {"file": "https://base64:///9j/4AAQSkZJRg"}}
await om._walk(seg)
assert seg["data"]["file"] == "base64:///9j/4AAQSkZJRg"
async def test_walk_repairs_real_jpeg_base64(om, tmp_path: Path, monkeypatch):
"""用真实 JPEG 魔数验证前提:载荷以 /9j/ 开头 → urlparse 解析不出 hostname
→ 正是 alconna 会补 https:// 的情形;还原后应与原始 base64:// 串一致。"""
import base64 as _b64
from urllib.parse import urlparse
fp = tmp_path / "a.jpg"
fp.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 20)
payload = _b64.b64encode(fp.read_bytes()).decode()
assert payload.startswith("/9j/") # JPEG 恒如此,补头的前提
assert urlparse(f"base64://{payload}").hostname is None # netloc 为空才会被补头
monkeypatch.setattr(om, "_mounted", False)
seg = {"type": "image", "data": {"file": f"https://base64://{payload}"}}
await om._walk(seg)
assert seg["data"]["file"] == f"base64://{payload}"
# ------------------------------------------------------------ 钩子
@@ -316,6 +505,29 @@ async def test_hook_disabled_by_env(om, media_file: Path, monkeypatch):
assert before.startswith("file://")
async def test_hook_repairs_mangled_value_even_in_file_mode(
om, media_file: Path, monkeypatch
):
"""file 模式不改写本地文件,但构造期就坏掉的伪协议任何模式都该修。
2026-10-10 老婆图报 `ActionFailed ... fetch failed` 发的就是这种值:
与协议端在哪台机器无关,不还原则必死。
"""
monkeypatch.setenv("HEXI_MEDIA_MODE", "file")
data = {
"message": Message(
[
MessageSegment.image(media_file),
MessageSegment.image("https://base64:///9j/4AAQSkZJRg"),
]
)
}
before = data["message"][0].data["file"]
await om._inline_outbound_media(_FakeBot(), "send_group_msg", data)
assert data["message"][0].data["file"] == before # 本地文件原样
assert data["message"][1].data["file"] == "base64:///9j/4AAQSkZJRg"
async def test_hook_handles_forward_messages_key(om, media_file: Path, monkeypatch):
monkeypatch.setattr(om, "_mounted", False)
data = {
+161
View File
@@ -0,0 +1,161 @@
"""定时任务薄封装(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)
+347
View File
@@ -0,0 +1,347 @@
"""任务槽(hexi/core/task_slots.py)单元测试
只测注册表本身:执行/去重/进度/取消/推送/回收。模块只依赖 asyncio + nonebot.log,
按路径裸加载即可(与 test_rate_limit 同法)。
任务体跑在线程池里,进度回调是"切回事件循环再改状态",所以断言进度时必须等
状态落地 —— 用 `_until()` 轮询事件循环,别用 `sleep` 猜时序。
"""
import asyncio
import importlib.util
import sys
import threading
import time
from pathlib import Path
import pytest
_MODULE_PATH = Path(__file__).resolve().parents[1] / "hexi" / "core" / "task_slots.py"
_MOD_NAME = "hexi_core_task_slots_under_test"
@pytest.fixture(scope="module")
def ts():
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)
yield module
sys.modules.pop(_MOD_NAME, None)
@pytest.fixture
def reg(ts):
"""每条用例一个干净的注册表(不用全局单例,避免用例互相污染)。"""
return ts.TaskSlots()
async def _until(pred, timeout: float = 2.0) -> None:
"""轮询事件循环直到条件成立(进度是跨线程/跨回调落地的,不能靠猜时序)。"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if pred():
return
await asyncio.sleep(0.01)
raise AssertionError("等待条件超时")
# ───────────────────────── 执行与结果 ─────────────────────────
async def test_runs_in_thread_and_records_result(reg):
def work(report):
return "done!"
task, reused = reg.submit(slot="plug:job", title="干活", func=work, plugin_id="plug")
assert reused is False
assert task.active # submit 即返回,此时后台任务还没被调度
await asyncio.wait_for(reg.wait(task), 2)
assert task.status == "done"
assert task.result == "done!"
assert task.started_at > 0
assert task.finished_at >= task.started_at
assert task.duration >= 0
assert reg.active("plug:job") is None
async def test_exception_marks_failed_without_raising(reg):
def boom(report):
raise RuntimeError("炸了")
task, _ = reg.submit(slot="plug:boom", title="会炸", func=boom)
await asyncio.wait_for(reg.wait(task), 2)
assert task.status == "failed"
assert "炸了" in task.error
assert task.percent == 100 # 终态一律显示满(前端别再转圈)
# ───────────────────────── 同槽去重 ─────────────────────────
async def test_same_slot_reuses_running_task(reg):
calls = []
gate = threading.Event()
def slow(report):
calls.append(1)
gate.wait(2)
return "ok"
first, reused = reg.submit(slot="plug:clean", title="清理", func=slow)
second, reused2 = reg.submit(slot="plug:clean", title="清理(再点一次)", func=slow)
assert reused is False and reused2 is True
assert second.id == first.id # 返回的是正在跑的那个
gate.set()
await asyncio.wait_for(reg.wait(first), 2)
assert calls == [1] # 任务体只跑了一次
async def test_same_slot_available_again_after_finish(reg):
def work(report):
return 1
first, _ = reg.submit(slot="plug:clean", title="第一次", func=work)
await asyncio.wait_for(reg.wait(first), 2)
second, reused = reg.submit(slot="plug:clean", title="第二次", func=work)
assert reused is False and second.id != first.id
await asyncio.wait_for(reg.wait(second), 2)
async def test_different_slots_run_in_parallel(reg):
gate = threading.Event()
def slow(report):
gate.wait(2)
a, _ = reg.submit(slot="plug:a", title="A", func=slow)
b, reused = reg.submit(slot="plug:b", title="B", func=slow)
assert reused is False and b.id != a.id
gate.set()
await asyncio.gather(reg.wait(a), reg.wait(b))
# ───────────────────────── 进度 ─────────────────────────
async def test_progress_fields_and_percent(reg):
gate = threading.Event()
def work(report):
report(done=3, total=12, phase="清理中", unit="个文件", message="已删除 2 个")
gate.wait(2)
return "ok"
task, _ = reg.submit(slot="plug:job", title="进度", func=work)
await _until(lambda: task.done == 3)
assert (task.total, task.unit, task.phase) == (12, "个文件", "清理中")
assert task.message == "已删除 2 个"
assert task.percent == 25
payload = task.to_dict()["progress"]
assert payload == {
"percent": 25, "done": 3, "total": 12, "unit": "个文件",
"phase": "清理中", "message": "已删除 2 个",
}
gate.set()
await asyncio.wait_for(reg.wait(task), 2)
async def test_unknown_total_reports_none_percent(reg):
"""扫描阶段总数未知 → percent None(前端显示不确定进度而不是 0%)。"""
gate = threading.Event()
def work(report):
report(phase="扫描中", unit="个文件", message="正在统计文件…")
gate.wait(2)
task, _ = reg.submit(slot="plug:job", title="扫描", func=work)
await _until(lambda: task.phase == "扫描中")
assert task.percent is None
gate.set()
await asyncio.wait_for(reg.wait(task), 2)
async def test_warnings_accumulate(reg):
def work(report):
report(warning="跳过 a.mp4(被占用)")
report(warning="跳过 b.mp4(被占用)")
return (0, 2)
task, _ = reg.submit(slot="plug:job", title="部分失败", func=work)
await asyncio.wait_for(reg.wait(task), 2)
assert task.to_dict()["warnings"] == ["跳过 a.mp4(被占用)", "跳过 b.mp4(被占用)"]
async def test_progress_is_throttled(reg):
"""上千次上报不能变成上千次推送(按百分比桶 + 只在有意义的字段变化时发)。"""
queue = reg.subscribe()
count = 500
def work(report):
for i in range(1, count + 1):
report(done=i, total=count)
return "ok"
task, _ = reg.submit(slot="plug:job", title="节流", func=work)
await asyncio.wait_for(reg.wait(task), 2)
frames = 0
while not queue.empty():
queue.get_nowait()
frames += 1
# 理论上限:每个百分点一帧 + 起止几帧;远小于 500 次上报
assert frames <= 110, f"推送 {frames} 帧,节流没生效"
assert frames >= 2 # 但也不能一帧都不发
reg.unsubscribe(queue)
# ───────────────────────── 协作式取消 ─────────────────────────
async def test_cancel_stops_cooperative_task(reg):
def work(report):
for i in range(1, 1000):
if not report(done=i, total=999):
return ("canceled", i) # 任务体自己收尾,带上已有进度
time.sleep(0.005)
return ("finished", 999)
task, _ = reg.submit(slot="plug:job", title="可取消", func=work)
await _until(lambda: task.done >= 1)
assert reg.cancel(task.id) is True
assert task.cancel_requested is True
await asyncio.wait_for(reg.wait(task), 2)
assert task.status == "canceled"
assert task.result[0] == "canceled"
# 取消不是硬杀:任务体返回前的进度照常保留
assert task.done < 999
async def test_cancel_finished_or_unknown_task_is_noop(reg):
def work(report):
return 1
task, _ = reg.submit(slot="plug:job", title="快活", func=work)
await asyncio.wait_for(reg.wait(task), 2)
assert reg.cancel(task.id) is False
assert reg.cancel("不存在的 id") is False
# ───────────────────────── 订阅 / 回收 ─────────────────────────
async def test_subscribe_receives_snapshots(reg):
queue = reg.subscribe()
assert queue.empty()
def work(report):
report(done=1, total=2, phase="干活")
return "ok"
task, _ = reg.submit(slot="plug:job", title="推送", func=work)
await asyncio.wait_for(reg.wait(task), 2)
assert not queue.empty()
frames = []
while not queue.empty():
frames.append(queue.get_nowait())
assert frames[0][0]["title"] == "推送"
assert frames[-1][0]["status"] == "done"
# 每帧都是全量快照
assert isinstance(frames[-1], list)
reg.unsubscribe(queue)
reg.unsubscribe(queue) # 幂等
async def test_clear_finished_keeps_running(reg):
gate = threading.Event()
def slow(report):
gate.wait(2)
def quick(report):
return 1
running, _ = reg.submit(slot="plug:slow", title="在跑", func=slow)
done, _ = reg.submit(slot="plug:quick", title="跑完了", func=quick)
await asyncio.wait_for(reg.wait(done), 2)
assert reg.clear_finished() == 1
assert reg.get(done.id) is None
assert reg.get(running.id) is not None
gate.set()
await asyncio.wait_for(reg.wait(running), 2)
async def test_prune_by_max_tasks_keeps_running(reg):
small = type(reg)() # 同类型新实例
small.max_tasks = 2
gate = threading.Event()
def slow(report):
gate.wait(2)
def quick(report):
return 1
running, _ = small.submit(slot="s:run", title="在跑", func=slow)
for i in range(3):
t, _ = small.submit(slot=f"s:{i}", title=f"第{i}次", func=quick)
await asyncio.wait_for(small.wait(t), 2)
assert len(small.list()) <= 2 # 上限生效(终态的被砍)
assert small.get(running.id) is not None # 在跑的绝不动
gate.set()
await asyncio.wait_for(small.wait(running), 2)
async def test_dismiss_only_finished(reg):
"""已结束的能删,在跑的删不掉(删了状态就没人回传了)。"""
gate = threading.Event()
def slow(report):
gate.wait(2)
def quick(report):
return 1
running, _ = reg.submit(slot="plug:slow", title="在跑", func=slow)
done, _ = reg.submit(slot="plug:quick", title="跑完了", func=quick)
await asyncio.wait_for(reg.wait(done), 2)
assert reg.dismiss(running.id) is False # 在跑:拒绝
assert reg.get(running.id) is not None
assert reg.dismiss(done.id) is True
assert reg.get(done.id) is None
assert reg.dismiss(done.id) is False # 已删过 → 再删是 no-op
assert reg.dismiss("不存在的 id") is False
gate.set()
await asyncio.wait_for(reg.wait(running), 2)
async def test_audit_fields_round_trip(reg):
def work(report):
return "ok"
task, _ = reg.submit(
slot="plug:job", title="审计", func=work,
plugin_id="video_analysis", trigger="web", actor="admin", group_id="123456",
)
await asyncio.wait_for(reg.wait(task), 2)
d = task.to_dict()
assert (d["plugin_id"], d["trigger"], d["actor"], d["group_id"]) == (
"video_analysis", "web", "admin", "123456",
)
assert d["status"] == "done" and d["active"] is False