import { describe, it, expect } from "vitest"; import { parseCommandSchema } from "@/utils/command/schema-parse"; import { createCommandRegistry, registerCommand, unregisterCommand, hasCommand, getCommand, runCommand, createCommandRunnerContext, type CommandRegistry, type CommandRunnerContextExport, } from "@/utils/command/command-registry"; import type { CommandRunner, PromptEvent, } from "@/utils/command/command-runner"; type TestContext = { counter: number; log: string[]; }; describe("CommandRegistry", () => { it("should create an empty registry", () => { const registry = createCommandRegistry(); expect(registry.size).toBe(0); }); it("should register a command", () => { const registry = createCommandRegistry(); const runner: CommandRunner = { schema: parseCommandSchema("add "), run: async function (cmd) { return Number(cmd.params[0]) + Number(cmd.params[1]); }, }; registerCommand(registry, runner); expect(registry.size).toBe(1); expect(hasCommand(registry, "add")).toBe(true); }); it("should unregister a command", () => { const registry = createCommandRegistry(); const runner: CommandRunner = { schema: parseCommandSchema("remove"), run: async () => {}, }; registerCommand(registry, runner); expect(hasCommand(registry, "remove")).toBe(true); unregisterCommand(registry, "remove"); expect(hasCommand(registry, "remove")).toBe(false); }); it("should get a command runner", () => { const registry = createCommandRegistry(); const runner: CommandRunner = { schema: parseCommandSchema("get"), run: async () => {}, }; registerCommand(registry, runner); const retrieved = getCommand(registry, "get"); expect(retrieved).toBe(runner); }); it("should return undefined for unknown command", () => { const registry = createCommandRegistry(); const retrieved = getCommand(registry, "unknown"); expect(retrieved).toBeUndefined(); }); }); describe("runCommand", () => { it("should run a command successfully", async () => { const registry = createCommandRegistry(); const runner: CommandRunner = { schema: parseCommandSchema("add "), run: async function (cmd) { return Number(cmd.params[0]) + Number(cmd.params[1]); }, }; registerCommand(registry, runner); const result = await runCommand( registry, { counter: 0, log: [] }, "add 1 2", ); expect(result.success).toBe(true); if (result.success) { expect(result.result).toBe(3); } }); it("should fail for unknown command", async () => { const registry = createCommandRegistry(); const result = await runCommand( registry, { counter: 0, log: [] }, "unknown", ); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain("Unknown command"); } }); it("should fail for invalid command params", async () => { const registry = createCommandRegistry(); const runner: CommandRunner = { schema: parseCommandSchema("add "), run: async () => {}, }; registerCommand(registry, runner); const result = await runCommand(registry, { counter: 0, log: [] }, "add 1"); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain("参数不足"); } }); it("should access context via this.context", async () => { const registry = createCommandRegistry(); const runner: CommandRunner = { schema: parseCommandSchema("increment"), run: async function () { this.context.counter++; return this.context.counter; }, }; registerCommand(registry, runner); const ctx = { counter: 0, log: [] }; await runCommand(registry, ctx, "increment"); expect(ctx.counter).toBe(1); }); it("should handle async errors", async () => { const registry = createCommandRegistry(); const runner: CommandRunner = { schema: parseCommandSchema("fail"), run: async () => { throw new Error("Something went wrong"); }, }; registerCommand(registry, runner); const result = await runCommand(registry, { counter: 0, log: [] }, "fail"); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toBe("Something went wrong"); } }); }); describe("CommandRunnerContext", () => { it("should create a runner context", () => { const registry = createCommandRegistry(); const ctx = { counter: 0, log: [] }; const runnerCtx = createCommandRunnerContext(registry, ctx); expect(runnerCtx.registry).toBe(registry); expect(runnerCtx.context).toBe(ctx); }); it("should run commands via runner context", async () => { const registry = createCommandRegistry(); const runner: CommandRunner = { schema: parseCommandSchema("greet "), run: async function (cmd) { this.context.log.push(`Hello, ${cmd.params[0]}!`); return `Hello, ${cmd.params[0]}!`; }, }; registerCommand(registry, runner); const ctx = { counter: 0, log: [] }; const runnerCtx = createCommandRunnerContext(registry, ctx); const result = await runnerCtx.run("greet World"); expect(result.success).toBe(true); if (result.success) { expect(result.result).toBe("Hello, World!"); } expect(ctx.log).toEqual(["Hello, World!"]); }); it("should allow commands to call other commands via this.run", async () => { const registry = createCommandRegistry(); const addRunner: CommandRunner = { schema: parseCommandSchema("add "), run: async function (cmd) { return Number(cmd.params[0]) + Number(cmd.params[1]); }, }; registerCommand(registry, addRunner); const multiplyRunner: CommandRunner = { schema: parseCommandSchema("multiply "), run: async function (cmd) { const a = Number(cmd.params[0]); const b = Number(cmd.params[1]); const addResult = await this.run(`add ${a} ${a}`); if (!addResult.success) throw new Error("add failed"); return (addResult.result as number) * b; }, }; registerCommand(registry, multiplyRunner); const ctx = { counter: 0, log: [] }; const result = await runCommand(registry, ctx, "multiply 3 4"); expect(result.success).toBe(true); if (result.success) { expect(result.result).toBe(24); } }); it("should allow commands to call other commands via this.runParsed", async () => { const registry = createCommandRegistry(); const doubleRunner: CommandRunner = { schema: parseCommandSchema("double "), run: async function (cmd) { return Number(cmd.params[0]) * 2; }, }; registerCommand(registry, doubleRunner); const quadrupleRunner: CommandRunner = { schema: parseCommandSchema("quadruple "), run: async function (cmd) { const n = Number(cmd.params[0]); const doubleResult = await this.runParsed({ name: "double", params: [String(n)], options: {}, flags: {}, }); if (!doubleResult.success) throw new Error("double failed"); return (doubleResult.result as number) * 2; }, }; registerCommand(registry, quadrupleRunner); const ctx = { counter: 0, log: [] }; const result = await runCommand(registry, ctx, "quadruple 5"); expect(result.success).toBe(true); if (result.success) { expect(result.result).toBe(20); } }); }); describe("prompt", () => { it("should dispatch prompt event with string schema", async () => { const registry = createCommandRegistry(); const chooseRunner: CommandRunner = { schema: parseCommandSchema("choose"), run: async function () { const result = await this.prompt( parseCommandSchema("select "), (cmd) => cmd.params[0] as string, ); return result; }, }; registerCommand(registry, chooseRunner); const ctx = { counter: 0, log: [] }; let promptEvent: PromptEvent | null = null; const runnerCtx = createCommandRunnerContext(registry, ctx); runnerCtx.on("prompt", (e) => { promptEvent = e; }); const runPromise = runnerCtx.run("choose"); await new Promise((r) => setTimeout(r, 0)); expect(promptEvent).not.toBeNull(); expect(promptEvent!.schema.name).toBe("select"); promptEvent!.cancel("test cleanup"); await runPromise; }); it("should resolve prompt with valid input", async () => { const registry = createCommandRegistry(); const chooseRunner: CommandRunner = { schema: parseCommandSchema("choose"), run: async function () { const result = await this.prompt( parseCommandSchema("select "), (card) => card as string, ); this.context.log.push(`selected ${result}`); return result; }, }; registerCommand(registry, chooseRunner); const ctx = { counter: 0, log: [] }; let promptEvent: PromptEvent | null = null; const runnerCtx = createCommandRunnerContext(registry, ctx); runnerCtx.on("prompt", (e) => { promptEvent = e; }); const runPromise = runnerCtx.run("choose"); await new Promise((r) => setTimeout(r, 0)); expect(promptEvent).not.toBeNull(); const parsed = { name: "select", params: ["Ace"], options: {}, flags: {} }; const error = promptEvent!.tryCommit(parsed); expect(error).toBeNull(); const result = await runPromise; expect(result.success).toBe(true); if (result.success) { expect(result.result).toBe("Ace"); } expect(ctx.log).toEqual(["selected Ace"]); }); it("should reject prompt with invalid input", async () => { const registry = createCommandRegistry(); const chooseRunner: CommandRunner = { schema: parseCommandSchema("choose"), run: async function () { try { await this.prompt( parseCommandSchema("select "), (card) => card as string, ); return "unexpected success"; } catch (e) { return (e as Error).message; } }, }; registerCommand(registry, chooseRunner); const ctx = { counter: 0, log: [] }; let promptEvent: PromptEvent | null = null; const runnerCtx = createCommandRunnerContext(registry, ctx); runnerCtx.on("prompt", (e) => { promptEvent = e; }); const runPromise = runnerCtx.run("choose"); await new Promise((r) => setTimeout(r, 0)); expect(promptEvent).not.toBeNull(); promptEvent!.cancel("user cancelled"); const result = await runPromise; expect(result.success).toBe(true); if (result.success) { expect(result.result).toBe("user cancelled"); } }); it("should accept CommandSchema object in prompt", async () => { const registry = createCommandRegistry(); const schema = parseCommandSchema("pick "); const pickRunner: CommandRunner = { schema: parseCommandSchema("pick"), run: async function () { const result = await this.prompt(schema, (item) => item as string); return result; }, }; registerCommand(registry, pickRunner); const ctx = { counter: 0, log: [] }; let promptEvent: PromptEvent | null = null; const runnerCtx = createCommandRunnerContext(registry, ctx); runnerCtx.on("prompt", (e) => { promptEvent = e; }); const runPromise = runnerCtx.run("pick"); await new Promise((r) => setTimeout(r, 0)); expect(promptEvent).not.toBeNull(); expect(promptEvent!.schema.name).toBe("pick"); const error = promptEvent!.tryCommit({ name: "pick", params: ["sword"], options: {}, flags: {}, }); expect(error).toBeNull(); const result = await runPromise; expect(result.success).toBe(true); if (result.success) { expect(result.result).toBe("sword"); } }); it("should allow multiple sequential prompts", async () => { const registry = createCommandRegistry(); const multiPromptRunner: CommandRunner = { schema: parseCommandSchema("multi"), run: async function () { const first = await this.prompt( parseCommandSchema("first "), (a) => a as string, ); const second = await this.prompt( parseCommandSchema("second "), (b) => b as string, ); return [first, second]; }, }; registerCommand(registry, multiPromptRunner); const ctx = { counter: 0, log: [] }; const promptEvents: PromptEvent[] = []; const runnerCtx = createCommandRunnerContext(registry, ctx); runnerCtx.on("prompt", (e) => { promptEvents.push(e); }); const runPromise = runnerCtx.run("multi"); await new Promise((r) => setTimeout(r, 0)); expect(promptEvents.length).toBe(1); expect(promptEvents[0].schema.name).toBe("first"); const error1 = promptEvents[0].tryCommit({ name: "first", params: ["one"], options: {}, flags: {}, }); expect(error1).toBeNull(); await new Promise((r) => setTimeout(r, 0)); expect(promptEvents.length).toBe(2); expect(promptEvents[1].schema.name).toBe("second"); const error2 = promptEvents[1].tryCommit({ name: "second", params: ["two"], options: {}, flags: {}, }); expect(error2).toBeNull(); const result = await runPromise; expect(result.success).toBe(true); if (result.success) { expect(result.result).toEqual(["one", "two"]); } }); it("should validate input with validator function", async () => { const registry = createCommandRegistry(); const chooseRunner: CommandRunner = { schema: parseCommandSchema("choose"), run: async function () { const result = await this.prompt( parseCommandSchema("select "), (card) => { const cardStr = card as string; if (!["Ace", "King", "Queen"].includes(cardStr)) { throw `Invalid card: ${cardStr}. Must be Ace, King, or Queen.`; } return cardStr; }, ); return result; }, }; registerCommand(registry, chooseRunner); const ctx = { counter: 0, log: [] }; let promptEvent: PromptEvent | null = null; const runnerCtx = createCommandRunnerContext(registry, ctx); runnerCtx.on("prompt", (e) => { promptEvent = e; }); const runPromise = runnerCtx.run("choose"); await new Promise((r) => setTimeout(r, 0)); expect(promptEvent).not.toBeNull(); // Try invalid input const invalidError = promptEvent!.tryCommit({ name: "select", params: ["Jack"], options: {}, flags: {}, }); expect(invalidError).toContain("Invalid card: Jack"); // Try valid input const validError = promptEvent!.tryCommit({ name: "select", params: ["Ace"], options: {}, flags: {}, }); expect(validError).toBeNull(); const result = await runPromise; expect(result.success).toBe(true); if (result.success) { expect(result.result).toBe("Ace"); } }); it("should allow cancel with custom reason", async () => { const registry = createCommandRegistry(); const chooseRunner: CommandRunner = { schema: parseCommandSchema("choose"), run: async function () { try { await this.prompt( parseCommandSchema("select "), (cmd) => cmd.params[0] as string, ); return "unexpected success"; } catch (e) { return (e as Error).message; } }, }; registerCommand(registry, chooseRunner); const ctx = { counter: 0, log: [] }; let promptEvent: PromptEvent | null = null; const runnerCtx = createCommandRunnerContext(registry, ctx); runnerCtx.on("prompt", (e) => { promptEvent = e; }); const runPromise = runnerCtx.run("choose"); await new Promise((r) => setTimeout(r, 0)); expect(promptEvent).not.toBeNull(); promptEvent!.cancel("custom cancellation reason"); const result = await runPromise; expect(result.success).toBe(true); if (result.success) { expect(result.result).toBe("custom cancellation reason"); } }); });