2026-04-20 15:16:03 +08:00
|
|
|
import { describe, it, expect } from "vitest";
|
2026-04-04 00:54:19 +08:00
|
|
|
import {
|
2026-04-20 15:16:03 +08:00
|
|
|
registry,
|
|
|
|
|
createInitialState,
|
|
|
|
|
start,
|
|
|
|
|
TicTacToeState,
|
|
|
|
|
} from "@/samples/tic-tac-toe";
|
|
|
|
|
import { createGameHost, GameHost } from "@/core/game-host";
|
|
|
|
|
import type { PromptEvent } from "@/utils/command";
|
2026-04-04 00:54:19 +08:00
|
|
|
|
|
|
|
|
function createTestHost() {
|
2026-04-20 15:16:03 +08:00
|
|
|
const host = createGameHost({ registry, createInitialState, start });
|
|
|
|
|
return { host };
|
2026-04-04 00:54:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function waitForPromptEvent(host: GameHost<any>): Promise<PromptEvent> {
|
2026-04-20 15:16:03 +08:00
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
// @ts-ignore - accessing private _context for testing
|
|
|
|
|
host._context._commands.on("prompt", resolve);
|
|
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
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();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
expect(host.status.value).toBe("created");
|
|
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should have null activePromptSchema initially", () => {
|
|
|
|
|
const { host } = createTestHost();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
expect(host.activePromptSchema.value).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
describe("tryInput", () => {
|
|
|
|
|
it('should return "No active prompt" when no prompt is active', () => {
|
|
|
|
|
const { host } = createTestHost();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const result = host.tryInput("play X 1 1");
|
|
|
|
|
expect(result).toBe("No active prompt");
|
2026-04-04 00:54:19 +08:00
|
|
|
});
|
|
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
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");
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const error = host.tryInput("play X 1 1");
|
|
|
|
|
expect(error).toBeNull();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
// Cancel to end the game since start runs until game over
|
|
|
|
|
const nextPromptPromise = waitForPromptEvent(host);
|
|
|
|
|
const nextPrompt = await nextPromptPromise;
|
|
|
|
|
nextPrompt.cancel("test cleanup");
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
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();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const promptPromise = waitForPromptEvent(host);
|
|
|
|
|
const runPromise = host.start();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const promptEvent = await promptPromise;
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const error = host.tryInput("invalid command");
|
|
|
|
|
expect(error).not.toBeNull();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
promptEvent.cancel("test cleanup");
|
|
|
|
|
try {
|
|
|
|
|
await runPromise;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const error = e as Error;
|
|
|
|
|
expect(error.message).toBe("test cleanup");
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should return error when disposed", () => {
|
|
|
|
|
const { host } = createTestHost();
|
|
|
|
|
host.dispose();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const result = host.tryInput("play X 1 1");
|
|
|
|
|
expect(result).toBe("GameHost is disposed");
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
describe("getActivePromptSchema", () => {
|
|
|
|
|
it("should return schema when prompt is active", async () => {
|
|
|
|
|
const { host } = createTestHost();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const promptPromise = waitForPromptEvent(host);
|
|
|
|
|
const runPromise = host.start();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const promptEvent = await promptPromise;
|
|
|
|
|
const schema = host.activePromptSchema.value;
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
expect(schema).not.toBeNull();
|
|
|
|
|
expect(schema?.name).toBe("play");
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
promptEvent.cancel("test cleanup");
|
|
|
|
|
try {
|
|
|
|
|
await runPromise;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const error = e as Error;
|
|
|
|
|
expect(error.message).toBe("test cleanup");
|
|
|
|
|
}
|
2026-04-04 00:54:19 +08:00
|
|
|
});
|
|
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should cancel active prompt during start", async () => {
|
|
|
|
|
const { host } = createTestHost();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const promptPromise = waitForPromptEvent(host);
|
|
|
|
|
const runPromise = host.start();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
await promptPromise;
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
// Setup should cancel the active prompt and reset state
|
|
|
|
|
host.start();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
// The original runPromise should be rejected due to cancellation
|
|
|
|
|
try {
|
|
|
|
|
await runPromise;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const error = e as Error;
|
|
|
|
|
expect(error.message).toContain("Cancelled");
|
|
|
|
|
}
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
// State should be reset
|
|
|
|
|
expect(host.state.value.currentPlayer).toBe("X");
|
|
|
|
|
expect(host.state.value.turn).toBe(0);
|
2026-04-04 00:54:19 +08:00
|
|
|
});
|
|
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should throw error when disposed", () => {
|
|
|
|
|
const { host } = createTestHost();
|
|
|
|
|
host.dispose();
|
|
|
|
|
|
|
|
|
|
expect(() => host.start()).toThrow("GameHost is disposed");
|
2026-04-04 00:54:19 +08:00
|
|
|
});
|
2026-04-20 15:16:03 +08:00
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
describe("dispose", () => {
|
|
|
|
|
it("should change status to disposed", () => {
|
|
|
|
|
const { host } = createTestHost();
|
|
|
|
|
host.dispose();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
expect(host.status.value).toBe("disposed");
|
|
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should cancel active prompt on dispose", async () => {
|
|
|
|
|
const { host } = createTestHost();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
const promptPromise = waitForPromptEvent(host);
|
|
|
|
|
const runPromise = host.start();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
await promptPromise;
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
host.dispose();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
// The runPromise should be rejected due to cancellation
|
|
|
|
|
try {
|
|
|
|
|
await runPromise;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const error = e as Error;
|
|
|
|
|
expect(error.message).toContain("Cancelled");
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should be idempotent", () => {
|
|
|
|
|
const { host } = createTestHost();
|
|
|
|
|
host.dispose();
|
|
|
|
|
host.dispose(); // Should not throw
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
expect(host.status.value).toBe("disposed");
|
2026-04-04 00:54:19 +08:00
|
|
|
});
|
2026-04-20 15:16:03 +08:00
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
describe("events", () => {
|
|
|
|
|
it("should emit start event", async () => {
|
|
|
|
|
const { host } = createTestHost();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
let setupCount = 0;
|
|
|
|
|
host.on("start", () => {
|
|
|
|
|
setupCount++;
|
|
|
|
|
});
|
2026-04-06 10:46:08 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
// Setup listener before calling setup
|
|
|
|
|
const promptPromise = waitForPromptEvent(host);
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
// Initial setup via reset
|
|
|
|
|
const runPromise = host.start();
|
|
|
|
|
expect(setupCount).toBe(1);
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
// State should be running
|
|
|
|
|
expect(host.status.value).toBe("running");
|
2026-04-06 10:46:08 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
// Cancel the background setup command
|
|
|
|
|
const prompt = await promptPromise;
|
|
|
|
|
prompt.cancel("test end");
|
2026-04-06 10:46:08 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
try {
|
|
|
|
|
await runPromise;
|
|
|
|
|
} catch {
|
|
|
|
|
// Expected - cancelled
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should emit dispose event", () => {
|
|
|
|
|
const { host } = createTestHost();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
let disposeCount = 0;
|
|
|
|
|
host.on("dispose", () => {
|
|
|
|
|
disposeCount++;
|
|
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
host.dispose();
|
|
|
|
|
expect(disposeCount).toBe(1);
|
|
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should allow unsubscribing from events", () => {
|
|
|
|
|
const { host } = createTestHost();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
let setupCount = 0;
|
|
|
|
|
const unsubscribe = host.on("start", () => {
|
|
|
|
|
setupCount++;
|
|
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
unsubscribe();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
// No event should be emitted
|
|
|
|
|
// (we can't easily test this without triggering setup, but we verify unsubscribe works)
|
|
|
|
|
expect(typeof unsubscribe).toBe("function");
|
2026-04-04 00:54:19 +08:00
|
|
|
});
|
2026-04-20 15:16:03 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
2026-04-04 00:54:19 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
// 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");
|
|
|
|
|
}
|
2026-04-04 00:54:19 +08:00
|
|
|
});
|
2026-04-04 01:11:09 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
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();
|
2026-04-04 01:11:09 +08:00
|
|
|
});
|
2026-04-20 15:16:03 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
2026-04-04 10:30:00 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
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");
|
|
|
|
|
}
|
2026-04-04 10:30:00 +08:00
|
|
|
});
|
2026-04-20 15:16:03 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("addInterruption and clearInterruptions", () => {
|
|
|
|
|
it("should add interruption promise to state", async () => {
|
|
|
|
|
const { host } = createTestHost();
|
2026-04-06 16:09:05 +08:00
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
let resolveInterruption: () => void;
|
|
|
|
|
const interruptionPromise = new Promise<void>((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
|
|
|
|
|
}
|
2026-04-06 16:09:05 +08:00
|
|
|
});
|
|
|
|
|
|
2026-04-20 15:16:03 +08:00
|
|
|
it("should clear all pending interruptions", async () => {
|
|
|
|
|
const { host } = createTestHost();
|
|
|
|
|
|
|
|
|
|
let resolveInterruption1: () => void;
|
|
|
|
|
let resolveInterruption2: () => void;
|
|
|
|
|
const interruptionPromise1 = new Promise<void>((resolve) => {
|
|
|
|
|
resolveInterruption1 = resolve;
|
|
|
|
|
});
|
|
|
|
|
const interruptionPromise2 = new Promise<void>((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)
|
2026-04-06 16:09:05 +08:00
|
|
|
});
|
2026-04-20 15:16:03 +08:00
|
|
|
});
|
2026-04-04 00:54:19 +08:00
|
|
|
});
|