From ad0d349090a109bce3b1d1f190c177c062456660 Mon Sep 17 00:00:00 2001 From: hypercross Date: Wed, 1 Apr 2026 22:31:07 +0800 Subject: [PATCH] chore: tests for rule --- src/core/rule.ts | 47 ++++- src/utils/command.ts | 9 +- tests/core/rule.test.ts | 392 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 438 insertions(+), 10 deletions(-) create mode 100644 tests/core/rule.test.ts diff --git a/src/core/rule.ts b/src/core/rule.ts index 8882e7c..7910aa4 100644 --- a/src/core/rule.ts +++ b/src/core/rule.ts @@ -1,4 +1,4 @@ -import {Command, CommandSchema, parseCommand, parseCommandSchema, validateCommand} from "../utils/command"; +import {Command, CommandSchema, parseCommand, parseCommandSchema} from "../utils/command"; export type RuleState = 'running' | 'yielded' | 'waiting' | 'done'; @@ -24,14 +24,14 @@ export function createRule( fn: (cmd: Command) => Generator ): RuleDef { return { - schema: parseCommandSchema(schemaStr), + schema: parseCommandSchema(schemaStr, ''), create: fn as RuleDef['create'], }; } function parseYieldedSchema(value: string | CommandSchema): CommandSchema { if (typeof value === 'string') { - return parseCommandSchema(value); + return parseCommandSchema(value, ''); } return value; } @@ -57,6 +57,29 @@ function discardChildren(game: GameContextLike, parent: RuleContext) { parent.state = 'yielded'; } +function validateYieldedSchema(command: Command, schema: CommandSchema): boolean { + const requiredParams = schema.params.filter(p => p.required); + const variadicParam = schema.params.find(p => p.variadic); + + if (command.params.length < requiredParams.length) { + return false; + } + + if (!variadicParam && command.params.length > schema.params.length) { + return false; + } + + const requiredOptions = schema.options.filter(o => o.required); + for (const opt of requiredOptions) { + const hasOption = opt.name in command.options || (opt.short && opt.short in command.options); + if (!hasOption) { + return false; + } + } + + return true; +} + function invokeRule( game: GameContextLike, command: Command, @@ -98,14 +121,16 @@ export function dispatchCommand(game: GameContextLike, input: string): RuleConte if (game.rules.has(command.name)) { const ruleDef = game.rules.get(command.name)!; - return invokeRule(game, command, ruleDef); + + const parent = findYieldedParent(game); + + return invokeRule(game, command, ruleDef, parent); } for (let i = game.ruleContexts.length - 1; i >= 0; i--) { const ctx = game.ruleContexts[i]; if (ctx.state === 'yielded' && ctx.schema) { - const validation = validateCommand(command, ctx.schema); - if (validation.valid) { + if (validateYieldedSchema(command, ctx.schema)) { const result = ctx.generator.next(command); if (result.done) { ctx.resolution = result.value; @@ -122,6 +147,16 @@ export function dispatchCommand(game: GameContextLike, input: string): RuleConte return undefined; } +function findYieldedParent(game: GameContextLike): RuleContext | undefined { + for (let i = game.ruleContexts.length - 1; i >= 0; i--) { + const ctx = game.ruleContexts[i]; + if (ctx.state === 'yielded') { + return ctx; + } + } + return undefined; +} + type GameContextLike = { rules: RuleRegistry; ruleContexts: RuleContext[]; diff --git a/src/utils/command.ts b/src/utils/command.ts index b02f214..731123e 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -230,9 +230,9 @@ function tokenize(input: string): string[] { * parseCommandSchema('move [--all]') * parseCommandSchema('move [--speed: number = 10 -s]') */ -export function parseCommandSchema(schemaStr: string): CommandSchema { +export function parseCommandSchema(schemaStr: string, name?: string): CommandSchema { const schema: CommandSchema = { - name: '', + name: name ?? '', params: [], options: [], flags: [], @@ -243,9 +243,10 @@ export function parseCommandSchema(schemaStr: string): CommandSchema { return schema; } - schema.name = tokens[0]; + const startIdx = name !== undefined ? 0 : 1; + schema.name = name ?? tokens[0]; - let i = 1; + let i = startIdx; while (i < tokens.length) { const token = tokens[i]; diff --git a/tests/core/rule.test.ts b/tests/core/rule.test.ts new file mode 100644 index 0000000..d95ff25 --- /dev/null +++ b/tests/core/rule.test.ts @@ -0,0 +1,392 @@ +import { describe, it, expect } from 'vitest'; +import { createRule, dispatchCommand, type RuleContext, type RuleRegistry } from '../../src/core/rule'; +import { createGameContext } from '../../src/core/context'; + +describe('Rule System', () => { + function createTestGame() { + const game = createGameContext(); + return game; + } + + describe('createRule', () => { + it('should create a rule definition with parsed schema', () => { + const rule = createRule(' [--force]', function*(cmd) { + return { from: cmd.params[0], to: cmd.params[1] }; + }); + + expect(rule.schema.params).toHaveLength(2); + expect(rule.schema.params[0].name).toBe('from'); + expect(rule.schema.params[0].required).toBe(true); + expect(rule.schema.params[1].name).toBe('to'); + expect(rule.schema.params[1].required).toBe(true); + expect(rule.schema.flags).toHaveLength(1); + expect(rule.schema.flags[0].name).toBe('force'); + }); + + it('should create a generator when called', () => { + const rule = createRule('', function*(cmd) { + return cmd.params[0]; + }); + + const gen = rule.create({ name: 'test', params: ['card1'], flags: {}, options: {} }); + const result = gen.next(); + expect(result.done).toBe(true); + expect(result.value).toBe('card1'); + }); + }); + + describe('dispatchCommand - rule invocation', () => { + it('should invoke a registered rule and yield schema', () => { + const game = createTestGame(); + + game.rules.value.set('move', createRule(' ', function*(cmd) { + yield { name: '', params: [], options: [], flags: [] }; + return { moved: cmd.params[0] }; + })); + + const ctx = game.dispatchCommand('move card1 hand'); + + expect(ctx).toBeDefined(); + expect(ctx!.state).toBe('yielded'); + expect(ctx!.schema).toBeDefined(); + expect(ctx!.resolution).toBeUndefined(); + }); + + it('should complete a rule when final command matches yielded schema', () => { + const game = createTestGame(); + + game.rules.value.set('move', createRule(' ', function*(cmd) { + const confirm = yield { name: '', params: [], options: [], flags: [] }; + return { moved: cmd.params[0], confirmed: confirm.name === 'confirm' }; + })); + + game.dispatchCommand('move card1 hand'); + const ctx = game.dispatchCommand('confirm'); + + expect(ctx).toBeDefined(); + expect(ctx!.state).toBe('done'); + expect(ctx!.resolution).toEqual({ moved: 'card1', confirmed: true }); + }); + + it('should return undefined when command matches no rule and no yielded context', () => { + const game = createTestGame(); + + const result = game.dispatchCommand('unknown command'); + + expect(result).toBeUndefined(); + }); + + it('should pass the initial command to the generator', () => { + const game = createTestGame(); + + game.rules.value.set('attack', createRule(' [--power: number]', function*(cmd) { + return { target: cmd.params[0], power: cmd.options.power || '1' }; + })); + + const ctx = game.dispatchCommand('attack goblin --power 5'); + + expect(ctx!.state).toBe('done'); + expect(ctx!.resolution).toEqual({ target: 'goblin', power: '5' }); + }); + + it('should complete immediately if generator does not yield', () => { + const game = createTestGame(); + + game.rules.value.set('look', createRule('[--at]', function*() { + return 'looked'; + })); + + const ctx = game.dispatchCommand('look'); + + expect(ctx!.state).toBe('done'); + expect(ctx!.resolution).toBe('looked'); + }); + }); + + describe('dispatchCommand - rule priority', () => { + it('should prioritize new rule invocation over feeding yielded context', () => { + const game = createTestGame(); + + game.rules.value.set('move', createRule(' ', function*(cmd) { + yield { name: '', params: [], options: [], flags: [] }; + return { moved: cmd.params[0] }; + })); + + game.rules.value.set('confirm', createRule('', function*() { + return 'new confirm rule'; + })); + + game.dispatchCommand('move card1 hand'); + + const ctx = game.dispatchCommand('confirm'); + + expect(ctx!.state).toBe('done'); + expect(ctx!.resolution).toBe('new confirm rule'); + expect(ctx!.type).toBe(''); + }); + }); + + describe('dispatchCommand - fallback to yielded context', () => { + it('should feed a yielded context when command does not match any rule', () => { + const game = createTestGame(); + + game.rules.value.set('move', createRule(' ', function*(cmd) { + const response = yield { name: '', params: [], options: [], flags: [] }; + return { moved: cmd.params[0], response: response.name }; + })); + + game.dispatchCommand('move card1 hand'); + const ctx = game.dispatchCommand('yes'); + + expect(ctx!.state).toBe('done'); + expect(ctx!.resolution).toEqual({ moved: 'card1', response: 'yes' }); + }); + + it('should skip non-matching commands for yielded context', () => { + const game = createTestGame(); + + game.rules.value.set('move', createRule(' ', function*(cmd) { + const response = yield ''; + return { response: response.params[0] }; + })); + + game.dispatchCommand('move card1 hand'); + + const ctx = game.dispatchCommand('goblin'); + + expect(ctx).toBeUndefined(); + }); + + it('should validate command against yielded schema', () => { + const game = createTestGame(); + + game.rules.value.set('trade', createRule(' ', function*(cmd) { + const response = yield ' [amount: number]'; + return { traded: response.params[0] }; + })); + + game.dispatchCommand('trade player1 player2'); + const ctx = game.dispatchCommand('offer gold 5'); + + expect(ctx!.state).toBe('done'); + expect(ctx!.resolution).toEqual({ traded: 'gold' }); + }); + }); + + describe('dispatchCommand - deepest context first', () => { + it('should feed the deepest yielded context', () => { + const game = createTestGame(); + + game.rules.value.set('parent', createRule('', function*() { + yield { name: '', params: [], options: [], flags: [] }; + return 'parent done'; + })); + + game.rules.value.set('child', createRule('', function*() { + yield { name: '', params: [], options: [], flags: [] }; + return 'child done'; + })); + + game.dispatchCommand('parent start'); + game.dispatchCommand('child target1'); + + const ctx = game.dispatchCommand('grandchild_cmd'); + + expect(ctx!.state).toBe('done'); + expect(ctx!.resolution).toBe('child done'); + }); + }); + + describe('nested rule invocations', () => { + it('should link child to parent', () => { + const game = createTestGame(); + + game.rules.value.set('parent', createRule('', function*() { + yield 'child_cmd'; + return 'parent done'; + })); + + game.rules.value.set('child_cmd', createRule('', function*() { + return 'child done'; + })); + + game.dispatchCommand('parent start'); + const parentCtx = game.ruleContexts.value[0]; + + game.dispatchCommand('child_cmd target1'); + + expect(parentCtx.state).toBe('waiting'); + + const childCtx = game.ruleContexts.value[1]; + expect(childCtx.parent).toBe(parentCtx); + expect(parentCtx.children).toContain(childCtx); + }); + + it('should discard previous children when a new child is invoked', () => { + const game = createTestGame(); + + game.rules.value.set('parent', createRule('', function*() { + yield 'child_a | child_b'; + return 'parent done'; + })); + + game.rules.value.set('child_a', createRule('', function*() { + return 'child_a done'; + })); + + game.rules.value.set('child_b', createRule('', function*() { + return 'child_b done'; + })); + + game.dispatchCommand('parent start'); + game.dispatchCommand('child_a target1'); + + expect(game.ruleContexts.value.length).toBe(2); + + const oldParent = game.ruleContexts.value[0]; + expect(oldParent.children).toHaveLength(1); + + game.dispatchCommand('parent start'); + game.dispatchCommand('child_b target2'); + + const newParent = game.ruleContexts.value[2]; + expect(newParent.children).toHaveLength(1); + expect(newParent.children[0].resolution).toBe('child_b done'); + }); + }); + + describe('context tracking', () => { + it('should track rule contexts in ruleContexts signal', () => { + const game = createTestGame(); + + game.rules.value.set('test', createRule('', function*() { + yield { name: '', params: [], options: [], flags: [] }; + return 'done'; + })); + + expect(game.ruleContexts.value.length).toBe(0); + + game.dispatchCommand('test arg1'); + + expect(game.ruleContexts.value.length).toBe(1); + expect(game.ruleContexts.value[0].state).toBe('yielded'); + }); + + it('should add context to the context stack', () => { + const game = createTestGame(); + + game.rules.value.set('test', createRule('', function*() { + yield { name: '', params: [], options: [], flags: [] }; + return 'done'; + })); + + const initialStackLength = game.contexts.value.length; + + game.dispatchCommand('test arg1'); + + expect(game.contexts.value.length).toBe(initialStackLength + 1); + }); + }); + + describe('error handling', () => { + it('should leave context in place when generator throws', () => { + const game = createTestGame(); + + game.rules.value.set('failing', createRule('', function*() { + throw new Error('rule error'); + })); + + expect(() => game.dispatchCommand('failing arg1')).toThrow('rule error'); + + expect(game.ruleContexts.value.length).toBe(1); + }); + + it('should leave children in place when child generator throws', () => { + const game = createTestGame(); + + game.rules.value.set('parent', createRule('', function*() { + yield 'child'; + return 'parent done'; + })); + + game.rules.value.set('child', createRule('', function*() { + throw new Error('child error'); + })); + + game.dispatchCommand('parent start'); + expect(() => game.dispatchCommand('child target1')).toThrow('child error'); + + expect(game.ruleContexts.value.length).toBe(2); + }); + }); + + describe('schema yielding', () => { + it('should accept a CommandSchema object as yield value', () => { + const game = createTestGame(); + + const customSchema = { + name: 'custom', + params: [{ name: 'x', required: true, variadic: false }], + options: [], + flags: [], + }; + + game.rules.value.set('test', createRule('', function*() { + const cmd = yield customSchema; + return { received: cmd.params[0] }; + })); + + game.dispatchCommand('test val1'); + const ctx = game.dispatchCommand('custom hello'); + + expect(ctx!.state).toBe('done'); + expect(ctx!.resolution).toEqual({ received: 'hello' }); + }); + + it('should parse string schema on each yield', () => { + const game = createTestGame(); + + game.rules.value.set('multi', createRule('', function*() { + const a = yield ''; + const b = yield ''; + return { a: a.params[0], b: b.params[0] }; + })); + + game.dispatchCommand('multi init'); + game.dispatchCommand('cmd first'); + const ctx = game.dispatchCommand('cmd second'); + + expect(ctx!.state).toBe('done'); + expect(ctx!.resolution).toEqual({ a: 'first', b: 'second' }); + }); + }); + + describe('complex flow', () => { + it('should handle a multi-step game flow', () => { + const game = createTestGame(); + + game.rules.value.set('start', createRule('', function*(cmd) { + const player = cmd.params[0]; + const action = yield { name: '', params: [], options: [], flags: [] }; + + if (action.name === 'move') { + yield ''; + } else if (action.name === 'attack') { + yield ' [--power: number]'; + } + + return { player, action: action.name }; + })); + + const ctx1 = game.dispatchCommand('start alice'); + expect(ctx1!.state).toBe('yielded'); + + const ctx2 = game.dispatchCommand('attack'); + expect(ctx2!.state).toBe('yielded'); + + const ctx3 = game.dispatchCommand('attack goblin --power 3'); + expect(ctx3!.state).toBe('done'); + expect(ctx3!.resolution).toEqual({ player: 'alice', action: 'attack' }); + }); + }); +});