From 6352977791f7cbdc956f86511a37e8d3b575edc8 Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 6 Apr 2026 16:09:05 +0800 Subject: [PATCH] chore: add more tests --- src/index.ts | 2 +- tests/core/game-host.test.ts | 118 ++++++++++++++++++ tests/core/game.test.ts | 58 ++++++++- tests/core/region.test.ts | 47 +++++++- tests/samples/boop-utils.test.ts | 191 ++++++++++++++++++++++++++++++ tests/samples/boop.test.ts | 137 +++++++++++++++++++++ tests/samples/tic-tac-toe.test.ts | 39 +++++- tests/utils/rng.test.ts | 46 ++++++- 8 files changed, 633 insertions(+), 5 deletions(-) create mode 100644 tests/samples/boop-utils.test.ts diff --git a/src/index.ts b/src/index.ts index 46c8735..7176b12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ // Core types export type { IGameContext } from './core/game'; -export { createGameCommandRegistry } from './core/game'; +export { createGameCommandRegistry, createPromptDef } from './core/game'; export type { GameHost, GameHostStatus, GameModule } from './core/game-host'; export { createGameHost } from './core/game-host'; diff --git a/tests/core/game-host.test.ts b/tests/core/game-host.test.ts index 303d990..06cf0d3 100644 --- a/tests/core/game-host.test.ts +++ b/tests/core/game-host.test.ts @@ -515,4 +515,122 @@ describe('GameHost', () => { expect(host.activePromptPlayer.value).toBeNull(); }); }); + + describe('tryAnswerPrompt', () => { + it('should answer prompt with valid arguments', async () => { + const { host } = createTestHost(); + + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); + + const promptEvent = await promptPromise; + expect(promptEvent.schema.name).toBe('play'); + + // Use tryAnswerPrompt with the prompt def + const { prompts } = await import('@/samples/tic-tac-toe'); + const error = host.tryAnswerPrompt(prompts.play, 'X', 1, 1); + expect(error).toBeNull(); + + // Wait for next prompt and cancel + const nextPromptPromise = waitForPromptEvent(host); + const nextPrompt = await nextPromptPromise; + nextPrompt.cancel('test cleanup'); + + try { + await runPromise; + } catch (e) { + const error = e as Error; + expect(error.message).toBe('test cleanup'); + } + }); + + it('should reject invalid arguments', async () => { + const { host } = createTestHost(); + + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); + + const promptEvent = await promptPromise; + + // Use tryAnswerPrompt with invalid position + const { prompts } = await import('@/samples/tic-tac-toe'); + const error = host.tryAnswerPrompt(prompts.play, 'X', 5, 5); + expect(error).not.toBeNull(); + + promptEvent.cancel('test cleanup'); + try { + await runPromise; + } catch (e) { + const error = e as Error; + expect(error.message).toBe('test cleanup'); + } + }); + }); + + describe('addInterruption and clearInterruptions', () => { + it('should add interruption promise to state', async () => { + const { host } = createTestHost(); + + let resolveInterruption: () => void; + const interruptionPromise = new Promise(resolve => { + resolveInterruption = resolve; + }); + + // Add interruption + host.addInterruption(interruptionPromise); + + // Start the game - produceAsync should wait for interruption + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); + + const promptEvent = await promptPromise; + + // Resolve interruption + resolveInterruption!(); + + // Cancel and cleanup + promptEvent.cancel('test cleanup'); + try { + await runPromise; + } catch { + // Expected + } + }); + + it('should clear all pending interruptions', async () => { + const { host } = createTestHost(); + + let resolveInterruption1: () => void; + let resolveInterruption2: () => void; + const interruptionPromise1 = new Promise(resolve => { + resolveInterruption1 = resolve; + }); + const interruptionPromise2 = new Promise(resolve => { + resolveInterruption2 = resolve; + }); + + // Add multiple interruptions + host.addInterruption(interruptionPromise1); + host.addInterruption(interruptionPromise2); + + // Clear all interruptions + host.clearInterruptions(); + + // Start the game - should not wait for cleared interruptions + const promptPromise = waitForPromptEvent(host); + const runPromise = host.start(); + + const promptEvent = await promptPromise; + promptEvent.cancel('test cleanup'); + + try { + await runPromise; + } catch { + // Expected + } + + // Original interruption promises should still be pending + // (they were cleared, not resolved) + }); + }); }); diff --git a/tests/core/game.test.ts b/tests/core/game.test.ts index 4acb49a..de82ff6 100644 --- a/tests/core/game.test.ts +++ b/tests/core/game.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createGameContext, createGameCommandRegistry, IGameContext } from '@/core/game'; +import { createGameContext, createGameCommandRegistry, createPromptDef, IGameContext, PromptDef } from '@/core/game'; import type { PromptEvent, Command } from '@/utils/command'; type MyState = { @@ -132,3 +132,59 @@ describe('createGameCommand', () => { } }); }); + +describe('createPromptDef', () => { + it('should create a PromptDef with string schema', () => { + const promptDef = createPromptDef<[string, number]>('play '); + + expect(promptDef).toBeDefined(); + expect(promptDef.schema).toBe('play '); + }); + + it('should create a PromptDef with CommandSchema object', () => { + const schemaObj = { + name: 'test', + params: [], + options: {}, + flags: {} + }; + const promptDef = createPromptDef<[]>(schemaObj); + + expect(promptDef.schema).toEqual(schemaObj); + }); + + it('should be usable with game.prompt', async () => { + const registry = createGameCommandRegistry<{ score: number }>(); + + registry.register('test-prompt', async function(ctx) { + const promptDef = createPromptDef<[number]>('input '); + const result = await ctx.prompt( + promptDef, + (value) => { + if (value < 0) throw 'Value must be positive'; + return value; + } + ); + return result; + }); + + const ctx = createGameContext(registry, { score: 0 }); + + const promptPromise = new Promise(resolve => { + ctx._commands.on('prompt', resolve); + }); + const runPromise = ctx.run('test-prompt'); + + const promptEvent = await promptPromise; + expect(promptEvent.schema.name).toBe('input'); + + const error = promptEvent.tryCommit({ name: 'input', params: [42], options: {}, flags: {} }); + expect(error).toBeNull(); + + const result = await runPromise; + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe(42); + } + }); +}); diff --git a/tests/core/region.test.ts b/tests/core/region.test.ts index ee066f1..a10f80b 100644 --- a/tests/core/region.test.ts +++ b/tests/core/region.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createRegion, applyAlign, shuffle, moveToRegion, type Region, type RegionAxis } from '@/core/region'; +import { createRegion, createRegionAxis, applyAlign, shuffle, moveToRegion, type Region, type RegionAxis } from '@/core/region'; import { createRNG } from '@/utils/rng'; import { type Part } from '@/core/part'; @@ -304,3 +304,48 @@ describe('Region', () => { }); }); }); + +describe('createRegionAxis', () => { + it('should create axis with name only', () => { + const axis = createRegionAxis('x'); + + expect(axis.name).toBe('x'); + expect(axis.min).toBeUndefined(); + expect(axis.max).toBeUndefined(); + expect(axis.align).toBeUndefined(); + }); + + it('should create axis with min and max', () => { + const axis = createRegionAxis('y', 0, 10); + + expect(axis.name).toBe('y'); + expect(axis.min).toBe(0); + expect(axis.max).toBe(10); + }); + + it('should create axis with align start', () => { + const axis = createRegionAxis('x', 0, 5, 'start'); + + expect(axis.name).toBe('x'); + expect(axis.min).toBe(0); + expect(axis.max).toBe(5); + expect(axis.align).toBe('start'); + }); + + it('should create axis with align end', () => { + const axis = createRegionAxis('x', undefined, 10, 'end'); + + expect(axis.name).toBe('x'); + expect(axis.max).toBe(10); + expect(axis.align).toBe('end'); + }); + + it('should create axis with align center', () => { + const axis = createRegionAxis('x', 0, 10, 'center'); + + expect(axis.name).toBe('x'); + expect(axis.min).toBe(0); + expect(axis.max).toBe(10); + expect(axis.align).toBe('center'); + }); +}); diff --git a/tests/samples/boop-utils.test.ts b/tests/samples/boop-utils.test.ts new file mode 100644 index 0000000..7561131 --- /dev/null +++ b/tests/samples/boop-utils.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect } from 'vitest'; +import { + getLineCandidates, + isInBounds, + isCellOccupied, + getNeighborPositions, + findPartInRegion, + findPartAtPosition +} from '@/samples/boop/utils'; +import { createInitialState, BOARD_SIZE, WIN_LENGTH } from '@/samples/boop/data'; +import { createGameContext } from '@/core/game'; +import { registry } from '@/samples/boop'; + +describe('Boop Utils', () => { + describe('isInBounds', () => { + it('should return true for valid board positions', () => { + expect(isInBounds(0, 0)).toBe(true); + expect(isInBounds(3, 3)).toBe(true); + expect(isInBounds(5, 5)).toBe(true); + }); + + it('should return false for positions outside board', () => { + expect(isInBounds(-1, 0)).toBe(false); + expect(isInBounds(0, -1)).toBe(false); + expect(isInBounds(BOARD_SIZE, 0)).toBe(false); + expect(isInBounds(0, BOARD_SIZE)).toBe(false); + expect(isInBounds(6, 6)).toBe(false); + }); + }); + + describe('getLineCandidates', () => { + it('should generate all possible winning lines', () => { + const lines = Array.from(getLineCandidates()); + // For 8x8 board with WIN_LENGTH=3: + // 4 directions × various starting positions + expect(lines.length).toBeGreaterThan(0); + }); + + it('should generate lines with correct length', () => { + const lines = Array.from(getLineCandidates()); + for (const line of lines) { + expect(line.length).toBe(WIN_LENGTH); + } + }); + + it('should generate horizontal lines', () => { + const lines = Array.from(getLineCandidates()); + const horizontalLines = lines.filter(line => + line.every(([_, y]) => y === line[0][1]) + ); + expect(horizontalLines.length).toBeGreaterThan(0); + // First horizontal line should start at [0,0], [1,0], [2,0] (direction [0,1] means varying x) + const firstHorizontal = horizontalLines[0]; + expect(firstHorizontal).toEqual([[0, 0], [1, 0], [2, 0]]); + }); + + it('should generate vertical lines', () => { + const lines = Array.from(getLineCandidates()); + const verticalLines = lines.filter(line => + line.every(([x, _]) => x === line[0][0]) + ); + expect(verticalLines.length).toBeGreaterThan(0); + }); + + it('should generate diagonal lines', () => { + const lines = Array.from(getLineCandidates()); + const diagonalLines = lines.filter(line => { + const [[x1, y1], [x2, y2]] = line; + return x1 !== x2 && y1 !== y2; + }); + expect(diagonalLines.length).toBeGreaterThan(0); + }); + + it('should only include lines that fit within board bounds', () => { + const lines = Array.from(getLineCandidates()); + for (const line of lines) { + for (const [x, y] of line) { + expect(isInBounds(x, y)).toBe(true); + } + } + }); + }); + + describe('isCellOccupied', () => { + it('should return false for empty cell in initial state', () => { + const state = createInitialState(); + expect(isCellOccupied(state, 0, 0)).toBe(false); + expect(isCellOccupied(state, 3, 3)).toBe(false); + }); + + it('should return true for occupied cell', async () => { + const ctx = createGameContext(registry, createInitialState()); + + // Place a piece via command (need to await) + await ctx._commands.run('place 2 2 white kitten'); + + expect(isCellOccupied(ctx, 2, 2)).toBe(true); + }); + }); + + describe('getNeighborPositions', () => { + it('should return 8 neighbor positions for center position', () => { + const neighbors = Array.from(getNeighborPositions(2, 2)); + expect(neighbors.length).toBe(8); + + const expected = [ + [1, 1], [1, 2], [1, 3], + [2, 1], [2, 3], + [3, 1], [3, 2], [3, 3] + ]; + expect(neighbors).toEqual(expect.arrayContaining(expected)); + }); + + it('should include diagonal neighbors', () => { + const neighbors = Array.from(getNeighborPositions(0, 0)); + expect(neighbors).toContainEqual([1, 1]); + expect(neighbors).toContainEqual([-1, -1]); + }); + + it('should not include the center position itself', () => { + const neighbors = Array.from(getNeighborPositions(5, 5)); + expect(neighbors).not.toContainEqual([5, 5]); + }); + }); + + describe('findPartInRegion', () => { + it('should find a piece in the specified region', () => { + const state = createInitialState(); + + // Find a white kitten in white's supply + const piece = findPartInRegion(state, 'white', 'kitten'); + expect(piece).not.toBeNull(); + expect(piece?.player).toBe('white'); + expect(piece?.type).toBe('kitten'); + expect(piece?.regionId).toBe('white'); + }); + + it('should return null if no matching piece in region', () => { + const state = createInitialState(); + + // No kittens on board initially + const piece = findPartInRegion(state, 'board', 'kitten'); + expect(piece).toBeNull(); + }); + + it('should filter by player when specified', () => { + const state = createInitialState(); + + const whitePiece = findPartInRegion(state, 'white', 'kitten', 'white'); + expect(whitePiece).not.toBeNull(); + expect(whitePiece?.player).toBe('white'); + + const blackPiece = findPartInRegion(state, 'white', 'kitten', 'black'); + expect(blackPiece).toBeNull(); + }); + + it('should search all regions when regionId is empty string', () => { + const state = createInitialState(); + + // Find any cat piece + const piece = findPartInRegion(state, '', 'cat'); + expect(piece).not.toBeNull(); + expect(piece?.type).toBe('cat'); + }); + }); + + describe('findPartAtPosition', () => { + it('should return null for empty position', () => { + const state = createInitialState(); + expect(findPartAtPosition(state, 0, 0)).toBeNull(); + }); + + it('should find piece at specified position', async () => { + const ctx = createGameContext(registry, createInitialState()); + + // Place a piece + await ctx._commands.run('place 3 3 white kitten'); + + const piece = findPartAtPosition(ctx, 3, 3); + expect(piece).not.toBeNull(); + expect(piece?.player).toBe('white'); + expect(piece?.type).toBe('kitten'); + }); + + it('should work with game context', () => { + const ctx = createGameContext(registry, createInitialState()); + const piece = findPartAtPosition(ctx, 5, 5); + expect(piece).toBeNull(); + }); + }); +}); diff --git a/tests/samples/boop.test.ts b/tests/samples/boop.test.ts index 56cc44c..27fb41f 100644 --- a/tests/samples/boop.test.ts +++ b/tests/samples/boop.test.ts @@ -531,4 +531,141 @@ describe('Boop Game', () => { expect(whiteCatsInSupply.length).toBe(0); }); }); + + describe('Check Full Board', () => { + it('should not trigger when player has fewer than 8 pieces on board', async () => { + const { ctx } = createTestContext(); + + // White places a single kitten + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx.run('turn white'); + const prompt = await promptPromise; + const error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} }); + expect(error).toBeNull(); + const result = await runPromise; + expect(result.success).toBe(true); + + // check-full-board should return without prompting + const fullBoardResult = await ctx._commands.run('check-full-board white'); + expect(fullBoardResult.success).toBe(true); + }); + + it('should force graduation when all 8 pieces are on board', async () => { + const { ctx } = createTestContext(); + + // Manually place all 8 white pieces on the board + ctx.produce(state => { + for (let i = 1; i <= 8; i++) { + const piece = state.pieces[`white-kitten-${i}`]; + if (piece) { + const row = Math.floor((i - 1) / 4); + const col = (i - 1) % 4; + piece.regionId = 'board'; + piece.position = [row, col]; + state.regions.board.partMap[`${row},${col}`] = piece.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== piece.id); + } + } + }); + + // Verify 8 pieces on board + const stateBefore = ctx.value; + expect(Object.keys(stateBefore.regions.board.partMap).length).toBe(8); + + // Run check-full-board - should prompt for piece to graduate + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx._commands.run('check-full-board white'); + const prompt = await promptPromise; + expect(prompt.schema.name).toBe('choose'); + + // Select a piece to graduate + const error = prompt.tryCommit({ name: 'choose', params: ['white', 0, 0], options: {}, flags: {} }); + expect(error).toBeNull(); + + const result = await runPromise; + expect(result.success).toBe(true); + + const state = ctx.value; + // Position 0,0 should be empty (piece moved to box) + expect(state.regions.board.partMap['0,0']).toBeUndefined(); + }); + + it('should not trigger when player has a winner', async () => { + const { ctx } = createTestContext(); + + // Set up a winning state for white + ctx.produce(state => { + state.winner = 'white'; + for (let i = 1; i <= 8; i++) { + const piece = state.pieces[`white-kitten-${i}`]; + if (piece) { + const row = Math.floor((i - 1) / 4); + const col = (i - 1) % 4; + piece.regionId = 'board'; + piece.position = [row, col]; + state.regions.board.partMap[`${row},${col}`] = piece.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== piece.id); + } + } + }); + + // check-full-board should return without prompting + const fullBoardResult = await ctx._commands.run('check-full-board white'); + expect(fullBoardResult.success).toBe(true); + }); + }); + + describe('Start Command', () => { + it('should run a complete game until there is a winner', async () => { + const { ctx } = createTestContext(); + + // Set up a quick win scenario + ctx.produce(state => { + // Place three white cats in a row + const c1 = state.pieces['white-cat-1']; + const c2 = state.pieces['white-cat-2']; + const c3 = state.pieces['white-cat-3']; + + if (c1) { + c1.type = 'cat'; + c1.regionId = 'board'; + c1.position = [0, 0]; + state.regions.board.partMap['0,0'] = c1.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== c1.id); + } + if (c2) { + c2.type = 'cat'; + c2.regionId = 'board'; + c2.position = [0, 1]; + state.regions.board.partMap['0,1'] = c2.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== c2.id); + } + if (c3) { + c3.type = 'cat'; + c3.regionId = 'board'; + c3.position = [0, 2]; + state.regions.board.partMap['0,2'] = c3.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== c3.id); + } + }); + + // start() should detect the win and return quickly + // Note: start() runs indefinitely until there's a winner + // Since we already set up a win, it should complete after one turn + const promptPromise = waitForPrompt(ctx); + const startPromise = ctx.run('turn white'); + + const prompt = await promptPromise; + // Complete the turn + const error = prompt.tryCommit({ name: 'play', params: ['white', 3, 3, 'kitten'], options: {}, flags: {} }); + expect(error).toBeNull(); + + const result = await startPromise; + expect(result.success).toBe(true); + + // Game should have detected white's win + const state = ctx.value; + // After turn, check-win would find white's existing win + }); + }); }); diff --git a/tests/samples/tic-tac-toe.test.ts b/tests/samples/tic-tac-toe.test.ts index 31bd0d0..7bf9236 100644 --- a/tests/samples/tic-tac-toe.test.ts +++ b/tests/samples/tic-tac-toe.test.ts @@ -8,7 +8,8 @@ import { TicTacToeState, TicTacToeGame, WinnerType, - PlayerType + PlayerType, + hasWinningLine } from '@/samples/tic-tac-toe'; import { createGameContext } from '@/core/game'; import type { PromptEvent } from '@/utils/command'; @@ -143,6 +144,42 @@ describe('TicTacToe - helper functions', () => { }); }); + describe('hasWinningLine', () => { + it('should return true for horizontal winning line', () => { + const positions = [[0, 0], [0, 1], [0, 2]]; + expect(hasWinningLine(positions)).toBe(true); + }); + + it('should return true for vertical winning line', () => { + const positions = [[0, 0], [1, 0], [2, 0]]; + expect(hasWinningLine(positions)).toBe(true); + }); + + it('should return true for diagonal winning line (top-left to bottom-right)', () => { + const positions = [[0, 0], [1, 1], [2, 2]]; + expect(hasWinningLine(positions)).toBe(true); + }); + + it('should return true for diagonal winning line (top-right to bottom-left)', () => { + const positions = [[0, 2], [1, 1], [2, 0]]; + expect(hasWinningLine(positions)).toBe(true); + }); + + it('should return false for non-winning positions', () => { + const positions = [[0, 0], [0, 1], [1, 0]]; + expect(hasWinningLine(positions)).toBe(false); + }); + + it('should return false for empty positions', () => { + expect(hasWinningLine([])).toBe(false); + }); + + it('should return true when positions contain winning line plus extra', () => { + const positions = [[0, 0], [0, 1], [0, 2], [2, 2]]; + expect(hasWinningLine(positions)).toBe(true); + }); + }); + describe('placePiece', () => { it('should add a piece to the board', () => { const { ctx } = createTestContext(); diff --git a/tests/utils/rng.test.ts b/tests/utils/rng.test.ts index 24811ab..aed2e65 100644 --- a/tests/utils/rng.test.ts +++ b/tests/utils/rng.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createRNG } from '@/utils/rng'; +import { createRNG, Mulberry32RNG } from '@/utils/rng'; describe('createRNG', () => { it('should create RNG with default seed', () => { @@ -90,3 +90,47 @@ describe('createRNG', () => { }); }); }); + +describe('Mulberry32RNG', () => { + it('should instantiate with default seed', () => { + const rng = new Mulberry32RNG(); + expect(rng.getSeed()).toBe(1); + }); + + it('should instantiate with custom seed', () => { + const rng = new Mulberry32RNG(99999); + expect(rng.getSeed()).toBe(99999); + }); + + it('should implement RNG interface', () => { + const rng = new Mulberry32RNG(42); + + // Should have all RNG methods + expect(typeof rng.next).toBe('function'); + expect(typeof rng.nextInt).toBe('function'); + expect(typeof rng.setSeed).toBe('function'); + expect(typeof rng.getSeed).toBe('function'); + }); + + it('should produce same results as createRNG with same seed', () => { + const factoryRng = createRNG(12345); + const directRng = new Mulberry32RNG(12345); + + for (let i = 0; i < 10; i++) { + expect(factoryRng.next()).toBe(directRng.next()); + expect(factoryRng.nextInt(100)).toBe(directRng.nextInt(100)); + } + }); + + it('should allow seed changes after instantiation', () => { + const rng = new Mulberry32RNG(100); + expect(rng.getSeed()).toBe(100); + + rng.setSeed(200); + expect(rng.getSeed()).toBe(200); + + const value = rng.next(); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + }); +});