boardgame-core/tests/utils/command-runner.test.ts

583 lines
17 KiB
TypeScript

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<TestContext>();
expect(registry.size).toBe(0);
});
it("should register a command", () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema("add <a> <b>"),
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<TestContext>();
const runner: CommandRunner<TestContext> = {
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<TestContext>();
const runner: CommandRunner<TestContext> = {
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<TestContext>();
const retrieved = getCommand(registry, "unknown");
expect(retrieved).toBeUndefined();
});
});
describe("runCommand", () => {
it("should run a command successfully", async () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema("add <a> <b>"),
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<TestContext>();
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<TestContext>();
const runner: CommandRunner<TestContext> = {
schema: parseCommandSchema("add <a> <b>"),
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<TestContext>();
const runner: CommandRunner<TestContext, number> = {
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<TestContext>();
const runner: CommandRunner<TestContext> = {
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<TestContext>();
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<TestContext>();
const runner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema("greet <name>"),
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<TestContext>();
const addRunner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema("add <a> <b>"),
run: async function (cmd) {
return Number(cmd.params[0]) + Number(cmd.params[1]);
},
};
registerCommand(registry, addRunner);
const multiplyRunner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema("multiply <a> <b>"),
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<TestContext>();
const doubleRunner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema("double <n>"),
run: async function (cmd) {
return Number(cmd.params[0]) * 2;
},
};
registerCommand(registry, doubleRunner);
const quadrupleRunner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema("quadruple <n>"),
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<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema("choose"),
run: async function () {
const result = await this.prompt(
parseCommandSchema("select <card>"),
(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<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema("choose"),
run: async function () {
const result = await this.prompt(
parseCommandSchema("select <card>"),
(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<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema("choose"),
run: async function () {
try {
await this.prompt(
parseCommandSchema("select <card>"),
(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<TestContext>();
const schema = parseCommandSchema("pick <item>");
const pickRunner: CommandRunner<TestContext, string> = {
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<TestContext>();
const multiPromptRunner: CommandRunner<TestContext, string[]> = {
schema: parseCommandSchema("multi"),
run: async function () {
const first = await this.prompt(
parseCommandSchema("first <a>"),
(a) => a as string,
);
const second = await this.prompt(
parseCommandSchema("second <b>"),
(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<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema("choose"),
run: async function () {
const result = await this.prompt(
parseCommandSchema("select <card>"),
(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<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema("choose"),
run: async function () {
try {
await this.prompt(
parseCommandSchema("select <card>"),
(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");
}
});
});