takt: github-issue-189 (#196)
This commit is contained in:
parent
f4c105c0c3
commit
0e4e9e9046
347
src/__tests__/aggregate-evaluator.test.ts
Normal file
347
src/__tests__/aggregate-evaluator.test.ts
Normal file
@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
110
src/__tests__/blocked-handler.test.ts
Normal file
110
src/__tests__/blocked-handler.test.ts
Normal file
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Unit tests for blocked-handler
|
||||
*
|
||||
* Tests blocked state handling including user input callback flow.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { handleBlocked } from '../core/piece/engine/blocked-handler.js';
|
||||
import type { PieceMovement, AgentResponse } from '../core/models/types.js';
|
||||
import type { PieceEngineOptions } from '../core/piece/types.js';
|
||||
|
||||
function makeMovement(): PieceMovement {
|
||||
return {
|
||||
name: 'test-movement',
|
||||
personaDisplayName: 'tester',
|
||||
instructionTemplate: '',
|
||||
passPreviousResponse: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeResponse(content: string): AgentResponse {
|
||||
return {
|
||||
persona: 'tester',
|
||||
status: 'blocked',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeOptions(overrides: Partial<PieceEngineOptions> = {}): PieceEngineOptions {
|
||||
return {
|
||||
projectCwd: '/tmp/project',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('handleBlocked', () => {
|
||||
it('should return shouldContinue=false when no onUserInput callback', async () => {
|
||||
const result = await handleBlocked(
|
||||
makeMovement(),
|
||||
makeResponse('blocked message'),
|
||||
makeOptions(),
|
||||
);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.userInput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should call onUserInput and return user input', async () => {
|
||||
const onUserInput = vi.fn().mockResolvedValue('user response');
|
||||
const result = await handleBlocked(
|
||||
makeMovement(),
|
||||
makeResponse('質問: どうしますか?'),
|
||||
makeOptions({ onUserInput }),
|
||||
);
|
||||
|
||||
expect(result.shouldContinue).toBe(true);
|
||||
expect(result.userInput).toBe('user response');
|
||||
expect(onUserInput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should return shouldContinue=false when user cancels (returns null)', async () => {
|
||||
const onUserInput = vi.fn().mockResolvedValue(null);
|
||||
const result = await handleBlocked(
|
||||
makeMovement(),
|
||||
makeResponse('blocked'),
|
||||
makeOptions({ onUserInput }),
|
||||
);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.userInput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should pass extracted prompt in the request', async () => {
|
||||
const onUserInput = vi.fn().mockResolvedValue('answer');
|
||||
await handleBlocked(
|
||||
makeMovement(),
|
||||
makeResponse('質問: 環境は何ですか?'),
|
||||
makeOptions({ onUserInput }),
|
||||
);
|
||||
|
||||
const request = onUserInput.mock.calls[0]![0];
|
||||
expect(request.prompt).toBe('環境は何ですか?');
|
||||
});
|
||||
|
||||
it('should pass the full content as prompt when no pattern matches', async () => {
|
||||
const onUserInput = vi.fn().mockResolvedValue('answer');
|
||||
const content = 'I need more information to continue';
|
||||
await handleBlocked(
|
||||
makeMovement(),
|
||||
makeResponse(content),
|
||||
makeOptions({ onUserInput }),
|
||||
);
|
||||
|
||||
const request = onUserInput.mock.calls[0]![0];
|
||||
expect(request.prompt).toBe(content);
|
||||
});
|
||||
|
||||
it('should pass movement and response in the request', async () => {
|
||||
const step = makeMovement();
|
||||
const response = makeResponse('blocked');
|
||||
const onUserInput = vi.fn().mockResolvedValue('answer');
|
||||
|
||||
await handleBlocked(step, response, makeOptions({ onUserInput }));
|
||||
|
||||
const request = onUserInput.mock.calls[0]![0];
|
||||
expect(request.movement).toBe(step);
|
||||
expect(request.response).toBe(response);
|
||||
});
|
||||
});
|
||||
39
src/__tests__/error-utils.test.ts
Normal file
39
src/__tests__/error-utils.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Unit tests for error utilities
|
||||
*
|
||||
* Tests error message extraction from unknown error types.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getErrorMessage } from '../shared/utils/error.js';
|
||||
|
||||
describe('getErrorMessage', () => {
|
||||
it('should extract message from Error instances', () => {
|
||||
expect(getErrorMessage(new Error('test error'))).toBe('test error');
|
||||
});
|
||||
|
||||
it('should extract message from Error subclasses', () => {
|
||||
expect(getErrorMessage(new TypeError('type error'))).toBe('type error');
|
||||
expect(getErrorMessage(new RangeError('range error'))).toBe('range error');
|
||||
});
|
||||
|
||||
it('should convert string to message', () => {
|
||||
expect(getErrorMessage('string error')).toBe('string error');
|
||||
});
|
||||
|
||||
it('should convert number to message', () => {
|
||||
expect(getErrorMessage(42)).toBe('42');
|
||||
});
|
||||
|
||||
it('should convert null to message', () => {
|
||||
expect(getErrorMessage(null)).toBe('null');
|
||||
});
|
||||
|
||||
it('should convert undefined to message', () => {
|
||||
expect(getErrorMessage(undefined)).toBe('undefined');
|
||||
});
|
||||
|
||||
it('should convert object to message', () => {
|
||||
expect(getErrorMessage({ code: 'ERR' })).toBe('[object Object]');
|
||||
});
|
||||
});
|
||||
190
src/__tests__/escape.test.ts
Normal file
190
src/__tests__/escape.test.ts
Normal file
@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Unit tests for template escaping and placeholder replacement
|
||||
*
|
||||
* Tests escapeTemplateChars and replaceTemplatePlaceholders functions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
escapeTemplateChars,
|
||||
replaceTemplatePlaceholders,
|
||||
} from '../core/piece/instruction/escape.js';
|
||||
import type { PieceMovement } from '../core/models/types.js';
|
||||
import type { InstructionContext } from '../core/piece/instruction/instruction-context.js';
|
||||
|
||||
function makeMovement(overrides: Partial<PieceMovement> = {}): PieceMovement {
|
||||
return {
|
||||
name: 'test-movement',
|
||||
personaDisplayName: 'tester',
|
||||
instructionTemplate: '',
|
||||
passPreviousResponse: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(overrides: Partial<InstructionContext> = {}): InstructionContext {
|
||||
return {
|
||||
task: 'test task',
|
||||
iteration: 1,
|
||||
maxIterations: 10,
|
||||
movementIteration: 1,
|
||||
cwd: '/tmp/test',
|
||||
projectCwd: '/tmp/project',
|
||||
userInputs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('escapeTemplateChars', () => {
|
||||
it('should replace curly braces with full-width equivalents', () => {
|
||||
expect(escapeTemplateChars('{hello}')).toBe('{hello}');
|
||||
});
|
||||
|
||||
it('should handle multiple braces', () => {
|
||||
expect(escapeTemplateChars('{{nested}}')).toBe('{{nested}}');
|
||||
});
|
||||
|
||||
it('should return unchanged string when no braces', () => {
|
||||
expect(escapeTemplateChars('no braces here')).toBe('no braces here');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(escapeTemplateChars('')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle braces in code snippets', () => {
|
||||
const input = 'function foo() { return { a: 1 }; }';
|
||||
const expected = 'function foo() { return { a: 1 }; }';
|
||||
expect(escapeTemplateChars(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceTemplatePlaceholders', () => {
|
||||
it('should replace {task} placeholder', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ task: 'implement feature X' });
|
||||
const template = 'Your task is: {task}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
expect(result).toBe('Your task is: implement feature X');
|
||||
});
|
||||
|
||||
it('should escape braces in task content', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ task: 'fix {bug} in code' });
|
||||
const template = '{task}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
expect(result).toBe('fix {bug} in code');
|
||||
});
|
||||
|
||||
it('should replace {iteration} and {max_iterations}', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ iteration: 3, maxIterations: 20 });
|
||||
const template = 'Iteration {iteration}/{max_iterations}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
expect(result).toBe('Iteration 3/20');
|
||||
});
|
||||
|
||||
it('should replace {movement_iteration}', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ movementIteration: 5 });
|
||||
const template = 'Movement run #{movement_iteration}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
expect(result).toBe('Movement run #5');
|
||||
});
|
||||
|
||||
it('should replace {previous_response} when passPreviousResponse is true', () => {
|
||||
const step = makeMovement({ passPreviousResponse: true });
|
||||
const ctx = makeContext({
|
||||
previousOutput: {
|
||||
persona: 'coder',
|
||||
status: 'done',
|
||||
content: 'previous output text',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
const template = 'Previous: {previous_response}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
expect(result).toBe('Previous: previous output text');
|
||||
});
|
||||
|
||||
it('should replace {previous_response} with empty string when no previous output', () => {
|
||||
const step = makeMovement({ passPreviousResponse: true });
|
||||
const ctx = makeContext();
|
||||
const template = 'Previous: {previous_response}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
expect(result).toBe('Previous: ');
|
||||
});
|
||||
|
||||
it('should not replace {previous_response} when passPreviousResponse is false', () => {
|
||||
const step = makeMovement({ passPreviousResponse: false });
|
||||
const ctx = makeContext({
|
||||
previousOutput: {
|
||||
persona: 'coder',
|
||||
status: 'done',
|
||||
content: 'should not appear',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
const template = 'Previous: {previous_response}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
expect(result).toBe('Previous: {previous_response}');
|
||||
});
|
||||
|
||||
it('should replace {user_inputs} with joined inputs', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ userInputs: ['input 1', 'input 2', 'input 3'] });
|
||||
const template = 'Inputs: {user_inputs}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
expect(result).toBe('Inputs: input 1\ninput 2\ninput 3');
|
||||
});
|
||||
|
||||
it('should replace {report_dir} with report directory', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports/run-1' });
|
||||
const template = 'Reports: {report_dir}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
expect(result).toBe('Reports: /tmp/reports/run-1');
|
||||
});
|
||||
|
||||
it('should replace {report:filename} with full path', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports' });
|
||||
const template = 'Read {report:review.md} and {report:plan.md}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
expect(result).toBe('Read /tmp/reports/review.md and /tmp/reports/plan.md');
|
||||
});
|
||||
|
||||
it('should handle template with multiple different placeholders', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({
|
||||
task: 'test task',
|
||||
iteration: 2,
|
||||
maxIterations: 5,
|
||||
movementIteration: 1,
|
||||
reportDir: '/reports',
|
||||
});
|
||||
const template = '{task} - iter {iteration}/{max_iterations} - mv {movement_iteration} - dir {report_dir}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
expect(result).toBe('test task - iter 2/5 - mv 1 - dir /reports');
|
||||
});
|
||||
|
||||
it('should leave unreplaced placeholders when reportDir is undefined', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ reportDir: undefined });
|
||||
const template = 'Dir: {report_dir} File: {report:test.md}';
|
||||
|
||||
const result = replaceTemplatePlaceholders(template, step, ctx);
|
||||
expect(result).toBe('Dir: {report_dir} File: {report:test.md}');
|
||||
});
|
||||
});
|
||||
48
src/__tests__/instruction-context.test.ts
Normal file
48
src/__tests__/instruction-context.test.ts
Normal file
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Unit tests for instruction-context
|
||||
*
|
||||
* Tests buildEditRule function for localized edit permission messages.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildEditRule } from '../core/piece/instruction/instruction-context.js';
|
||||
|
||||
describe('buildEditRule', () => {
|
||||
describe('edit = true', () => {
|
||||
it('should return English editing-enabled message', () => {
|
||||
const result = buildEditRule(true, 'en');
|
||||
expect(result).toContain('Editing is ENABLED');
|
||||
expect(result).toContain('create, modify, and delete files');
|
||||
});
|
||||
|
||||
it('should return Japanese editing-enabled message', () => {
|
||||
const result = buildEditRule(true, 'ja');
|
||||
expect(result).toContain('編集が許可されています');
|
||||
expect(result).toContain('ファイルの作成・変更・削除');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit = false', () => {
|
||||
it('should return English editing-disabled message', () => {
|
||||
const result = buildEditRule(false, 'en');
|
||||
expect(result).toContain('Editing is DISABLED');
|
||||
expect(result).toContain('Do NOT create, modify, or delete');
|
||||
});
|
||||
|
||||
it('should return Japanese editing-disabled message', () => {
|
||||
const result = buildEditRule(false, 'ja');
|
||||
expect(result).toContain('編集が禁止されています');
|
||||
expect(result).toContain('作成・変更・削除しないで');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit = undefined', () => {
|
||||
it('should return empty string for English', () => {
|
||||
expect(buildEditRule(undefined, 'en')).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for Japanese', () => {
|
||||
expect(buildEditRule(undefined, 'ja')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
135
src/__tests__/instruction-helpers.test.ts
Normal file
135
src/__tests__/instruction-helpers.test.ts
Normal file
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Unit tests for InstructionBuilder helper functions
|
||||
*
|
||||
* Tests isOutputContractItem, renderReportContext, and renderReportOutputInstruction.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isOutputContractItem,
|
||||
renderReportContext,
|
||||
renderReportOutputInstruction,
|
||||
} from '../core/piece/instruction/InstructionBuilder.js';
|
||||
import type { PieceMovement, OutputContractEntry } from '../core/models/types.js';
|
||||
import type { InstructionContext } from '../core/piece/instruction/instruction-context.js';
|
||||
|
||||
function makeMovement(overrides: Partial<PieceMovement> = {}): PieceMovement {
|
||||
return {
|
||||
name: 'test-movement',
|
||||
personaDisplayName: 'tester',
|
||||
instructionTemplate: '',
|
||||
passPreviousResponse: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(overrides: Partial<InstructionContext> = {}): InstructionContext {
|
||||
return {
|
||||
task: 'test task',
|
||||
iteration: 1,
|
||||
maxIterations: 10,
|
||||
movementIteration: 1,
|
||||
cwd: '/tmp/test',
|
||||
projectCwd: '/tmp/project',
|
||||
userInputs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('isOutputContractItem', () => {
|
||||
it('should return true for OutputContractItem (has name)', () => {
|
||||
expect(isOutputContractItem({ name: 'report.md' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for OutputContractItem with order/format', () => {
|
||||
expect(isOutputContractItem({ name: 'report.md', order: 'Output to file', format: 'markdown' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for OutputContractLabelPath (has label and path)', () => {
|
||||
expect(isOutputContractItem({ label: 'Report', path: 'report.md' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderReportContext', () => {
|
||||
it('should render single OutputContractItem', () => {
|
||||
const contracts: OutputContractEntry[] = [{ name: '00-plan.md' }];
|
||||
const result = renderReportContext(contracts, '/tmp/reports');
|
||||
|
||||
expect(result).toContain('Report Directory: /tmp/reports/');
|
||||
expect(result).toContain('Report File: /tmp/reports/00-plan.md');
|
||||
});
|
||||
|
||||
it('should render single OutputContractLabelPath', () => {
|
||||
const contracts: OutputContractEntry[] = [{ label: 'Plan', path: 'plan.md' }];
|
||||
const result = renderReportContext(contracts, '/tmp/reports');
|
||||
|
||||
expect(result).toContain('Report Directory: /tmp/reports/');
|
||||
expect(result).toContain('Report File: /tmp/reports/plan.md');
|
||||
});
|
||||
|
||||
it('should render multiple contracts as list', () => {
|
||||
const contracts: OutputContractEntry[] = [
|
||||
{ name: '00-plan.md' },
|
||||
{ label: 'Review', path: '01-review.md' },
|
||||
];
|
||||
const result = renderReportContext(contracts, '/tmp/reports');
|
||||
|
||||
expect(result).toContain('Report Directory: /tmp/reports/');
|
||||
expect(result).toContain('Report Files:');
|
||||
expect(result).toContain('00-plan.md: /tmp/reports/00-plan.md');
|
||||
expect(result).toContain('Review: /tmp/reports/01-review.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderReportOutputInstruction', () => {
|
||||
it('should return empty string when no output contracts', () => {
|
||||
const step = makeMovement();
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports' });
|
||||
expect(renderReportOutputInstruction(step, ctx, 'en')).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when no reportDir', () => {
|
||||
const step = makeMovement({ outputContracts: [{ name: 'report.md' }] });
|
||||
const ctx = makeContext();
|
||||
expect(renderReportOutputInstruction(step, ctx, 'en')).toBe('');
|
||||
});
|
||||
|
||||
it('should render English single-file instruction', () => {
|
||||
const step = makeMovement({ outputContracts: [{ name: 'report.md' }] });
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports', movementIteration: 2 });
|
||||
|
||||
const result = renderReportOutputInstruction(step, ctx, 'en');
|
||||
expect(result).toContain('Report output');
|
||||
expect(result).toContain('Report File');
|
||||
expect(result).toContain('Iteration 2');
|
||||
});
|
||||
|
||||
it('should render English multi-file instruction', () => {
|
||||
const step = makeMovement({
|
||||
outputContracts: [{ name: 'plan.md' }, { name: 'review.md' }],
|
||||
});
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports' });
|
||||
|
||||
const result = renderReportOutputInstruction(step, ctx, 'en');
|
||||
expect(result).toContain('Report Files');
|
||||
});
|
||||
|
||||
it('should render Japanese single-file instruction', () => {
|
||||
const step = makeMovement({ outputContracts: [{ name: 'report.md' }] });
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports', movementIteration: 1 });
|
||||
|
||||
const result = renderReportOutputInstruction(step, ctx, 'ja');
|
||||
expect(result).toContain('レポート出力');
|
||||
expect(result).toContain('Report File');
|
||||
});
|
||||
|
||||
it('should render Japanese multi-file instruction', () => {
|
||||
const step = makeMovement({
|
||||
outputContracts: [{ name: 'plan.md' }, { name: 'review.md' }],
|
||||
});
|
||||
const ctx = makeContext({ reportDir: '/tmp/reports' });
|
||||
|
||||
const result = renderReportOutputInstruction(step, ctx, 'ja');
|
||||
expect(result).toContain('Report Files');
|
||||
});
|
||||
});
|
||||
204
src/__tests__/judgment-strategies.test.ts
Normal file
204
src/__tests__/judgment-strategies.test.ts
Normal file
@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Unit tests for FallbackStrategy judgment strategies
|
||||
*
|
||||
* Tests AutoSelectStrategy and canApply logic for all strategies.
|
||||
* Strategies requiring external agent calls (ReportBased, ResponseBased,
|
||||
* AgentConsult) are tested for canApply and input validation only.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
AutoSelectStrategy,
|
||||
ReportBasedStrategy,
|
||||
ResponseBasedStrategy,
|
||||
AgentConsultStrategy,
|
||||
JudgmentStrategyFactory,
|
||||
type JudgmentContext,
|
||||
} from '../core/piece/judgment/FallbackStrategy.js';
|
||||
import type { PieceMovement } from '../core/models/types.js';
|
||||
|
||||
function makeMovement(overrides: Partial<PieceMovement> = {}): PieceMovement {
|
||||
return {
|
||||
name: 'test-movement',
|
||||
personaDisplayName: 'tester',
|
||||
instructionTemplate: '',
|
||||
passPreviousResponse: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(overrides: Partial<JudgmentContext> = {}): JudgmentContext {
|
||||
return {
|
||||
step: makeMovement(),
|
||||
cwd: '/tmp/test',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AutoSelectStrategy', () => {
|
||||
const strategy = new AutoSelectStrategy();
|
||||
|
||||
it('should have name "AutoSelect"', () => {
|
||||
expect(strategy.name).toBe('AutoSelect');
|
||||
});
|
||||
|
||||
describe('canApply', () => {
|
||||
it('should return true when movement has exactly one rule', () => {
|
||||
const ctx = makeContext({
|
||||
step: makeMovement({
|
||||
rules: [{ condition: 'done', next: 'COMPLETE' }],
|
||||
}),
|
||||
});
|
||||
expect(strategy.canApply(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when movement has multiple rules', () => {
|
||||
const ctx = makeContext({
|
||||
step: makeMovement({
|
||||
rules: [
|
||||
{ condition: 'approved', next: 'implement' },
|
||||
{ condition: 'rejected', next: 'review' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(strategy.canApply(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when movement has no rules', () => {
|
||||
const ctx = makeContext({
|
||||
step: makeMovement({ rules: undefined }),
|
||||
});
|
||||
expect(strategy.canApply(ctx)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return auto-selected tag for single-branch movement', async () => {
|
||||
const ctx = makeContext({
|
||||
step: makeMovement({
|
||||
name: 'review',
|
||||
rules: [{ condition: 'done', next: 'COMPLETE' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await strategy.execute(ctx);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tag).toBe('[REVIEW:1]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReportBasedStrategy', () => {
|
||||
const strategy = new ReportBasedStrategy();
|
||||
|
||||
it('should have name "ReportBased"', () => {
|
||||
expect(strategy.name).toBe('ReportBased');
|
||||
});
|
||||
|
||||
describe('canApply', () => {
|
||||
it('should return true when reportDir and outputContracts are present', () => {
|
||||
const ctx = makeContext({
|
||||
reportDir: '/tmp/reports',
|
||||
step: makeMovement({
|
||||
outputContracts: [{ name: 'report.md' }],
|
||||
}),
|
||||
});
|
||||
expect(strategy.canApply(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when reportDir is missing', () => {
|
||||
const ctx = makeContext({
|
||||
step: makeMovement({
|
||||
outputContracts: [{ name: 'report.md' }],
|
||||
}),
|
||||
});
|
||||
expect(strategy.canApply(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when outputContracts is empty', () => {
|
||||
const ctx = makeContext({
|
||||
reportDir: '/tmp/reports',
|
||||
step: makeMovement({ outputContracts: [] }),
|
||||
});
|
||||
expect(strategy.canApply(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when outputContracts is undefined', () => {
|
||||
const ctx = makeContext({
|
||||
reportDir: '/tmp/reports',
|
||||
step: makeMovement(),
|
||||
});
|
||||
expect(strategy.canApply(ctx)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResponseBasedStrategy', () => {
|
||||
const strategy = new ResponseBasedStrategy();
|
||||
|
||||
it('should have name "ResponseBased"', () => {
|
||||
expect(strategy.name).toBe('ResponseBased');
|
||||
});
|
||||
|
||||
describe('canApply', () => {
|
||||
it('should return true when lastResponse is non-empty', () => {
|
||||
const ctx = makeContext({ lastResponse: 'some response' });
|
||||
expect(strategy.canApply(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when lastResponse is undefined', () => {
|
||||
const ctx = makeContext({ lastResponse: undefined });
|
||||
expect(strategy.canApply(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when lastResponse is empty string', () => {
|
||||
const ctx = makeContext({ lastResponse: '' });
|
||||
expect(strategy.canApply(ctx)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentConsultStrategy', () => {
|
||||
const strategy = new AgentConsultStrategy();
|
||||
|
||||
it('should have name "AgentConsult"', () => {
|
||||
expect(strategy.name).toBe('AgentConsult');
|
||||
});
|
||||
|
||||
describe('canApply', () => {
|
||||
it('should return true when sessionId is non-empty', () => {
|
||||
const ctx = makeContext({ sessionId: 'session-123' });
|
||||
expect(strategy.canApply(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when sessionId is undefined', () => {
|
||||
const ctx = makeContext({ sessionId: undefined });
|
||||
expect(strategy.canApply(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when sessionId is empty string', () => {
|
||||
const ctx = makeContext({ sessionId: '' });
|
||||
expect(strategy.canApply(ctx)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return failure when sessionId is not provided', async () => {
|
||||
const ctx = makeContext({ sessionId: undefined });
|
||||
const result = await strategy.execute(ctx);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe('Session ID not provided');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JudgmentStrategyFactory', () => {
|
||||
it('should create strategies in correct priority order', () => {
|
||||
const strategies = JudgmentStrategyFactory.createStrategies();
|
||||
expect(strategies).toHaveLength(4);
|
||||
expect(strategies[0]!.name).toBe('AutoSelect');
|
||||
expect(strategies[1]!.name).toBe('ReportBased');
|
||||
expect(strategies[2]!.name).toBe('ResponseBased');
|
||||
expect(strategies[3]!.name).toBe('AgentConsult');
|
||||
});
|
||||
});
|
||||
120
src/__tests__/loop-detector.test.ts
Normal file
120
src/__tests__/loop-detector.test.ts
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Unit tests for LoopDetector
|
||||
*
|
||||
* Tests consecutive same-movement detection and configurable actions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { LoopDetector } from '../core/piece/engine/loop-detector.js';
|
||||
|
||||
describe('LoopDetector', () => {
|
||||
describe('with default config', () => {
|
||||
let detector: LoopDetector;
|
||||
|
||||
beforeEach(() => {
|
||||
detector = new LoopDetector();
|
||||
});
|
||||
|
||||
it('should not detect loop for different movements', () => {
|
||||
const result1 = detector.check('step-a');
|
||||
const result2 = detector.check('step-b');
|
||||
const result3 = detector.check('step-a');
|
||||
expect(result1.isLoop).toBe(false);
|
||||
expect(result2.isLoop).toBe(false);
|
||||
expect(result3.isLoop).toBe(false);
|
||||
});
|
||||
|
||||
it('should not detect loop below threshold (10 consecutive)', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const result = detector.check('step-a');
|
||||
expect(result.isLoop).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should detect loop at 11th consecutive execution (default threshold 10)', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
detector.check('step-a');
|
||||
}
|
||||
const result = detector.check('step-a');
|
||||
expect(result.isLoop).toBe(true);
|
||||
expect(result.count).toBe(11);
|
||||
expect(result.shouldWarn).toBe(true);
|
||||
expect(result.shouldAbort).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset consecutive count when movement changes', () => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
detector.check('step-a');
|
||||
}
|
||||
detector.check('step-b');
|
||||
const result = detector.check('step-a');
|
||||
expect(result.isLoop).toBe(false);
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
it('should track consecutive count correctly', () => {
|
||||
detector.check('step-a');
|
||||
expect(detector.getConsecutiveCount()).toBe(1);
|
||||
detector.check('step-a');
|
||||
expect(detector.getConsecutiveCount()).toBe(2);
|
||||
detector.check('step-b');
|
||||
expect(detector.getConsecutiveCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with abort action', () => {
|
||||
it('should set shouldAbort when action is abort', () => {
|
||||
const detector = new LoopDetector({ maxConsecutiveSameStep: 3, action: 'abort' });
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
detector.check('step-a');
|
||||
}
|
||||
const result = detector.check('step-a');
|
||||
expect(result.isLoop).toBe(true);
|
||||
expect(result.shouldAbort).toBe(true);
|
||||
expect(result.shouldWarn).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with ignore action', () => {
|
||||
it('should not warn or abort when action is ignore', () => {
|
||||
const detector = new LoopDetector({ maxConsecutiveSameStep: 3, action: 'ignore' });
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
detector.check('step-a');
|
||||
}
|
||||
const result = detector.check('step-a');
|
||||
expect(result.isLoop).toBe(true);
|
||||
expect(result.shouldAbort).toBe(false);
|
||||
expect(result.shouldWarn).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with custom threshold', () => {
|
||||
it('should detect loop at custom threshold + 1', () => {
|
||||
const detector = new LoopDetector({ maxConsecutiveSameStep: 2 });
|
||||
|
||||
detector.check('step-a');
|
||||
detector.check('step-a');
|
||||
const result = detector.check('step-a');
|
||||
expect(result.isLoop).toBe(true);
|
||||
expect(result.count).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should clear all state', () => {
|
||||
const detector = new LoopDetector({ maxConsecutiveSameStep: 2 });
|
||||
|
||||
detector.check('step-a');
|
||||
detector.check('step-a');
|
||||
detector.reset();
|
||||
|
||||
expect(detector.getConsecutiveCount()).toBe(0);
|
||||
|
||||
const result = detector.check('step-a');
|
||||
expect(result.isLoop).toBe(false);
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
87
src/__tests__/naming.test.ts
Normal file
87
src/__tests__/naming.test.ts
Normal file
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Unit tests for task naming utilities
|
||||
*
|
||||
* Tests nowIso, firstLine, and sanitizeTaskName functions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { nowIso, firstLine, sanitizeTaskName } from '../infra/task/naming.js';
|
||||
|
||||
describe('nowIso', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return a valid ISO 8601 string', () => {
|
||||
const result = nowIso();
|
||||
expect(() => new Date(result)).not.toThrow();
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it('should return current time', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-06-15T14:30:00.000Z'));
|
||||
|
||||
expect(nowIso()).toBe('2025-06-15T14:30:00.000Z');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('firstLine', () => {
|
||||
it('should return the first line of text', () => {
|
||||
expect(firstLine('first line\nsecond line\nthird line')).toBe('first line');
|
||||
});
|
||||
|
||||
it('should trim leading whitespace from content', () => {
|
||||
expect(firstLine(' hello world\nsecond')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('should truncate to 80 characters', () => {
|
||||
const longLine = 'a'.repeat(100);
|
||||
expect(firstLine(longLine)).toBe('a'.repeat(80));
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(firstLine('')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle single line', () => {
|
||||
expect(firstLine('just one line')).toBe('just one line');
|
||||
});
|
||||
|
||||
it('should handle whitespace-only input', () => {
|
||||
expect(firstLine(' \n ')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeTaskName', () => {
|
||||
it('should lowercase the input', () => {
|
||||
expect(sanitizeTaskName('Hello World')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('should replace special characters with spaces then hyphens', () => {
|
||||
expect(sanitizeTaskName('task@name#123')).toBe('task-name-123');
|
||||
});
|
||||
|
||||
it('should collapse multiple hyphens', () => {
|
||||
expect(sanitizeTaskName('a---b')).toBe('a-b');
|
||||
});
|
||||
|
||||
it('should trim leading/trailing whitespace', () => {
|
||||
expect(sanitizeTaskName(' hello ')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should handle typical task names', () => {
|
||||
expect(sanitizeTaskName('Fix: login bug (#42)')).toBe('fix-login-bug-42');
|
||||
});
|
||||
|
||||
it('should generate fallback name for empty result', () => {
|
||||
const result = sanitizeTaskName('!@#$%');
|
||||
expect(result).toMatch(/^task-\d+$/);
|
||||
});
|
||||
|
||||
it('should preserve numbers and lowercase letters', () => {
|
||||
expect(sanitizeTaskName('abc123def')).toBe('abc123def');
|
||||
});
|
||||
});
|
||||
70
src/__tests__/reportDir.test.ts
Normal file
70
src/__tests__/reportDir.test.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Unit tests for report directory name generation
|
||||
*
|
||||
* Tests timestamp formatting and task summary slugification.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { generateReportDir } from '../shared/utils/reportDir.js';
|
||||
|
||||
describe('generateReportDir', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should generate directory name with timestamp and task summary', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-15T10:30:45.000Z'));
|
||||
|
||||
const result = generateReportDir('Add login feature');
|
||||
expect(result).toBe('20250115-103045-add-login-feature');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should truncate long task descriptions to 30 characters', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
|
||||
|
||||
const longTask = 'This is a very long task description that should be truncated';
|
||||
const result = generateReportDir(longTask);
|
||||
// Timestamp is fixed, summary is truncated from first 30 chars
|
||||
expect(result).toMatch(/^20250101-000000-/);
|
||||
// The slug part should be derived from the first 30 chars
|
||||
const slug = result.replace(/^20250101-000000-/, '');
|
||||
expect(slug.length).toBeLessThanOrEqual(30);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should preserve Japanese characters in summary', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-06-01T12:00:00.000Z'));
|
||||
|
||||
const result = generateReportDir('タスク指示書の実装');
|
||||
expect(result).toContain('タスク指示書の実装');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should replace special characters with hyphens', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
|
||||
|
||||
const result = generateReportDir('Fix: bug (#42)');
|
||||
const slug = result.replace(/^20250101-000000-/, '');
|
||||
expect(slug).not.toMatch(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf-]/);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should default to "task" when summary is empty after cleanup', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
|
||||
|
||||
const result = generateReportDir('!@#$%^&*()');
|
||||
expect(result).toBe('20250101-000000-task');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
229
src/__tests__/rule-evaluator.test.ts
Normal file
229
src/__tests__/rule-evaluator.test.ts
Normal file
@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Unit tests for RuleEvaluator
|
||||
*
|
||||
* Tests the evaluation pipeline: aggregate → tag detection → ai() → ai judge fallback.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { RuleEvaluator, type RuleEvaluatorContext } from '../core/piece/evaluation/RuleEvaluator.js';
|
||||
import type { PieceMovement, PieceState } from '../core/models/types.js';
|
||||
|
||||
function makeMovement(overrides: Partial<PieceMovement> = {}): PieceMovement {
|
||||
return {
|
||||
name: 'test-movement',
|
||||
personaDisplayName: 'tester',
|
||||
instructionTemplate: '',
|
||||
passPreviousResponse: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeState(): PieceState {
|
||||
return {
|
||||
pieceName: 'test',
|
||||
currentMovement: 'test-movement',
|
||||
iteration: 1,
|
||||
movementOutputs: new Map(),
|
||||
userInputs: [],
|
||||
personaSessions: new Map(),
|
||||
movementIterations: new Map(),
|
||||
status: 'running',
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(overrides: Partial<RuleEvaluatorContext> = {}): RuleEvaluatorContext {
|
||||
return {
|
||||
state: makeState(),
|
||||
cwd: '/tmp/test',
|
||||
detectRuleIndex: vi.fn().mockReturnValue(-1),
|
||||
callAiJudge: vi.fn().mockResolvedValue(-1),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RuleEvaluator', () => {
|
||||
describe('evaluate', () => {
|
||||
it('should return undefined when movement has no rules', async () => {
|
||||
const step = makeMovement({ rules: undefined });
|
||||
const ctx = makeContext();
|
||||
const evaluator = new RuleEvaluator(step, ctx);
|
||||
|
||||
const result = await evaluator.evaluate('agent output', 'tag output');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when rules array is empty', async () => {
|
||||
const step = makeMovement({ rules: [] });
|
||||
const ctx = makeContext();
|
||||
const evaluator = new RuleEvaluator(step, ctx);
|
||||
|
||||
const result = await evaluator.evaluate('agent output', 'tag output');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should detect rule via Phase 3 tag output', async () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'approved', next: 'implement' },
|
||||
{ condition: 'rejected', next: 'review' },
|
||||
],
|
||||
});
|
||||
const detectRuleIndex = vi.fn().mockReturnValue(0);
|
||||
const ctx = makeContext({ detectRuleIndex });
|
||||
const evaluator = new RuleEvaluator(step, ctx);
|
||||
|
||||
const result = await evaluator.evaluate('agent content', 'tag content with [TEST-MOVEMENT:1]');
|
||||
expect(result).toEqual({ index: 0, method: 'phase3_tag' });
|
||||
expect(detectRuleIndex).toHaveBeenCalledWith('tag content with [TEST-MOVEMENT:1]', 'test-movement');
|
||||
});
|
||||
|
||||
it('should fallback to Phase 1 tag when Phase 3 tag not found', async () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'approved', next: 'implement' },
|
||||
{ condition: 'rejected', next: 'review' },
|
||||
],
|
||||
});
|
||||
// Phase 3 tagContent is non-empty but detectRuleIndex returns -1 (no match)
|
||||
// Phase 1 agentContent check: detectRuleIndex returns 1
|
||||
const detectRuleIndex = vi.fn()
|
||||
.mockReturnValueOnce(-1) // Phase 3 tag not found
|
||||
.mockReturnValueOnce(1); // Phase 1 tag found
|
||||
const ctx = makeContext({ detectRuleIndex });
|
||||
const evaluator = new RuleEvaluator(step, ctx);
|
||||
|
||||
const result = await evaluator.evaluate('agent content', 'phase3 content');
|
||||
expect(result).toEqual({ index: 1, method: 'phase1_tag' });
|
||||
});
|
||||
|
||||
it('should skip interactiveOnly rules in non-interactive mode', async () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'user-fix', next: 'fix', interactiveOnly: true },
|
||||
{ condition: 'auto-fix', next: 'autofix' },
|
||||
],
|
||||
});
|
||||
// Tag detection returns index 0 (interactiveOnly rule)
|
||||
const detectRuleIndex = vi.fn().mockReturnValue(0);
|
||||
const callAiJudge = vi.fn().mockResolvedValue(-1);
|
||||
const ctx = makeContext({ detectRuleIndex, callAiJudge, interactive: false });
|
||||
const evaluator = new RuleEvaluator(step, ctx);
|
||||
|
||||
// Should skip interactive-only rule and eventually throw
|
||||
await expect(evaluator.evaluate('content', 'tag')).rejects.toThrow('no rule matched');
|
||||
});
|
||||
|
||||
it('should allow interactiveOnly rules in interactive mode', async () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'user-fix', next: 'fix', interactiveOnly: true },
|
||||
{ condition: 'auto-fix', next: 'autofix' },
|
||||
],
|
||||
});
|
||||
const detectRuleIndex = vi.fn().mockReturnValue(0);
|
||||
const ctx = makeContext({ detectRuleIndex, interactive: true });
|
||||
const evaluator = new RuleEvaluator(step, ctx);
|
||||
|
||||
const result = await evaluator.evaluate('content', 'tag');
|
||||
expect(result).toEqual({ index: 0, method: 'phase3_tag' });
|
||||
});
|
||||
|
||||
it('should evaluate ai() conditions via AI judge', async () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'approved', next: 'implement', isAiCondition: true, aiConditionText: 'is it approved?' },
|
||||
{ condition: 'rejected', next: 'review', isAiCondition: true, aiConditionText: 'is it rejected?' },
|
||||
],
|
||||
});
|
||||
// callAiJudge returns 0 (first ai condition matched)
|
||||
const callAiJudge = vi.fn().mockResolvedValue(0);
|
||||
const ctx = makeContext({ callAiJudge });
|
||||
const evaluator = new RuleEvaluator(step, ctx);
|
||||
|
||||
const result = await evaluator.evaluate('agent output', '');
|
||||
expect(result).toEqual({ index: 0, method: 'ai_judge' });
|
||||
expect(callAiJudge).toHaveBeenCalledWith(
|
||||
'agent output',
|
||||
[
|
||||
{ index: 0, text: 'is it approved?' },
|
||||
{ index: 1, text: 'is it rejected?' },
|
||||
],
|
||||
{ cwd: '/tmp/test' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should use ai_judge_fallback when no other method matches', async () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'approved', next: 'implement' },
|
||||
{ condition: 'rejected', next: 'review' },
|
||||
],
|
||||
});
|
||||
// No rules have isAiCondition, so evaluateAiConditions returns -1 without calling callAiJudge.
|
||||
// evaluateAllConditionsViaAiJudge is the only caller of callAiJudge.
|
||||
const callAiJudge = vi.fn().mockResolvedValue(1);
|
||||
const ctx = makeContext({ callAiJudge });
|
||||
const evaluator = new RuleEvaluator(step, ctx);
|
||||
|
||||
const result = await evaluator.evaluate('agent output', '');
|
||||
expect(result).toEqual({ index: 1, method: 'ai_judge_fallback' });
|
||||
});
|
||||
|
||||
it('should throw when no rule matches after all detection phases', async () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'approved', next: 'implement' },
|
||||
{ condition: 'rejected', next: 'review' },
|
||||
],
|
||||
});
|
||||
const ctx = makeContext();
|
||||
const evaluator = new RuleEvaluator(step, ctx);
|
||||
|
||||
await expect(evaluator.evaluate('', '')).rejects.toThrow(
|
||||
'Status not found for movement "test-movement": no rule matched after all detection phases',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject out-of-bounds tag detection index', async () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'approved', next: 'implement' },
|
||||
],
|
||||
});
|
||||
// Tag detection returns index 5 (out of bounds)
|
||||
const detectRuleIndex = vi.fn().mockReturnValue(5);
|
||||
const callAiJudge = vi.fn().mockResolvedValue(-1);
|
||||
const ctx = makeContext({ detectRuleIndex, callAiJudge });
|
||||
const evaluator = new RuleEvaluator(step, ctx);
|
||||
|
||||
await expect(evaluator.evaluate('content', 'tag')).rejects.toThrow('no rule matched');
|
||||
});
|
||||
|
||||
it('should skip ai() conditions for interactiveOnly rules in non-interactive mode', async () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{
|
||||
condition: 'user confirms',
|
||||
next: 'fix',
|
||||
interactiveOnly: true,
|
||||
isAiCondition: true,
|
||||
aiConditionText: 'did the user confirm?',
|
||||
},
|
||||
{ condition: 'auto proceed', next: 'COMPLETE' },
|
||||
],
|
||||
});
|
||||
// In non-interactive mode, interactiveOnly rules are filtered out from ai judge.
|
||||
// evaluateAiConditions skips the interactiveOnly ai() rule, returning -1.
|
||||
// evaluateAllConditionsViaAiJudge filters to only non-interactive rules,
|
||||
// passing conditions=[{index: 1, text: 'auto proceed'}] to judge.
|
||||
// The judge returns 0 (first condition in filtered array).
|
||||
const callAiJudge = vi.fn().mockResolvedValue(0);
|
||||
const ctx = makeContext({ callAiJudge, interactive: false });
|
||||
const evaluator = new RuleEvaluator(step, ctx);
|
||||
|
||||
const result = await evaluator.evaluate('output', '');
|
||||
// Returns the judge result index (0) directly — it's the index into the filtered conditions array
|
||||
expect(result).toEqual({ index: 0, method: 'ai_judge_fallback' });
|
||||
});
|
||||
});
|
||||
});
|
||||
164
src/__tests__/rule-utils.test.ts
Normal file
164
src/__tests__/rule-utils.test.ts
Normal file
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Unit tests for rule-utils
|
||||
*
|
||||
* Tests tag-based rule detection, single-branch auto-selection,
|
||||
* and report file extraction from output contracts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
hasTagBasedRules,
|
||||
hasOnlyOneBranch,
|
||||
getAutoSelectedTag,
|
||||
getReportFiles,
|
||||
} from '../core/piece/evaluation/rule-utils.js';
|
||||
import type { PieceMovement, OutputContractEntry } from '../core/models/types.js';
|
||||
|
||||
function makeMovement(overrides: Partial<PieceMovement> = {}): PieceMovement {
|
||||
return {
|
||||
name: 'test-movement',
|
||||
personaDisplayName: 'tester',
|
||||
instructionTemplate: '',
|
||||
passPreviousResponse: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('hasTagBasedRules', () => {
|
||||
it('should return false when movement has no rules', () => {
|
||||
const step = makeMovement({ rules: undefined });
|
||||
expect(hasTagBasedRules(step)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when rules array is empty', () => {
|
||||
const step = makeMovement({ rules: [] });
|
||||
expect(hasTagBasedRules(step)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when rules contain tag-based conditions', () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'approved' },
|
||||
{ condition: 'rejected' },
|
||||
],
|
||||
});
|
||||
expect(hasTagBasedRules(step)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when all rules are ai() conditions', () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'approved', isAiCondition: true, aiConditionText: 'is it approved?' },
|
||||
{ condition: 'rejected', isAiCondition: true, aiConditionText: 'is it rejected?' },
|
||||
],
|
||||
});
|
||||
expect(hasTagBasedRules(step)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when all rules are aggregate conditions', () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'all approved', isAggregateCondition: true, aggregateType: 'all', aggregateConditionText: 'approved' },
|
||||
],
|
||||
});
|
||||
expect(hasTagBasedRules(step)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when mixed rules include tag-based ones', () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'approved', isAiCondition: true, aiConditionText: 'approved?' },
|
||||
{ condition: 'manual check' },
|
||||
],
|
||||
});
|
||||
expect(hasTagBasedRules(step)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasOnlyOneBranch', () => {
|
||||
it('should return false when rules is undefined', () => {
|
||||
const step = makeMovement({ rules: undefined });
|
||||
expect(hasOnlyOneBranch(step)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when rules array is empty', () => {
|
||||
const step = makeMovement({ rules: [] });
|
||||
expect(hasOnlyOneBranch(step)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when exactly one rule exists', () => {
|
||||
const step = makeMovement({
|
||||
rules: [{ condition: 'done', next: 'COMPLETE' }],
|
||||
});
|
||||
expect(hasOnlyOneBranch(step)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when multiple rules exist', () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'approved', next: 'implement' },
|
||||
{ condition: 'rejected', next: 'review' },
|
||||
],
|
||||
});
|
||||
expect(hasOnlyOneBranch(step)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoSelectedTag', () => {
|
||||
it('should return uppercase tag for single-branch movement', () => {
|
||||
const step = makeMovement({
|
||||
name: 'ai-review',
|
||||
rules: [{ condition: 'done', next: 'COMPLETE' }],
|
||||
});
|
||||
expect(getAutoSelectedTag(step)).toBe('[AI-REVIEW:1]');
|
||||
});
|
||||
|
||||
it('should throw when multiple branches exist', () => {
|
||||
const step = makeMovement({
|
||||
rules: [
|
||||
{ condition: 'approved', next: 'implement' },
|
||||
{ condition: 'rejected', next: 'review' },
|
||||
],
|
||||
});
|
||||
expect(() => getAutoSelectedTag(step)).toThrow('Cannot auto-select tag when multiple branches exist');
|
||||
});
|
||||
|
||||
it('should throw when no rules exist', () => {
|
||||
const step = makeMovement({ rules: undefined });
|
||||
expect(() => getAutoSelectedTag(step)).toThrow('Cannot auto-select tag when multiple branches exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReportFiles', () => {
|
||||
it('should return empty array when outputContracts is undefined', () => {
|
||||
expect(getReportFiles(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when outputContracts is empty', () => {
|
||||
expect(getReportFiles([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should extract name from OutputContractItem entries', () => {
|
||||
const contracts: OutputContractEntry[] = [
|
||||
{ name: '00-plan.md' },
|
||||
{ name: '01-review.md' },
|
||||
];
|
||||
expect(getReportFiles(contracts)).toEqual(['00-plan.md', '01-review.md']);
|
||||
});
|
||||
|
||||
it('should extract path from OutputContractLabelPath entries', () => {
|
||||
const contracts: OutputContractEntry[] = [
|
||||
{ label: 'Scope', path: 'scope.md' },
|
||||
{ label: 'Decisions', path: 'decisions.md' },
|
||||
];
|
||||
expect(getReportFiles(contracts)).toEqual(['scope.md', 'decisions.md']);
|
||||
});
|
||||
|
||||
it('should handle mixed entry types', () => {
|
||||
const contracts: OutputContractEntry[] = [
|
||||
{ name: '00-plan.md' },
|
||||
{ label: 'Review', path: 'review.md' },
|
||||
];
|
||||
expect(getReportFiles(contracts)).toEqual(['00-plan.md', 'review.md']);
|
||||
});
|
||||
});
|
||||
53
src/__tests__/slug.test.ts
Normal file
53
src/__tests__/slug.test.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Unit tests for slugify utility
|
||||
*
|
||||
* Tests URL/filename-safe slug generation with CJK support.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { slugify } from '../shared/utils/slug.js';
|
||||
|
||||
describe('slugify', () => {
|
||||
it('should convert to lowercase', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('should replace non-alphanumeric characters with hyphens', () => {
|
||||
expect(slugify('foo bar_baz')).toBe('foo-bar-baz');
|
||||
});
|
||||
|
||||
it('should collapse consecutive special characters into single hyphen', () => {
|
||||
expect(slugify('foo---bar baz')).toBe('foo-bar-baz');
|
||||
});
|
||||
|
||||
it('should strip leading and trailing hyphens', () => {
|
||||
expect(slugify('--hello--')).toBe('hello');
|
||||
expect(slugify(' hello ')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should truncate to 50 characters', () => {
|
||||
const long = 'a'.repeat(100);
|
||||
expect(slugify(long).length).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('should preserve CJK characters', () => {
|
||||
expect(slugify('タスク指示書')).toBe('タスク指示書');
|
||||
});
|
||||
|
||||
it('should handle mixed ASCII and CJK', () => {
|
||||
expect(slugify('Add タスク Feature')).toBe('add-タスク-feature');
|
||||
});
|
||||
|
||||
it('should handle numbers', () => {
|
||||
expect(slugify('issue 123')).toBe('issue-123');
|
||||
});
|
||||
|
||||
it('should handle empty result after stripping', () => {
|
||||
// All special characters → becomes empty string
|
||||
expect(slugify('!@#$%')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle typical GitHub issue titles', () => {
|
||||
expect(slugify('Fix: login not working (#42)')).toBe('fix-login-not-working-42');
|
||||
});
|
||||
});
|
||||
227
src/__tests__/state-manager.test.ts
Normal file
227
src/__tests__/state-manager.test.ts
Normal file
@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Unit tests for StateManager
|
||||
*
|
||||
* Tests piece state initialization, user input management,
|
||||
* movement iteration tracking, and output retrieval.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
StateManager,
|
||||
createInitialState,
|
||||
incrementMovementIteration,
|
||||
addUserInput,
|
||||
getPreviousOutput,
|
||||
} from '../core/piece/engine/state-manager.js';
|
||||
import { MAX_USER_INPUTS, MAX_INPUT_LENGTH } from '../core/piece/constants.js';
|
||||
import type { PieceConfig, AgentResponse, PieceState } from '../core/models/types.js';
|
||||
import type { PieceEngineOptions } from '../core/piece/types.js';
|
||||
|
||||
function makeConfig(overrides: Partial<PieceConfig> = {}): PieceConfig {
|
||||
return {
|
||||
name: 'test-piece',
|
||||
movements: [],
|
||||
initialMovement: 'start',
|
||||
maxIterations: 10,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeOptions(overrides: Partial<PieceEngineOptions> = {}): PieceEngineOptions {
|
||||
return {
|
||||
projectCwd: '/tmp/project',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeResponse(content: string): AgentResponse {
|
||||
return {
|
||||
persona: 'tester',
|
||||
status: 'done',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('StateManager', () => {
|
||||
describe('constructor', () => {
|
||||
it('should initialize state with config defaults', () => {
|
||||
const manager = new StateManager(makeConfig(), makeOptions());
|
||||
|
||||
expect(manager.state.pieceName).toBe('test-piece');
|
||||
expect(manager.state.currentMovement).toBe('start');
|
||||
expect(manager.state.iteration).toBe(0);
|
||||
expect(manager.state.status).toBe('running');
|
||||
expect(manager.state.userInputs).toEqual([]);
|
||||
expect(manager.state.movementOutputs.size).toBe(0);
|
||||
expect(manager.state.personaSessions.size).toBe(0);
|
||||
expect(manager.state.movementIterations.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should use startMovement option when provided', () => {
|
||||
const manager = new StateManager(
|
||||
makeConfig(),
|
||||
makeOptions({ startMovement: 'custom-start' }),
|
||||
);
|
||||
|
||||
expect(manager.state.currentMovement).toBe('custom-start');
|
||||
});
|
||||
|
||||
it('should restore initial sessions from options', () => {
|
||||
const manager = new StateManager(
|
||||
makeConfig(),
|
||||
makeOptions({
|
||||
initialSessions: { coder: 'session-1', reviewer: 'session-2' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(manager.state.personaSessions.get('coder')).toBe('session-1');
|
||||
expect(manager.state.personaSessions.get('reviewer')).toBe('session-2');
|
||||
});
|
||||
|
||||
it('should restore initial user inputs from options', () => {
|
||||
const manager = new StateManager(
|
||||
makeConfig(),
|
||||
makeOptions({
|
||||
initialUserInputs: ['input1', 'input2'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(manager.state.userInputs).toEqual(['input1', 'input2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementMovementIteration', () => {
|
||||
it('should start at 1 for new movement', () => {
|
||||
const manager = new StateManager(makeConfig(), makeOptions());
|
||||
const count = manager.incrementMovementIteration('review');
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('should increment correctly for repeated movements', () => {
|
||||
const manager = new StateManager(makeConfig(), makeOptions());
|
||||
manager.incrementMovementIteration('review');
|
||||
manager.incrementMovementIteration('review');
|
||||
const count = manager.incrementMovementIteration('review');
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
|
||||
it('should track different movements independently', () => {
|
||||
const manager = new StateManager(makeConfig(), makeOptions());
|
||||
manager.incrementMovementIteration('review');
|
||||
manager.incrementMovementIteration('review');
|
||||
manager.incrementMovementIteration('implement');
|
||||
expect(manager.state.movementIterations.get('review')).toBe(2);
|
||||
expect(manager.state.movementIterations.get('implement')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addUserInput', () => {
|
||||
it('should add input to state', () => {
|
||||
const manager = new StateManager(makeConfig(), makeOptions());
|
||||
manager.addUserInput('hello');
|
||||
expect(manager.state.userInputs).toEqual(['hello']);
|
||||
});
|
||||
|
||||
it('should truncate input exceeding max length', () => {
|
||||
const manager = new StateManager(makeConfig(), makeOptions());
|
||||
const longInput = 'x'.repeat(MAX_INPUT_LENGTH + 100);
|
||||
manager.addUserInput(longInput);
|
||||
expect(manager.state.userInputs[0]!.length).toBe(MAX_INPUT_LENGTH);
|
||||
});
|
||||
|
||||
it('should evict oldest input when exceeding max inputs', () => {
|
||||
const manager = new StateManager(makeConfig(), makeOptions());
|
||||
for (let i = 0; i < MAX_USER_INPUTS; i++) {
|
||||
manager.addUserInput(`input-${i}`);
|
||||
}
|
||||
expect(manager.state.userInputs.length).toBe(MAX_USER_INPUTS);
|
||||
|
||||
manager.addUserInput('overflow');
|
||||
expect(manager.state.userInputs.length).toBe(MAX_USER_INPUTS);
|
||||
expect(manager.state.userInputs[0]).toBe('input-1');
|
||||
expect(manager.state.userInputs[manager.state.userInputs.length - 1]).toBe('overflow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreviousOutput', () => {
|
||||
it('should return undefined when no outputs exist', () => {
|
||||
const manager = new StateManager(makeConfig(), makeOptions());
|
||||
expect(manager.getPreviousOutput()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the last output from movementOutputs', () => {
|
||||
const manager = new StateManager(makeConfig(), makeOptions());
|
||||
const response1 = makeResponse('first');
|
||||
const response2 = makeResponse('second');
|
||||
manager.state.movementOutputs.set('step-1', response1);
|
||||
manager.state.movementOutputs.set('step-2', response2);
|
||||
expect(manager.getPreviousOutput()?.content).toBe('second');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('standalone functions', () => {
|
||||
function makeState(): PieceState {
|
||||
return {
|
||||
pieceName: 'test',
|
||||
currentMovement: 'start',
|
||||
iteration: 0,
|
||||
movementOutputs: new Map(),
|
||||
userInputs: [],
|
||||
personaSessions: new Map(),
|
||||
movementIterations: new Map(),
|
||||
status: 'running',
|
||||
};
|
||||
}
|
||||
|
||||
describe('createInitialState', () => {
|
||||
it('should create state from config and options', () => {
|
||||
const state = createInitialState(makeConfig(), makeOptions());
|
||||
expect(state.pieceName).toBe('test-piece');
|
||||
expect(state.currentMovement).toBe('start');
|
||||
expect(state.status).toBe('running');
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementMovementIteration (standalone)', () => {
|
||||
it('should increment counter on state', () => {
|
||||
const state = makeState();
|
||||
expect(incrementMovementIteration(state, 'review')).toBe(1);
|
||||
expect(incrementMovementIteration(state, 'review')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addUserInput (standalone)', () => {
|
||||
it('should add input and truncate', () => {
|
||||
const state = makeState();
|
||||
addUserInput(state, 'test input');
|
||||
expect(state.userInputs).toEqual(['test input']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreviousOutput (standalone)', () => {
|
||||
it('should prefer lastOutput over movementOutputs', () => {
|
||||
const state = makeState();
|
||||
const lastOutput = makeResponse('last');
|
||||
const mapOutput = makeResponse('from-map');
|
||||
state.lastOutput = lastOutput;
|
||||
state.movementOutputs.set('step-1', mapOutput);
|
||||
|
||||
expect(getPreviousOutput(state)?.content).toBe('last');
|
||||
});
|
||||
|
||||
it('should fall back to movementOutputs when lastOutput is undefined', () => {
|
||||
const state = makeState();
|
||||
const mapOutput = makeResponse('from-map');
|
||||
state.movementOutputs.set('step-1', mapOutput);
|
||||
|
||||
expect(getPreviousOutput(state)?.content).toBe('from-map');
|
||||
});
|
||||
|
||||
it('should return undefined when both are empty', () => {
|
||||
const state = makeState();
|
||||
expect(getPreviousOutput(state)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
224
src/__tests__/task-schema.test.ts
Normal file
224
src/__tests__/task-schema.test.ts
Normal file
@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Unit tests for task schema validation
|
||||
*
|
||||
* Tests TaskRecordSchema cross-field validation rules (status-dependent constraints).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
TaskRecordSchema,
|
||||
TaskFileSchema,
|
||||
TaskExecutionConfigSchema,
|
||||
} from '../infra/task/schema.js';
|
||||
|
||||
function makePendingRecord() {
|
||||
return {
|
||||
name: 'test-task',
|
||||
status: 'pending' as const,
|
||||
content: 'task content',
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRunningRecord() {
|
||||
return {
|
||||
name: 'test-task',
|
||||
status: 'running' as const,
|
||||
content: 'task content',
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
started_at: '2025-01-01T01:00:00.000Z',
|
||||
completed_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCompletedRecord() {
|
||||
return {
|
||||
name: 'test-task',
|
||||
status: 'completed' as const,
|
||||
content: 'task content',
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
started_at: '2025-01-01T01:00:00.000Z',
|
||||
completed_at: '2025-01-01T02:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function makeFailedRecord() {
|
||||
return {
|
||||
name: 'test-task',
|
||||
status: 'failed' as const,
|
||||
content: 'task content',
|
||||
created_at: '2025-01-01T00:00:00.000Z',
|
||||
started_at: '2025-01-01T01:00:00.000Z',
|
||||
completed_at: '2025-01-01T02:00:00.000Z',
|
||||
failure: { error: 'something went wrong' },
|
||||
};
|
||||
}
|
||||
|
||||
describe('TaskExecutionConfigSchema', () => {
|
||||
it('should accept valid config with all optional fields', () => {
|
||||
const config = {
|
||||
worktree: true,
|
||||
branch: 'feature/test',
|
||||
piece: 'unit-test',
|
||||
issue: 42,
|
||||
start_movement: 'plan',
|
||||
retry_note: 'retry after fix',
|
||||
auto_pr: true,
|
||||
};
|
||||
expect(() => TaskExecutionConfigSchema.parse(config)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept empty config (all fields optional)', () => {
|
||||
expect(() => TaskExecutionConfigSchema.parse({})).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept worktree as string', () => {
|
||||
expect(() => TaskExecutionConfigSchema.parse({ worktree: '/custom/path' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject negative issue number', () => {
|
||||
expect(() => TaskExecutionConfigSchema.parse({ issue: -1 })).toThrow();
|
||||
});
|
||||
|
||||
it('should reject non-integer issue number', () => {
|
||||
expect(() => TaskExecutionConfigSchema.parse({ issue: 1.5 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskFileSchema', () => {
|
||||
it('should accept valid task with required fields', () => {
|
||||
expect(() => TaskFileSchema.parse({ task: 'do something' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject empty task string', () => {
|
||||
expect(() => TaskFileSchema.parse({ task: '' })).toThrow();
|
||||
});
|
||||
|
||||
it('should reject missing task field', () => {
|
||||
expect(() => TaskFileSchema.parse({})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskRecordSchema', () => {
|
||||
describe('pending status', () => {
|
||||
it('should accept valid pending record', () => {
|
||||
expect(() => TaskRecordSchema.parse(makePendingRecord())).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject pending record with started_at', () => {
|
||||
const record = { ...makePendingRecord(), started_at: '2025-01-01T01:00:00.000Z' };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject pending record with completed_at', () => {
|
||||
const record = { ...makePendingRecord(), completed_at: '2025-01-01T02:00:00.000Z' };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject pending record with failure', () => {
|
||||
const record = { ...makePendingRecord(), failure: { error: 'fail' } };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject pending record with owner_pid', () => {
|
||||
const record = { ...makePendingRecord(), owner_pid: 1234 };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('running status', () => {
|
||||
it('should accept valid running record', () => {
|
||||
expect(() => TaskRecordSchema.parse(makeRunningRecord())).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject running record without started_at', () => {
|
||||
const record = { ...makeRunningRecord(), started_at: null };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject running record with completed_at', () => {
|
||||
const record = { ...makeRunningRecord(), completed_at: '2025-01-01T02:00:00.000Z' };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject running record with failure', () => {
|
||||
const record = { ...makeRunningRecord(), failure: { error: 'fail' } };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
|
||||
it('should accept running record with owner_pid', () => {
|
||||
const record = { ...makeRunningRecord(), owner_pid: 5678 };
|
||||
expect(() => TaskRecordSchema.parse(record)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('completed status', () => {
|
||||
it('should accept valid completed record', () => {
|
||||
expect(() => TaskRecordSchema.parse(makeCompletedRecord())).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject completed record without started_at', () => {
|
||||
const record = { ...makeCompletedRecord(), started_at: null };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject completed record without completed_at', () => {
|
||||
const record = { ...makeCompletedRecord(), completed_at: null };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject completed record with failure', () => {
|
||||
const record = { ...makeCompletedRecord(), failure: { error: 'fail' } };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject completed record with owner_pid', () => {
|
||||
const record = { ...makeCompletedRecord(), owner_pid: 1234 };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed status', () => {
|
||||
it('should accept valid failed record', () => {
|
||||
expect(() => TaskRecordSchema.parse(makeFailedRecord())).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject failed record without started_at', () => {
|
||||
const record = { ...makeFailedRecord(), started_at: null };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject failed record without completed_at', () => {
|
||||
const record = { ...makeFailedRecord(), completed_at: null };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject failed record without failure', () => {
|
||||
const record = { ...makeFailedRecord(), failure: undefined };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject failed record with owner_pid', () => {
|
||||
const record = { ...makeFailedRecord(), owner_pid: 1234 };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content requirement', () => {
|
||||
it('should accept record with content', () => {
|
||||
expect(() => TaskRecordSchema.parse(makePendingRecord())).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept record with content_file', () => {
|
||||
const record = { ...makePendingRecord(), content: undefined, content_file: './task.md' };
|
||||
expect(() => TaskRecordSchema.parse(record)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject record with neither content nor content_file', () => {
|
||||
const record = { ...makePendingRecord(), content: undefined };
|
||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
136
src/__tests__/text.test.ts
Normal file
136
src/__tests__/text.test.ts
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Unit tests for text display width utilities
|
||||
*
|
||||
* Tests full-width character detection, display width calculation,
|
||||
* ANSI stripping, and text truncation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isFullWidth,
|
||||
getDisplayWidth,
|
||||
stripAnsi,
|
||||
truncateText,
|
||||
} from '../shared/utils/text.js';
|
||||
|
||||
describe('isFullWidth', () => {
|
||||
it('should return false for ASCII characters', () => {
|
||||
expect(isFullWidth('A'.codePointAt(0)!)).toBe(false);
|
||||
expect(isFullWidth('z'.codePointAt(0)!)).toBe(false);
|
||||
expect(isFullWidth('0'.codePointAt(0)!)).toBe(false);
|
||||
expect(isFullWidth(' '.codePointAt(0)!)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for CJK ideographs', () => {
|
||||
expect(isFullWidth('漢'.codePointAt(0)!)).toBe(true);
|
||||
expect(isFullWidth('字'.codePointAt(0)!)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for Hangul syllables', () => {
|
||||
expect(isFullWidth('한'.codePointAt(0)!)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for fullwidth ASCII variants', () => {
|
||||
expect(isFullWidth('A'.codePointAt(0)!)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for Hangul Jamo', () => {
|
||||
// U+1100 (ᄀ) is in Hangul Jamo range
|
||||
expect(isFullWidth(0x1100)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for CJK radicals', () => {
|
||||
// U+2E80 is in CJK radicals range
|
||||
expect(isFullWidth(0x2E80)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDisplayWidth', () => {
|
||||
it('should return 0 for empty string', () => {
|
||||
expect(getDisplayWidth('')).toBe(0);
|
||||
});
|
||||
|
||||
it('should count ASCII characters as width 1', () => {
|
||||
expect(getDisplayWidth('hello')).toBe(5);
|
||||
expect(getDisplayWidth('abc123')).toBe(6);
|
||||
});
|
||||
|
||||
it('should count CJK characters as width 2', () => {
|
||||
expect(getDisplayWidth('漢字')).toBe(4);
|
||||
expect(getDisplayWidth('テスト')).toBe(6);
|
||||
});
|
||||
|
||||
it('should handle mixed ASCII and CJK', () => {
|
||||
expect(getDisplayWidth('hello漢字')).toBe(9); // 5 + 4
|
||||
expect(getDisplayWidth('AB漢C')).toBe(5); // 1+1+2+1
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripAnsi', () => {
|
||||
it('should strip CSI color codes', () => {
|
||||
expect(stripAnsi('\x1b[31mred text\x1b[0m')).toBe('red text');
|
||||
});
|
||||
|
||||
it('should strip multiple CSI sequences', () => {
|
||||
expect(stripAnsi('\x1b[1m\x1b[32mbold green\x1b[0m')).toBe('bold green');
|
||||
});
|
||||
|
||||
it('should strip cursor movement sequences', () => {
|
||||
expect(stripAnsi('\x1b[2Amove up')).toBe('move up');
|
||||
});
|
||||
|
||||
it('should strip OSC sequences (BEL terminated)', () => {
|
||||
expect(stripAnsi('\x1b]0;title\x07rest')).toBe('rest');
|
||||
});
|
||||
|
||||
it('should strip OSC sequences (ST terminated)', () => {
|
||||
expect(stripAnsi('\x1b]0;title\x1b\\rest')).toBe('rest');
|
||||
});
|
||||
|
||||
it('should return unchanged string with no escapes', () => {
|
||||
expect(stripAnsi('plain text')).toBe('plain text');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(stripAnsi('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateText', () => {
|
||||
it('should return empty string for maxWidth 0', () => {
|
||||
expect(truncateText('hello', 0)).toBe('');
|
||||
});
|
||||
|
||||
it('should not truncate text shorter than maxWidth', () => {
|
||||
expect(truncateText('hello', 10)).toBe('hello');
|
||||
});
|
||||
|
||||
it('should truncate and add ellipsis for long text', () => {
|
||||
const result = truncateText('hello world', 6);
|
||||
expect(result).toBe('hello…');
|
||||
expect(getDisplayWidth(result)).toBeLessThanOrEqual(6);
|
||||
});
|
||||
|
||||
it('should handle CJK characters correctly when truncating', () => {
|
||||
// Each CJK char is width 2, so "漢字テスト" = 10 width
|
||||
const result = truncateText('漢字テスト', 5);
|
||||
// Should fit within 5 columns including ellipsis
|
||||
expect(getDisplayWidth(result)).toBeLessThanOrEqual(5);
|
||||
expect(result.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle mixed content', () => {
|
||||
const result = truncateText('AB漢字CD', 5);
|
||||
expect(getDisplayWidth(result)).toBeLessThanOrEqual(5);
|
||||
expect(result.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
it('should truncate text at exact maxWidth since ellipsis space is reserved', () => {
|
||||
// truncateText always reserves 1 column for ellipsis
|
||||
expect(truncateText('abcde', 5)).toBe('abcd…');
|
||||
});
|
||||
|
||||
it('should return text as-is when shorter than maxWidth', () => {
|
||||
expect(truncateText('abcd', 5)).toBe('abcd');
|
||||
});
|
||||
});
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { determineNextMovementByRules } from '../core/piece/index.js';
|
||||
import { extractBlockedPrompt } from '../core/piece/engine/transitions.js';
|
||||
import type { PieceMovement } from '../core/models/index.js';
|
||||
|
||||
function createMovementWithRules(rules: { condition: string; next: string }[]): PieceMovement {
|
||||
@ -79,3 +80,40 @@ describe('determineNextMovementByRules', () => {
|
||||
expect(determineNextMovementByRules(step, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBlockedPrompt', () => {
|
||||
it('should extract prompt after "必要な情報:" pattern', () => {
|
||||
const content = '処理がブロックされました。\n必要な情報: デプロイ先の環境を教えてください';
|
||||
expect(extractBlockedPrompt(content)).toBe('デプロイ先の環境を教えてください');
|
||||
});
|
||||
|
||||
it('should extract prompt after "質問:" pattern', () => {
|
||||
const content = '質問: どのブランチにマージしますか?';
|
||||
expect(extractBlockedPrompt(content)).toBe('どのブランチにマージしますか?');
|
||||
});
|
||||
|
||||
it('should extract prompt after "理由:" pattern', () => {
|
||||
const content = '理由: 権限が不足しています';
|
||||
expect(extractBlockedPrompt(content)).toBe('権限が不足しています');
|
||||
});
|
||||
|
||||
it('should extract prompt after "確認:" pattern', () => {
|
||||
const content = '確認: この変更を続けてもよいですか?';
|
||||
expect(extractBlockedPrompt(content)).toBe('この変更を続けてもよいですか?');
|
||||
});
|
||||
|
||||
it('should support full-width colon', () => {
|
||||
const content = '必要な情報:ファイルパスを指定してください';
|
||||
expect(extractBlockedPrompt(content)).toBe('ファイルパスを指定してください');
|
||||
});
|
||||
|
||||
it('should return full content when no pattern matches', () => {
|
||||
const content = 'Something went wrong and I need help';
|
||||
expect(extractBlockedPrompt(content)).toBe('Something went wrong and I need help');
|
||||
});
|
||||
|
||||
it('should return first matching pattern when multiple exist', () => {
|
||||
const content = '質問: 最初の質問\n確認: 二番目の質問';
|
||||
expect(extractBlockedPrompt(content)).toBe('最初の質問');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user