Compare commits

...

4 Commits

13 changed files with 687 additions and 111 deletions

View File

@ -71,7 +71,7 @@ function commandMatchesSchema(command: Command, schema: CommandSchema): boolean
return false; return false;
} }
const requiredOptions = schema.options.filter(o => o.required); const requiredOptions = Object.values(schema.options).filter(o => o.required);
for (const opt of requiredOptions) { for (const opt of requiredOptions) {
const hasOption = opt.name in command.options || (opt.short && opt.short in command.options); const hasOption = opt.name in command.options || (opt.short && opt.short in command.options);
if (!hasOption) { if (!hasOption) {
@ -131,7 +131,7 @@ function handleGeneratorResult<T>(
ctx.state = 'invoking'; ctx.state = 'invoking';
return invokeChildRule(host, yielded.rule, yielded.command, ctx as RuleContext<unknown>); return invokeChildRule(host, yielded.rule, yielded.command, ctx as RuleContext<unknown>);
} else { } else {
ctx.schema = parseYieldedSchema({ name: '', params: [], options: [], flags: [] }); ctx.schema = parseYieldedSchema({ name: '', params: [], options: {}, flags: {} });
ctx.state = 'yielded'; ctx.state = 'yielded';
} }
} else { } else {

View File

@ -20,6 +20,9 @@ export { createRule, dispatchCommand } from './core/rule';
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command'; export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
export { parseCommand, parseCommandSchema, validateCommand, parseCommandWithSchema, applyCommandSchema } from './utils/command'; export { parseCommand, parseCommandSchema, validateCommand, parseCommandWithSchema, applyCommandSchema } from './utils/command';
export type { CommandRunner, CommandRunnerHandler, CommandRegistry, CommandRunnerContext } from './utils/command';
export { createCommandRegistry, registerCommand, unregisterCommand, hasCommand, getCommand, runCommand, createCommandRunnerContext } from './utils/command';
export type { Entity, EntityAccessor } from './utils/entity'; export type { Entity, EntityAccessor } from './utils/entity';
export { createEntityCollection } from './utils/entity'; export { createEntityCollection } from './utils/entity';

View File

@ -1,10 +0,0 @@
export { parseCommand } from './command/command-parse.js';
export { parseCommandSchema } from './command/schema-parse.js';
export { validateCommand, parseCommandWithSchema, applyCommandSchema } from './command/command-validate.js';
export type {
Command,
CommandParamSchema,
CommandOptionSchema,
CommandFlagSchema,
CommandSchema,
} from './command/types.js';

View File

@ -19,7 +19,7 @@ function validateCommandCore(command: Command, schema: CommandSchema): string[]
errors.push(`参数过多:最多 ${schema.params.length} 个参数,实际 ${command.params.length}`); errors.push(`参数过多:最多 ${schema.params.length} 个参数,实际 ${command.params.length}`);
} }
const requiredOptions = schema.options.filter(o => o.required); const requiredOptions = Object.values(schema.options).filter(o => o.required);
for (const opt of requiredOptions) { for (const opt of requiredOptions) {
const hasOption = opt.name in command.options || (opt.short && opt.short in command.options); const hasOption = opt.name in command.options || (opt.short && opt.short in command.options);
if (!hasOption) { if (!hasOption) {
@ -58,7 +58,7 @@ export function applyCommandSchema(
const parsedOptions: Record<string, unknown> = { ...command.options }; const parsedOptions: Record<string, unknown> = { ...command.options };
for (const [key, value] of Object.entries(command.options)) { for (const [key, value] of Object.entries(command.options)) {
const optSchema = schema.options.find(o => o.name === key || o.short === key); const optSchema = schema.options[key] ?? (key.length === 1 ? Object.values(schema.options).find(o => o.short === key) : undefined);
if (optSchema?.schema && typeof value === 'string') { if (optSchema?.schema && typeof value === 'string') {
try { try {
parsedOptions[key] = optSchema.schema.parse(value); parsedOptions[key] = optSchema.schema.parse(value);

View File

@ -0,0 +1,141 @@
import type { Command } from './types.js';
import type { CommandRunner, CommandRunnerContext, PromptEvent } from './command-runner.js';
import { parseCommand } from './command-parse.js';
import { applyCommandSchema } from './command-apply.js';
import { parseCommandSchema } from './schema-parse.js';
export type CommandRegistry<TContext> = Map<string, CommandRunner<TContext, unknown>>;
export function createCommandRegistry<TContext>(): CommandRegistry<TContext> {
return new Map();
}
export function registerCommand<TContext, TResult>(
registry: CommandRegistry<TContext>,
runner: CommandRunner<TContext, TResult>
): void {
registry.set(runner.schema.name, runner as CommandRunner<TContext, unknown>);
}
export function unregisterCommand<TContext>(
registry: CommandRegistry<TContext>,
name: string
): void {
registry.delete(name);
}
export function hasCommand<TContext>(
registry: CommandRegistry<TContext>,
name: string
): boolean {
return registry.has(name);
}
export function getCommand<TContext>(
registry: CommandRegistry<TContext>,
name: string
): CommandRunner<TContext, unknown> | undefined {
return registry.get(name);
}
type Listener = (e: PromptEvent) => void;
export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext> & {
registry: CommandRegistry<TContext>;
};
export function createCommandRunnerContext<TContext>(
registry: CommandRegistry<TContext>,
context: TContext
): CommandRunnerContextExport<TContext> {
const listeners = new Set<Listener>();
const on = (_event: 'prompt', listener: Listener) => {
listeners.add(listener);
};
const off = (_event: 'prompt', listener: Listener) => {
listeners.delete(listener);
};
const prompt = (schema: Parameters<CommandRunnerContext<TContext>['prompt']>[0]): Promise<Command> => {
const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
return new Promise((resolve, reject) => {
const event: PromptEvent = { schema: resolvedSchema, resolve, reject };
for (const listener of listeners) {
listener(event);
}
});
};
const runnerCtx: CommandRunnerContextExport<TContext> = {
registry,
context,
run: (input: string) => runCommandWithContext(registry, runnerCtx, input),
runParsed: (command: Command) => runCommandParsedWithContext(registry, runnerCtx, command),
prompt,
on,
off,
};
return runnerCtx;
}
async function executeWithRunnerContext<TContext>(
runnerCtx: CommandRunnerContextExport<TContext>,
runner: CommandRunner<TContext, unknown>,
command: Command
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
try {
const result = await runner.run.call(runnerCtx, command);
return { success: true, result };
} catch (e) {
const error = e as Error;
return { success: false, error: error.message };
}
}
export async function runCommand<TContext>(
registry: CommandRegistry<TContext>,
context: TContext,
input: string
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
const runnerCtx = createCommandRunnerContext(registry, context);
return await runCommandWithContext(registry, runnerCtx, input);
}
async function runCommandWithContext<TContext>(
registry: CommandRegistry<TContext>,
runnerCtx: CommandRunnerContextExport<TContext>,
input: string
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
const command = parseCommand(input);
return await runCommandParsedWithContext(registry, runnerCtx, command);
}
export async function runCommandParsed<TContext>(
registry: CommandRegistry<TContext>,
context: TContext,
command: Command
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
const runnerCtx = createCommandRunnerContext(registry, context);
return await runCommandParsedWithContext(registry, runnerCtx, command);
}
async function runCommandParsedWithContext<TContext>(
registry: CommandRegistry<TContext>,
runnerCtx: CommandRunnerContextExport<TContext>,
command: Command
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
const runner = registry.get(command.name);
if (!runner) {
return { success: false, error: `Unknown command: ${command.name}` };
}
const validationResult = applyCommandSchema(command, runner.schema);
if (!validationResult.valid) {
return { success: false, error: validationResult.errors.join('; ') };
}
return await executeWithRunnerContext(runnerCtx, runner, validationResult.command);
}

View File

@ -0,0 +1,30 @@
import type { Command, CommandSchema } from './types.js';
export type PromptEvent = {
schema: CommandSchema;
resolve: (command: Command) => void;
reject: (error: Error) => void;
};
export type CommandRunnerEvents = {
prompt: PromptEvent;
};
export type CommandRunnerContext<TContext> = {
context: TContext;
run: (input: string) => 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>;
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;
};
export type CommandRunnerHandler<TContext, TResult> = (
this: CommandRunnerContext<TContext>,
command: Command
) => Promise<TResult>;
export type CommandRunner<TContext, TResult = unknown> = {
schema: CommandSchema;
run: CommandRunnerHandler<TContext, TResult>;
};

View File

@ -0,0 +1,22 @@
export { parseCommand } from './command-parse';
export { parseCommandSchema } from './schema-parse';
export { validateCommand, parseCommandWithSchema, applyCommandSchema } from './command-validate';
export {
createCommandRegistry,
registerCommand,
unregisterCommand,
hasCommand,
getCommand,
runCommand,
runCommandParsed,
createCommandRunnerContext,
} from './command-registry';
export type {
Command,
CommandParamSchema,
CommandOptionSchema,
CommandFlagSchema,
CommandSchema,
} from './types';
export type { CommandRunner, CommandRunnerHandler, CommandRunnerContext, PromptEvent, CommandRunnerEvents } from './command-runner';
export type { CommandRegistry, CommandRunnerContextExport } from './command-registry';

View File

@ -5,8 +5,8 @@ export function parseCommandSchema(schemaStr: string, name?: string): CommandSch
const schema: CommandSchema = { const schema: CommandSchema = {
name: name ?? '', name: name ?? '',
params: [], params: [],
options: [], options: {},
flags: [], flags: {},
}; };
const tokens = tokenizeSchema(schemaStr); const tokens = tokenizeSchema(schemaStr);
@ -27,28 +27,28 @@ export function parseCommandSchema(schemaStr: string, name?: string): CommandSch
if (inner.startsWith('--')) { if (inner.startsWith('--')) {
const result = parseOptionToken(inner.slice(2), false); const result = parseOptionToken(inner.slice(2), false);
if (result.isFlag) { if (result.isFlag) {
schema.flags.push({ name: result.name, short: result.short }); schema.flags[result.name] = { name: result.name, short: result.short };
} else { } else {
schema.options.push({ schema.options[result.name] = {
name: result.name, name: result.name,
short: result.short, short: result.short,
required: false, required: false,
defaultValue: result.defaultValue, defaultValue: result.defaultValue,
schema: result.schema, schema: result.schema,
}); };
} }
} else if (inner.startsWith('-') && inner.length > 1 && !inner.includes('--')) { } else if (inner.startsWith('-') && inner.length > 1 && !inner.includes('--')) {
const result = parseOptionToken(inner.slice(1), false); const result = parseOptionToken(inner.slice(1), false);
if (result.isFlag) { if (result.isFlag) {
schema.flags.push({ name: result.name, short: result.short || result.name }); schema.flags[result.name] = { name: result.name, short: result.short || result.name };
} else { } else {
schema.options.push({ schema.options[result.name] = {
name: result.name, name: result.name,
short: result.short || result.name, short: result.short || result.name,
required: false, required: false,
defaultValue: result.defaultValue, defaultValue: result.defaultValue,
schema: result.schema, schema: result.schema,
}); };
} }
} else { } else {
const isVariadic = inner.endsWith('...'); const isVariadic = inner.endsWith('...');
@ -78,29 +78,29 @@ export function parseCommandSchema(schemaStr: string, name?: string): CommandSch
} else if (token.startsWith('--')) { } else if (token.startsWith('--')) {
const result = parseOptionToken(token.slice(2), true); const result = parseOptionToken(token.slice(2), true);
if (result.isFlag) { if (result.isFlag) {
schema.flags.push({ name: result.name, short: result.short }); schema.flags[result.name] = { name: result.name, short: result.short };
} else { } else {
schema.options.push({ schema.options[result.name] = {
name: result.name, name: result.name,
short: result.short, short: result.short,
required: true, required: true,
defaultValue: result.defaultValue, defaultValue: result.defaultValue,
schema: result.schema, schema: result.schema,
}); };
} }
i++; i++;
} else if (token.startsWith('-') && token.length > 1 && !/^-?\d+$/.test(token)) { } else if (token.startsWith('-') && token.length > 1 && !/^-?\d+$/.test(token)) {
const result = parseOptionToken(token.slice(1), true); const result = parseOptionToken(token.slice(1), true);
if (result.isFlag) { if (result.isFlag) {
schema.flags.push({ name: result.name, short: result.short || result.name }); schema.flags[result.name] = { name: result.name, short: result.short || result.name };
} else { } else {
schema.options.push({ schema.options[result.name] = {
name: result.name, name: result.name,
short: result.short || result.name, short: result.short || result.name,
required: true, required: true,
defaultValue: result.defaultValue, defaultValue: result.defaultValue,
schema: result.schema, schema: result.schema,
}); };
} }
i++; i++;
} else if (token.startsWith('<') && token.endsWith('>')) { } else if (token.startsWith('<') && token.endsWith('>')) {

View File

@ -30,8 +30,8 @@ export type CommandFlagSchema = {
export type CommandSchema = { export type CommandSchema = {
name: string; name: string;
params: CommandParamSchema[]; params: CommandParamSchema[];
options: CommandOptionSchema[]; options: Record<string, CommandOptionSchema>;
flags: CommandFlagSchema[]; flags: Record<string, CommandFlagSchema>;
} }
export interface ParsedOptionResult { export interface ParsedOptionResult {

View File

@ -28,8 +28,8 @@ describe('Rule System', () => {
expect(rule.schema.params[0].required).toBe(true); expect(rule.schema.params[0].required).toBe(true);
expect(rule.schema.params[1].name).toBe('to'); expect(rule.schema.params[1].name).toBe('to');
expect(rule.schema.params[1].required).toBe(true); expect(rule.schema.params[1].required).toBe(true);
expect(rule.schema.flags).toHaveLength(1); expect(Object.keys(rule.schema.flags)).toHaveLength(1);
expect(rule.schema.flags[0].name).toBe('force'); expect(rule.schema.flags.force.name).toBe('force');
}); });
it('should create a generator when called', () => { it('should create a generator when called', () => {

View File

@ -21,11 +21,11 @@ describe('parseCommandSchema with inline-schema', () => {
it('should parse schema with typed options', () => { it('should parse schema with typed options', () => {
const schema = parseCommandSchema('move <from> <to> [--all: boolean] [--count: number]'); const schema = parseCommandSchema('move <from> <to> [--all: boolean] [--count: number]');
expect(schema.name).toBe('move'); expect(schema.name).toBe('move');
expect(schema.flags).toHaveLength(1); expect(Object.keys(schema.flags)).toHaveLength(1);
expect(schema.options).toHaveLength(1); expect(Object.keys(schema.options)).toHaveLength(1);
expect(schema.flags[0].name).toBe('all'); expect(schema.flags.all.name).toBe('all');
expect(schema.options[0].name).toBe('count'); expect(schema.options.count.name).toBe('count');
expect(schema.options[0].schema).toBeDefined(); expect(schema.options.count.schema).toBeDefined();
}); });
it('should parse schema with tuple type', () => { it('should parse schema with tuple type', () => {
@ -58,7 +58,7 @@ describe('parseCommandSchema with inline-schema', () => {
); );
expect(schema.name).toBe('move'); expect(schema.name).toBe('move');
expect(schema.params).toHaveLength(2); expect(schema.params).toHaveLength(2);
expect(schema.options).toHaveLength(1); expect(Object.keys(schema.options)).toHaveLength(1);
}); });
it('should parse schema with optional typed param', () => { it('should parse schema with optional typed param', () => {
@ -73,9 +73,9 @@ describe('parseCommandSchema with inline-schema', () => {
it('should parse schema with optional typed option', () => { it('should parse schema with optional typed option', () => {
const schema = parseCommandSchema('move <from> [--speed: number]'); const schema = parseCommandSchema('move <from> [--speed: number]');
expect(schema.name).toBe('move'); expect(schema.name).toBe('move');
expect(schema.options).toHaveLength(1); expect(Object.keys(schema.options)).toHaveLength(1);
expect(schema.options[0].required).toBe(false); expect(schema.options.speed.required).toBe(false);
expect(schema.options[0].schema).toBeDefined(); expect(schema.options.speed.schema).toBeDefined();
}); });
}); });

View File

@ -0,0 +1,427 @@
import { describe, it, expect } from 'vitest';
import { parseCommandSchema } from '../../src/utils/command/schema-parse';
import {
createCommandRegistry,
registerCommand,
unregisterCommand,
hasCommand,
getCommand,
runCommand,
createCommandRunnerContext,
type CommandRegistry,
type CommandRunnerContextExport,
} from '../../src/utils/command/command-registry';
import type { CommandRunner, PromptEvent } from '../../src/utils/command/command-runner';
type TestContext = {
counter: number;
log: string[];
};
describe('CommandRegistry', () => {
it('should create an empty registry', () => {
const registry = createCommandRegistry<TestContext>();
expect(registry.size).toBe(0);
});
it('should register a command', () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('add <a> <b>'),
run: async function (cmd) {
return Number(cmd.params[0]) + Number(cmd.params[1]);
},
};
registerCommand(registry, runner);
expect(registry.size).toBe(1);
expect(hasCommand(registry, 'add')).toBe(true);
});
it('should unregister a command', () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext> = {
schema: parseCommandSchema('remove'),
run: async () => {},
};
registerCommand(registry, runner);
expect(hasCommand(registry, 'remove')).toBe(true);
unregisterCommand(registry, 'remove');
expect(hasCommand(registry, 'remove')).toBe(false);
});
it('should get a command runner', () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext> = {
schema: parseCommandSchema('get'),
run: async () => {},
};
registerCommand(registry, runner);
const retrieved = getCommand(registry, 'get');
expect(retrieved).toBe(runner);
});
it('should return undefined for unknown command', () => {
const registry = createCommandRegistry<TestContext>();
const retrieved = getCommand(registry, 'unknown');
expect(retrieved).toBeUndefined();
});
});
describe('runCommand', () => {
it('should run a command successfully', async () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('add <a> <b>'),
run: async function (cmd) {
return Number(cmd.params[0]) + Number(cmd.params[1]);
},
};
registerCommand(registry, runner);
const result = await runCommand(registry, { counter: 0, log: [] }, 'add 1 2');
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe(3);
}
});
it('should fail for unknown command', async () => {
const registry = createCommandRegistry<TestContext>();
const result = await runCommand(registry, { counter: 0, log: [] }, 'unknown');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('Unknown command');
}
});
it('should fail for invalid command params', async () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext> = {
schema: parseCommandSchema('add <a> <b>'),
run: async () => {},
};
registerCommand(registry, runner);
const result = await runCommand(registry, { counter: 0, log: [] }, 'add 1');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('参数不足');
}
});
it('should access context via this.context', async () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('increment'),
run: async function () {
this.context.counter++;
return this.context.counter;
},
};
registerCommand(registry, runner);
const ctx = { counter: 0, log: [] };
await runCommand(registry, ctx, 'increment');
expect(ctx.counter).toBe(1);
});
it('should handle async errors', async () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext> = {
schema: parseCommandSchema('fail'),
run: async () => {
throw new Error('Something went wrong');
},
};
registerCommand(registry, runner);
const result = await runCommand(registry, { counter: 0, log: [] }, 'fail');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('Something went wrong');
}
});
});
describe('CommandRunnerContext', () => {
it('should create a runner context', () => {
const registry = createCommandRegistry<TestContext>();
const ctx = { counter: 0, log: [] };
const runnerCtx = createCommandRunnerContext(registry, ctx);
expect(runnerCtx.registry).toBe(registry);
expect(runnerCtx.context).toBe(ctx);
});
it('should run commands via runner context', async () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('greet <name>'),
run: async function (cmd) {
this.context.log.push(`Hello, ${cmd.params[0]}!`);
return `Hello, ${cmd.params[0]}!`;
},
};
registerCommand(registry, runner);
const ctx = { counter: 0, log: [] };
const runnerCtx = createCommandRunnerContext(registry, ctx);
const result = await runnerCtx.run('greet World');
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('Hello, World!');
}
expect(ctx.log).toEqual(['Hello, World!']);
});
it('should allow commands to call other commands via this.run', async () => {
const registry = createCommandRegistry<TestContext>();
const addRunner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('add <a> <b>'),
run: async function (cmd) {
return Number(cmd.params[0]) + Number(cmd.params[1]);
},
};
registerCommand(registry, addRunner);
const multiplyRunner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('multiply <a> <b>'),
run: async function (cmd) {
const a = Number(cmd.params[0]);
const b = Number(cmd.params[1]);
const addResult = await this.run(`add ${a} ${a}`);
if (!addResult.success) throw new Error('add failed');
return (addResult.result as number) * b;
},
};
registerCommand(registry, multiplyRunner);
const ctx = { counter: 0, log: [] };
const result = await runCommand(registry, ctx, 'multiply 3 4');
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe(24);
}
});
it('should allow commands to call other commands via this.runParsed', async () => {
const registry = createCommandRegistry<TestContext>();
const doubleRunner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('double <n>'),
run: async function (cmd) {
return Number(cmd.params[0]) * 2;
},
};
registerCommand(registry, doubleRunner);
const quadrupleRunner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('quadruple <n>'),
run: async function (cmd) {
const n = Number(cmd.params[0]);
const doubleResult = await this.runParsed({ name: 'double', params: [String(n)], options: {}, flags: {} });
if (!doubleResult.success) throw new Error('double failed');
return (doubleResult.result as number) * 2;
},
};
registerCommand(registry, quadrupleRunner);
const ctx = { counter: 0, log: [] };
const result = await runCommand(registry, ctx, 'quadruple 5');
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe(20);
}
});
});
describe('prompt', () => {
it('should dispatch prompt event with string schema', async () => {
const registry = createCommandRegistry<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('choose'),
run: async function () {
const result = await this.prompt('select <card>');
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();
expect(promptEvent!.schema.name).toBe('select');
});
it('should resolve prompt with valid input', async () => {
const registry = createCommandRegistry<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('choose'),
run: async function () {
const result = await this.prompt('select <card>');
this.context.log.push(`selected ${result.params[0]}`);
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();
const parsed = { name: 'select', params: ['Ace'], options: {}, flags: {} };
promptEvent!.resolve(parsed);
const result = await runPromise;
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('Ace');
}
expect(ctx.log).toEqual(['selected Ace']);
});
it('should reject prompt with invalid input', 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!.reject(new Error('user cancelled'));
const result = await runPromise;
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('user cancelled');
}
});
it('should accept CommandSchema object in prompt', async () => {
const registry = createCommandRegistry<TestContext>();
const schema = parseCommandSchema('pick <item>');
const pickRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('pick'),
run: async function () {
const result = await this.prompt(schema);
return result.params[0] as string;
},
};
registerCommand(registry, pickRunner);
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('pick');
await new Promise((r) => setTimeout(r, 0));
expect(promptEvent).not.toBeNull();
expect(promptEvent!.schema.name).toBe('pick');
promptEvent!.resolve({ name: 'pick', params: ['sword'], options: {}, flags: {} });
const result = await runPromise;
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('sword');
}
});
it('should allow multiple sequential prompts', async () => {
const registry = createCommandRegistry<TestContext>();
const multiPromptRunner: CommandRunner<TestContext, string[]> = {
schema: parseCommandSchema('multi'),
run: async function () {
const first = await this.prompt('first <a>');
const second = await this.prompt('second <b>');
return [first.params[0] as string, second.params[0] as string];
},
};
registerCommand(registry, multiPromptRunner);
const ctx = { counter: 0, log: [] };
const promptEvents: PromptEvent[] = [];
const runnerCtx = createCommandRunnerContext(registry, ctx);
runnerCtx.on('prompt', (e) => {
promptEvents.push(e);
});
const runPromise = runnerCtx.run('multi');
await new Promise((r) => setTimeout(r, 0));
expect(promptEvents.length).toBe(1);
expect(promptEvents[0].schema.name).toBe('first');
promptEvents[0].resolve({ name: 'first', params: ['one'], options: {}, flags: {} });
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 result = await runPromise;
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toEqual(['one', 'two']);
}
});
});

