tests: game and tic tac toe
This commit is contained in:
parent
1cb7fa05ec
commit
846badc081
|
|
@ -13,30 +13,18 @@ type TurnResult = {
|
|||
winner: 'X' | 'O' | 'draw' | null;
|
||||
};
|
||||
|
||||
function getBoardRegion(host: IGameContext) {
|
||||
export function getBoardRegion(host: IGameContext) {
|
||||
return host.regions.get('board');
|
||||
}
|
||||
|
||||
function isCellOccupied(host: IGameContext, row: number, col: number): boolean {
|
||||
export function isCellOccupied(host: IGameContext, row: number, col: number): boolean {
|
||||
const board = getBoardRegion(host);
|
||||
return board.value.children.some(
|
||||
(child: { value: { position: number[] } }) => child.value.position[0] === row && child.value.position[1] === col
|
||||
);
|
||||
}
|
||||
|
||||
function checkWinner(host: IGameContext): 'X' | 'O' | 'draw' | null {
|
||||
const parts = Object.values(host.parts.collection.value).map((s: { value: Part }) => s.value);
|
||||
|
||||
const xPositions = parts.filter((_: Part, i: number) => i % 2 === 0).map((p: Part) => p.position);
|
||||
const oPositions = parts.filter((_: Part, i: number) => i % 2 === 1).map((p: Part) => p.position);
|
||||
|
||||
if (hasWinningLine(xPositions)) return 'X';
|
||||
if (hasWinningLine(oPositions)) return 'O';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasWinningLine(positions: number[][]): boolean {
|
||||
export function hasWinningLine(positions: number[][]): boolean {
|
||||
const lines = [
|
||||
[[0, 0], [0, 1], [0, 2]],
|
||||
[[1, 0], [1, 1], [1, 2]],
|
||||
|
|
@ -55,7 +43,19 @@ function hasWinningLine(positions: number[][]): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function placePiece(host: IGameContext, row: number, col: number, moveCount: number) {
|
||||
export function checkWinner(host: IGameContext): 'X' | 'O' | 'draw' | null {
|
||||
const parts = Object.values(host.parts.collection.value).map((s: { value: Part }) => s.value);
|
||||
|
||||
const xPositions = parts.filter((_: Part, i: number) => i % 2 === 0).map((p: Part) => p.position);
|
||||
const oPositions = parts.filter((_: Part, i: number) => i % 2 === 1).map((p: Part) => p.position);
|
||||
|
||||
if (hasWinningLine(xPositions)) return 'X';
|
||||
if (hasWinningLine(oPositions)) return 'O';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function placePiece(host: IGameContext, row: number, col: number, moveCount: number) {
|
||||
const board = getBoardRegion(host);
|
||||
const piece: Part = {
|
||||
id: `piece-${moveCount}`,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { createGameContext, createGameCommand } from '../../src/core/game';
|
||||
import { createCommandRegistry, parseCommandSchema, type CommandRegistry } from '../../src/utils/command';
|
||||
import type { IGameContext } from '../../src/core/game';
|
||||
|
||||
describe('createGameContext', () => {
|
||||
it('should create a game context with empty parts and regions', () => {
|
||||
const registry = createCommandRegistry<IGameContext>();
|
||||
const ctx = createGameContext(registry);
|
||||
|
||||
expect(ctx.parts.collection.value).toEqual({});
|
||||
expect(ctx.regions.collection.value).toEqual({});
|
||||
});
|
||||
|
||||
it('should wire commands to the context', () => {
|
||||
const registry = createCommandRegistry<IGameContext>();
|
||||
const ctx = createGameContext(registry);
|
||||
|
||||
expect(ctx.commands).not.toBeNull();
|
||||
expect(ctx.commands.registry).toBe(registry);
|
||||
expect(ctx.commands.context).toBe(ctx);
|
||||
});
|
||||
|
||||
it('should forward prompt events to the prompts queue', async () => {
|
||||
const registry = createCommandRegistry<IGameContext>();
|
||||
const ctx = createGameContext(registry);
|
||||
|
||||
const schema = parseCommandSchema('test <value>');
|
||||
registry.set('test', {
|
||||
schema,
|
||||
run: async function () {
|
||||
return this.prompt('prompt <answer>');
|
||||
},
|
||||
});
|
||||
|
||||
const runPromise = ctx.commands.run('test hello');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const promptEvent = await ctx.prompts.pop();
|
||||
expect(promptEvent).not.toBeNull();
|
||||
expect(promptEvent.schema.name).toBe('prompt');
|
||||
|
||||
promptEvent.resolve({ name: 'prompt', params: ['yes'], options: {}, flags: {} });
|
||||
|
||||
const result = await runPromise;
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect((result.result as any).params[0]).toBe('yes');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGameCommand', () => {
|
||||
it('should create a command from a string schema', () => {
|
||||
const cmd = createGameCommand('test <a>', async function () {
|
||||
return 1;
|
||||
});
|
||||
|
||||
expect(cmd.schema.name).toBe('test');
|
||||
expect(cmd.schema.params[0].name).toBe('a');
|
||||
expect(cmd.schema.params[0].required).toBe(true);
|
||||
});
|
||||
|
||||
it('should create a command from a CommandSchema object', () => {
|
||||
const schema = parseCommandSchema('foo <x> [y]');
|
||||
const cmd = createGameCommand(schema, async function () {
|
||||
return 2;
|
||||
});
|
||||
|
||||
expect(cmd.schema.name).toBe('foo');
|
||||
expect(cmd.schema.params[0].name).toBe('x');
|
||||
expect(cmd.schema.params[0].required).toBe(true);
|
||||
expect(cmd.schema.params[1].name).toBe('y');
|
||||
expect(cmd.schema.params[1].required).toBe(false);
|
||||
});
|
||||
|
||||
it('should run a command with access to game context', async () => {
|
||||
const registry = createCommandRegistry<IGameContext>();
|
||||
const ctx = createGameContext(registry);
|
||||
|
||||
const addRegion = createGameCommand('add-region <id>', async function (cmd) {
|
||||
const id = cmd.params[0] as string;
|
||||
this.context.regions.add({ id, axes: [], children: [] });
|
||||
return id;
|
||||
});
|
||||
|
||||
registry.set('add-region', addRegion);
|
||||
|
||||
const result = await ctx.commands.run('add-region board');
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.result).toBe('board');
|
||||
}
|
||||
expect(ctx.regions.get('board')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should run a command that adds parts', async () => {
|
||||
const registry = createCommandRegistry<IGameContext>();
|
||||
const ctx = createGameContext(registry);
|
||||
|
||||
ctx.regions.add({ id: 'zone', axes: [], children: [] });
|
||||
|
||||
const addPart = createGameCommand('add-part <id>', async function (cmd) {
|
||||
const id = cmd.params[0] as string;
|
||||
const part = {
|
||||
id,
|
||||
sides: 1,
|
||||
side: 0,
|
||||
region: this.context.regions.get('zone'),
|
||||
position: [0],
|
||||
};
|
||||
this.context.parts.add(part);
|
||||
return id;
|
||||
});
|
||||
|
||||
registry.set('add-part', addPart);
|
||||
|
||||
const result = await ctx.commands.run('add-part piece-1');
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.result).toBe('piece-1');
|
||||
}
|
||||
expect(ctx.parts.get('piece-1')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue