794 lines
24 KiB
TypeScript
794 lines
24 KiB
TypeScript
/**
|
|
* Piece loader integration tests.
|
|
*
|
|
* Tests the 3-tier piece resolution (project-local → user → builtin)
|
|
* and YAML parsing including special rule syntax (ai(), all(), any()).
|
|
*
|
|
* Mocked: loadConfig (for language/builtins)
|
|
* Not mocked: loadPiece, parsePiece, rule parsing
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
// --- Mocks ---
|
|
const languageState = vi.hoisted(() => ({ value: 'en' as 'en' | 'ja' }));
|
|
|
|
vi.mock('../infra/config/global/globalConfig.js', () => ({
|
|
loadGlobalConfig: vi.fn().mockReturnValue({}),
|
|
}));
|
|
|
|
vi.mock('../infra/config/resolveConfigValue.js', () => ({
|
|
resolveConfigValue: vi.fn((_cwd: string, key: string) => {
|
|
if (key === 'language') return languageState.value;
|
|
if (key === 'enableBuiltinPieces') return true;
|
|
if (key === 'disabledBuiltins') return [];
|
|
return undefined;
|
|
}),
|
|
resolveConfigValues: vi.fn((_cwd: string, keys: readonly string[]) => {
|
|
const result: Record<string, unknown> = {};
|
|
for (const key of keys) {
|
|
if (key === 'language') result[key] = languageState.value;
|
|
if (key === 'enableBuiltinPieces') result[key] = true;
|
|
if (key === 'disabledBuiltins') result[key] = [];
|
|
}
|
|
return result;
|
|
}),
|
|
}));
|
|
|
|
// --- Imports (after mocks) ---
|
|
|
|
import { loadPiece } from '../infra/config/index.js';
|
|
import { listBuiltinPieceNames } from '../infra/config/loaders/pieceResolver.js';
|
|
|
|
// --- Test helpers ---
|
|
|
|
function createTestDir(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'takt-it-wfl-'));
|
|
mkdirSync(join(dir, '.takt'), { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
describe('Piece Loader IT: builtin piece loading', () => {
|
|
let testDir: string;
|
|
const builtinNames = listBuiltinPieceNames(process.cwd(), { includeDisabled: true });
|
|
|
|
beforeEach(() => {
|
|
testDir = createTestDir();
|
|
languageState.value = 'en';
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
for (const name of builtinNames) {
|
|
it(`should load builtin piece: ${name}`, () => {
|
|
const config = loadPiece(name, testDir);
|
|
|
|
expect(config).not.toBeNull();
|
|
expect(config!.name).toBe(name);
|
|
expect(config!.movements.length).toBeGreaterThan(0);
|
|
expect(config!.initialMovement).toBeDefined();
|
|
expect(config!.maxMovements).toBeGreaterThan(0);
|
|
});
|
|
}
|
|
|
|
it('should return null for non-existent piece', () => {
|
|
const config = loadPiece('non-existent-piece-xyz', testDir);
|
|
expect(config).toBeNull();
|
|
});
|
|
|
|
it('should include and load e2e-test as a builtin piece', () => {
|
|
expect(builtinNames).toContain('e2e-test');
|
|
|
|
const config = loadPiece('e2e-test', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
const planMovement = config!.movements.find((movement) => movement.name === 'plan_test');
|
|
const implementMovement = config!.movements.find((movement) => movement.name === 'implement_test');
|
|
|
|
expect(planMovement).toBeDefined();
|
|
expect(implementMovement).toBeDefined();
|
|
expect(planMovement!.instructionTemplate).toContain('missing E2E tests');
|
|
expect(implementMovement!.instructionTemplate).toContain('npm run test:e2e:mock');
|
|
});
|
|
|
|
it('should load e2e-test as a builtin piece in ja locale', () => {
|
|
languageState.value = 'ja';
|
|
|
|
const jaBuiltinNames = listBuiltinPieceNames(testDir, { includeDisabled: true });
|
|
expect(jaBuiltinNames).toContain('e2e-test');
|
|
|
|
const config = loadPiece('e2e-test', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
const planMovement = config!.movements.find((movement) => movement.name === 'plan_test');
|
|
const implementMovement = config!.movements.find((movement) => movement.name === 'implement_test');
|
|
|
|
expect(planMovement).toBeDefined();
|
|
expect(implementMovement).toBeDefined();
|
|
expect(planMovement!.instructionTemplate).toContain('E2Eテスト');
|
|
expect(implementMovement!.instructionTemplate).toContain('npm run test:e2e:mock');
|
|
});
|
|
});
|
|
|
|
describe('Piece Loader IT: project-local piece override', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = createTestDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should load project-local piece from .takt/pieces/', () => {
|
|
const piecesDir = join(testDir, '.takt', 'pieces');
|
|
mkdirSync(piecesDir, { recursive: true });
|
|
|
|
const agentsDir = join(testDir, 'agents');
|
|
mkdirSync(agentsDir, { recursive: true });
|
|
writeFileSync(join(agentsDir, 'custom.md'), 'Custom agent');
|
|
|
|
writeFileSync(join(piecesDir, 'custom-wf.yaml'), `
|
|
name: custom-wf
|
|
description: Custom project piece
|
|
max_movements: 5
|
|
initial_movement: start
|
|
|
|
movements:
|
|
- name: start
|
|
persona: ./agents/custom.md
|
|
rules:
|
|
- condition: Done
|
|
next: COMPLETE
|
|
instruction: "Do the work"
|
|
`);
|
|
|
|
const config = loadPiece('custom-wf', testDir);
|
|
|
|
expect(config).not.toBeNull();
|
|
expect(config!.name).toBe('custom-wf');
|
|
expect(config!.movements.length).toBe(1);
|
|
expect(config!.movements[0]!.name).toBe('start');
|
|
});
|
|
});
|
|
|
|
describe('Piece Loader IT: agent path resolution', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = createTestDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should resolve relative agent paths from piece YAML location', () => {
|
|
const config = loadPiece('default-mini', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
for (const movement of config!.movements) {
|
|
if (movement.personaPath) {
|
|
// Agent paths should be resolved to absolute paths
|
|
expect(movement.personaPath).toMatch(/^\//);
|
|
// Agent files should exist
|
|
expect(existsSync(movement.personaPath)).toBe(true);
|
|
}
|
|
if (movement.parallel) {
|
|
for (const sub of movement.parallel) {
|
|
if (sub.personaPath) {
|
|
expect(sub.personaPath).toMatch(/^\//);
|
|
expect(existsSync(sub.personaPath)).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Piece Loader IT: rule syntax parsing', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = createTestDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should parse all() aggregate conditions from default piece', () => {
|
|
const config = loadPiece('default', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
// Find the parallel reviewers movement
|
|
const reviewersStep = config!.movements.find(
|
|
(s) => s.parallel && s.parallel.length > 0,
|
|
);
|
|
expect(reviewersStep).toBeDefined();
|
|
|
|
// Should have aggregate rules
|
|
const allRule = reviewersStep!.rules?.find(
|
|
(r) => r.isAggregateCondition && r.aggregateType === 'all',
|
|
);
|
|
expect(allRule).toBeDefined();
|
|
expect(allRule!.aggregateConditionText).toBe('approved');
|
|
});
|
|
|
|
it('should parse any() aggregate conditions from default piece', () => {
|
|
const config = loadPiece('default', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
const reviewersStep = config!.movements.find(
|
|
(s) => s.parallel && s.parallel.length > 0,
|
|
);
|
|
|
|
const anyRule = reviewersStep!.rules?.find(
|
|
(r) => r.isAggregateCondition && r.aggregateType === 'any',
|
|
);
|
|
expect(anyRule).toBeDefined();
|
|
expect(anyRule!.aggregateConditionText).toBe('needs_fix');
|
|
});
|
|
|
|
it('should parse standard rules with next movement', () => {
|
|
const config = loadPiece('default-mini', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
const implementStep = config!.movements.find((s) => s.name === 'implement');
|
|
expect(implementStep).toBeDefined();
|
|
expect(implementStep!.rules).toBeDefined();
|
|
expect(implementStep!.rules!.length).toBeGreaterThan(0);
|
|
|
|
// Each rule should have condition and next
|
|
for (const rule of implementStep!.rules!) {
|
|
expect(typeof rule.condition).toBe('string');
|
|
expect(rule.condition.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Piece Loader IT: piece config validation', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = createTestDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should set max_movements from YAML', () => {
|
|
const config = loadPiece('default-mini', testDir);
|
|
expect(config).not.toBeNull();
|
|
expect(typeof config!.maxMovements).toBe('number');
|
|
expect(config!.maxMovements).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should set initial_movement from YAML', () => {
|
|
const config = loadPiece('default-mini', testDir);
|
|
expect(config).not.toBeNull();
|
|
expect(typeof config!.initialMovement).toBe('string');
|
|
|
|
// initial_movement should reference an existing movement
|
|
const movementNames = config!.movements.map((s) => s.name);
|
|
expect(movementNames).toContain(config!.initialMovement);
|
|
});
|
|
|
|
it('should preserve edit property on movements (review-only has no edit: true)', () => {
|
|
const config = loadPiece('review-only', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
// review-only: no movement should have edit: true
|
|
for (const movement of config!.movements) {
|
|
expect(movement.edit).not.toBe(true);
|
|
if (movement.parallel) {
|
|
for (const sub of movement.parallel) {
|
|
expect(sub.edit).not.toBe(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// expert: implement movement should have edit: true
|
|
const expertConfig = loadPiece('expert', testDir);
|
|
expect(expertConfig).not.toBeNull();
|
|
const implementStep = expertConfig!.movements.find((s) => s.name === 'implement');
|
|
expect(implementStep).toBeDefined();
|
|
expect(implementStep!.edit).toBe(true);
|
|
});
|
|
|
|
it('should set passPreviousResponse from YAML', () => {
|
|
const config = loadPiece('default-mini', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
// At least some movements should have passPreviousResponse set
|
|
const movementsWithPassPrev = config!.movements.filter((s) => s.passPreviousResponse === true);
|
|
expect(movementsWithPassPrev.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('Piece Loader IT: parallel movement loading', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = createTestDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should load parallel sub-movements from default piece', () => {
|
|
const config = loadPiece('default', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
const parallelStep = config!.movements.find(
|
|
(s) => s.parallel && s.parallel.length > 0,
|
|
);
|
|
expect(parallelStep).toBeDefined();
|
|
expect(parallelStep!.parallel!.length).toBeGreaterThanOrEqual(2);
|
|
|
|
// Each sub-movement should have required fields
|
|
for (const sub of parallelStep!.parallel!) {
|
|
expect(sub.name).toBeDefined();
|
|
expect(sub.persona).toBeDefined();
|
|
expect(sub.rules).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('should load 4 parallel reviewers from expert piece', () => {
|
|
const config = loadPiece('expert', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
const parallelStep = config!.movements.find(
|
|
(s) => s.parallel && s.parallel.length === 4,
|
|
);
|
|
expect(parallelStep).toBeDefined();
|
|
|
|
const subNames = parallelStep!.parallel!.map((s) => s.name);
|
|
expect(subNames).toContain('arch-review');
|
|
expect(subNames).toContain('frontend-review');
|
|
expect(subNames).toContain('security-review');
|
|
expect(subNames).toContain('qa-review');
|
|
});
|
|
});
|
|
|
|
describe('Piece Loader IT: report config loading', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = createTestDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should load single report config', () => {
|
|
const config = loadPiece('default', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
// default piece: plan movement has output contracts
|
|
const planStep = config!.movements.find((s) => s.name === 'plan');
|
|
expect(planStep).toBeDefined();
|
|
expect(planStep!.outputContracts).toBeDefined();
|
|
});
|
|
|
|
it('should load multi-report config from expert piece', () => {
|
|
const config = loadPiece('expert', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
// implement movement has multi-output contracts: [Scope, Decisions]
|
|
const implementStep = config!.movements.find((s) => s.name === 'implement');
|
|
expect(implementStep).toBeDefined();
|
|
expect(implementStep!.outputContracts).toBeDefined();
|
|
expect(Array.isArray(implementStep!.outputContracts)).toBe(true);
|
|
expect((implementStep!.outputContracts as unknown[]).length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('Piece Loader IT: quality_gates loading', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = createTestDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should parse quality_gates from YAML', () => {
|
|
const piecesDir = join(testDir, '.takt', 'pieces');
|
|
mkdirSync(piecesDir, { recursive: true });
|
|
|
|
writeFileSync(join(piecesDir, 'with-gates.yaml'), `
|
|
name: with-gates
|
|
description: Piece with quality gates
|
|
max_movements: 5
|
|
initial_movement: implement
|
|
|
|
movements:
|
|
- name: implement
|
|
persona: coder
|
|
edit: true
|
|
quality_gates:
|
|
- "All tests must pass"
|
|
- "No TypeScript errors"
|
|
- "Coverage must be above 80%"
|
|
rules:
|
|
- condition: Done
|
|
next: COMPLETE
|
|
instruction: "Implement the feature"
|
|
`);
|
|
|
|
const config = loadPiece('with-gates', testDir);
|
|
|
|
expect(config).not.toBeNull();
|
|
const implementStep = config!.movements.find((s) => s.name === 'implement');
|
|
expect(implementStep).toBeDefined();
|
|
expect(implementStep!.qualityGates).toBeDefined();
|
|
expect(implementStep!.qualityGates).toEqual([
|
|
'All tests must pass',
|
|
'No TypeScript errors',
|
|
'Coverage must be above 80%',
|
|
]);
|
|
});
|
|
|
|
it('should allow movement without quality_gates', () => {
|
|
const piecesDir = join(testDir, '.takt', 'pieces');
|
|
mkdirSync(piecesDir, { recursive: true });
|
|
|
|
writeFileSync(join(piecesDir, 'no-gates.yaml'), `
|
|
name: no-gates
|
|
description: Piece without quality gates
|
|
max_movements: 5
|
|
initial_movement: implement
|
|
|
|
movements:
|
|
- name: implement
|
|
persona: coder
|
|
rules:
|
|
- condition: Done
|
|
next: COMPLETE
|
|
instruction: "Implement the feature"
|
|
`);
|
|
|
|
const config = loadPiece('no-gates', testDir);
|
|
|
|
expect(config).not.toBeNull();
|
|
const implementStep = config!.movements.find((s) => s.name === 'implement');
|
|
expect(implementStep).toBeDefined();
|
|
expect(implementStep!.qualityGates).toBeUndefined();
|
|
});
|
|
|
|
it('should allow empty quality_gates array', () => {
|
|
const piecesDir = join(testDir, '.takt', 'pieces');
|
|
mkdirSync(piecesDir, { recursive: true });
|
|
|
|
writeFileSync(join(piecesDir, 'empty-gates.yaml'), `
|
|
name: empty-gates
|
|
description: Piece with empty quality gates
|
|
max_movements: 5
|
|
initial_movement: implement
|
|
|
|
movements:
|
|
- name: implement
|
|
persona: coder
|
|
quality_gates: []
|
|
rules:
|
|
- condition: Done
|
|
next: COMPLETE
|
|
instruction: "Implement the feature"
|
|
`);
|
|
|
|
const config = loadPiece('empty-gates', testDir);
|
|
|
|
expect(config).not.toBeNull();
|
|
const implementStep = config!.movements.find((s) => s.name === 'implement');
|
|
expect(implementStep).toBeDefined();
|
|
expect(implementStep!.qualityGates).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('Piece Loader IT: mcp_servers parsing', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = createTestDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should parse mcp_servers from YAML to PieceMovement.mcpServers', () => {
|
|
const piecesDir = join(testDir, '.takt', 'pieces');
|
|
mkdirSync(piecesDir, { recursive: true });
|
|
|
|
writeFileSync(join(piecesDir, 'with-mcp.yaml'), `
|
|
name: with-mcp
|
|
description: Piece with MCP servers
|
|
max_movements: 5
|
|
initial_movement: e2e-test
|
|
|
|
movements:
|
|
- name: e2e-test
|
|
persona: coder
|
|
mcp_servers:
|
|
playwright:
|
|
command: npx
|
|
args: ["-y", "@anthropic-ai/mcp-server-playwright"]
|
|
allowed_tools:
|
|
- Read
|
|
- Bash
|
|
- mcp__playwright__*
|
|
rules:
|
|
- condition: Done
|
|
next: COMPLETE
|
|
instruction: "Run E2E tests"
|
|
`);
|
|
|
|
const config = loadPiece('with-mcp', testDir);
|
|
|
|
expect(config).not.toBeNull();
|
|
const e2eStep = config!.movements.find((s) => s.name === 'e2e-test');
|
|
expect(e2eStep).toBeDefined();
|
|
expect(e2eStep!.mcpServers).toEqual({
|
|
playwright: {
|
|
command: 'npx',
|
|
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should allow movement without mcp_servers', () => {
|
|
const piecesDir = join(testDir, '.takt', 'pieces');
|
|
mkdirSync(piecesDir, { recursive: true });
|
|
|
|
writeFileSync(join(piecesDir, 'no-mcp.yaml'), `
|
|
name: no-mcp
|
|
description: Piece without MCP servers
|
|
max_movements: 5
|
|
initial_movement: implement
|
|
|
|
movements:
|
|
- name: implement
|
|
persona: coder
|
|
rules:
|
|
- condition: Done
|
|
next: COMPLETE
|
|
instruction: "Implement the feature"
|
|
`);
|
|
|
|
const config = loadPiece('no-mcp', testDir);
|
|
|
|
expect(config).not.toBeNull();
|
|
const implementStep = config!.movements.find((s) => s.name === 'implement');
|
|
expect(implementStep).toBeDefined();
|
|
expect(implementStep!.mcpServers).toBeUndefined();
|
|
});
|
|
|
|
it('should parse mcp_servers with multiple servers and transports', () => {
|
|
const piecesDir = join(testDir, '.takt', 'pieces');
|
|
mkdirSync(piecesDir, { recursive: true });
|
|
|
|
writeFileSync(join(piecesDir, 'multi-mcp.yaml'), `
|
|
name: multi-mcp
|
|
description: Piece with multiple MCP servers
|
|
max_movements: 5
|
|
initial_movement: test
|
|
|
|
movements:
|
|
- name: test
|
|
persona: coder
|
|
mcp_servers:
|
|
playwright:
|
|
command: npx
|
|
args: ["-y", "@anthropic-ai/mcp-server-playwright"]
|
|
remote-api:
|
|
type: http
|
|
url: http://localhost:3000/mcp
|
|
headers:
|
|
Authorization: "Bearer token123"
|
|
rules:
|
|
- condition: Done
|
|
next: COMPLETE
|
|
instruction: "Run tests"
|
|
`);
|
|
|
|
const config = loadPiece('multi-mcp', testDir);
|
|
|
|
expect(config).not.toBeNull();
|
|
const testStep = config!.movements.find((s) => s.name === 'test');
|
|
expect(testStep).toBeDefined();
|
|
expect(testStep!.mcpServers).toEqual({
|
|
playwright: {
|
|
command: 'npx',
|
|
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
|
|
},
|
|
'remote-api': {
|
|
type: 'http',
|
|
url: 'http://localhost:3000/mcp',
|
|
headers: { Authorization: 'Bearer token123' },
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Piece Loader IT: structural-reform piece', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = createTestDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should load structural-reform with 7 movements', () => {
|
|
const config = loadPiece('structural-reform', testDir);
|
|
|
|
expect(config).not.toBeNull();
|
|
expect(config!.name).toBe('structural-reform');
|
|
expect(config!.movements.length).toBe(7);
|
|
expect(config!.maxMovements).toBe(50);
|
|
expect(config!.initialMovement).toBe('review');
|
|
});
|
|
|
|
it('should have expected movement names in order', () => {
|
|
const config = loadPiece('structural-reform', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
const movementNames = config!.movements.map((m) => m.name);
|
|
expect(movementNames).toEqual([
|
|
'review',
|
|
'plan_reform',
|
|
'implement',
|
|
'reviewers',
|
|
'fix',
|
|
'verify',
|
|
'next_target',
|
|
]);
|
|
});
|
|
|
|
it('should have review as read-only with instruction_template', () => {
|
|
const config = loadPiece('structural-reform', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
const review = config!.movements.find((m) => m.name === 'review');
|
|
expect(review).toBeDefined();
|
|
expect(review!.edit).not.toBe(true);
|
|
expect(review!.instructionTemplate).toBeDefined();
|
|
expect(review!.instructionTemplate).toContain('{task}');
|
|
});
|
|
|
|
it('should have implement with edit: true and session: refresh', () => {
|
|
const config = loadPiece('structural-reform', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
const implement = config!.movements.find((m) => m.name === 'implement');
|
|
expect(implement).toBeDefined();
|
|
expect(implement!.edit).toBe(true);
|
|
expect(implement!.session).toBe('refresh');
|
|
});
|
|
|
|
it('should have 2 parallel reviewers (arch-review and qa-review)', () => {
|
|
const config = loadPiece('structural-reform', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
const reviewers = config!.movements.find(
|
|
(m) => m.parallel && m.parallel.length > 0,
|
|
);
|
|
expect(reviewers).toBeDefined();
|
|
expect(reviewers!.parallel!.length).toBe(2);
|
|
|
|
const subNames = reviewers!.parallel!.map((s) => s.name);
|
|
expect(subNames).toContain('arch-review');
|
|
expect(subNames).toContain('qa-review');
|
|
});
|
|
|
|
it('should have aggregate rules on reviewers movement', () => {
|
|
const config = loadPiece('structural-reform', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
const reviewers = config!.movements.find(
|
|
(m) => m.parallel && m.parallel.length > 0,
|
|
);
|
|
expect(reviewers).toBeDefined();
|
|
|
|
const allRule = reviewers!.rules?.find(
|
|
(r) => r.isAggregateCondition && r.aggregateType === 'all',
|
|
);
|
|
expect(allRule).toBeDefined();
|
|
expect(allRule!.aggregateConditionText).toBe('approved');
|
|
expect(allRule!.next).toBe('verify');
|
|
|
|
const anyRule = reviewers!.rules?.find(
|
|
(r) => r.isAggregateCondition && r.aggregateType === 'any',
|
|
);
|
|
expect(anyRule).toBeDefined();
|
|
expect(anyRule!.aggregateConditionText).toBe('needs_fix');
|
|
expect(anyRule!.next).toBe('fix');
|
|
});
|
|
|
|
it('should have verify movement with instruction_template', () => {
|
|
const config = loadPiece('structural-reform', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
const verify = config!.movements.find((m) => m.name === 'verify');
|
|
expect(verify).toBeDefined();
|
|
expect(verify!.edit).not.toBe(true);
|
|
expect(verify!.instructionTemplate).toBeDefined();
|
|
});
|
|
|
|
it('should have next_target movement routing to implement or COMPLETE', () => {
|
|
const config = loadPiece('structural-reform', testDir);
|
|
expect(config).not.toBeNull();
|
|
|
|
const nextTarget = config!.movements.find((m) => m.name === 'next_target');
|
|
expect(nextTarget).toBeDefined();
|
|
expect(nextTarget!.edit).not.toBe(true);
|
|
|
|
const nextValues = nextTarget!.rules?.map((r) => r.next);
|
|
expect(nextValues).toContain('implement');
|
|
expect(nextValues).toContain('COMPLETE');
|
|
});
|
|
|
|
it('should have loop_monitors for implement-fix cycle', () => {
|
|
const config = loadPiece('structural-reform', testDir);
|
|
expect(config).not.toBeNull();
|
|
expect(config!.loopMonitors).toBeDefined();
|
|
expect(config!.loopMonitors!.length).toBe(1);
|
|
|
|
const monitor = config!.loopMonitors![0]!;
|
|
expect(monitor.cycle).toEqual(['implement', 'fix']);
|
|
expect(monitor.threshold).toBe(3);
|
|
expect(monitor.judge).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('Piece Loader IT: invalid YAML handling', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = createTestDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should throw for piece file with invalid YAML', () => {
|
|
const piecesDir = join(testDir, '.takt', 'pieces');
|
|
mkdirSync(piecesDir, { recursive: true });
|
|
|
|
writeFileSync(join(piecesDir, 'broken.yaml'), `
|
|
name: broken
|
|
this is not: valid yaml: [[[[
|
|
- bad: {
|
|
`);
|
|
|
|
expect(() => loadPiece('broken', testDir)).toThrow();
|
|
});
|
|
|
|
it('should throw for piece missing required fields', () => {
|
|
const piecesDir = join(testDir, '.takt', 'pieces');
|
|
mkdirSync(piecesDir, { recursive: true });
|
|
|
|
writeFileSync(join(piecesDir, 'incomplete.yaml'), `
|
|
name: incomplete
|
|
description: Missing movements
|
|
`);
|
|
|
|
expect(() => loadPiece('incomplete', testDir)).toThrow();
|
|
});
|
|
});
|