View File

@ -7,18 +7,8 @@ describe('parseCommandSchema', () => {
expect(schema).toEqual({ expect(schema).toEqual({
name: '', name: '',
params: [], params: [],
options: [], options: {},
flags: [], flags: {},
});
});
it('should parse command name only', () => {
const schema = parseCommandSchema('move');
expect(schema).toEqual({
name: 'move',
params: [],
options: [],
flags: [],
}); });
}); });
@ -55,34 +45,36 @@ describe('parseCommandSchema', () => {
it('should parse long flags', () => { it('should parse long flags', () => {
const schema = parseCommandSchema('move [--force] [--quiet]'); const schema = parseCommandSchema('move [--force] [--quiet]');
expect(schema.flags).toEqual([ expect(schema.flags).toEqual({
{ name: 'force' }, force: { name: 'force', short: undefined },
{ name: 'quiet' }, quiet: { name: 'quiet', short: undefined },
]); });
}); });
it('should parse short flags', () => { it('should parse short flags', () => {
const schema = parseCommandSchema('move [-f] [-q]'); const schema = parseCommandSchema('move [-f] [-q]');
expect(schema.flags).toEqual([ expect(schema.flags).toEqual({
{ name: 'f', short: 'f' }, f: { name: 'f', short: 'f' },
{ name: 'q', short: 'q' }, q: { name: 'q', short: 'q' },
]); });
}); });
it('should parse long options', () => { it('should parse long options', () => {
const schema = parseCommandSchema('move --x: string [--y: string]'); const schema = parseCommandSchema('move --x: string [--y: string]');
expect(schema.options).toEqual([ expect(Object.keys(schema.options)).toEqual(['x', 'y']);
{ name: 'x', required: true, schema: expect.any(Object) }, expect(schema.options.x).toMatchObject({ name: 'x', required: true });
{ name: 'y', required: false, schema: expect.any(Object) }, expect(schema.options.x.schema).toBeDefined();
]); expect(schema.options.y).toMatchObject({ name: 'y', required: false });
expect(schema.options.y.schema).toBeDefined();
}); });
it('should parse short options', () => { it('should parse short options', () => {
const schema = parseCommandSchema('move -x: string [-y: string]'); const schema = parseCommandSchema('move -x: string [-y: string]');
expect(schema.options).toEqual([ expect(Object.keys(schema.options)).toEqual(['x', 'y']);
{ name: 'x', short: 'x', required: true, schema: expect.any(Object) }, expect(schema.options.x).toMatchObject({ name: 'x', short: 'x', required: true });
{ name: 'y', short: 'y', required: false, schema: expect.any(Object) }, expect(schema.options.x.schema).toBeDefined();
]); expect(schema.options.y).toMatchObject({ name: 'y', short: 'y', required: false });
expect(schema.options.y.schema).toBeDefined();
}); });
it('should parse mixed schema', () => { it('should parse mixed schema', () => {
@ -93,13 +85,13 @@ describe('parseCommandSchema', () => {
{ name: 'from', required: true, variadic: false, schema: undefined }, { name: 'from', required: true, variadic: false, schema: undefined },
{ name: 'to', required: true, variadic: false, schema: undefined }, { name: 'to', required: true, variadic: false, schema: undefined },
], ],
flags: [ flags: {
{ name: 'force' }, force: { name: 'force', short: undefined },
{ name: 'f', short: 'f' }, f: { name: 'f', short: 'f' },
], },
options: [ options: {
{ name: 'speed', short: 's', required: false, schema: expect.any(Object), defaultValue: undefined }, speed: { name: 'speed', short: 's', required: false, schema: expect.any(Object), defaultValue: undefined },
], },
}); });
}); });
@ -107,8 +99,8 @@ describe('parseCommandSchema', () => {
const schema = parseCommandSchema('place <piece> <region> [x...] [--rotate: number] [--force] [-f]'); const schema = parseCommandSchema('place <piece> <region> [x...] [--rotate: number] [--force] [-f]');
expect(schema.name).toBe('place'); expect(schema.name).toBe('place');
expect(schema.params).toHaveLength(3); expect(schema.params).toHaveLength(3);
expect(schema.flags).toHaveLength(2); expect(Object.keys(schema.flags)).toHaveLength(2);
expect(schema.options).toHaveLength(1); expect(Object.keys(schema.options)).toHaveLength(1);
}); });
}); });
@ -237,39 +229,8 @@ describe('integration', () => {
it('should parse short alias syntax', () => { it('should parse short alias syntax', () => {
const schema = parseCommandSchema('move <from> [--verbose: boolean -v]'); const schema = parseCommandSchema('move <from> [--verbose: boolean -v]');
expect(schema.flags).toHaveLength(1); expect(Object.keys(schema.flags)).toHaveLength(1);
expect(schema.flags[0]).toEqual({ name: 'verbose', short: 'v' }); expect(schema.flags.verbose).toEqual({ name: 'verbose', short: 'v' });
});
it('should parse short alias for options', () => {
const schema = parseCommandSchema('move <from> [--speed: number -s]');
expect(schema.options).toHaveLength(1);
expect(schema.options[0]).toEqual({
name: 'speed',
short: 's',
required: false,
schema: expect.any(Object),
defaultValue: undefined,
});
});
it('should parse default value syntax', () => {
const schema = parseCommandSchema('move <from> [--speed: number = 10]');
expect(schema.options).toHaveLength(1);
expect(schema.options[0].defaultValue).toBe(10);
});
it('should parse default string value', () => {
const schema = parseCommandSchema('move <from> [--name: string = "default"]');
expect(schema.options).toHaveLength(1);
expect(schema.options[0].defaultValue).toBe('default');
});
it('should parse short alias with default value', () => {
const schema = parseCommandSchema('move <from> [--speed: number -s = 5]');
expect(schema.options).toHaveLength(1);
expect(schema.options[0].short).toBe('s');
expect(schema.options[0].defaultValue).toBe(5);
}); });
it('should parse command with short alias', () => { it('should parse command with short alias', () => {
@ -288,3 +249,5 @@ describe('integration', () => {
expect(command.options.s).toBe('100'); expect(command.options.s).toBe('100');
}); });
}); });