348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
/**
|
|
* Unit tests for AggregateEvaluator
|
|
*
|
|
* Tests all()/any() aggregate condition evaluation against sub-movement results.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { AggregateEvaluator } from '../core/piece/evaluation/AggregateEvaluator.js';
|
|
import type { PieceMovement, PieceState, AgentResponse } from '../core/models/types.js';
|
|
|
|
function makeState(outputs: Record<string, { matchedRuleIndex?: number }>): PieceState {
|
|
const movementOutputs = new Map<string, AgentResponse>();
|
|
for (const [name, data] of Object.entries(outputs)) {
|
|
movementOutputs.set(name, {
|
|
persona: name,
|
|
status: 'done',
|
|
content: '',
|
|
timestamp: new Date(),
|
|
matchedRuleIndex: data.matchedRuleIndex,
|
|
});
|
|
}
|
|
return {
|
|
pieceName: 'test',
|
|
currentMovement: 'parent',
|
|
iteration: 1,
|
|
movementOutputs,
|
|
userInputs: [],
|
|
personaSessions: new Map(),
|
|
movementIterations: new Map(),
|
|
status: 'running',
|
|
};
|
|
}
|
|
|
|
function makeSubMovement(name: string, conditions: string[]): PieceMovement {
|
|
return {
|
|
name,
|
|
personaDisplayName: name,
|
|
instructionTemplate: '',
|
|
passPreviousResponse: false,
|
|
rules: conditions.map((c) => ({ condition: c })),
|
|
};
|
|
}
|
|
|
|
function makeParentMovement(
|
|
parallel: PieceMovement[],
|
|
rules: PieceMovement['rules'],
|
|
): PieceMovement {
|
|
return {
|
|
name: 'parent',
|
|
personaDisplayName: 'parent',
|
|
instructionTemplate: '',
|
|
passPreviousResponse: false,
|
|
parallel,
|
|
rules,
|
|
};
|
|
}
|
|
|
|
describe('AggregateEvaluator', () => {
|
|
describe('all() with single condition', () => {
|
|
it('should match when all sub-movements have matching condition', () => {
|
|
const sub1 = makeSubMovement('review-a', ['approved', 'rejected']);
|
|
const sub2 = makeSubMovement('review-b', ['approved', 'rejected']);
|
|
|
|
const step = makeParentMovement([sub1, sub2], [
|
|
{
|
|
condition: 'all approved',
|
|
isAggregateCondition: true,
|
|
aggregateType: 'all',
|
|
aggregateConditionText: 'approved',
|
|
next: 'COMPLETE',
|
|
},
|
|
]);
|
|
|
|
// Both sub-movements matched rule index 0 ("approved")
|
|
const state = makeState({
|
|
'review-a': { matchedRuleIndex: 0 },
|
|
'review-b': { matchedRuleIndex: 0 },
|
|
});
|
|
|
|
const evaluator = new AggregateEvaluator(step, state);
|
|
expect(evaluator.evaluate()).toBe(0);
|
|
});
|
|
|
|
it('should not match when one sub-movement has different condition', () => {
|
|
const sub1 = makeSubMovement('review-a', ['approved', 'rejected']);
|
|
const sub2 = makeSubMovement('review-b', ['approved', 'rejected']);
|
|
|
|
const step = makeParentMovement([sub1, sub2], [
|
|
{
|
|
condition: 'all approved',
|
|
isAggregateCondition: true,
|
|
aggregateType: 'all',
|
|
aggregateConditionText: 'approved',
|
|
next: 'COMPLETE',
|
|
},
|
|
]);
|
|
|
|
// sub1 matched "approved" (index 0), sub2 matched "rejected" (index 1)
|
|
const state = makeState({
|
|
'review-a': { matchedRuleIndex: 0 },
|
|
'review-b': { matchedRuleIndex: 1 },
|
|
});
|
|
|
|
const evaluator = new AggregateEvaluator(step, state);
|
|
expect(evaluator.evaluate()).toBe(-1);
|
|
});
|
|
|
|
it('should not match when sub-movement has no matched rule', () => {
|
|
const sub1 = makeSubMovement('review-a', ['approved', 'rejected']);
|
|
const sub2 = makeSubMovement('review-b', ['approved', 'rejected']);
|
|
|
|
const step = makeParentMovement([sub1, sub2], [
|
|
{
|
|
condition: 'all approved',
|
|
isAggregateCondition: true,
|
|
aggregateType: 'all',
|
|
aggregateConditionText: 'approved',
|
|
next: 'COMPLETE',
|
|
},
|
|
]);
|
|
|
|
// sub2 has no matched rule
|
|
const state = makeState({
|
|
'review-a': { matchedRuleIndex: 0 },
|
|
'review-b': {},
|
|
});
|
|
|
|
const evaluator = new AggregateEvaluator(step, state);
|
|
expect(evaluator.evaluate()).toBe(-1);
|
|
});
|
|
});
|
|
|
|
describe('all() with multiple conditions (order-based)', () => {
|
|
it('should match when each sub-movement matches its corresponding condition', () => {
|
|
const sub1 = makeSubMovement('review-a', ['approved', 'rejected']);
|
|
const sub2 = makeSubMovement('review-b', ['approved', 'rejected']);
|
|
|
|
const step = makeParentMovement([sub1, sub2], [
|
|
{
|
|
condition: 'A approved, B rejected',
|
|
isAggregateCondition: true,
|
|
aggregateType: 'all',
|
|
aggregateConditionText: ['approved', 'rejected'],
|
|
next: 'COMPLETE',
|
|
},
|
|
]);
|
|
|
|
const state = makeState({
|
|
'review-a': { matchedRuleIndex: 0 }, // "approved"
|
|
'review-b': { matchedRuleIndex: 1 }, // "rejected"
|
|
});
|
|
|
|
const evaluator = new AggregateEvaluator(step, state);
|
|
expect(evaluator.evaluate()).toBe(0);
|
|
});
|
|
|
|
it('should not match when condition count differs from sub-movement count', () => {
|
|
const sub1 = makeSubMovement('review-a', ['approved']);
|
|
|
|
const step = makeParentMovement([sub1], [
|
|
{
|
|
condition: 'mismatch',
|
|
isAggregateCondition: true,
|
|
aggregateType: 'all',
|
|
aggregateConditionText: ['approved', 'rejected'],
|
|
next: 'COMPLETE',
|
|
},
|
|
]);
|
|
|
|
const state = makeState({
|
|
'review-a': { matchedRuleIndex: 0 },
|
|
});
|
|
|
|
const evaluator = new AggregateEvaluator(step, state);
|
|
expect(evaluator.evaluate()).toBe(-1);
|
|
});
|
|
});
|
|
|
|
describe('any() with single condition', () => {
|
|
it('should match when at least one sub-movement has matching condition', () => {
|
|
const sub1 = makeSubMovement('review-a', ['approved', 'rejected']);
|
|
const sub2 = makeSubMovement('review-b', ['approved', 'rejected']);
|
|
|
|
const step = makeParentMovement([sub1, sub2], [
|
|
{
|
|
condition: 'any approved',
|
|
isAggregateCondition: true,
|
|
aggregateType: 'any',
|
|
aggregateConditionText: 'approved',
|
|
next: 'fix',
|
|
},
|
|
]);
|
|
|
|
// Only sub1 matched "approved"
|
|
const state = makeState({
|
|
'review-a': { matchedRuleIndex: 0 },
|
|
'review-b': { matchedRuleIndex: 1 },
|
|
});
|
|
|
|
const evaluator = new AggregateEvaluator(step, state);
|
|
expect(evaluator.evaluate()).toBe(0);
|
|
});
|
|
|
|
it('should not match when no sub-movement has matching condition', () => {
|
|
const sub1 = makeSubMovement('review-a', ['approved', 'rejected']);
|
|
const sub2 = makeSubMovement('review-b', ['approved', 'rejected']);
|
|
|
|
const step = makeParentMovement([sub1, sub2], [
|
|
{
|
|
condition: 'any approved',
|
|
isAggregateCondition: true,
|
|
aggregateType: 'any',
|
|
aggregateConditionText: 'approved',
|
|
next: 'fix',
|
|
},
|
|
]);
|
|
|
|
// Both matched "rejected" (index 1)
|
|
const state = makeState({
|
|
'review-a': { matchedRuleIndex: 1 },
|
|
'review-b': { matchedRuleIndex: 1 },
|
|
});
|
|
|
|
const evaluator = new AggregateEvaluator(step, state);
|
|
expect(evaluator.evaluate()).toBe(-1);
|
|
});
|
|
});
|
|
|
|
describe('any() with multiple conditions', () => {
|
|
it('should match when any sub-movement matches any of the conditions', () => {
|
|
const sub1 = makeSubMovement('review-a', ['approved', 'rejected', 'needs-work']);
|
|
const sub2 = makeSubMovement('review-b', ['approved', 'rejected', 'needs-work']);
|
|
|
|
const step = makeParentMovement([sub1, sub2], [
|
|
{
|
|
condition: 'any approved or needs-work',
|
|
isAggregateCondition: true,
|
|
aggregateType: 'any',
|
|
aggregateConditionText: ['approved', 'needs-work'],
|
|
next: 'fix',
|
|
},
|
|
]);
|
|
|
|
// sub1 matched "rejected" (index 1), sub2 matched "needs-work" (index 2)
|
|
const state = makeState({
|
|
'review-a': { matchedRuleIndex: 1 },
|
|
'review-b': { matchedRuleIndex: 2 },
|
|
});
|
|
|
|
const evaluator = new AggregateEvaluator(step, state);
|
|
expect(evaluator.evaluate()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should return -1 when step has no rules', () => {
|
|
const step = makeParentMovement([], undefined);
|
|
const state = makeState({});
|
|
const evaluator = new AggregateEvaluator(step, state);
|
|
expect(evaluator.evaluate()).toBe(-1);
|
|
});
|
|
|
|
it('should return -1 when step has no parallel sub-movements', () => {
|
|
const step: PieceMovement = {
|
|
name: 'test-movement',
|
|
personaDisplayName: 'tester',
|
|
instructionTemplate: '',
|
|
passPreviousResponse: false,
|
|
rules: [
|
|
{
|
|
condition: 'all approved',
|
|
isAggregateCondition: true,
|
|
aggregateType: 'all',
|
|
aggregateConditionText: 'approved',
|
|
},
|
|
],
|
|
};
|
|
const state = makeState({});
|
|
const evaluator = new AggregateEvaluator(step, state);
|
|
expect(evaluator.evaluate()).toBe(-1);
|
|
});
|
|
|
|
it('should return -1 when rules exist but none are aggregate conditions', () => {
|
|
const sub1 = makeSubMovement('review-a', ['approved']);
|
|
const step = makeParentMovement([sub1], [
|
|
{ condition: 'approved', next: 'COMPLETE' },
|
|
]);
|
|
const state = makeState({ 'review-a': { matchedRuleIndex: 0 } });
|
|
const evaluator = new AggregateEvaluator(step, state);
|
|
expect(evaluator.evaluate()).toBe(-1);
|
|
});
|
|
|
|
it('should evaluate multiple rules and return first matching index', () => {
|
|
const sub1 = makeSubMovement('review-a', ['approved', 'rejected']);
|
|
const sub2 = makeSubMovement('review-b', ['approved', 'rejected']);
|
|
|
|
const step = makeParentMovement([sub1, sub2], [
|
|
{
|
|
condition: 'all approved',
|
|
isAggregateCondition: true,
|
|
aggregateType: 'all',
|
|
aggregateConditionText: 'approved',
|
|
next: 'COMPLETE',
|
|
},
|
|
{
|
|
condition: 'any rejected',
|
|
isAggregateCondition: true,
|
|
aggregateType: 'any',
|
|
aggregateConditionText: 'rejected',
|
|
next: 'fix',
|
|
},
|
|
]);
|
|
|
|
// sub1: approved, sub2: rejected → first rule (all approved) fails, second (any rejected) matches
|
|
const state = makeState({
|
|
'review-a': { matchedRuleIndex: 0 },
|
|
'review-b': { matchedRuleIndex: 1 },
|
|
});
|
|
|
|
const evaluator = new AggregateEvaluator(step, state);
|
|
expect(evaluator.evaluate()).toBe(1);
|
|
});
|
|
|
|
it('should skip sub-movements missing from state outputs', () => {
|
|
const sub1 = makeSubMovement('review-a', ['approved']);
|
|
const sub2 = makeSubMovement('review-b', ['approved']);
|
|
|
|
const step = makeParentMovement([sub1, sub2], [
|
|
{
|
|
condition: 'all approved',
|
|
isAggregateCondition: true,
|
|
aggregateType: 'all',
|
|
aggregateConditionText: 'approved',
|
|
next: 'COMPLETE',
|
|
},
|
|
]);
|
|
|
|
// review-b is missing from state
|
|
const state = makeState({
|
|
'review-a': { matchedRuleIndex: 0 },
|
|
});
|
|
|
|
const evaluator = new AggregateEvaluator(step, state);
|
|
expect(evaluator.evaluate()).toBe(-1);
|
|
});
|
|
});
|
|
});
|