From 846badc08192d4b91fe3e08ff129105279a8a30d Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 2 Apr 2026 11:21:57 +0800 Subject: [PATCH] tests: game and tic tac toe --- src/samples/tic-tac-toe.ts | 32 +-- tests/core/game.test.ts | 126 +++++++++++ tests/samples/tic-tac-toe.test.ts | 346 ++++++++++++++++++++++++++++++ 3 files changed, 488 insertions(+), 16 deletions(-) create mode 100644 tests/core/game.test.ts create mode 100644 tests/samples/tic-tac-toe.test.ts diff --git a/src/samples/tic-tac-toe.ts b/src/samples/tic-tac-toe.ts index d3eb09f..4ec3909 100644 --- a/src/samples/tic-tac-toe.ts +++ b/src/samples/tic-tac-toe.ts @@ -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}`, diff --git a/tests/core/game.test.ts b/tests/core/game.test.ts new file mode 100644 index 0000000..6689005 --- /dev/null +++ b/tests/core/game.test.ts @@ -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(); + 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(); + 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(); + const ctx = createGameContext(registry); + + const schema = parseCommandSchema('test '); + registry.set('test', { + schema, + run: async function () { + return this.prompt('prompt '); + }, + }); + + 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 ', 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 [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(); + const ctx = createGameContext(registry); + + const addRegion = createGameCommand('add-region ', 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(); + const ctx = createGameContext(registry); + + ctx.regions.add({ id: 'zone', axes: [], children: [] }); + + const addPart = createGameCommand('add-part ', 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(); + }); +}); diff --git a/tests/samples/tic-tac-toe.test.ts b/tests/samples/tic-tac-toe.test.ts new file mode 100644 index 0000000..d5effef --- /dev/null +++ b/tests/samples/tic-tac-toe.test.ts @@ -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(); + 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'); + }); +});