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

524 lines
18 KiB
TypeScript
Raw Normal View History

2026-04-02 08:58:11 +08:00
import { describe, it, expect } from 'vitest';
2026-04-02 15:59:27 +08:00
import { parseCommandSchema } from '@/utils/command/schema-parse';
2026-04-02 08:58:11 +08:00
import {
createCommandRegistry,
registerCommand,
unregisterCommand,
hasCommand,
getCommand,
runCommand,
createCommandRunnerContext,
type CommandRegistry,
type CommandRunnerContextExport,
2026-04-02 15:59:27 +08:00
} from '@/utils/command/command-registry';
import type { CommandRunner, PromptEvent } from '@/utils/command/command-runner';
2026-04-02 08:58:11 +08:00
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 () {
2026-04-04 22:23:15 +08:00
const result = await this.prompt('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');
2026-04-04 22:23:15 +08:00
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 () {
2026-04-04 22:23:15 +08:00
const result = await this.prompt('select <card>', (cmd) => cmd.params[0] 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 {
2026-04-04 22:23:15 +08:00
await this.prompt('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('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 () {
2026-04-04 22:23:15 +08:00
const result = await this.prompt(schema, (cmd) => cmd.params[0] 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 () {
2026-04-04 22:23:15 +08:00
const first = await this.prompt('first <a>', (cmd) => cmd.params[0] as string);
const second = await this.prompt('second <b>', (cmd) => cmd.params[0] 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(
'select <card>',
(cmd) => {
const card = cmd.params[0] as string;
if (!['Ace', 'King', 'Queen'].includes(card)) {
2026-04-04 22:23:15 +08:00
throw `Invalid card: ${card}. Must be Ace, King, or Queen.`;
}
2026-04-04 22:23:15 +08:00
return card;
}
);
2026-04-04 22:23:15 +08:00
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 {
2026-04-04 22:23:15 +08:00
await this.prompt('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');
}
});
});