fix: fix tests

This commit is contained in:
hypercross 2026-04-04 22:23:15 +08:00
parent a02403d2c7
commit b9105efd03
5 changed files with 486 additions and 168 deletions

View File

@ -19,7 +19,7 @@ function createTestHost() {
function waitForPromptEvent(host: GameHost<any>): Promise<PromptEvent> {
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');

View File

@ -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<MyState>();
const registry = createGameCommandRegistry<MyState>();
const ctx = createGameContext<MyState>(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<MyState>();
const registry = createGameCommandRegistry<MyState>();
const ctx = createGameContext<MyState>(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 <value>', async function () {
return this.prompt('prompt <answer>');
registry.register('test <value>', async function (_ctx, value) {
return this.prompt<string>('prompt <answer>', () => 'ok');
});
const promptPromise = new Promise<PromptEvent>(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: '' });
const registry = createGameCommandRegistry<{ marker: string }>();
createGameCommand(registry, 'set-marker <id>', async function (cmd) {
const id = cmd.params[0] as string;
this.context.produce(state => {
registry.register('set-marker <id>', 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<MyState>();
const registry = createGameCommandRegistry<MyState>();
createGameCommand<MyState, number>(
registry,
registry.register(
'add-score <amount:number>',
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');

View File

@ -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<string, Part> = { 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']);
});
});
});

View File

@ -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);
});
});
});

View File

@ -246,8 +246,8 @@ describe('prompt', () => {
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('choose'),
run: async function () {
const result = await this.prompt('select <card>');
return result.params[0] as string;
const result = await this.prompt('select <card>', (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<TestContext, string> = {
schema: parseCommandSchema('choose'),
run: async function () {
const result = await this.prompt('select <card>');
this.context.log.push(`selected ${result.params[0]}`);
return result.params[0] as string;
const result = await this.prompt('select <card>', (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 <card>');
await this.prompt('select <card>', (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<TestContext, string> = {
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<TestContext, string[]> = {
schema: parseCommandSchema('multi'),
run: async function () {
const first = await this.prompt('first <a>');
const second = await this.prompt('second <b>');
return [first.params[0] as string, second.params[0] as string];
const first = await this.prompt('first <a>', (cmd) => cmd.params[0] as string);
const second = await this.prompt('second <b>', (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 <card>');
await this.prompt('select <card>', (cmd) => cmd.params[0] as string);
return 'unexpected success';
} catch (e) {
return (e as Error).message;