From b9105efd03ec6ef50fbf51b5e74919a280bf3485 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 4 Apr 2026 22:23:15 +0800 Subject: [PATCH] fix: fix tests --- tests/core/game-host.test.ts | 76 +++--- tests/core/game.test.ts | 81 +++--- tests/core/region.test.ts | 74 +----- tests/samples/boop.test.ts | 390 +++++++++++++++++++++++++++++ tests/utils/command-runner.test.ts | 33 +-- 5 files changed, 486 insertions(+), 168 deletions(-) diff --git a/tests/core/game-host.test.ts b/tests/core/game-host.test.ts index 431c6b9..240ea7f 100644 --- a/tests/core/game-host.test.ts +++ b/tests/core/game-host.test.ts @@ -19,7 +19,7 @@ function createTestHost() { function waitForPromptEvent(host: GameHost): Promise { return new Promise(resolve => { - host.commands.on('prompt', resolve); + host.context._commands.on('prompt', resolve); }); } @@ -28,10 +28,10 @@ describe('GameHost', () => { it('should create host with initial state', () => { const { host } = createTestHost(); - expect(host.state.value.currentPlayer).toBe('X'); - expect(host.state.value.winner).toBeNull(); - expect(host.state.value.turn).toBe(0); - expect(Object.keys(host.state.value.parts).length).toBe(0); + expect(host.context._state.value.currentPlayer).toBe('X'); + expect(host.context._state.value.winner).toBeNull(); + expect(host.context._state.value.turn).toBe(0); + expect(Object.keys(host.context._state.value.parts).length).toBe(0); }); it('should have status "created" by default', () => { @@ -59,7 +59,7 @@ describe('GameHost', () => { const { host } = createTestHost(); const promptPromise = waitForPromptEvent(host); - const runPromise = host.commands.run('setup'); + const runPromise = host.context._commands.run('setup'); const promptEvent = await promptPromise; expect(promptEvent.schema.name).toBe('play'); @@ -81,7 +81,7 @@ describe('GameHost', () => { const { host } = createTestHost(); const promptPromise = waitForPromptEvent(host); - const runPromise = host.commands.run('setup'); + const runPromise = host.context._commands.run('setup'); const promptEvent = await promptPromise; @@ -106,7 +106,7 @@ describe('GameHost', () => { const { host } = createTestHost(); const promptPromise = waitForPromptEvent(host); - const runPromise = host.commands.run('setup'); + const runPromise = host.context._commands.run('setup'); const promptEvent = await promptPromise; const schema = host.activePromptSchema.value; @@ -131,7 +131,7 @@ describe('GameHost', () => { // First setup - make one move let promptPromise = waitForPromptEvent(host); - let runPromise = host.commands.run('setup'); + let runPromise = host.context._commands.run('setup'); let promptEvent = await promptPromise; // Make a move @@ -144,7 +144,7 @@ describe('GameHost', () => { let result = await runPromise; expect(result.success).toBe(false); // Cancelled - expect(Object.keys(host.state.value.parts).length).toBe(1); + expect(Object.keys(host.context._state.value.parts).length).toBe(1); // Setup listener before calling setup const newPromptPromise = waitForPromptEvent(host); @@ -153,10 +153,10 @@ describe('GameHost', () => { await host.setup('setup'); // State should be back to initial - expect(host.state.value.currentPlayer).toBe('X'); - expect(host.state.value.winner).toBeNull(); - expect(host.state.value.turn).toBe(0); - expect(Object.keys(host.state.value.parts).length).toBe(0); + expect(host.context._state.value.currentPlayer).toBe('X'); + expect(host.context._state.value.winner).toBeNull(); + expect(host.context._state.value.turn).toBe(0); + expect(Object.keys(host.context._state.value.parts).length).toBe(0); // New game should be running and prompting const newPrompt = await newPromptPromise; @@ -168,7 +168,7 @@ describe('GameHost', () => { const { host } = createTestHost(); const promptPromise = waitForPromptEvent(host); - const runPromise = host.commands.run('setup'); + const runPromise = host.context._commands.run('setup'); await promptPromise; @@ -184,8 +184,8 @@ describe('GameHost', () => { } // State should be reset - expect(host.state.value.currentPlayer).toBe('X'); - expect(host.state.value.turn).toBe(0); + expect(host.context._state.value.currentPlayer).toBe('X'); + expect(host.context._state.value.turn).toBe(0); }); it('should throw error when disposed', async () => { @@ -208,7 +208,7 @@ describe('GameHost', () => { const { host } = createTestHost(); const promptPromise = waitForPromptEvent(host); - const runPromise = host.commands.run('setup'); + const runPromise = host.context._commands.run('setup'); await promptPromise; @@ -289,12 +289,12 @@ describe('GameHost', () => { const { host } = createTestHost(); // Initial state - expect(host.state.value.currentPlayer).toBe('X'); - expect(host.state.value.turn).toBe(0); + expect(host.context._state.value.currentPlayer).toBe('X'); + expect(host.context._state.value.turn).toBe(0); // Make a move const promptPromise = waitForPromptEvent(host); - const runPromise = host.commands.run('setup'); + const runPromise = host.context._commands.run('setup'); const promptEvent = await promptPromise; promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); @@ -307,9 +307,9 @@ describe('GameHost', () => { const result = await runPromise; expect(result.success).toBe(false); // Cancelled - expect(host.state.value.currentPlayer).toBe('O'); - expect(host.state.value.turn).toBe(1); - expect(Object.keys(host.state.value.parts).length).toBe(1); + expect(host.context._state.value.currentPlayer).toBe('O'); + expect(host.context._state.value.turn).toBe(1); + expect(Object.keys(host.context._state.value.parts).length).toBe(1); }); it('should update activePromptSchema reactively', async () => { @@ -320,7 +320,7 @@ describe('GameHost', () => { // Start a command that triggers prompt const promptPromise = waitForPromptEvent(host); - const runPromise = host.commands.run('setup'); + const runPromise = host.context._commands.run('setup'); await promptPromise; @@ -330,7 +330,7 @@ describe('GameHost', () => { // Cancel and wait const cancelEvent = host.activePromptSchema.value; - host.commands._cancel(); + host.context._commands._cancel(); try { await runPromise; } catch { @@ -347,10 +347,10 @@ describe('GameHost', () => { const { host } = createTestHost(); // Initial state - expect(host.state.value.currentPlayer).toBe('X'); - expect(host.state.value.winner).toBeNull(); - expect(host.state.value.turn).toBe(0); - expect(Object.keys(host.state.value.parts).length).toBe(0); + expect(host.context._state.value.currentPlayer).toBe('X'); + expect(host.context._state.value.winner).toBeNull(); + expect(host.context._state.value.turn).toBe(0); + expect(Object.keys(host.context._state.value.parts).length).toBe(0); // X wins diagonally: (0,0), (1,1), (2,2) // O plays: (0,1), (2,1) @@ -364,12 +364,12 @@ describe('GameHost', () => { // Track prompt events in a queue const promptEvents: PromptEvent[] = []; - host.commands.on('prompt', (e) => { + host.context._commands.on('prompt', (e) => { promptEvents.push(e); }); // Start setup command (runs game loop until completion) - const setupPromise = host.commands.run('setup'); + const setupPromise = host.context._commands.run('setup'); for (let i = 0; i < moves.length; i++) { // Wait until the next prompt event arrives @@ -393,12 +393,12 @@ describe('GameHost', () => { } // Final state checks - expect(host.state.value.winner).toBe('X'); - expect(host.state.value.currentPlayer).toBe('X'); - expect(Object.keys(host.state.value.parts).length).toBe(5); + expect(host.context._state.value.winner).toBe('X'); + expect(host.context._state.value.currentPlayer).toBe('X'); + expect(Object.keys(host.context._state.value.parts).length).toBe(5); // Verify winning diagonal - const parts = Object.values(host.state.value.parts); + const parts = Object.values(host.context._state.value.parts); const xPieces = parts.filter(p => p.player === 'X'); expect(xPieces).toHaveLength(3); expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([0, 0]))).toBe(true); @@ -415,7 +415,7 @@ describe('GameHost', () => { const { host } = createTestHost(); const promptPromise = waitForPromptEvent(host); - const runPromise = host.commands.run('setup'); + const runPromise = host.context._commands.run('setup'); const promptEvent = await promptPromise; expect(promptEvent.currentPlayer).toBe('X'); @@ -433,7 +433,7 @@ describe('GameHost', () => { // First prompt - X's turn let promptPromise = waitForPromptEvent(host); - let runPromise = host.commands.run('setup'); + let runPromise = host.context._commands.run('setup'); let promptEvent = await promptPromise; expect(promptEvent.currentPlayer).toBe('X'); expect(host.activePromptPlayer.value).toBe('X'); diff --git a/tests/core/game.test.ts b/tests/core/game.test.ts index b09238c..904cd65 100644 --- a/tests/core/game.test.ts +++ b/tests/core/game.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { createGameContext, createGameCommand, createGameCommandRegistry } from '@/core/game'; -import type { PromptEvent } from '@/utils/command'; +import { createGameContext, createGameCommandRegistry, IGameContext } from '@/core/game'; +import type { PromptEvent, Command } from '@/utils/command'; type MyState = { score: number; @@ -9,56 +9,55 @@ type MyState = { describe('createGameContext', () => { it('should create a game context with state', () => { - const { registry } = createGameCommandRegistry(); + const registry = createGameCommandRegistry(); const ctx = createGameContext(registry); - expect(ctx.state).not.toBeNull(); - expect(ctx.state.value).toBeDefined(); + expect(ctx._state).not.toBeNull(); + expect(ctx._state.value).toBeDefined(); }); it('should wire commands to the context', () => { - const { registry } = createGameCommandRegistry(); + const registry = createGameCommandRegistry(); const ctx = createGameContext(registry); - expect(ctx.commands).not.toBeNull(); - expect(ctx.commands.registry).toBe(registry); - expect(ctx.commands.context).toBe(ctx.state); + expect(ctx._commands).not.toBeNull(); + expect(ctx._commands.registry).toBe(registry); }); it('should accept initial state as an object', () => { - const { registry } = createGameCommandRegistry(); + const registry = createGameCommandRegistry(); const ctx = createGameContext(registry, { score: 0, round: 1, }); - expect(ctx.state.value.score).toBe(0); - expect(ctx.state.value.round).toBe(1); + expect(ctx._state.value.score).toBe(0); + expect(ctx._state.value.round).toBe(1); }); it('should accept initial state as a factory function', () => { - const { registry } = createGameCommandRegistry(); + const registry = createGameCommandRegistry(); const ctx = createGameContext(registry, () => ({ score: 10, round: 3, })); - expect(ctx.state.value.score).toBe(10); - expect(ctx.state.value.round).toBe(3); + expect(ctx._state.value.score).toBe(10); + expect(ctx._state.value.round).toBe(3); }); it('should forward prompt events via listener', async () => { - const { registry } = createGameCommandRegistry(); + const registry = createGameCommandRegistry(); const ctx = createGameContext(registry); - createGameCommand(registry, 'test ', async function () { - return this.prompt('prompt '); + registry.register('test ', async function (_ctx, value) { + return this.prompt('prompt ', () => 'ok'); }); const promptPromise = new Promise(resolve => { - ctx.commands.on('prompt', resolve); + ctx._commands.on('prompt', resolve); }); - const runPromise = ctx.commands.run('test hello'); + const runPromise = ctx.run('test hello'); const promptEvent = await promptPromise; expect(promptEvent).not.toBeNull(); @@ -69,45 +68,43 @@ describe('createGameContext', () => { 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 run a command with access to game context', async () => { - const { registry } = createGameCommandRegistry<{ marker: string }>(); - const ctx = createGameContext(registry, { marker: '' }); - - createGameCommand(registry, 'set-marker ', async function (cmd) { - const id = cmd.params[0] as string; - this.context.produce(state => { + const registry = createGameCommandRegistry<{ marker: string }>(); + + registry.register('set-marker ', async function (ctx, id) { + ctx.produce(state => { state.marker = id; }); return id; }); - const result = await ctx.commands.run('set-marker board'); + const ctx = createGameContext(registry, { marker: '' }); + + const result = await ctx.run('set-marker board'); + if (!result.success) { + console.error('Error:', result.error); + } expect(result.success).toBe(true); if (result.success) { expect(result.result).toBe('board'); } - expect(ctx.state.value.marker).toBe('board'); + expect(ctx._state.value.marker).toBe('board'); }); it('should run a typed command with extended context', async () => { - const { registry } = createGameCommandRegistry(); + const registry = createGameCommandRegistry(); - createGameCommand( - registry, + registry.register( 'add-score ', - async function (cmd) { - const amount = cmd.params[0] as number; - this.context.produce(state => { + async function (ctx, amount) { + ctx.produce(state => { state.score += amount; }); - return this.context.value.score; + return ctx.value.score; } ); @@ -116,19 +113,19 @@ describe('createGameCommand', () => { round: 1, })); - const result = await ctx.commands.run('add-score 5'); + const result = await ctx.run('add-score 5'); expect(result.success).toBe(true); if (result.success) { expect(result.result).toBe(5); } - expect(ctx.state.value.score).toBe(5); + expect(ctx._state.value.score).toBe(5); }); it('should return error for unknown command', async () => { - const { registry } = createGameCommandRegistry(); + const registry = createGameCommandRegistry(); const ctx = createGameContext(registry); - const result = await ctx.commands.run('nonexistent'); + const result = await ctx.run('nonexistent'); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('nonexistent'); diff --git a/tests/core/region.test.ts b/tests/core/region.test.ts index 6c246aa..ee066f1 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, moveToRegionAll, removeFromRegion, type Region, type RegionAxis } from '@/core/region'; +import { createRegion, applyAlign, shuffle, moveToRegion, type Region, type RegionAxis } from '@/core/region'; import { createRNG } from '@/utils/rng'; import { type Part } from '@/core/part'; @@ -303,76 +303,4 @@ describe('Region', () => { expect(part.position).toEqual([3]); }); }); - - describe('moveToRegionAll', () => { - it('should move multiple parts to a target region', () => { - const sourceRegion = createRegion('source', [{ name: 'x' }]); - const targetRegion = createRegion('target', [{ name: 'x' }]); - - const parts = { - p1: { id: 'p1', regionId: 'source', position: [0] } as Part, - p2: { id: 'p2', regionId: 'source', position: [1] } as Part, - p3: { id: 'p3', regionId: 'source', position: [2] } as Part, - }; - sourceRegion.childIds.push('p1', 'p2', 'p3'); - sourceRegion.partMap = { '0': 'p1', '1': 'p2', '2': 'p3' }; - - moveToRegionAll([parts.p1, parts.p2, parts.p3], sourceRegion, targetRegion, [[0], [1], [2]]); - - expect(sourceRegion.childIds).toHaveLength(0); - expect(targetRegion.childIds).toHaveLength(3); - expect(parts.p1.position).toEqual([0]); - expect(parts.p2.position).toEqual([1]); - expect(parts.p3.position).toEqual([2]); - }); - - it('should keep existing positions if no positions provided', () => { - const sourceRegion = createRegion('source', [{ name: 'x' }]); - const targetRegion = createRegion('target', [{ name: 'x' }]); - - const parts = { - p1: { id: 'p1', regionId: 'source', position: [5] } as Part, - p2: { id: 'p2', regionId: 'source', position: [8] } as Part, - }; - sourceRegion.childIds.push('p1', 'p2'); - sourceRegion.partMap = { '5': 'p1', '8': 'p2' }; - - moveToRegionAll([parts.p1, parts.p2], sourceRegion, targetRegion); - - expect(parts.p1.position).toEqual([5]); - expect(parts.p2.position).toEqual([8]); - }); - }); - - describe('removeFromRegion', () => { - it('should remove a part from its region', () => { - const region = createRegion('region1', [{ name: 'x' }]); - - const part: Part = { id: 'p1', regionId: 'region1', position: [2] }; - const parts: Record = { p1: part }; - region.childIds.push('p1'); - region.partMap['2'] = 'p1'; - - expect(region.childIds).toHaveLength(1); - - removeFromRegion(part, region); - - expect(region.childIds).toHaveLength(0); - }); - - it('should leave other parts unaffected', () => { - const region = createRegion('region1', [{ name: 'x' }]); - - const p1 = { id: 'p1', regionId: 'region1', position: [0] } as Part; - const p2 = { id: 'p2', regionId: 'region1', position: [1] } as Part; - const p3 = { id: 'p3', regionId: 'region1', position: [2] } as Part; - region.childIds.push('p1', 'p2', 'p3'); - region.partMap = { '0': 'p1', '1': 'p2', '2': 'p3' }; - - removeFromRegion(p2, region); - - expect(region.childIds).toHaveLength(2); - expect(region.childIds).toEqual(['p1', 'p3']); - }); - }); }); diff --git a/tests/samples/boop.test.ts b/tests/samples/boop.test.ts index 4c2a9e1..5eb94cc 100644 --- a/tests/samples/boop.test.ts +++ b/tests/samples/boop.test.ts @@ -141,4 +141,394 @@ describe('Boop Game', () => { expect(stateAfterWhite.regions.board.partMap['2,2']).toBeDefined(); }); }); + + describe('Kitten vs Cat Hierarchy', () => { + it('should not boop cats when placing a kitten', async () => { + const { ctx } = createTestContext(); + + // White places a kitten at 2,2 + let promptPromise = waitForPrompt(ctx); + let runPromise = ctx.run('turn white'); + let prompt = await promptPromise; + let error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} }); + expect(error).toBeNull(); + let result = await runPromise; + expect(result.success).toBe(true); + + // Manually move white's kitten to box and replace with a cat (for testing) + ctx.produce(state => { + const whiteKitten = state.pieces['white-kitten-1']; + if (whiteKitten && whiteKitten.regionId === 'board') { + whiteKitten.type = 'cat'; + } + }); + + // Black places a kitten at 2,3 (adjacent to the cat) + promptPromise = waitForPrompt(ctx); + runPromise = ctx.run('turn black'); + prompt = await promptPromise; + error = prompt.tryCommit({ name: 'play', params: ['black', 2, 3, 'kitten'], options: {}, flags: {} }); + expect(error).toBeNull(); + result = await runPromise; + expect(result.success).toBe(true); + + const state = ctx.value; + // White's cat should still be at 2,2 (not booped) + expect(state.regions.board.partMap['2,2']).toBe('white-kitten-1'); + // Black's kitten should be at 2,3 + expect(state.regions.board.partMap['2,3']).toBe('black-kitten-1'); + }); + + it('should boop both kittens and cats when placing a cat', async () => { + const { ctx } = createTestContext(); + + // Manually set up: white cat at 2,3, black cat at 3,2 + // First move cats to white and black supplies + ctx.produce(state => { + const whiteCat = state.pieces['white-cat-1']; + const blackCat = state.pieces['black-cat-1']; + if (whiteCat && whiteCat.regionId === '') { + whiteCat.regionId = 'white'; + state.regions.white.childIds.push(whiteCat.id); + } + if (blackCat && blackCat.regionId === '') { + blackCat.regionId = 'black'; + state.regions.black.childIds.push(blackCat.id); + } + }); + + // Now move them to the board + ctx.produce(state => { + const whiteCat = state.pieces['white-cat-1']; + const blackCat = state.pieces['black-cat-1']; + if (whiteCat && whiteCat.regionId === 'white') { + whiteCat.regionId = 'board'; + whiteCat.position = [2, 3]; + state.regions.board.partMap['2,3'] = whiteCat.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== whiteCat.id); + } + if (blackCat && blackCat.regionId === 'black') { + blackCat.regionId = 'board'; + blackCat.position = [3, 2]; + state.regions.board.partMap['3,2'] = blackCat.id; + state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== blackCat.id); + } + }); + + // Give white another cat for placement + ctx.produce(state => { + const whiteCat2 = state.pieces['white-cat-2']; + if (whiteCat2 && whiteCat2.regionId === '') { + whiteCat2.regionId = 'white'; + state.regions.white.childIds.push(whiteCat2.id); + } + }); + + // White places a cat at 2,2 (should boop black's cat at 3,2 to 4,2) + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx.run('turn white'); + const prompt = await promptPromise; + const error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'cat'], options: {}, flags: {} }); + expect(error).toBeNull(); + const result = await runPromise; + expect(result.success).toBe(true); + + const state = ctx.value; + // Black's cat should have been booped to 4,2 + expect(state.regions.board.partMap['4,2']).toBeDefined(); + const pieceAt42 = state.pieces[state.regions.board.partMap['4,2']]; + expect(pieceAt42?.player).toBe('black'); + expect(pieceAt42?.type).toBe('cat'); + }); + }); + + describe('Boop Obstructions', () => { + it('should boop pieces to empty positions', async () => { + const { ctx } = createTestContext(); + + // White places at 2,2 + let promptPromise = waitForPrompt(ctx); + let runPromise = ctx.run('turn white'); + let prompt = await promptPromise; + let error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} }); + expect(error).toBeNull(); + let result = await runPromise; + expect(result.success).toBe(true); + + // Check board has 1 piece after first placement + let state = ctx.value; + expect(Object.keys(state.regions.board.partMap).length).toBe(1); + + // Black places at 3,3 + promptPromise = waitForPrompt(ctx); + runPromise = ctx.run('turn black'); + prompt = await promptPromise; + error = prompt.tryCommit({ name: 'play', params: ['black', 3, 3, 'kitten'], options: {}, flags: {} }); + expect(error).toBeNull(); + result = await runPromise; + expect(result.success).toBe(true); + + state = ctx.value; + expect(Object.keys(state.regions.board.partMap).length).toBe(2); + + // Verify the pieces are on the board (positions may vary due to boop) + const boardPieces = Object.entries(state.regions.board.partMap); + expect(boardPieces.length).toBe(2); + + // Find black's piece + const blackPiece = boardPieces.find(([pos, id]) => state.pieces[id]?.player === 'black'); + expect(blackPiece).toBeDefined(); + }); + + it('should keep both pieces in place when boop is blocked', async () => { + const { ctx } = createTestContext(); + + // Setup: place white at 2,2 and 4,4, black at 3,3 + await ctx._commands.run('place 2 2 white kitten'); + await ctx._commands.run('place 3 3 black kitten'); + await ctx._commands.run('place 4 4 white kitten'); + + const stateBefore = ctx.value; + // Verify setup - 3 pieces on board + const boardPiecesBefore = Object.keys(stateBefore.regions.board.partMap); + expect(boardPiecesBefore.length).toBe(3); + expect(stateBefore.regions.board.partMap['2,2']).toBeDefined(); + expect(stateBefore.regions.board.partMap['3,3']).toBeDefined(); + expect(stateBefore.regions.board.partMap['4,4']).toBeDefined(); + + // Black places at 2,3 - should try to boop piece at 3,3 to 4,4 + // but 4,4 is occupied, so both should stay + await ctx._commands.run('place 2 3 black kitten'); + + const state = ctx.value; + // Should now have 4 pieces on board + const boardPiecesAfter = Object.keys(state.regions.board.partMap); + expect(boardPiecesAfter.length).toBe(4); + // 3,3 should still have the same piece (not booped) + expect(state.regions.board.partMap['3,3']).toBeDefined(); + // 4,4 should still be occupied + expect(state.regions.board.partMap['4,4']).toBeDefined(); + // 2,3 should have black's new piece + expect(state.regions.board.partMap['2,3']).toBeDefined(); + }); + }); + + describe('Graduation Mechanic', () => { + it('should graduate three kittens in a row to cats', async () => { + const { ctx } = createTestContext(); + + // Manually place three white kittens in a row + ctx.produce(state => { + const k1 = state.pieces['white-kitten-1']; + const k2 = state.pieces['white-kitten-2']; + const k3 = state.pieces['white-kitten-3']; + + if (k1) { + k1.regionId = 'board'; + k1.position = [0, 0]; + state.regions.board.partMap['0,0'] = k1.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k1.id); + } + if (k2) { + k2.regionId = 'board'; + k2.position = [0, 1]; + state.regions.board.partMap['0,1'] = k2.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k2.id); + } + if (k3) { + k3.regionId = 'board'; + k3.position = [0, 2]; + state.regions.board.partMap['0,2'] = k3.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k3.id); + } + }); + + const stateBefore = ctx.value; + // Verify three kittens on board + expect(stateBefore.regions.board.partMap['0,0']).toBeDefined(); + expect(stateBefore.regions.board.partMap['0,1']).toBeDefined(); + expect(stateBefore.regions.board.partMap['0,2']).toBeDefined(); + + // Count cats in white supply before graduation + const catsInWhiteSupplyBefore = stateBefore.regions.white.childIds.filter( + id => stateBefore.pieces[id].type === 'cat' + ); + expect(catsInWhiteSupplyBefore.length).toBe(0); + + // Run check-graduates command + const result = await ctx._commands.run('check-graduates'); + expect(result.success).toBe(true); + + const state = ctx.value; + // The three positions on board should now be empty (kittens removed) + expect(state.regions.board.partMap['0,0']).toBeUndefined(); + expect(state.regions.board.partMap['0,1']).toBeUndefined(); + expect(state.regions.board.partMap['0,2']).toBeUndefined(); + + // White's supply should now have 3 cats (graduated) + const catsInWhiteSupply = state.regions.white.childIds.filter( + id => state.pieces[id].type === 'cat' + ); + expect(catsInWhiteSupply.length).toBe(3); + + // White's supply should have 5 kittens left (8 - 3 graduated) + const kittensInWhiteSupply = state.regions.white.childIds.filter( + id => state.pieces[id].type === 'kitten' + ); + expect(kittensInWhiteSupply.length).toBe(5); + }); + }); + + describe('Win Detection', () => { + it('should detect horizontal win with three cats', async () => { + const { ctx } = createTestContext(); + + // Manually set up a winning scenario for white + ctx.produce(state => { + const k1 = state.pieces['white-kitten-1']; + const k2 = state.pieces['white-kitten-2']; + const k3 = state.pieces['white-kitten-3']; + + if (k1) { + k1.type = 'cat'; + k1.regionId = 'board'; + k1.position = [0, 0]; + state.regions.board.partMap['0,0'] = k1.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k1.id); + } + if (k2) { + k2.type = 'cat'; + k2.regionId = 'board'; + k2.position = [0, 1]; + state.regions.board.partMap['0,1'] = k2.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k2.id); + } + if (k3) { + k3.type = 'cat'; + k3.regionId = 'board'; + k3.position = [0, 2]; + state.regions.board.partMap['0,2'] = k3.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k3.id); + } + }); + + // Run check-win command + const result = await ctx._commands.run('check-win'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe('white'); + } + }); + + it('should detect vertical win with three cats', async () => { + const { ctx } = createTestContext(); + + // Manually set up a vertical winning scenario for black + ctx.produce(state => { + const k1 = state.pieces['black-kitten-1']; + const k2 = state.pieces['black-kitten-2']; + const k3 = state.pieces['black-kitten-3']; + + if (k1) { + k1.type = 'cat'; + k1.regionId = 'board'; + k1.position = [0, 0]; + state.regions.board.partMap['0,0'] = k1.id; + state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== k1.id); + } + if (k2) { + k2.type = 'cat'; + k2.regionId = 'board'; + k2.position = [1, 0]; + state.regions.board.partMap['1,0'] = k2.id; + state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== k2.id); + } + if (k3) { + k3.type = 'cat'; + k3.regionId = 'board'; + k3.position = [2, 0]; + state.regions.board.partMap['2,0'] = k3.id; + state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== k3.id); + } + }); + + // Run check-win command + const result = await ctx._commands.run('check-win'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe('black'); + } + }); + + it('should detect diagonal win with three cats', async () => { + const { ctx } = createTestContext(); + + // Manually set up a diagonal winning scenario for white + ctx.produce(state => { + const k1 = state.pieces['white-kitten-1']; + const k2 = state.pieces['white-kitten-2']; + const k3 = state.pieces['white-kitten-3']; + + if (k1) { + k1.type = 'cat'; + k1.regionId = 'board'; + k1.position = [0, 0]; + state.regions.board.partMap['0,0'] = k1.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k1.id); + } + if (k2) { + k2.type = 'cat'; + k2.regionId = 'board'; + k2.position = [1, 1]; + state.regions.board.partMap['1,1'] = k2.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k2.id); + } + if (k3) { + k3.type = 'cat'; + k3.regionId = 'board'; + k3.position = [2, 2]; + state.regions.board.partMap['2,2'] = k3.id; + state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k3.id); + } + }); + + // Run check-win command + const result = await ctx._commands.run('check-win'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.result).toBe('white'); + } + }); + }); + + describe('Placing Cats', () => { + it('should allow placing a cat from supply', async () => { + const { ctx } = createTestContext(); + + // Manually give a cat to white's supply + ctx.produce(state => { + const cat = state.pieces['white-cat-1']; + if (cat && cat.regionId === '') { + cat.regionId = 'white'; + state.regions.white.childIds.push(cat.id); + } + }); + + // White places a cat at 2,2 + const promptPromise = waitForPrompt(ctx); + const runPromise = ctx.run('turn white'); + const prompt = await promptPromise; + const error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'cat'], options: {}, flags: {} }); + expect(error).toBeNull(); + const result = await runPromise; + expect(result.success).toBe(true); + + const state = ctx.value; + // Cat should be on the board + expect(state.regions.board.partMap['2,2']).toBe('white-cat-1'); + // Cat should no longer be in supply + const whiteCatsInSupply = state.regions.white.childIds.filter(id => state.pieces[id].type === 'cat'); + expect(whiteCatsInSupply.length).toBe(0); + }); + }); }); diff --git a/tests/utils/command-runner.test.ts b/tests/utils/command-runner.test.ts index 661e7c1..28ad5cc 100644 --- a/tests/utils/command-runner.test.ts +++ b/tests/utils/command-runner.test.ts @@ -246,8 +246,8 @@ describe('prompt', () => { const chooseRunner: CommandRunner = { schema: parseCommandSchema('choose'), run: async function () { - const result = await this.prompt('select '); - return result.params[0] as string; + const result = await this.prompt('select ', (cmd) => cmd.params[0] as string); + return result; }, }; @@ -266,6 +266,9 @@ describe('prompt', () => { await new Promise((r) => setTimeout(r, 0)); expect(promptEvent).not.toBeNull(); expect(promptEvent!.schema.name).toBe('select'); + + promptEvent!.cancel('test cleanup'); + await runPromise; }); it('should resolve prompt with valid input', async () => { @@ -274,9 +277,9 @@ describe('prompt', () => { const chooseRunner: CommandRunner = { schema: parseCommandSchema('choose'), run: async function () { - const result = await this.prompt('select '); - this.context.log.push(`selected ${result.params[0]}`); - return result.params[0] as string; + const result = await this.prompt('select ', (cmd) => cmd.params[0] as string); + this.context.log.push(`selected ${result}`); + return result; }, }; @@ -314,7 +317,7 @@ describe('prompt', () => { schema: parseCommandSchema('choose'), run: async function () { try { - await this.prompt('select '); + await this.prompt('select ', (cmd) => cmd.params[0] as string); return 'unexpected success'; } catch (e) { return (e as Error).message; @@ -353,8 +356,8 @@ describe('prompt', () => { const pickRunner: CommandRunner = { schema: parseCommandSchema('pick'), run: async function () { - const result = await this.prompt(schema); - return result.params[0] as string; + const result = await this.prompt(schema, (cmd) => cmd.params[0] as string); + return result; }, }; @@ -390,9 +393,9 @@ describe('prompt', () => { const multiPromptRunner: CommandRunner = { schema: parseCommandSchema('multi'), run: async function () { - const first = await this.prompt('first '); - const second = await this.prompt('second '); - return [first.params[0] as string, second.params[0] as string]; + const first = await this.prompt('first ', (cmd) => cmd.params[0] as string); + const second = await this.prompt('second ', (cmd) => cmd.params[0] as string); + return [first, second]; }, }; @@ -440,12 +443,12 @@ describe('prompt', () => { (cmd) => { const card = cmd.params[0] as string; if (!['Ace', 'King', 'Queen'].includes(card)) { - return `Invalid card: ${card}. Must be Ace, King, or Queen.`; + throw `Invalid card: ${card}. Must be Ace, King, or Queen.`; } - return null; + return card; } ); - return result.params[0] as string; + return result; }, }; @@ -486,7 +489,7 @@ describe('prompt', () => { schema: parseCommandSchema('choose'), run: async function () { try { - await this.prompt('select '); + await this.prompt('select ', (cmd) => cmd.params[0] as string); return 'unexpected success'; } catch (e) { return (e as Error).message;