Compare commits

..

No commits in common. "bcb31da77301546b976092178c605d915d61c9ca" and "e06dc8ecba509c971c2daa5e4fe40182a2d8c679" have entirely different histories.

13 changed files with 111 additions and 687 deletions

View File

@ -71,7 +71,7 @@ function commandMatchesSchema(command: Command, schema: CommandSchema): boolean
return false; return false;
} }
const requiredOptions = Object.values(schema.options).filter(o => o.required); const requiredOptions = 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,9 +20,6 @@ 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';

10
src/utils/command.ts Normal file
View File

@ -0,0 +1,10 @@
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 = Object.values(schema.options).filter(o => o.required); const requiredOptions = 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[key] ?? (key.length === 1 ? Object.values(schema.options).find(o => o.short === key) : undefined); const optSchema = schema.options.find(o => o.name === key || o.short === key);
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

@ -1,141 +0,0 @@
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

@ -1,30 +0,0 @@
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

@ -1,22 +0,0 @@
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[result.name] = { name: result.name, short: result.short }; schema.flags.push({ name: result.name, short: result.short });
} else { } else {
schema.options[result.name] = { schema.options.push({
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[result.name] = { name: result.name, short: result.short || result.name }; schema.flags.push({ name: result.name, short: result.short || result.name });
} else { } else {
schema.options[result.name] = { schema.options.push({
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[result.name] = { name: result.name, short: result.short }; schema.flags.push({ name: result.name, short: result.short });
} else { } else {
schema.options[result.name] = { schema.options.push({
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[result.name] = { name: result.name, short: result.short || result.name }; schema.flags.push({ name: result.name, short: result.short || result.name });
} else { } else {
schema.options[result.name] = { schema.options.push({
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: Record<string, CommandOptionSchema>; options: CommandOptionSchema[];
flags: Record<string, CommandFlagSchema>; flags: 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(Object.keys(rule.schema.flags)).toHaveLength(1); expect(rule.schema.flags).toHaveLength(1);
expect(rule.schema.flags.force.name).toBe('force'); expect(rule.schema.flags[0].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(Object.keys(schema.flags)).toHaveLength(1); expect(schema.flags).toHaveLength(1);
expect(Object.keys(schema.options)).toHaveLength(1); expect(schema.options).toHaveLength(1);
expect(schema.flags.all.name).toBe('all'); expect(schema.flags[0].name).toBe('all');
expect(schema.options.count.name).toBe('count'); expect(schema.options[0].name).toBe('count');
expect(schema.options.count.schema).toBeDefined(); expect(schema.options[0].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(Object.keys(schema.options)).toHaveLength(1); expect(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(Object.keys(schema.options)).toHaveLength(1); expect(schema.options).toHaveLength(1);
expect(schema.options.speed.required).toBe(false); expect(schema.options[0].required).toBe(false);
expect(schema.options.speed.schema).toBeDefined(); expect(schema.options[0].schema).toBeDefined();
}); });
}); });

View File

@ -1,427 +0,0 @@
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,8 +7,18 @@ 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: [],
}); });
}); });
@ -45,36 +55,34 @@ 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([
force: { name: 'force', short: undefined }, { name: 'force' },
quiet: { name: 'quiet', short: undefined }, { name: 'quiet' },
}); ]);
}); });
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([
f: { name: 'f', short: 'f' }, { name: 'f', short: 'f' },
q: { name: 'q', short: '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(Object.keys(schema.options)).toEqual(['x', 'y']); expect(schema.options).toEqual([
expect(schema.options.x).toMatchObject({ name: 'x', required: true }); { name: 'x', required: true, schema: expect.any(Object) },
expect(schema.options.x.schema).toBeDefined(); { name: 'y', required: false, schema: expect.any(Object) },
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(Object.keys(schema.options)).toEqual(['x', 'y']); expect(schema.options).toEqual([
expect(schema.options.x).toMatchObject({ name: 'x', short: 'x', required: true }); { name: 'x', short: 'x', required: true, schema: expect.any(Object) },
expect(schema.options.x.schema).toBeDefined(); { name: 'y', short: 'y', required: false, schema: expect.any(Object) },
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', () => {
@ -85,13 +93,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: [
force: { name: 'force', short: undefined }, { name: 'force' },
f: { name: 'f', short: 'f' }, { name: 'f', short: 'f' },
}, ],
options: { options: [
speed: { name: 'speed', short: 's', required: false, schema: expect.any(Object), defaultValue: undefined }, { name: 'speed', short: 's', required: false, schema: expect.any(Object), defaultValue: undefined },
}, ],
}); });
}); });
@ -99,8 +107,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(Object.keys(schema.flags)).toHaveLength(2); expect(schema.flags).toHaveLength(2);
expect(Object.keys(schema.options)).toHaveLength(1); expect(schema.options).toHaveLength(1);
}); });
}); });
@ -229,8 +237,39 @@ 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(Object.keys(schema.flags)).toHaveLength(1); expect(schema.flags).toHaveLength(1);
expect(schema.flags.verbose).toEqual({ name: 'verbose', short: 'v' }); expect(schema.flags[0]).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', () => {
@ -249,5 +288,3 @@ describe('integration', () => {
expect(command.options.s).toBe('100'); expect(command.options.s).toBe('100');
}); });
}); });