diff --git a/src/utils/command/command-registry.ts b/src/utils/command/command-registry.ts index e91252e..e2361f7 100644 --- a/src/utils/command/command-registry.ts +++ b/src/utils/command/command-registry.ts @@ -45,8 +45,8 @@ export type CommandRunnerContextExport = CommandRunnerContext; promptQueue: AsyncQueue; _activePrompt: PromptEvent | null; - _resolvePrompt: (command: Command) => void; - _rejectPrompt: (error: Error) => void; + _tryCommit: (command: Command) => string | null; + _cancel: (reason?: string) => void; _pendingInput: string | null; }; @@ -66,25 +66,41 @@ export function createCommandRunnerContext( let activePrompt: PromptEvent | null = null; - const resolvePrompt = (command: Command) => { + const tryCommit = (command: Command) => { 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; } }; - const rejectPrompt = (error: Error) => { - if (activePrompt) { - activePrompt.reject(error); - activePrompt = null; - } - }; - - const prompt = (schema: Parameters['prompt']>[0]): Promise => { + const prompt = ( + schema: CommandSchema | string, + validator?: (command: Command) => string | null + ): Promise => { const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema; return new Promise((resolve, reject) => { - activePrompt = { schema: resolvedSchema, resolve, reject }; - const event: PromptEvent = { schema: resolvedSchema, resolve, reject }; + const tryCommit = (command: Command) => { + 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) { listener(event); } @@ -100,8 +116,8 @@ export function createCommandRunnerContext( on, off, _activePrompt: null, - _resolvePrompt: resolvePrompt, - _rejectPrompt: rejectPrompt, + _tryCommit: tryCommit, + _cancel: cancel, _pendingInput: null, promptQueue: null! }; diff --git a/src/utils/command/command-runner.ts b/src/utils/command/command-runner.ts index 3103beb..0f588b2 100644 --- a/src/utils/command/command-runner.ts +++ b/src/utils/command/command-runner.ts @@ -2,8 +2,14 @@ import type { Command, CommandSchema } from './types'; export type PromptEvent = { schema: CommandSchema; - resolve: (command: Command) => void; - reject: (error: Error) => void; + /** + * 尝试提交命令 + * @returns null - 验证成功,Promise 已 resolve + * @returns string - 验证失败,返回错误消息,Promise 未 resolve + */ + tryCommit: (command: Command) => string | null; + /** 取消 prompt,Promise 被 reject */ + cancel: (reason?: string) => void; }; export type CommandRunnerEvents = { @@ -22,7 +28,7 @@ export type CommandRunnerContext = { context: TContext; run: (input: string) => Promise>; runParsed: (command: Command) => Promise<{ success: true; result: unknown } | { success: false; error: string }>; - prompt: (schema: CommandSchema | string) => Promise; + prompt: (schema: CommandSchema | string, validator?: (command: Command) => string | null) => Promise; on: (event: T, listener: (e: CommandRunnerEvents[T]) => void) => void; off: (event: T, listener: (e: CommandRunnerEvents[T]) => void) => void; }; diff --git a/tests/core/game.test.ts b/tests/core/game.test.ts index 4c4d726..b09238c 100644 --- a/tests/core/game.test.ts +++ b/tests/core/game.test.ts @@ -64,7 +64,8 @@ describe('createGameContext', () => { expect(promptEvent).not.toBeNull(); 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; expect(result.success).toBe(true); diff --git a/tests/samples/boop.test.ts b/tests/samples/boop.test.ts index d0864dc..41a32ad 100644 --- a/tests/samples/boop.test.ts +++ b/tests/samples/boop.test.ts @@ -464,7 +464,7 @@ describe('Boop - game flow', () => { expect(promptEvent).not.toBeNull(); expect(promptEvent.schema.name).toBe('play'); - promptEvent.reject(new Error('test end')); + promptEvent.cancel('test end'); const result = await runPromise; expect(result.success).toBe(false); @@ -480,7 +480,8 @@ describe('Boop - game flow', () => { expect(promptEvent).not.toBeNull(); 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; expect(result.success).toBe(true); @@ -497,12 +498,14 @@ describe('Boop - game flow', () => { const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); 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).not.toBeNull(); const promptEvent2 = await waitForPrompt(ctx); expect(promptEvent2).not.toBeNull(); - promptEvent2.resolve({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} }); + const error2 = promptEvent2.tryCommit({ name: 'play', params: ['white', 2, 2], options: {}, flags: {} }); + expect(error2).toBeNull(); const result = await runPromise; expect(result.success).toBe(true); @@ -519,12 +522,14 @@ describe('Boop - game flow', () => { const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); 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).not.toBeNull(); const promptEvent2 = await waitForPrompt(ctx); expect(promptEvent2).not.toBeNull(); - promptEvent2.resolve({ name: 'play', params: ['white', 0, 0], options: {}, flags: {} }); + const error2 = promptEvent2.tryCommit({ name: 'play', params: ['white', 0, 0], options: {}, flags: {} }); + expect(error2).toBeNull(); const result = await runPromise; expect(result.success).toBe(true); @@ -543,12 +548,13 @@ describe('Boop - game flow', () => { const runPromise = ctx.commands.run<{winner: WinnerType}>('turn white'); 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).not.toBeNull(); const promptEvent2 = await waitForPrompt(ctx); expect(promptEvent2).not.toBeNull(); - promptEvent2.reject(new Error('test end')); + promptEvent2.cancel('test end'); const result = await runPromise; expect(result.success).toBe(false); diff --git a/tests/utils/command-runner.test.ts b/tests/utils/command-runner.test.ts index 94e74f9..661e7c1 100644 --- a/tests/utils/command-runner.test.ts +++ b/tests/utils/command-runner.test.ts @@ -296,7 +296,8 @@ describe('prompt', () => { expect(promptEvent).not.toBeNull(); const parsed = { name: 'select', params: ['Ace'], options: {}, flags: {} }; - promptEvent!.resolve(parsed); + const error = promptEvent!.tryCommit(parsed); + expect(error).toBeNull(); const result = await runPromise; expect(result.success).toBe(true); @@ -336,7 +337,7 @@ describe('prompt', () => { await new Promise((r) => setTimeout(r, 0)); expect(promptEvent).not.toBeNull(); - promptEvent!.reject(new Error('user cancelled')); + promptEvent!.cancel('user cancelled'); const result = await runPromise; expect(result.success).toBe(true); @@ -373,7 +374,8 @@ describe('prompt', () => { expect(promptEvent).not.toBeNull(); 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; expect(result.success).toBe(true); @@ -410,13 +412,15 @@ describe('prompt', () => { expect(promptEvents.length).toBe(1); 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)); expect(promptEvents.length).toBe(2); 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; expect(result.success).toBe(true); @@ -424,4 +428,93 @@ describe('prompt', () => { expect(result.result).toEqual(['one', 'two']); } }); + + it('should validate input with validator function', async () => { + const registry = createCommandRegistry(); + + const chooseRunner: CommandRunner = { + schema: parseCommandSchema('choose'), + run: async function () { + const result = await this.prompt( + 'select ', + (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(); + + const chooseRunner: CommandRunner = { + schema: parseCommandSchema('choose'), + run: async function () { + try { + await this.prompt('select '); + 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'); + } + }); });