346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
|
|
import { describe, it, expect } from 'vitest';
|
||
|
|
import {
|
||
|
|
registry,
|
||
|
|
createInitialState,
|
||
|
|
TicTacToeState,
|
||
|
|
WinnerType,
|
||
|
|
PlayerType
|
||
|
|
} from '@/samples/tic-tac-toe';
|
||
|
|
import { createGameHost, GameHost } from '@/core/game-host';
|
||
|
|
import type { PromptEvent } from '@/utils/command';
|
||
|
|
import { MutableSignal } from '@/utils/mutable-signal';
|
||
|
|
|
||
|
|
function createTestHost() {
|
||
|
|
const host = createGameHost(
|
||
|
|
{ registry, createInitialState },
|
||
|
|
'setup'
|
||
|
|
);
|
||
|
|
return { host };
|
||
|
|
}
|
||
|
|
|
||
|
|
function waitForPromptEvent(host: GameHost<any>): Promise<PromptEvent> {
|
||
|
|
return new Promise(resolve => {
|
||
|
|
host.commands.on('prompt', resolve);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('GameHost', () => {
|
||
|
|
describe('creation', () => {
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should have status "running" by default', () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
expect(host.status.value).toBe('running');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should have null activePromptSchema initially', () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
expect(host.activePromptSchema.value).toBeNull();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('onInput', () => {
|
||
|
|
it('should return "No active prompt" when no prompt is active', () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
const result = host.onInput('play X 1 1');
|
||
|
|
expect(result).toBe('No active prompt');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should accept valid input when prompt is active', async () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
const promptPromise = waitForPromptEvent(host);
|
||
|
|
const runPromise = host.commands.run('setup');
|
||
|
|
|
||
|
|
const promptEvent = await promptPromise;
|
||
|
|
expect(promptEvent.schema.name).toBe('play');
|
||
|
|
expect(host.activePromptSchema.value?.name).toBe('play');
|
||
|
|
|
||
|
|
const error = host.onInput('play X 1 1');
|
||
|
|
expect(error).toBeNull();
|
||
|
|
|
||
|
|
// Cancel to end the game since setup runs until game over
|
||
|
|
const nextPromptPromise = waitForPromptEvent(host);
|
||
|
|
const nextPrompt = await nextPromptPromise;
|
||
|
|
nextPrompt.cancel('test cleanup');
|
||
|
|
|
||
|
|
const result = await runPromise;
|
||
|
|
expect(result.success).toBe(false); // Cancelled
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should reject invalid input', async () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
const promptPromise = waitForPromptEvent(host);
|
||
|
|
const runPromise = host.commands.run('setup');
|
||
|
|
|
||
|
|
const promptEvent = await promptPromise;
|
||
|
|
|
||
|
|
const error = host.onInput('invalid command');
|
||
|
|
expect(error).not.toBeNull();
|
||
|
|
|
||
|
|
promptEvent.cancel('test cleanup');
|
||
|
|
await runPromise;
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should return error when disposed', () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
host.dispose();
|
||
|
|
|
||
|
|
const result = host.onInput('play X 1 1');
|
||
|
|
expect(result).toBe('GameHost is disposed');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('getActivePromptSchema', () => {
|
||
|
|
it('should return schema when prompt is active', async () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
const promptPromise = waitForPromptEvent(host);
|
||
|
|
const runPromise = host.commands.run('setup');
|
||
|
|
|
||
|
|
const promptEvent = await promptPromise;
|
||
|
|
const schema = host.activePromptSchema.value;
|
||
|
|
|
||
|
|
expect(schema).not.toBeNull();
|
||
|
|
expect(schema?.name).toBe('play');
|
||
|
|
|
||
|
|
promptEvent.cancel('test cleanup');
|
||
|
|
await runPromise;
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should return null when no prompt is active', () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
expect(host.activePromptSchema.value).toBeNull();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('setup', () => {
|
||
|
|
it('should reset state and run setup command', async () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
// First setup - make one move
|
||
|
|
let promptPromise = waitForPromptEvent(host);
|
||
|
|
let runPromise = host.commands.run('setup');
|
||
|
|
let promptEvent = await promptPromise;
|
||
|
|
|
||
|
|
// Make a move
|
||
|
|
promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
|
||
|
|
|
||
|
|
// Wait for next prompt (next turn) and cancel
|
||
|
|
promptPromise = waitForPromptEvent(host);
|
||
|
|
promptEvent = await promptPromise;
|
||
|
|
promptEvent.cancel('test end');
|
||
|
|
|
||
|
|
let result = await runPromise;
|
||
|
|
expect(result.success).toBe(false); // Cancelled
|
||
|
|
expect(Object.keys(host.state.value.parts).length).toBe(1);
|
||
|
|
|
||
|
|
// Setup listener before calling setup
|
||
|
|
const newPromptPromise = waitForPromptEvent(host);
|
||
|
|
|
||
|
|
// Reset - should reset state and start new game
|
||
|
|
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);
|
||
|
|
|
||
|
|
// New game should be running and prompting
|
||
|
|
const newPrompt = await newPromptPromise;
|
||
|
|
expect(newPrompt.schema.name).toBe('play');
|
||
|
|
newPrompt.cancel('test end');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should cancel active prompt during setup', async () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
const promptPromise = waitForPromptEvent(host);
|
||
|
|
const runPromise = host.commands.run('setup');
|
||
|
|
|
||
|
|
await promptPromise;
|
||
|
|
|
||
|
|
// Setup should cancel the active prompt and reset state
|
||
|
|
await host.setup('setup');
|
||
|
|
|
||
|
|
// The original runPromise should be rejected due to cancellation
|
||
|
|
try {
|
||
|
|
await runPromise;
|
||
|
|
} catch (e) {
|
||
|
|
const error = e as Error;
|
||
|
|
expect(error.message).toContain('Cancelled');
|
||
|
|
}
|
||
|
|
|
||
|
|
// State should be reset
|
||
|
|
expect(host.state.value.currentPlayer).toBe('X');
|
||
|
|
expect(host.state.value.turn).toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should throw error when disposed', async () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
host.dispose();
|
||
|
|
|
||
|
|
await expect(host.setup('setup')).rejects.toThrow('GameHost is disposed');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('dispose', () => {
|
||
|
|
it('should change status to disposed', () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
host.dispose();
|
||
|
|
|
||
|
|
expect(host.status.value).toBe('disposed');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should cancel active prompt on dispose', async () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
const promptPromise = waitForPromptEvent(host);
|
||
|
|
const runPromise = host.commands.run('setup');
|
||
|
|
|
||
|
|
await promptPromise;
|
||
|
|
|
||
|
|
host.dispose();
|
||
|
|
|
||
|
|
// The runPromise should be rejected due to cancellation
|
||
|
|
try {
|
||
|
|
await runPromise;
|
||
|
|
} catch (e) {
|
||
|
|
const error = e as Error;
|
||
|
|
expect(error.message).toContain('Cancelled');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should be idempotent', () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
host.dispose();
|
||
|
|
host.dispose(); // Should not throw
|
||
|
|
|
||
|
|
expect(host.status.value).toBe('disposed');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('events', () => {
|
||
|
|
it('should emit setup event', async () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
let setupCount = 0;
|
||
|
|
host.on('setup', () => {
|
||
|
|
setupCount++;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Setup listener before calling setup
|
||
|
|
const promptPromise = waitForPromptEvent(host);
|
||
|
|
|
||
|
|
// Initial setup via reset
|
||
|
|
await host.setup('setup');
|
||
|
|
expect(setupCount).toBe(1);
|
||
|
|
|
||
|
|
// State should be running
|
||
|
|
expect(host.status.value).toBe('running');
|
||
|
|
|
||
|
|
// Cancel the background setup command
|
||
|
|
const prompt = await promptPromise;
|
||
|
|
prompt.cancel('test end');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should emit dispose event', () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
let disposeCount = 0;
|
||
|
|
host.on('dispose', () => {
|
||
|
|
disposeCount++;
|
||
|
|
});
|
||
|
|
|
||
|
|
host.dispose();
|
||
|
|
expect(disposeCount).toBe(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should allow unsubscribing from events', () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
let setupCount = 0;
|
||
|
|
const unsubscribe = host.on('setup', () => {
|
||
|
|
setupCount++;
|
||
|
|
});
|
||
|
|
|
||
|
|
unsubscribe();
|
||
|
|
|
||
|
|
// No event should be emitted
|
||
|
|
// (we can't easily test this without triggering setup, but we verify unsubscribe works)
|
||
|
|
expect(typeof unsubscribe).toBe('function');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('reactive state', () => {
|
||
|
|
it('should have state that reflects game progress', async () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
// Initial state
|
||
|
|
expect(host.state.value.currentPlayer).toBe('X');
|
||
|
|
expect(host.state.value.turn).toBe(0);
|
||
|
|
|
||
|
|
// Make a move
|
||
|
|
const promptPromise = waitForPromptEvent(host);
|
||
|
|
const runPromise = host.commands.run('setup');
|
||
|
|
|
||
|
|
const promptEvent = await promptPromise;
|
||
|
|
promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
|
||
|
|
|
||
|
|
// Wait for next prompt and cancel
|
||
|
|
const nextPromptPromise = waitForPromptEvent(host);
|
||
|
|
const nextPrompt = await nextPromptPromise;
|
||
|
|
nextPrompt.cancel('test end');
|
||
|
|
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should update activePromptSchema reactively', async () => {
|
||
|
|
const { host } = createTestHost();
|
||
|
|
|
||
|
|
// Initially null
|
||
|
|
expect(host.activePromptSchema.value).toBeNull();
|
||
|
|
|
||
|
|
// Start a command that triggers prompt
|
||
|
|
const promptPromise = waitForPromptEvent(host);
|
||
|
|
const runPromise = host.commands.run('setup');
|
||
|
|
|
||
|
|
await promptPromise;
|
||
|
|
|
||
|
|
// Now schema should be set
|
||
|
|
expect(host.activePromptSchema.value).not.toBeNull();
|
||
|
|
expect(host.activePromptSchema.value?.name).toBe('play');
|
||
|
|
|
||
|
|
// Cancel and wait
|
||
|
|
const cancelEvent = host.activePromptSchema.value;
|
||
|
|
host.commands._cancel();
|
||
|
|
try {
|
||
|
|
await runPromise;
|
||
|
|
} catch {
|
||
|
|
// Expected
|
||
|
|
}
|
||
|
|
|
||
|
|
// Schema should be null again
|
||
|
|
expect(host.activePromptSchema.value).toBeNull();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|