Files
UHD_Media_Tool/apps/server/test/modules.test.ts
T
rootandClaude Opus 5.5 7aa5182f3d feat: 功能清单进智能体提示,补字幕按功能模块约定改为样板
- 外围功能模块(补字幕、原站视图、桌面模式、网页搜索、MCP、定时任务)声明 manifest,与 FEATURES 一一对应;
  系统提示「能力地图」末尾列出外围功能(没有开关,能力只列本次可用的)
- 补字幕:订阅 organization.finished(认出值得补字幕的整理,自动补字幕还没做,不建任务),
  写入字幕后在同一事务发 subtitle.written;原有行为不变

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 19:27:14 +08:00

213 lines
8.6 KiB
TypeScript

import { createRoute, z } from "@hono/zod-openapi";
import { apiResponse, CAPABILITY_IDS, capabilitySpec, FEATURES, LIFECYCLE_EVENTS } from "@umt/contracts";
import { defineTask, TASK_NAMES, TaskRegistry } from "@umt/tasks";
import { describe, expect, it } from "vitest";
import { buildHookRegistry, buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts";
import { createApp } from "../src/http/app.ts";
import { requireScope } from "../src/http/auth.ts";
import { EventBus } from "../src/http/sse.ts";
import { SERVER_MODULES } from "../src/modules/index.ts";
import type { ServerContext, ServerModule } from "../src/modules/types.ts";
import { DEV_KEY, json, memoryLogger } from "./helpers.ts";
// register* 阶段模块只保存 ctx 引用,这里不需要真实依赖
const fakeCtx = { role: "all" } as unknown as ServerContext;
const pingRoute = createRoute({
method: "get",
path: "/test-ping",
tags: ["test"],
responses: {
200: { description: "ok", content: { "application/json": { schema: apiResponse(z.object({ who: z.string() })) } } },
},
});
const pingModule: ServerModule = {
name: "test-ping",
registerRoutes(app) {
app.use("/test-ping", requireScope("admin"));
app.openapi(pingRoute, (c) => c.json({ ok: true as const, data: { who: c.get("principal").name } }, 200));
},
registerTasks(registry) {
registry.register(
defineTask({
name: "testPing",
version: 1,
inputSchema: z.object({ feedSourceId: z.string() }),
handler: async () => "real",
}),
);
},
// 测试专用事件类型:真实事件(如 release.resolved)已由业务模块认领
outboxRoutes: () => ({ "test.ping": () => ({ task: "evaluateAcquisition", input: {} }) }),
};
describe("ServerModule 骨架", () => {
it("清单里是四个 W2 模块 + 文件管理 + Pi 上游 + 定时任务 + 全局搜索 + 浏览器 + 桌面反代 + MCP + 网页搜索 + 缓存 + 进程与健康 + 日志(末尾),名字唯一", () => {
expect(SERVER_MODULES.map((m) => m.name)).toEqual([
"auth-settings",
"sources-identity",
"acquisition-organize",
"agent",
"file-manager",
"pi",
"schedules",
"search",
"subtitles",
"browser",
"desktop",
"mcp",
"websearch",
"cache",
"system",
"logs",
]);
});
it("模块路由挂在 /api/v1 下,经过鉴权与 scope 检查,并进入 OpenAPI 文档", async () => {
const { logger } = memoryLogger();
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
bus: new EventBus(5),
role: "all",
version: "0.0.0-test",
modules: [pingModule],
ctx: fakeCtx,
});
expect((await app.request("/api/v1/test-ping")).status).toBe(401);
const ok = await app.request("/api/v1/test-ping", { headers: { authorization: `Bearer ${DEV_KEY}` } });
expect(ok.status).toBe(200);
expect((await json(ok)).data.who).toBe("dev-admin");
const doc = await json(await app.request("/api/openapi.json"));
expect(doc.paths["/api/v1/test-ping"]).toBeDefined();
// 未知路径仍是 JSON 404
expect((await app.request("/api/v1/nope", { headers: { authorization: `Bearer ${DEV_KEY}` } })).status).toBe(404);
});
it("任务注册表:模块任务优先,其余 §3.1-8 任务由空壳兜底;重复注册报错", async () => {
const reg = buildTaskRegistry([...SERVER_MODULES, pingModule], fakeCtx);
expect(reg.names()).toEqual(expect.arrayContaining([...TASK_NAMES, "testPing"]));
const ingest = reg.latest("testPing");
expect(await ingest.handler({ input: {} } as never)).toBe("real");
expect(() => buildTaskRegistry([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复注册/);
});
it("生产装配:每个核心任务都由业务模块正式注册,不落到空壳兜底", () => {
const owned = new Set<string>();
for (const m of SERVER_MODULES) {
const own = new TaskRegistry();
m.registerTasks(own, fakeCtx);
for (const name of own.names()) owned.add(name);
}
expect(TASK_NAMES.filter((n) => !owned.has(n))).toEqual([]);
});
it("outbox 路由合并;同一非广播事件被两个模块认领报错", () => {
const merged = Object.keys(collectOutboxRoutes([...SERVER_MODULES, pingModule], fakeCtx));
expect(merged).toContain("test.ping");
expect(merged).toContain("release.resolved");
expect(merged).toEqual(expect.arrayContaining(["feed.due", "release.observed", "feed.gap_suspected"]));
expect(() => collectOutboxRoutes([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复认领/);
});
it("生命周期广播事件可以被多个模块订阅:按模块顺序合成,各自的任务都投递,某个订阅方出错不连累别人", () => {
const { logger } = memoryLogger();
const ctx = { role: "all", logger } as unknown as ServerContext;
const sub = (name: string, fn: () => unknown): ServerModule => ({
name,
registerRoutes() {},
registerTasks() {},
outboxRoutes: () => ({ "organization.finished": fn as never }),
});
const routes = collectOutboxRoutes(
[
...SERVER_MODULES,
sub("a", () => ({ task: "taskA", input: { n: 1 } })),
sub("broken", () => {
throw new Error("坏了");
}),
sub("b", () => [{ task: "taskB", input: {} }]),
sub("none", () => null),
],
ctx,
);
const ev = {
id: "e1",
type: "organization.finished",
at: new Date(),
payload: {
recordId: "00000000-0000-4000-8000-000000000001",
outcome: "succeeded",
action: "copy",
trigger: "auto",
mediaEntityId: "00000000-0000-4000-8000-000000000002",
downloadTaskId: null,
reason: null,
changeSeq: "7",
},
};
// 补字幕(样板)订阅了整理结束,返回 null(自动补字幕还没做)
expect(routes["organization.finished"]!(ev)).toEqual([
{ task: "taskA", input: { n: 1 } },
{ task: "taskB", input: {} },
]);
// 只有补字幕一个订阅方时它自己的路由不建任务
expect(collectOutboxRoutes(SERVER_MODULES, ctx)["organization.finished"]!(ev)).toBeNull();
// 非广播事件仍只能一个模块认领(release.observed 由 sources-identity 认领)
expect(() => collectOutboxRoutes([...SERVER_MODULES, sub("x", () => null)], ctx)).not.toThrow();
const dup: ServerModule = { ...sub("y", () => null), outboxRoutes: () => ({ "release.observed": () => null }) };
expect(() => collectOutboxRoutes([...SERVER_MODULES, dup], ctx)).toThrow(/重复认领/);
// 广播事件都登记了 payload schema
expect(LIFECYCLE_EVENTS.length).toBeGreaterThanOrEqual(17);
});
it("功能清单:外围功能模块都声明 manifest,与 contracts FEATURES 一一对应", () => {
const declared = SERVER_MODULES.filter((m) => m.manifest).map((m) => [m.name, m.manifest!.id]);
for (const [name, id] of declared) expect(id).toBe(name);
expect(declared.map(([n]) => n).sort()).toEqual(FEATURES.map((f) => f.id).sort());
const subtitles = SERVER_MODULES.find((m) => m.name === "subtitles")!;
expect(subtitles.manifest?.subscribes).toEqual(["organization.finished"]);
expect(Object.keys(subtitles.outboxRoutes?.(fakeCtx) ?? {})).toEqual(subtitles.manifest?.subscribes);
});
it("链式钩子按模块顺序注册;生产装配目前没有模块注册钩子(无注册零开销)", async () => {
const order: string[] = [];
const mod = (name: string): ServerModule => ({
name,
registerRoutes() {},
registerTasks() {},
registerHooks(hooks) {
hooks.rewrite("organize.rename", (n) => {
order.push(name);
return `${n}.${name}`;
});
},
});
const hooks = buildHookRegistry([mod("first"), mod("second")], fakeCtx);
expect(
await hooks.runRewrite("organize.rename", "a", {
targetPath: "/a",
sourcePath: "/b",
mediaEntityId: "e",
mediaType: "movie",
season: null,
episodes: null,
}),
).toBe("a.first.second");
expect(order).toEqual(["first", "second"]);
expect(buildHookRegistry(SERVER_MODULES, fakeCtx).list()).toEqual({});
});
it("引擎就绪前调用给出明确错误", async () => {
const e = new DeferredTaskEngine();
expect(e.ready).toBe(false);
expect(() => e.get("x")).toThrow(/尚未就绪/);
await e.stop();
});
it("能力规格取自 contracts,可按模块注册", () => {
expect(CAPABILITY_IDS.map((id) => capabilitySpec(id).id)).toEqual([...CAPABILITY_IDS]);
});
});