"""任务槽(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