From 0e4e9e904650c2ee132ce494f37e3f6c1fd55dbd Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 07:07:40 +0900 Subject: [PATCH] takt: github-issue-189 (#196) --- src/__tests__/aggregate-evaluator.test.ts | 347 ++++++++++++++++++++++ src/__tests__/blocked-handler.test.ts | 110 +++++++ src/__tests__/error-utils.test.ts | 39 +++ src/__tests__/escape.test.ts | 190 ++++++++++++ src/__tests__/instruction-context.test.ts | 48 +++ src/__tests__/instruction-helpers.test.ts | 135 +++++++++ src/__tests__/judgment-strategies.test.ts | 204 +++++++++++++ src/__tests__/loop-detector.test.ts | 120 ++++++++ src/__tests__/naming.test.ts | 87 ++++++ src/__tests__/reportDir.test.ts | 70 +++++ src/__tests__/rule-evaluator.test.ts | 229 ++++++++++++++ src/__tests__/rule-utils.test.ts | 164 ++++++++++ src/__tests__/slug.test.ts | 53 ++++ src/__tests__/state-manager.test.ts | 227 ++++++++++++++ src/__tests__/task-schema.test.ts | 224 ++++++++++++++ src/__tests__/text.test.ts | 136 +++++++++ src/__tests__/transitions.test.ts | 38 +++ 17 files changed, 2421 insertions(+) create mode 100644 src/__tests__/aggregate-evaluator.test.ts create mode 100644 src/__tests__/blocked-handler.test.ts create mode 100644 src/__tests__/error-utils.test.ts create mode 100644 src/__tests__/escape.test.ts create mode 100644 src/__tests__/instruction-context.test.ts create mode 100644 src/__tests__/instruction-helpers.test.ts create mode 100644 src/__tests__/judgment-strategies.test.ts create mode 100644 src/__tests__/loop-detector.test.ts create mode 100644 src/__tests__/naming.test.ts create mode 100644 src/__tests__/reportDir.test.ts create mode 100644 src/__tests__/rule-evaluator.test.ts create mode 100644 src/__tests__/rule-utils.test.ts create mode 100644 src/__tests__/slug.test.ts create mode 100644 src/__tests__/state-manager.test.ts create mode 100644 src/__tests__/task-schema.test.ts create mode 100644 src/__tests__/text.test.ts diff --git a/src/__tests__/aggregate-evaluator.test.ts b/src/__tests__/aggregate-evaluator.test.ts new file mode 100644 index 0000000..9091d81 --- /dev/null +++ b/src/__tests__/aggregate-evaluator.test.ts @@ -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): PieceState { + const movementOutputs = new Map(); + 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); + }); + }); +}); diff --git a/src/__tests__/blocked-handler.test.ts b/src/__tests__/blocked-handler.test.ts new file mode 100644 index 0000000..215b139 --- /dev/null +++ b/src/__tests__/blocked-handler.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/__tests__/error-utils.test.ts b/src/__tests__/error-utils.test.ts new file mode 100644 index 0000000..239934c --- /dev/null +++ b/src/__tests__/error-utils.test.ts @@ -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]'); + }); +}); diff --git a/src/__tests__/escape.test.ts b/src/__tests__/escape.test.ts new file mode 100644 index 0000000..e850fa3 --- /dev/null +++ b/src/__tests__/escape.test.ts @@ -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 { + return { + name: 'test-movement', + personaDisplayName: 'tester', + instructionTemplate: '', + passPreviousResponse: false, + ...overrides, + }; +} + +function makeContext(overrides: Partial = {}): 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}'); + }); +}); diff --git a/src/__tests__/instruction-context.test.ts b/src/__tests__/instruction-context.test.ts new file mode 100644 index 0000000..fc4639b --- /dev/null +++ b/src/__tests__/instruction-context.test.ts @@ -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(''); + }); + }); +}); diff --git a/src/__tests__/instruction-helpers.test.ts b/src/__tests__/instruction-helpers.test.ts new file mode 100644 index 0000000..0b9adff --- /dev/null +++ b/src/__tests__/instruction-helpers.test.ts @@ -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 { + return { + name: 'test-movement', + personaDisplayName: 'tester', + instructionTemplate: '', + passPreviousResponse: false, + ...overrides, + }; +} + +function makeContext(overrides: Partial = {}): 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'); + }); +}); diff --git a/src/__tests__/judgment-strategies.test.ts b/src/__tests__/judgment-strategies.test.ts new file mode 100644 index 0000000..927c1e9 --- /dev/null +++ b/src/__tests__/judgment-strategies.test.ts @@ -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 { + return { + name: 'test-movement', + personaDisplayName: 'tester', + instructionTemplate: '', + passPreviousResponse: false, + ...overrides, + }; +} + +function makeContext(overrides: Partial = {}): 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'); + }); +}); diff --git a/src/__tests__/loop-detector.test.ts b/src/__tests__/loop-detector.test.ts new file mode 100644 index 0000000..1e6732a --- /dev/null +++ b/src/__tests__/loop-detector.test.ts @@ -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); + }); + }); +}); diff --git a/src/__tests__/naming.test.ts b/src/__tests__/naming.test.ts new file mode 100644 index 0000000..d6bedbf --- /dev/null +++ b/src/__tests__/naming.test.ts @@ -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'); + }); +}); diff --git a/src/__tests__/reportDir.test.ts b/src/__tests__/reportDir.test.ts new file mode 100644 index 0000000..1163eaf --- /dev/null +++ b/src/__tests__/reportDir.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/rule-evaluator.test.ts b/src/__tests__/rule-evaluator.test.ts new file mode 100644 index 0000000..7cfd834 --- /dev/null +++ b/src/__tests__/rule-evaluator.test.ts @@ -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 { + 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 { + 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' }); + }); + }); +}); diff --git a/src/__tests__/rule-utils.test.ts b/src/__tests__/rule-utils.test.ts new file mode 100644 index 0000000..b690377 --- /dev/null +++ b/src/__tests__/rule-utils.test.ts @@ -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 { + 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']); + }); +}); diff --git a/src/__tests__/slug.test.ts b/src/__tests__/slug.test.ts new file mode 100644 index 0000000..fd9ef78 --- /dev/null +++ b/src/__tests__/slug.test.ts @@ -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'); + }); +}); diff --git a/src/__tests__/state-manager.test.ts b/src/__tests__/state-manager.test.ts new file mode 100644 index 0000000..3da87a9 --- /dev/null +++ b/src/__tests__/state-manager.test.ts @@ -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 { + return { + name: 'test-piece', + movements: [], + initialMovement: 'start', + maxIterations: 10, + ...overrides, + }; +} + +function makeOptions(overrides: Partial = {}): 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(); + }); + }); +}); diff --git a/src/__tests__/task-schema.test.ts b/src/__tests__/task-schema.test.ts new file mode 100644 index 0000000..c0971f2 --- /dev/null +++ b/src/__tests__/task-schema.test.ts @@ -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(); + }); + }); +}); diff --git a/src/__tests__/text.test.ts b/src/__tests__/text.test.ts new file mode 100644 index 0000000..92710ac --- /dev/null +++ b/src/__tests__/text.test.ts @@ -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'); + }); +}); diff --git a/src/__tests__/transitions.test.ts b/src/__tests__/transitions.test.ts index 50f7aa1..4bf3244 100644 --- a/src/__tests__/transitions.test.ts +++ b/src/__tests__/transitions.test.ts @@ -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('最初の質問'); + }); +});