import { describe, it, expect } from "vitest"; import { registry, createInitialState, start, TicTacToeState, } from "@/samples/tic-tac-toe"; import { createGameHost, GameHost } from "@/core/game-host"; import type { PromptEvent } from "@/utils/command"; function createTestHost() { const host = createGameHost({ registry, createInitialState, start }); return { host }; } function waitForPromptEvent(host: GameHost): Promise { return new Promise((resolve) => { // @ts-ignore - accessing private _context for testing host._context._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 "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("tryInput", () => { it('should return "No active prompt" when no prompt is active', () => { const { host } = createTestHost(); const result = host.tryInput("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.tryInput("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.tryInput("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.tryInput("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.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.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"); 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.state.value.currentPlayer).toBe("X"); expect(host.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.state.value.currentPlayer).toBe("X"); expect(host.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.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.start(); await promptPromise; // Now schema should be set expect(host.activePromptSchema.value).not.toBeNull(); expect(host.activePromptSchema.value?.name).toBe("play"); // Cancel and wait // @ts-ignore - accessing private _context for testing 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.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); // 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[] = []; // @ts-ignore - accessing private _context for testing 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.tryInput(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.state.value.winner).toBe("X"); expect(host.state.value.currentPlayer).toBe("X"); expect(Object.keys(host.state.value.parts).length).toBe(5); // Verify winning diagonal const parts = Object.values(host.state.value.parts); const xPieces = parts.filter((p: any) => p.player === "X"); expect(xPieces).toHaveLength(3); expect( xPieces.some( (p: any) => JSON.stringify(p.position) === JSON.stringify([0, 0]), ), ).toBe(true); expect( xPieces.some( (p: any) => JSON.stringify(p.position) === JSON.stringify([1, 1]), ), ).toBe(true); expect( xPieces.some( (p: any) => 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(); }); }); 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) }); }); });