boardgame-core/tests/samples/tic-tac-toe.test.ts

347 lines
12 KiB
TypeScript
Raw Normal View History

2026-04-02 11:21:57 +08:00
import { describe, it, expect } from 'vitest';
import { createGameContext } from '../../src/core/game';
import { createCommandRegistry } from '../../src/utils/command';
import { registerTicTacToeCommands, checkWinner, isCellOccupied, placePiece } from '../../src/samples/tic-tac-toe';
import type { IGameContext } from '../../src/core/game';
import type { Part } from '../../src/core/part';
function createTestContext() {
const registry = createCommandRegistry<IGameContext>();
registerTicTacToeCommands(registry);
const ctx = createGameContext(registry);
return { registry, ctx };
}
function setupBoard(ctx: IGameContext) {
ctx.regions.add({
id: 'board',
axes: [
{ name: 'x', min: 0, max: 2 },
{ name: 'y', min: 0, max: 2 },
],
children: [],
});
}
function addPiece(ctx: IGameContext, id: string, row: number, col: number) {
const board = ctx.regions.get('board');
const part: Part = {
id,
sides: 1,
side: 0,
region: board,
position: [row, col],
};
ctx.parts.add(part);
board.value.children.push(ctx.parts.get(id));
}
describe('TicTacToe - helper functions', () => {
describe('checkWinner', () => {
it('should return null for empty board', () => {
const { ctx } = createTestContext();
setupBoard(ctx);
expect(checkWinner(ctx)).toBeNull();
});
it('should detect horizontal win for X', () => {
const { ctx } = createTestContext();
setupBoard(ctx);
addPiece(ctx, 'piece-1', 0, 0);
addPiece(ctx, 'piece-2', 1, 0);
addPiece(ctx, 'piece-3', 0, 1);
addPiece(ctx, 'piece-4', 1, 1);
addPiece(ctx, 'piece-5', 0, 2);
expect(checkWinner(ctx)).toBe('X');
});
it('should detect horizontal win for O', () => {
const { ctx } = createTestContext();
setupBoard(ctx);
addPiece(ctx, 'piece-1', 2, 0);
addPiece(ctx, 'piece-2', 1, 0);
addPiece(ctx, 'piece-3', 2, 1);
addPiece(ctx, 'piece-4', 1, 1);
addPiece(ctx, 'piece-5', 0, 0);
addPiece(ctx, 'piece-6', 1, 2);
expect(checkWinner(ctx)).toBe('O');
});
it('should detect vertical win', () => {
const { ctx } = createTestContext();
setupBoard(ctx);
addPiece(ctx, 'piece-1', 0, 0);
addPiece(ctx, 'piece-2', 0, 1);
addPiece(ctx, 'piece-3', 1, 0);
addPiece(ctx, 'piece-4', 1, 1);
addPiece(ctx, 'piece-5', 2, 0);
expect(checkWinner(ctx)).toBe('X');
});
it('should detect diagonal win (top-left to bottom-right)', () => {
const { ctx } = createTestContext();
setupBoard(ctx);
addPiece(ctx, 'piece-1', 0, 0);
addPiece(ctx, 'piece-2', 0, 1);
addPiece(ctx, 'piece-3', 1, 1);
addPiece(ctx, 'piece-4', 0, 2);
addPiece(ctx, 'piece-5', 2, 2);
expect(checkWinner(ctx)).toBe('X');
});
it('should detect diagonal win (top-right to bottom-left)', () => {
const { ctx } = createTestContext();
setupBoard(ctx);
addPiece(ctx, 'piece-1', 0, 0);
addPiece(ctx, 'piece-2', 0, 2);
addPiece(ctx, 'piece-3', 1, 0);
addPiece(ctx, 'piece-4', 1, 1);
addPiece(ctx, 'piece-5', 1, 2);
addPiece(ctx, 'piece-6', 2, 0);
expect(checkWinner(ctx)).toBe('O');
});
it('should return null for no winner', () => {
const { ctx } = createTestContext();
setupBoard(ctx);
addPiece(ctx, 'piece-1', 0, 0);
addPiece(ctx, 'piece-2', 0, 1);
addPiece(ctx, 'piece-3', 1, 2);
expect(checkWinner(ctx)).toBeNull();
});
});
describe('isCellOccupied', () => {
it('should return false for empty cell', () => {
const { ctx } = createTestContext();
setupBoard(ctx);
expect(isCellOccupied(ctx, 1, 1)).toBe(false);
});
it('should return true for occupied cell', () => {
const { ctx } = createTestContext();
setupBoard(ctx);
addPiece(ctx, 'piece-1', 1, 1);
expect(isCellOccupied(ctx, 1, 1)).toBe(true);
});
it('should return false for different cell', () => {
const { ctx } = createTestContext();
setupBoard(ctx);
addPiece(ctx, 'piece-1', 0, 0);
expect(isCellOccupied(ctx, 1, 1)).toBe(false);
});
});
describe('placePiece', () => {
it('should add a piece to the board', () => {
const { ctx } = createTestContext();
setupBoard(ctx);
placePiece(ctx, 1, 1, 1);
expect(ctx.parts.get('piece-1')).not.toBeNull();
expect(ctx.parts.get('piece-1').value.position).toEqual([1, 1]);
});
it('should add piece to board region children', () => {
const { ctx } = createTestContext();
setupBoard(ctx);
placePiece(ctx, 0, 0, 1);
const board = ctx.regions.get('board');
expect(board.value.children.length).toBe(1);
});
});
});
describe('TicTacToe - game flow', () => {
it('should have setup and turn commands registered', () => {
const { registry } = createTestContext();
expect(registry.has('setup')).toBe(true);
expect(registry.has('turn')).toBe(true);
});
it('should setup board when setup command runs', async () => {
const { ctx } = createTestContext();
const runPromise = ctx.commands.run('setup');
const promptEvent = await ctx.prompts.pop();
expect(promptEvent).not.toBeNull();
expect(promptEvent.schema.name).toBe('play');
promptEvent.reject(new Error('test end'));
const result = await runPromise;
expect(result.success).toBe(false);
});
it('should accept valid move via turn command', async () => {
const { ctx } = createTestContext();
setupBoard(ctx);
const runPromise = ctx.commands.run('turn X 1');
const promptEvent = await ctx.prompts.pop();
expect(promptEvent).not.toBeNull();
expect(promptEvent.schema.name).toBe('play');
promptEvent.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
// After valid non-winning move, turn command prompts again, reject to stop
const promptEvent2 = await ctx.prompts.pop();
promptEvent2.reject(new Error('done'));
const result = await runPromise;
expect(result.success).toBe(false);
expect(ctx.parts.get('piece-1')).not.toBeNull();
expect(ctx.parts.get('piece-1').value.position).toEqual([1, 1]);
});
it('should reject move for wrong player and re-prompt', async () => {
const { ctx } = createTestContext();
setupBoard(ctx);
const runPromise = ctx.commands.run('turn X 1');
const promptEvent1 = await ctx.prompts.pop();
promptEvent1.resolve({ name: 'play', params: ['O', 1, 1], options: {}, flags: {} });
const promptEvent2 = await ctx.prompts.pop();
expect(promptEvent2).not.toBeNull();
promptEvent2.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
// After valid non-winning move, reject next prompt
const promptEvent3 = await ctx.prompts.pop();
promptEvent3.reject(new Error('done'));
const result = await runPromise;
expect(result.success).toBe(false);
});
it('should reject move to occupied cell and re-prompt', async () => {
const { ctx } = createTestContext();
setupBoard(ctx);
addPiece(ctx, 'piece-0', 1, 1);
const runPromise = ctx.commands.run('turn X 1');
const promptEvent1 = await ctx.prompts.pop();
promptEvent1.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
const promptEvent2 = await ctx.prompts.pop();
expect(promptEvent2).not.toBeNull();
promptEvent2.resolve({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} });
// After valid non-winning move, reject next prompt
const promptEvent3 = await ctx.prompts.pop();
promptEvent3.reject(new Error('done'));
const result = await runPromise;
expect(result.success).toBe(false);
});
it('should detect winner after winning move', async () => {
const { ctx } = createTestContext();
setupBoard(ctx);
// X plays (0,0)
let runPromise = ctx.commands.run('turn X 1');
let prompt = await ctx.prompts.pop();
prompt.resolve({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} });
let promptNext = await ctx.prompts.pop();
promptNext.reject(new Error('next turn'));
let result = await runPromise;
expect(result.success).toBe(false);
// O plays (0,1)
runPromise = ctx.commands.run('turn O 2');
prompt = await ctx.prompts.pop();
prompt.resolve({ name: 'play', params: ['O', 0, 1], options: {}, flags: {} });
promptNext = await ctx.prompts.pop();
promptNext.reject(new Error('next turn'));
result = await runPromise;
expect(result.success).toBe(false);
// X plays (1,0)
runPromise = ctx.commands.run('turn X 3');
prompt = await ctx.prompts.pop();
prompt.resolve({ name: 'play', params: ['X', 1, 0], options: {}, flags: {} });
promptNext = await ctx.prompts.pop();
promptNext.reject(new Error('next turn'));
result = await runPromise;
expect(result.success).toBe(false);
// O plays (0,2)
runPromise = ctx.commands.run('turn O 4');
prompt = await ctx.prompts.pop();
prompt.resolve({ name: 'play', params: ['O', 0, 2], options: {}, flags: {} });
promptNext = await ctx.prompts.pop();
promptNext.reject(new Error('next turn'));
result = await runPromise;
expect(result.success).toBe(false);
// X plays (2,0) - wins with vertical line
runPromise = ctx.commands.run('turn X 5');
prompt = await ctx.prompts.pop();
prompt.resolve({ name: 'play', params: ['X', 2, 0], options: {}, flags: {} });
result = await runPromise;
expect(result.success).toBe(true);
if (result.success) expect((result.result as any).winner).toBe('X');
});
it('should detect draw after 9 moves', async () => {
const { ctx } = createTestContext();
setupBoard(ctx);
// Pre-place 8 pieces that don't form any winning line for either player
// Using positions that clearly don't form lines
// X pieces at even indices, O pieces at odd indices
const pieces = [
{ id: 'p1', pos: [0, 0] }, // X
{ id: 'p2', pos: [2, 2] }, // O
{ id: 'p3', pos: [0, 2] }, // X
{ id: 'p4', pos: [2, 0] }, // O
{ id: 'p5', pos: [1, 0] }, // X
{ id: 'p6', pos: [0, 1] }, // O
{ id: 'p7', pos: [2, 1] }, // X
{ id: 'p8', pos: [1, 2] }, // O
];
for (const { id, pos } of pieces) {
addPiece(ctx, id, pos[0], pos[1]);
}
// Verify no winner before 9th move
expect(checkWinner(ctx)).toBeNull();
// Now X plays (1,1) for the 9th move -> draw
const runPromise = ctx.commands.run('turn X 9');
const prompt = await ctx.prompts.pop();
prompt.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
const result = await runPromise;
expect(result.success).toBe(true);
if (result.success) expect((result.result as any).winner).toBe('draw');
});
});