boardgame-core/tests/core/rule.test.ts

245 lines
8.3 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { createGameContext } from '../../src/core/context';
import type { Command, CommandRunner, CommandRunnerContext } from '../../src/utils/command';
import { parseCommandSchema } from '../../src/utils/command/schema-parse';
describe('Command System', () => {
function createTestGame() {
const game = createGameContext();
return game;
}
function createRunner<T = unknown>(
schemaStr: string,
fn: (this: CommandRunnerContext<any>, cmd: Command) => Promise<T>
): CommandRunner<any, T> {
return {
schema: parseCommandSchema(schemaStr),
run: fn,
};
}
describe('registerCommand', () => {
it('should register and execute a command', async () => {
const game = createTestGame();
game.registerCommand('look', createRunner('[--at]', async () => {
return 'looked';
}));
game.enqueue('look');
await new Promise(resolve => setTimeout(resolve, 50));
expect(game.commandRegistry.value.has('look')).toBe(true);
});
it('should return error for unknown command', async () => {
const game = createTestGame();
game.enqueue('unknown command');
await new Promise(resolve => setTimeout(resolve, 50));
});
});
describe('prompt and queue resolution', () => {
it('should resolve prompt from queue input', async () => {
const game = createTestGame();
let promptReceived: Command | null = null;
game.registerCommand('move', createRunner('<from> <to>', async function(this: CommandRunnerContext<any>, cmd) {
const confirm = await this.prompt('confirm');
promptReceived = confirm;
return { moved: cmd.params[0], confirmed: confirm.name };
}));
game.enqueueAll([
'move card1 hand',
'confirm',
]);
await new Promise(resolve => setTimeout(resolve, 100));
expect(promptReceived).not.toBeNull();
expect(promptReceived!.name).toBe('confirm');
});
it('should handle multiple prompts in sequence', async () => {
const game = createTestGame();
const prompts: Command[] = [];
game.registerCommand('multi', createRunner('<start>', async function() {
const a = await this.prompt('<value>');
prompts.push(a);
const b = await this.prompt('<value>');
prompts.push(b);
return { a: a.params[0], b: b.params[0] };
}));
game.enqueueAll([
'multi init',
'first',
'second',
]);
await new Promise(resolve => setTimeout(resolve, 100));
expect(prompts).toHaveLength(2);
expect(prompts[0].params[0]).toBe('first');
expect(prompts[1].params[0]).toBe('second');
});
it('should handle command that completes without prompting', async () => {
const game = createTestGame();
let executed = false;
game.registerCommand('attack', createRunner('<target> [--power: number]', async function(cmd) {
executed = true;
return { target: cmd.params[0], power: cmd.options.power || '1' };
}));
game.enqueue('attack goblin --power 5');
await new Promise(resolve => setTimeout(resolve, 50));
expect(executed).toBe(true);
});
});
describe('nested command execution', () => {
it('should allow a command to run another command', async () => {
const game = createTestGame();
let childResult: unknown;
game.registerCommand('child', createRunner('<arg>', async (cmd) => {
return `child:${cmd.params[0]}`;
}));
game.registerCommand('parent', createRunner('<action>', async function() {
const output = await this.run('child test_arg');
if (!output.success) throw new Error(output.error);
childResult = output.result;
return `parent:${output.result}`;
}));
game.enqueue('parent start');
await new Promise(resolve => setTimeout(resolve, 100));
expect(childResult).toBe('child:test_arg');
});
it('should handle nested commands with prompts', async () => {
const game = createTestGame();
let childPromptResult: Command | null = null;
game.registerCommand('child', createRunner('<target>', async function() {
const confirm = await this.prompt('yes | no');
childPromptResult = confirm;
return `child:${confirm.name}`;
}));
game.registerCommand('parent', createRunner('<action>', async function() {
const output = await this.run('child target1');
if (!output.success) throw new Error(output.error);
return `parent:${output.result}`;
}));
game.enqueueAll([
'parent start',
'yes',
]);
await new Promise(resolve => setTimeout(resolve, 100));
expect(childPromptResult).not.toBeNull();
expect(childPromptResult!.name).toBe('yes');
});
});
describe('enqueueAll for action log replay', () => {
it('should process all inputs in order', async () => {
const game = createTestGame();
const results: string[] = [];
game.registerCommand('step', createRunner('<value>', async (cmd) => {
results.push(cmd.params[0] as string);
return cmd.params[0];
}));
game.enqueueAll([
'step one',
'step two',
'step three',
]);
await new Promise(resolve => setTimeout(resolve, 100));
expect(results).toEqual(['one', 'two', 'three']);
});
it('should buffer inputs and resolve prompts automatically', async () => {
const game = createTestGame();
let prompted: Command | null = null;
game.registerCommand('interactive', createRunner('<start>', async function() {
const response = await this.prompt('<reply>');
prompted = response;
return { start: 'start', reply: response.params[0] };
}));
game.enqueueAll([
'interactive begin',
'hello',
]);
await new Promise(resolve => setTimeout(resolve, 100));
expect(prompted).not.toBeNull();
expect(prompted!.params[0]).toBe('hello');
});
});
describe('command schema validation', () => {
it('should reject commands that do not match schema', async () => {
const game = createTestGame();
let errors: string[] = [];
game.registerCommand('strict', createRunner('<required>', async () => {
return 'ok';
}));
const originalError = console.error;
console.error = (...args: unknown[]) => {
errors.push(String(args[0]));
};
game.enqueue('strict');
await new Promise(resolve => setTimeout(resolve, 50));
console.error = originalError;
expect(errors.some(e => e.includes('Unknown') || e.includes('error'))).toBe(true);
});
});
describe('context management', () => {
it('should push and pop contexts', () => {
const game = createTestGame();
game.pushContext({ type: 'sub-game' });
expect(game.contexts.value.length).toBe(2);
game.popContext();
expect(game.contexts.value.length).toBe(1);
});
it('should find latest context by type', () => {
const game = createTestGame();
game.pushContext({ type: 'sub-game' });
const found = game.latestContext('sub-game');
expect(found).toBeDefined();
expect(found!.value.type).toBe('sub-game');
});
});
});