Compare commits

..

4 Commits

Author SHA1 Message Date
hyper 15122defcc refactor: update usage pattern for tic tac toe and boop 2026-04-02 19:32:07 +08:00
hyper 5f812a3478 fix: fix tests for PromptEvent refactor 2026-04-02 19:27:36 +08:00
hyper c886e904a8 refactor: change PromptEvent reject/resolve to cancel/tryCommit 2026-04-02 19:08:14 +08:00
hyper a766050773 feat: upgrade inline-schema 2026-04-02 18:39:59 +08:00
9 changed files with 250 additions and 109 deletions

2
package-lock.json generated
View File

@ -1656,7 +1656,7 @@
}, },
"node_modules/inline-schema": { "node_modules/inline-schema": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "git+https://gitea.ayi-games.online/hypercross/inline-schema#cf55295ce79a2dcf3114e2910cc6a28ce872be90", "resolved": "git+https://gitea.ayi-games.online/hypercross/inline-schema#16d88d610827b5df7f77d7bc22f66b0a8a802dd7",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"csv-parse": "^5.5.6" "csv-parse": "^5.5.6"

View File

@ -17,6 +17,7 @@ type PlayerSupply = {
cat: PieceSupply; cat: PieceSupply;
}; };
// TODO refactor this into an Entity
function createPlayerSupply(): PlayerSupply { function createPlayerSupply(): PlayerSupply {
return { return {
kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 }, kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 },
@ -67,21 +68,32 @@ registration.add('setup', async function() {
registration.add('turn <player>', async function(cmd) { registration.add('turn <player>', async function(cmd) {
const [turnPlayer] = cmd.params as [PlayerType]; const [turnPlayer] = cmd.params as [PlayerType];
const maxRetries = 50;
let retries = 0;
while (retries < maxRetries) { const playCmd = await this.prompt(
retries++; 'play <player> <row:number> <col:number> [type:string]',
const playCmd = await this.prompt('play <player> <row:number> <col:number> [type:string]'); (command) => {
const [player, row, col, type] = playCmd.params as [PlayerType, number, number, PieceType?]; const [player, row, col, type] = command.params as [PlayerType, number, number, PieceType?];
const pieceType = type === 'cat' ? 'cat' : 'kitten'; const pieceType = type === 'cat' ? 'cat' : 'kitten';
if (player !== turnPlayer) continue; if (player !== turnPlayer) {
if (!isValidMove(row, col)) continue; return `Invalid player: ${player}. Expected ${turnPlayer}.`;
if (isCellOccupied(this.context, row, col)) continue; }
if (!isValidMove(row, col)) {
return `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
}
if (isCellOccupied(this.context, row, col)) {
return `Cell (${row}, ${col}) is already occupied.`;
}
const supply = this.context.value.players[player][pieceType].supply; const supply = this.context.value.players[player][pieceType].supply;
if (supply <= 0) continue; if (supply <= 0) {
return `No ${pieceType}s left in ${player}'s supply.`;
}
return null;
}
);
const [player, row, col, type] = playCmd.params as [PlayerType, number, number, PieceType?];
const pieceType = type === 'cat' ? 'cat' : 'kitten';
placePiece(this.context, row, col, turnPlayer, pieceType); placePiece(this.context, row, col, turnPlayer, pieceType);
applyBoops(this.context, row, col, pieceType); applyBoops(this.context, row, col, pieceType);
@ -95,9 +107,6 @@ registration.add('turn <player>', async function(cmd) {
if (winner) return { winner }; if (winner) return { winner };
return { winner: null }; return { winner: null };
}
throw new Error('Too many invalid attempts');
}); });
function isValidMove(row: number, col: number): boolean { function isValidMove(row: number, col: number): boolean {

View File

@ -61,18 +61,26 @@ registration.add('setup', async function() {
registration.add('turn <player> <turn:number>', async function(cmd) { registration.add('turn <player> <turn:number>', async function(cmd) {
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number]; const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
const maxRetries = MAX_TURNS * 2;
let retries = 0;
while (retries < maxRetries) { const playCmd = await this.prompt(
retries++; 'play <player> <row:number> <col:number>',
const playCmd = await this.prompt('play <player> <row:number> <col:number>'); (command) => {
const [player, row, col] = command.params as [PlayerType, number, number];
if (player !== turnPlayer) {
return `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
if (!isValidMove(row, col)) {
return `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
}
if (isCellOccupied(this.context, row, col)) {
return `Cell (${row}, ${col}) is already occupied.`;
}
return null;
}
);
const [player, row, col] = playCmd.params as [PlayerType, number, number]; const [player, row, col] = playCmd.params as [PlayerType, number, number];
if (player !== turnPlayer) continue;
if (!isValidMove(row, col)) continue;
if (isCellOccupied(this.context, row, col)) continue;
placePiece(this.context, row, col, turnPlayer); placePiece(this.context, row, col, turnPlayer);
const winner = checkWinner(this.context); const winner = checkWinner(this.context);
@ -80,9 +88,6 @@ registration.add('turn <player> <turn:number>', async function(cmd) {
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType }; if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
return { winner: null }; return { winner: null };
}
throw new Error('Too many invalid attempts');
}); });
function isValidMove(row: number, col: number): boolean { function isValidMove(row: number, col: number): boolean {

View File

@ -45,8 +45,8 @@ export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext
registry: CommandRegistry<TContext>; registry: CommandRegistry<TContext>;
promptQueue: AsyncQueue<PromptEvent>; promptQueue: AsyncQueue<PromptEvent>;
_activePrompt: PromptEvent | null; _activePrompt: PromptEvent | null;
_resolvePrompt: (command: Command) => void; _tryCommit: (command: Command) => string | null;
_rejectPrompt: (error: Error) => void; _cancel: (reason?: string) => void;
_pendingInput: string | null; _pendingInput: string | null;
}; };
@ -66,25 +66,41 @@ export function createCommandRunnerContext<TContext>(
let activePrompt: PromptEvent | null = null; let activePrompt: PromptEvent | null = null;
const resolvePrompt = (command: Command) => { const tryCommit = (command: Command) => {
if (activePrompt) { if (activePrompt) {
activePrompt.resolve(command); const result = activePrompt.tryCommit(command);
if (result === null) {
activePrompt = null;
}
return result;
}
return 'No active prompt';
};
const cancel = (reason?: string) => {
if (activePrompt) {
activePrompt.cancel(reason);
activePrompt = null; activePrompt = null;
} }
}; };
const rejectPrompt = (error: Error) => { const prompt = (
if (activePrompt) { schema: CommandSchema | string,
activePrompt.reject(error); validator?: (command: Command) => string | null
activePrompt = null; ): Promise<Command> => {
}
};
const prompt = (schema: Parameters<CommandRunnerContext<TContext>['prompt']>[0]): Promise<Command> => {
const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema; const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
activePrompt = { schema: resolvedSchema, resolve, reject }; const tryCommit = (command: Command) => {
const event: PromptEvent = { schema: resolvedSchema, resolve, reject }; const error = validator?.(command);
if (error) return error;
resolve(command);
return null;
};
const cancel = (reason?: string) => {
reject(new Error(reason ?? 'Cancelled'));
};
activePrompt = { schema: resolvedSchema, tryCommit, cancel };
const event: PromptEvent = { schema: resolvedSchema, tryCommit, cancel };
for (const listener of listeners) { for (const listener of listeners) {
listener(event); listener(event);
} }
@ -100,8 +116,8 @@ export function createCommandRunnerContext<TContext>(
on, on,
off, off,
_activePrompt: null, _activePrompt: null,
_resolvePrompt: resolvePrompt, _tryCommit: tryCommit,
_rejectPrompt: rejectPrompt, _cancel: cancel,
_pendingInput: null, _pendingInput: null,
promptQueue: null! promptQueue: null!
}; };

View File

@ -2,8 +2,14 @@ import type { Command, CommandSchema } from './types';
export type PromptEvent = { export type PromptEvent = {
schema: CommandSchema; schema: CommandSchema;
resolve: (command: Command) => void; /**
reject: (error: Error) => void; *
* @returns null - Promise resolve
* @returns string - Promise resolve
*/
tryCommit: (command: Command) => string | null;
/** 取消 promptPromise 被 reject */
cancel: (reason?: string) => void;
}; };
export type CommandRunnerEvents = { export type CommandRunnerEvents = {
@ -22,7 +28,7 @@ export type CommandRunnerContext<TContext> = {
context: TContext; context: TContext;
run: <T=unknown>(input: string) => Promise<CommandResult<T>>; run: <T=unknown>(input: string) => Promise<CommandResult<T>>;
runParsed: (command: Command) => Promise<{ success: true; result: unknown } | { success: false; error: string }>; runParsed: (command: Command) => Promise<{ success: true; result: unknown } | { success: false; error: string }>;
prompt: (schema: CommandSchema | string) => Promise<Command>; prompt: (schema: CommandSchema | string, validator?: (command: Command) => string | null) => Promise<Command>;
on: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void; on: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
off: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void; off: <T extends keyof CommandRunnerEvents>(event: T, listener: (e: CommandRunnerEvents[T]) => void) => void;
}; };

View File

@ -64,7 +64,8 @@ describe('createGameContext', () => {
expect(promptEvent).not.toBeNull(); expect(promptEvent).not.toBeNull();
expect(promptEvent.schema.name).toBe('prompt'); expect(promptEvent.schema.name).toBe('prompt');
promptEvent.resolve({ name: 'prompt', params: ['yes'], options: {}, flags: {} }); const error = promptEvent.tryCommit({ name: 'prompt', params: ['yes'], options: {}, flags: {} });
expect(error).toBeNull();
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);

View File

@ -464,7 +464,7 @@ describe('Boop - game flow', () => {
expect(promptEvent).not.toBeNull(); expect(promptEvent).not.toBeNull();
expect(promptEvent.schema.name).toBe('play'); expect(promptEvent.schema.name).toBe('play');
promptEvent.reject(new Error('test end')); promptEvent.cancel('test end');
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(false); expect(result.success).toBe(false);
@ -480,7 +480,8 @@ describe('Boop - game flow', () => {
expect(promptEvent).not.toBeNull(); expect(promptEvent).not.toBeNull();
expect(promptEvent.schema.name).toBe('play'); expect(promptEvent.schema.name).toBe('play');
promptEvent.resolve({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} }); const error = promptEvent.tryCommit({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} });
expect(error).toBeNull();
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -497,12 +498,13 @@ describe('Boop - game flow', () => {
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
const promptEvent1 = await promptPromise; const promptEvent1 = await promptPromise;
promptEvent1.resolve({ name: 'play', params: ['black', 2, 2], options: {}, flags: {} }); // 验证器会拒绝错误的玩家
const error1 = promptEvent1.tryCommit({ name: 'play', params: ['black', 2, 2], options: {}, flags: {} });
expect(error1).toContain('Invalid player');
const promptEvent2 = await waitForPrompt(ctx); // 验证失败后,再次尝试有效输入
expect(promptEvent2).not.toBeNull(); const error2 = promptEvent1.tryCommit({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} });
expect(error2).toBeNull();
promptEvent2.resolve({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} });
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -519,12 +521,12 @@ describe('Boop - game flow', () => {
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
const promptEvent1 = await promptPromise; const promptEvent1 = await promptPromise;
promptEvent1.resolve({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} }); const error1 = promptEvent1.tryCommit({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} });
expect(error1).toContain('occupied');
const promptEvent2 = await waitForPrompt(ctx); // 验证失败后,再次尝试有效输入
expect(promptEvent2).not.toBeNull(); const error2 = promptEvent1.tryCommit({ name: 'play', params: ['white', 0, 0], options: {}, flags: {} });
expect(error2).toBeNull();
promptEvent2.resolve({ name: 'play', params: ['white', 0, 0], options: {}, flags: {} });
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -543,12 +545,11 @@ describe('Boop - game flow', () => {
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
const promptEvent1 = await promptPromise; const promptEvent1 = await promptPromise;
promptEvent1.resolve({ name: 'play', params: ['white', 0, 0], options: {}, flags: {} }); const error1 = promptEvent1.tryCommit({ name: 'play', params: ['white', 0, 0], options: {}, flags: {} });
expect(error1).toContain('No kittens');
const promptEvent2 = await waitForPrompt(ctx); // 验证失败后,取消
expect(promptEvent2).not.toBeNull(); promptEvent1.cancel('test end');
promptEvent2.reject(new Error('test end'));
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(false); expect(result.success).toBe(false);
@ -561,7 +562,8 @@ describe('Boop - game flow', () => {
let promptPromise = waitForPrompt(ctx); let promptPromise = waitForPrompt(ctx);
let runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); let runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
let prompt = await promptPromise; let prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['white', 3, 3], options: {}, flags: {} }); const error1 = prompt.tryCommit({ name: 'play', params: ['white', 3, 3], options: {}, flags: {} });
expect(error1).toBeNull();
let result = await runPromise; let result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(getParts(state).length).toBe(1); expect(getParts(state).length).toBe(1);
@ -569,7 +571,8 @@ describe('Boop - game flow', () => {
promptPromise = waitForPrompt(ctx); promptPromise = waitForPrompt(ctx);
runPromise = ctx.commands.run<{winner: WinnerType}>('turn black'); runPromise = ctx.commands.run<{winner: WinnerType}>('turn black');
prompt = await promptPromise; prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['black', 2, 2], options: {}, flags: {} }); const error2 = prompt.tryCommit({ name: 'play', params: ['black', 2, 2], options: {}, flags: {} });
expect(error2).toBeNull();
result = await runPromise; result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(getParts(state).length).toBe(2); expect(getParts(state).length).toBe(2);
@ -610,7 +613,8 @@ describe('Boop - game flow', () => {
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
const promptEvent = await promptPromise; const promptEvent = await promptPromise;
promptEvent.resolve({ name: 'play', params: ['white', 2, 2, 'cat'], options: {}, flags: {} }); const error = promptEvent.tryCommit({ name: 'play', params: ['white', 2, 2, 'cat'], options: {}, flags: {} });
expect(error).toBeNull();
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -632,12 +636,11 @@ describe('Boop - game flow', () => {
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white');
const promptEvent1 = await promptPromise; const promptEvent1 = await promptPromise;
promptEvent1.resolve({ name: 'play', params: ['white', 0, 0, 'cat'], options: {}, flags: {} }); const error1 = promptEvent1.tryCommit({ name: 'play', params: ['white', 0, 0, 'cat'], options: {}, flags: {} });
expect(error1).toContain('No cats');
const promptEvent2 = await waitForPrompt(ctx); // 验证失败后,取消
expect(promptEvent2).not.toBeNull(); promptEvent1.cancel('test end');
promptEvent2.reject(new Error('test end'));
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(false); expect(result.success).toBe(false);

View File

@ -207,7 +207,7 @@ describe('TicTacToe - game flow', () => {
expect(promptEvent).not.toBeNull(); expect(promptEvent).not.toBeNull();
expect(promptEvent.schema.name).toBe('play'); expect(promptEvent.schema.name).toBe('play');
promptEvent.reject(new Error('test end')); promptEvent.cancel('test end');
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(false); expect(result.success).toBe(false);
@ -223,7 +223,8 @@ describe('TicTacToe - game flow', () => {
expect(promptEvent).not.toBeNull(); expect(promptEvent).not.toBeNull();
expect(promptEvent.schema.name).toBe('play'); expect(promptEvent.schema.name).toBe('play');
promptEvent.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); const error = promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
expect(error).toBeNull();
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -239,12 +240,13 @@ describe('TicTacToe - game flow', () => {
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1'); const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1');
const promptEvent1 = await promptPromise; const promptEvent1 = await promptPromise;
promptEvent1.resolve({ name: 'play', params: ['O', 1, 1], options: {}, flags: {} }); // 验证器会拒绝错误的玩家
const error1 = promptEvent1.tryCommit({ name: 'play', params: ['O', 1, 1], options: {}, flags: {} });
expect(error1).toContain('Invalid player');
const promptEvent2 = await waitForPrompt(ctx); // 验证失败后,再次尝试有效输入
expect(promptEvent2).not.toBeNull(); const error2 = promptEvent1.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
expect(error2).toBeNull();
promptEvent2.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -261,12 +263,12 @@ describe('TicTacToe - game flow', () => {
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1'); const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1');
const promptEvent1 = await promptPromise; const promptEvent1 = await promptPromise;
promptEvent1.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); const error1 = promptEvent1.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
expect(error1).toContain('occupied');
const promptEvent2 = await waitForPrompt(ctx); // 验证失败后,再次尝试有效输入
expect(promptEvent2).not.toBeNull(); const error2 = promptEvent1.tryCommit({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} });
expect(error2).toBeNull();
promptEvent2.resolve({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} });
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -279,7 +281,8 @@ describe('TicTacToe - game flow', () => {
let promptPromise = waitForPrompt(ctx); let promptPromise = waitForPrompt(ctx);
let runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1'); let runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 1');
let prompt = await promptPromise; let prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} }); const error1 = prompt.tryCommit({ name: 'play', params: ['X', 0, 0], options: {}, flags: {} });
expect(error1).toBeNull();
let result = await runPromise; let result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) expect(result.result.winner).toBeNull(); if (result.success) expect(result.result.winner).toBeNull();
@ -287,7 +290,8 @@ describe('TicTacToe - game flow', () => {
promptPromise = waitForPrompt(ctx); promptPromise = waitForPrompt(ctx);
runPromise = ctx.commands.run('turn O 2'); runPromise = ctx.commands.run('turn O 2');
prompt = await promptPromise; prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['O', 0, 1], options: {}, flags: {} }); const error2 = prompt.tryCommit({ name: 'play', params: ['O', 0, 1], options: {}, flags: {} });
expect(error2).toBeNull();
result = await runPromise; result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) expect(result.result.winner).toBeNull(); if (result.success) expect(result.result.winner).toBeNull();
@ -295,7 +299,8 @@ describe('TicTacToe - game flow', () => {
promptPromise = waitForPrompt(ctx); promptPromise = waitForPrompt(ctx);
runPromise = ctx.commands.run('turn X 3'); runPromise = ctx.commands.run('turn X 3');
prompt = await promptPromise; prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['X', 1, 0], options: {}, flags: {} }); const error3 = prompt.tryCommit({ name: 'play', params: ['X', 1, 0], options: {}, flags: {} });
expect(error3).toBeNull();
result = await runPromise; result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) expect(result.result.winner).toBeNull(); if (result.success) expect(result.result.winner).toBeNull();
@ -303,7 +308,8 @@ describe('TicTacToe - game flow', () => {
promptPromise = waitForPrompt(ctx); promptPromise = waitForPrompt(ctx);
runPromise = ctx.commands.run('turn O 4'); runPromise = ctx.commands.run('turn O 4');
prompt = await promptPromise; prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['O', 0, 2], options: {}, flags: {} }); const error4 = prompt.tryCommit({ name: 'play', params: ['O', 0, 2], options: {}, flags: {} });
expect(error4).toBeNull();
result = await runPromise; result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) expect(result.result.winner).toBeNull(); if (result.success) expect(result.result.winner).toBeNull();
@ -311,7 +317,8 @@ describe('TicTacToe - game flow', () => {
promptPromise = waitForPrompt(ctx); promptPromise = waitForPrompt(ctx);
runPromise = ctx.commands.run('turn X 5'); runPromise = ctx.commands.run('turn X 5');
prompt = await promptPromise; prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['X', 2, 0], options: {}, flags: {} }); const error5 = prompt.tryCommit({ name: 'play', params: ['X', 2, 0], options: {}, flags: {} });
expect(error5).toBeNull();
result = await runPromise; result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) expect(result.result.winner).toBe('X'); if (result.success) expect(result.result.winner).toBe('X');
@ -341,7 +348,8 @@ describe('TicTacToe - game flow', () => {
const promptPromise = waitForPrompt(ctx); const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 9'); const runPromise = ctx.commands.run<{winner: WinnerType}>('turn X 9');
const prompt = await promptPromise; const prompt = await promptPromise;
prompt.resolve({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} }); const error = prompt.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
expect(error).toBeNull();
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
if (result.success) expect(result.result.winner).toBe('draw'); if (result.success) expect(result.result.winner).toBe('draw');

View File

@ -296,7 +296,8 @@ describe('prompt', () => {
expect(promptEvent).not.toBeNull(); expect(promptEvent).not.toBeNull();
const parsed = { name: 'select', params: ['Ace'], options: {}, flags: {} }; const parsed = { name: 'select', params: ['Ace'], options: {}, flags: {} };
promptEvent!.resolve(parsed); const error = promptEvent!.tryCommit(parsed);
expect(error).toBeNull();
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -336,7 +337,7 @@ describe('prompt', () => {
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
expect(promptEvent).not.toBeNull(); expect(promptEvent).not.toBeNull();
promptEvent!.reject(new Error('user cancelled')); promptEvent!.cancel('user cancelled');
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -373,7 +374,8 @@ describe('prompt', () => {
expect(promptEvent).not.toBeNull(); expect(promptEvent).not.toBeNull();
expect(promptEvent!.schema.name).toBe('pick'); expect(promptEvent!.schema.name).toBe('pick');
promptEvent!.resolve({ name: 'pick', params: ['sword'], options: {}, flags: {} }); const error = promptEvent!.tryCommit({ name: 'pick', params: ['sword'], options: {}, flags: {} });
expect(error).toBeNull();
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -410,13 +412,15 @@ describe('prompt', () => {
expect(promptEvents.length).toBe(1); expect(promptEvents.length).toBe(1);
expect(promptEvents[0].schema.name).toBe('first'); expect(promptEvents[0].schema.name).toBe('first');
promptEvents[0].resolve({ name: 'first', params: ['one'], options: {}, flags: {} }); const error1 = promptEvents[0].tryCommit({ name: 'first', params: ['one'], options: {}, flags: {} });
expect(error1).toBeNull();
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
expect(promptEvents.length).toBe(2); expect(promptEvents.length).toBe(2);
expect(promptEvents[1].schema.name).toBe('second'); expect(promptEvents[1].schema.name).toBe('second');
promptEvents[1].resolve({ name: 'second', params: ['two'], options: {}, flags: {} }); const error2 = promptEvents[1].tryCommit({ name: 'second', params: ['two'], options: {}, flags: {} });
expect(error2).toBeNull();
const result = await runPromise; const result = await runPromise;
expect(result.success).toBe(true); expect(result.success).toBe(true);
@ -424,4 +428,93 @@ describe('prompt', () => {
expect(result.result).toEqual(['one', 'two']); expect(result.result).toEqual(['one', 'two']);
} }
}); });
it('should validate input with validator function', async () => {
const registry = createCommandRegistry<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('choose'),
run: async function () {
const result = await this.prompt(
'select <card>',
(cmd) => {
const card = cmd.params[0] as string;
if (!['Ace', 'King', 'Queen'].includes(card)) {
return `Invalid card: ${card}. Must be Ace, King, or Queen.`;
}
return null;
}
);
return result.params[0] as string;
},
};
registerCommand(registry, chooseRunner);
const ctx = { counter: 0, log: [] };
let promptEvent: PromptEvent | null = null;
const runnerCtx = createCommandRunnerContext(registry, ctx);
runnerCtx.on('prompt', (e) => {
promptEvent = e;
});
const runPromise = runnerCtx.run('choose');
await new Promise((r) => setTimeout(r, 0));
expect(promptEvent).not.toBeNull();
// Try invalid input
const invalidError = promptEvent!.tryCommit({ name: 'select', params: ['Jack'], options: {}, flags: {} });
expect(invalidError).toContain('Invalid card: Jack');
// Try valid input
const validError = promptEvent!.tryCommit({ name: 'select', params: ['Ace'], options: {}, flags: {} });
expect(validError).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('Ace');
}
});
it('should allow cancel with custom reason', async () => {
const registry = createCommandRegistry<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('choose'),
run: async function () {
try {
await this.prompt('select <card>');
return 'unexpected success';
} catch (e) {
return (e as Error).message;
}
},
};
registerCommand(registry, chooseRunner);
const ctx = { counter: 0, log: [] };
let promptEvent: PromptEvent | null = null;
const runnerCtx = createCommandRunnerContext(registry, ctx);
runnerCtx.on('prompt', (e) => {
promptEvent = e;
});
const runPromise = runnerCtx.run('choose');
await new Promise((r) => setTimeout(r, 0));
expect(promptEvent).not.toBeNull();
promptEvent!.cancel('custom cancellation reason');
const result = await runPromise;
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('custom cancellation reason');
}
});
}); });