import type { ParsedRelease } from "@umt/domain"; import { describe, expect, it, vi } from "vitest"; import { HookRegistry, hasHook, interceptHook, rewriteHook } from "../../src/hooks/index.ts"; // 链式钩子(ADR-006):注册顺序、改写、拦截、抛错隔离、超时、没有注册时零开销。 const warnings = () => { const warn = vi.fn(); return { logger: { warn } as never, warn }; }; const parsed = (title: string) => ({ title, raw: title, explicitIds: [] }) as unknown as ParsedRelease; const recognizeCtx = { title: "x", subtitle: null, sourceReleaseId: null, trigger: "observed" }; const completionCtx = { subscriptionId: "s", mediaEntityId: null, mediaKind: "movie", reason: "slot_satisfied", progress: {}, }; describe("链式钩子注册表", () => { it("按注册顺序执行(bootstrap 按模块顺序注册),每个钩子拿到上一个的结果;缺省名 <模块>#<序号>", async () => { const reg = new HookRegistry(); const seen: string[] = []; const a = reg.forModule("a"); const b = reg.forModule("b"); a.rewrite("organize.rename", (name) => { seen.push(`a1:${name}`); return `${name}-a1`; }); b.rewrite("organize.rename", (name) => { seen.push(`b:${name}`); return `${name}-b`; }); a.rewrite("organize.rename", (name) => { seen.push(`a2:${name}`); return undefined; // 不改 }); const ctx = { hooks: reg }; const out = await rewriteHook(ctx, "organize.rename", "X.mkv", { targetPath: "/lib/X.mkv", sourcePath: "/src/X.mkv", mediaEntityId: "e", mediaType: "movie", season: null, episodes: null, }); expect(out).toBe("X.mkv-a1-b"); expect(seen).toEqual(["a1:X.mkv", "b:X.mkv-a1", "a2:X.mkv-a1-b"]); expect(reg.list()).toEqual({ "organize.rename": ["a#1", "b#1", "a#2"] }); }); it("改写生效:异步钩子改写解析结果", async () => { const reg = new HookRegistry(); reg.forModule("m").rewrite("name.recognize", async (p) => ({ ...p, title: "改写后" })); const out = await rewriteHook({ hooks: reg }, "name.recognize", parsed("原名"), recognizeCtx); expect(out.title).toBe("改写后"); }); it("拦截生效:第一个返回原因的钩子拒绝,后面的不再执行;都放行返回 null", async () => { const reg = new HookRegistry(); const later = vi.fn(() => "不该执行"); const m = reg.forModule("m"); m.intercept("subscription.completion.check", () => null); m.intercept("subscription.completion.check", () => " 还缺特别篇 ", { name: "缺特别篇" }); m.intercept("subscription.completion.check", later); expect(await interceptHook({ hooks: reg }, "subscription.completion.check", completionCtx)).toEqual({ reason: "还缺特别篇", by: "缺特别篇", }); expect(later).not.toHaveBeenCalled(); const pass = new HookRegistry(); pass.forModule("m").intercept("subscription.completion.check", () => undefined); expect(await interceptHook({ hooks: pass }, "subscription.completion.check", completionCtx)).toBeNull(); }); it("抛错隔离:出错的钩子跳过并记日志,前后的钩子照常生效", async () => { const { logger, warn } = warnings(); const reg = new HookRegistry(logger); const m = reg.forModule("m"); m.rewrite("name.recognize", (p) => ({ ...p, title: `${p.title}+1` })); m.rewrite("name.recognize", () => { throw new Error("插件坏了"); }); m.rewrite("name.recognize", async () => { throw new Error("异步也坏了"); }); m.rewrite("name.recognize", (p) => ({ ...p, title: `${p.title}+2` })); const out = await rewriteHook({ hooks: reg }, "name.recognize", parsed("t"), recognizeCtx); expect(out.title).toBe("t+1+2"); expect(warn).toHaveBeenCalledTimes(2); expect(warn.mock.calls[0]![0]).toMatchObject({ point: "name.recognize", hook: "m#2", err: { message: "插件坏了" }, }); // 拦截类抛错按放行处理 const r2 = new HookRegistry(logger); r2.forModule("m").intercept("organize.intercept", () => { throw new Error("x"); }); expect( await interceptHook({ hooks: r2 }, "organize.intercept", { mediaEntityId: "e", mediaType: "movie", action: "copy", trigger: "auto", downloadTaskId: null, files: [], }), ).toBeNull(); }); it("超时:超过上限的钩子跳过(沿用上一个值),不等它跑完", async () => { const { logger, warn } = warnings(); const reg = new HookRegistry(logger); const m = reg.forModule("slow"); m.rewrite("organize.rename", () => new Promise((r) => setTimeout(() => r("迟到.mkv"), 5_000)), { timeoutMs: 30, }); m.rewrite("organize.rename", (n) => `${n}.ok`); const t0 = Date.now(); const out = await reg.runRewrite("organize.rename", "A.mkv", { targetPath: "/l/A.mkv", sourcePath: "/s/A.mkv", mediaEntityId: "e", mediaType: "movie", season: null, episodes: null, }); expect(out).toBe("A.mkv.ok"); expect(Date.now() - t0).toBeLessThan(2_000); expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "slow#1", timeoutMs: 30 }); }); it("没有注册时零开销:不构造上下文、原样返回同一个对象;没有注册表(手搭上下文)同样", async () => { const ctxBuilder = vi.fn(() => recognizeCtx); const p = parsed("t"); const empty = new HookRegistry(); // 别的挂点有钩子不影响这里 empty.forModule("m").intercept("organize.intercept", () => "x"); expect(hasHook({ hooks: empty }, "name.recognize")).toBe(false); expect(await rewriteHook({ hooks: empty }, "name.recognize", p, ctxBuilder)).toBe(p); expect(await rewriteHook({}, "name.recognize", p, ctxBuilder)).toBe(p); expect(await interceptHook({}, "subscription.completion.check", () => completionCtx)).toBeNull(); expect(ctxBuilder).not.toHaveBeenCalled(); }); it("调用方给了 accept 时,结果不可用的钩子跳过,后面的钩子拿到的是上一个可用值", async () => { const { logger, warn } = warnings(); const reg = new HookRegistry(logger); const m = reg.forModule("m"); m.rewrite("organize.rename", (n) => `../${n}`); m.rewrite("organize.rename", (n) => `${n}.ok`); const c = { targetPath: "/a", sourcePath: "/b", mediaEntityId: "e", mediaType: "movie" as const, season: null, episodes: null, }; expect(await rewriteHook({ hooks: reg }, "organize.rename", "A.mkv", c, (v) => !v.includes("/"))).toBe("A.mkv.ok"); expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "m#1" }); }); it("用错挂点类型注册直接报错", () => { const reg = new HookRegistry(); // biome-ignore lint/suspicious/noExplicitAny: 故意绕过类型检查 const m = reg.forModule("m") as any; expect(() => m.rewrite("organize.intercept", () => "x")).toThrow(/intercept 类/); }); });