import { describe, it, expect } from 'vitest'; import { registry, createInitialState, start, 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'; import {IGameContext} from "../../src"; type TestGameHost = GameHost & { _context: IGameContext; context: IGameContext; } function createTestHost() { const host: TestGameHost = createGameHost( { registry, createInitialState, start } ); host.context = host._context; return { host }; } function waitForPromptEvent(host: GameHost): Promise { return new Promise(resolve => { host.context._commands.on('prompt', resolve); }); } describe('GameHost', () => { describe('creation', () => { it('should create host with initial state', () => { const { host } = createTestHost(); 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', () => { const { host } = createTestHost(); expect(host.status.value).toBe('created'); }); 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.start(); 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 start runs until game over 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 input', async () => { const { host } = createTestHost(); const promptPromise = waitForPromptEvent(host); const runPromise = host.start(); const promptEvent = await promptPromise; const error = host.onInput('invalid command'); expect(error).not.toBeNull(); promptEvent.cancel('test cleanup'); try { await runPromise; } catch (e) { const error = e as Error; expect(error.message).toBe('test cleanup'); } }); 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.start(); const promptEvent = await promptPromise; const schema = host.activePromptSchema.value; expect(schema).not.toBeNull(); expect(schema?.name).toBe('play'); promptEvent.cancel('test cleanup'); try { await runPromise; } catch (e) { const error = e as Error; expect(error.message).toBe('test cleanup'); } }); it('should return null when no prompt is active', () => { const { host } = createTestHost(); expect(host.activePromptSchema.value).toBeNull(); }); }); describe('start', () => { it('should reset state and run start command', async () => { const { host } = createTestHost(); // First setup - make one move let promptPromise = waitForPromptEvent(host); let runPromise = host.start(); 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'); try { await runPromise; } catch (e) { const error = e as Error; expect(error.message).toBe('test end'); } expect(Object.keys(host.context._state.value.parts).length).toBe(1); // Setup listener before calling start const newPromptPromise = waitForPromptEvent(host); // Reset - should reset state and start new game const newRunPromise = host.start(); // State should be back to initial 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; expect(newPrompt.schema.name).toBe('play'); newPrompt.cancel('test end'); try { await newRunPromise; } catch { // Expected - cancelled } }); it('should cancel active prompt during start', async () => { const { host } = createTestHost(); const promptPromise = waitForPromptEvent(host); const runPromise = host.start(); await promptPromise; // Setup should cancel the active prompt and reset state host.start(); // 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.context._state.value.currentPlayer).toBe('X'); expect(host.context._state.value.turn).toBe(0); }); it('should throw error when disposed', () => { const { host } = createTestHost(); host.dispose(); expect(() => host.start()).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.start(); 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 start event', async () => { const { host } = createTestHost(); let setupCount = 0; host.on('start', () => { setupCount++; }); // Setup listener before calling setup const promptPromise = waitForPromptEvent(host); // Initial setup via reset const runPromise = host.start(); 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'); try { await runPromise; } catch { // Expected - cancelled } }); 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('start', () => { 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.context._state.value.currentPlayer).toBe('X'); expect(host.context._state.value.turn).toBe(0); // Make a move const promptPromise = waitForPromptEvent(host); const runPromise = host.start(); 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'); try { await runPromise; } catch (e) { const error = e as Error; expect(error.message).toBe('test end'); } 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 () => { const { host } = createTestHost(); // Initially null expect(host.activePromptSchema.value).toBeNull(); // Start a command that triggers prompt const promptPromise = waitForPromptEvent(host); const runPromise = host.start(); 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.context._commands._cancel(); try { await runPromise; } catch { // Expected } // Schema should be null again expect(host.activePromptSchema.value).toBeNull(); }); }); describe('full game', () => { it('should run a complete game of tic-tac-toe with X winning diagonally', async () => { const { host } = createTestHost(); // Initial state 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) const moves = [ 'play X 0 0', // turn 1: X 'play O 0 1', // turn 2: O 'play X 1 1', // turn 3: X 'play O 2 1', // turn 4: O 'play X 2 2', // turn 5: X wins! ]; // Track prompt events in a queue const promptEvents: PromptEvent[] = []; host.context._commands.on('prompt', (e) => { promptEvents.push(e); }); // Start setup command (runs game loop until completion) const setupPromise = host.start(); for (let i = 0; i < moves.length; i++) { // Wait until the next prompt event arrives while (i >= promptEvents.length) { await new Promise(r => setTimeout(r, 10)); } const promptEvent = promptEvents[i]; expect(promptEvent.schema.name).toBe('play'); // Submit the move const error = host.onInput(moves[i]); expect(error).toBeNull(); // Wait for the command to complete before submitting next move await new Promise(resolve => setImmediate(resolve)); } // Wait for setup to complete (game ended with winner) try { const finalState = await setupPromise; expect(finalState.winner).toBe('X'); // Final state checks 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.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); expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([1, 1]))).toBe(true); expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([2, 2]))).toBe(true); } catch (e) { // If setup fails due to cancellation, check state directly const error = e as Error; if (!error.message.includes('Cancelled')) { throw e; } } host.dispose(); expect(host.status.value).toBe('disposed'); }); }); describe('currentPlayer in prompt', () => { it('should have currentPlayer in PromptEvent', async () => { const { host } = createTestHost(); const promptPromise = waitForPromptEvent(host); const runPromise = host.start(); const promptEvent = await promptPromise; expect(promptEvent.currentPlayer).toBe('X'); expect(host.activePromptPlayer.value).toBe('X'); promptEvent.cancel('test cleanup'); try { await runPromise; } catch (e) { const error = e as Error; expect(error.message).toBe('test cleanup'); } }); it('should update activePromptPlayer reactively', async () => { const { host } = createTestHost(); // Initially null expect(host.activePromptPlayer.value).toBeNull(); // First prompt - X's turn let promptPromise = waitForPromptEvent(host); let runPromise = host.start(); let promptEvent = await promptPromise; expect(promptEvent.currentPlayer).toBe('X'); expect(host.activePromptPlayer.value).toBe('X'); // Make a move promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); // Second prompt - O's turn promptPromise = waitForPromptEvent(host); promptEvent = await promptPromise; expect(promptEvent.currentPlayer).toBe('O'); expect(host.activePromptPlayer.value).toBe('O'); // Cancel promptEvent.cancel('test cleanup'); try { await runPromise; } catch (e) { const error = e as Error; expect(error.message).toBe('test cleanup'); } // After prompt ends, player should be null expect(host.activePromptPlayer.value).toBeNull(); }); }); });