aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
448 lines (359 loc) • 15.1 kB
JavaScript
/**
* @fileoverview Unit tests for WaveAnalyzer
* @story WIS-4 - Wave Analysis Engine
*/
;
const {
WaveAnalyzer,
CircularDependencyError,
createWaveAnalyzer,
analyzeWaves,
DEFAULT_TASK_DURATIONS,
} = require('../engine/wave-analyzer');
describe('WaveAnalyzer', () => {
let analyzer;
beforeEach(() => {
analyzer = new WaveAnalyzer();
});
describe('constructor', () => {
it('should create instance with default options', () => {
const instance = new WaveAnalyzer();
expect(instance.taskDurations).toEqual(DEFAULT_TASK_DURATIONS);
});
it('should accept custom task durations', () => {
const customDurations = { 'custom-task': 25 };
const instance = new WaveAnalyzer({ taskDurations: customDurations });
expect(instance.taskDurations['custom-task']).toBe(25);
expect(instance.taskDurations['default']).toBe(DEFAULT_TASK_DURATIONS.default);
});
it('should accept custom registry', () => {
const mockRegistry = { getWorkflow: jest.fn() };
const instance = new WaveAnalyzer({ registry: mockRegistry });
expect(instance.registry).toBe(mockRegistry);
});
});
describe('buildDependencyGraph', () => {
it('should build graph from tasks with dependencies', () => {
const tasks = [
{ id: 'task-a', dependsOn: [] },
{ id: 'task-b', dependsOn: [] },
{ id: 'task-c', dependsOn: ['task-a', 'task-b'] },
{ id: 'task-d', dependsOn: ['task-c'] },
];
const graph = analyzer.buildDependencyGraph(tasks);
expect(graph.nodes.size).toBe(4);
expect(graph.nodes.has('task-a')).toBe(true);
expect(graph.nodes.has('task-b')).toBe(true);
expect(graph.nodes.has('task-c')).toBe(true);
expect(graph.nodes.has('task-d')).toBe(true);
// Check edges (a -> c, b -> c, c -> d)
expect(graph.edges.get('task-a').has('task-c')).toBe(true);
expect(graph.edges.get('task-b').has('task-c')).toBe(true);
expect(graph.edges.get('task-c').has('task-d')).toBe(true);
// Check in-edges
expect(graph.inEdges.get('task-a').size).toBe(0);
expect(graph.inEdges.get('task-b').size).toBe(0);
expect(graph.inEdges.get('task-c').size).toBe(2);
expect(graph.inEdges.get('task-d').size).toBe(1);
});
it('should handle tasks without dependencies', () => {
const tasks = [{ id: 'task-a' }, { id: 'task-b' }, { id: 'task-c' }];
const graph = analyzer.buildDependencyGraph(tasks);
expect(graph.nodes.size).toBe(3);
expect(graph.edges.get('task-a').size).toBe(0);
expect(graph.edges.get('task-b').size).toBe(0);
expect(graph.edges.get('task-c').size).toBe(0);
});
it('should use task name if id is missing', () => {
const tasks = [{ name: 'task-a' }, { name: 'task-b', dependsOn: ['task-a'] }];
const graph = analyzer.buildDependencyGraph(tasks);
expect(graph.nodes.has('task-a')).toBe(true);
expect(graph.nodes.has('task-b')).toBe(true);
expect(graph.edges.get('task-a').has('task-b')).toBe(true);
});
it('should ignore dependencies to non-existent nodes', () => {
const tasks = [{ id: 'task-a', dependsOn: ['non-existent'] }];
const graph = analyzer.buildDependencyGraph(tasks);
expect(graph.nodes.size).toBe(1);
expect(graph.inEdges.get('task-a').size).toBe(0);
});
});
describe('findCycle', () => {
it('should return null for acyclic graph', () => {
const tasks = [
{ id: 'a', dependsOn: [] },
{ id: 'b', dependsOn: ['a'] },
{ id: 'c', dependsOn: ['b'] },
];
const graph = analyzer.buildDependencyGraph(tasks);
const cycle = analyzer.findCycle(graph);
expect(cycle).toBeNull();
});
it('should detect simple cycle', () => {
const tasks = [
{ id: 'a', dependsOn: ['c'] },
{ id: 'b', dependsOn: ['a'] },
{ id: 'c', dependsOn: ['b'] },
];
const graph = analyzer.buildDependencyGraph(tasks);
const cycle = analyzer.findCycle(graph);
expect(cycle).not.toBeNull();
expect(cycle.length).toBeGreaterThan(1);
});
it('should detect self-loop', () => {
const tasks = [{ id: 'a', dependsOn: ['a'] }];
const graph = analyzer.buildDependencyGraph(tasks);
const cycle = analyzer.findCycle(graph);
expect(cycle).not.toBeNull();
expect(cycle).toContain('a');
});
it('should detect cycle in diamond pattern with back-edge', () => {
const tasks = [
{ id: 'a', dependsOn: ['d'] }, // Creates cycle: a -> b -> d -> a
{ id: 'b', dependsOn: ['a'] },
{ id: 'c', dependsOn: ['a'] },
{ id: 'd', dependsOn: ['b', 'c'] },
];
const graph = analyzer.buildDependencyGraph(tasks);
const cycle = analyzer.findCycle(graph);
expect(cycle).not.toBeNull();
});
});
describe('analyzeWaves with customTasks', () => {
it('should group independent tasks into same wave', () => {
const tasks = [
{ id: 'a', dependsOn: [], duration: 5 },
{ id: 'b', dependsOn: [], duration: 3 },
{ id: 'c', dependsOn: [], duration: 4 },
];
const result = analyzer.analyzeWaves('test-workflow', { customTasks: tasks });
expect(result.waves.length).toBe(1);
expect(result.waves[0].tasks).toEqual(expect.arrayContaining(['a', 'b', 'c']));
expect(result.waves[0].parallel).toBe(true);
});
it('should create sequential waves for linear dependencies', () => {
const tasks = [
{ id: 'a', dependsOn: [], duration: 5 },
{ id: 'b', dependsOn: ['a'], duration: 10 },
{ id: 'c', dependsOn: ['b'], duration: 3 },
{ id: 'd', dependsOn: ['c'], duration: 7 },
];
const result = analyzer.analyzeWaves('test-workflow', { customTasks: tasks });
expect(result.waves.length).toBe(4);
expect(result.waves[0].tasks).toEqual(['a']);
expect(result.waves[1].tasks).toEqual(['b']);
expect(result.waves[2].tasks).toEqual(['c']);
expect(result.waves[3].tasks).toEqual(['d']);
});
it('should handle diamond dependency pattern', () => {
const tasks = [
{ id: 'a', dependsOn: [], duration: 5 },
{ id: 'b', dependsOn: ['a'], duration: 10 },
{ id: 'c', dependsOn: ['a'], duration: 8 },
{ id: 'd', dependsOn: ['b', 'c'], duration: 5 },
];
const result = analyzer.analyzeWaves('test-workflow', { customTasks: tasks });
expect(result.waves.length).toBe(3);
expect(result.waves[0].tasks).toEqual(['a']);
expect(result.waves[1].tasks).toEqual(expect.arrayContaining(['b', 'c']));
expect(result.waves[1].parallel).toBe(true);
expect(result.waves[2].tasks).toEqual(['d']);
});
it('should handle mixed parallel and sequential', () => {
const tasks = [
{ id: 'a', dependsOn: [], duration: 5 },
{ id: 'b', dependsOn: [], duration: 3 },
{ id: 'c', dependsOn: ['a'], duration: 10 },
{ id: 'd', dependsOn: ['b'], duration: 8 },
{ id: 'e', dependsOn: ['c', 'd'], duration: 5 },
];
const result = analyzer.analyzeWaves('test-workflow', { customTasks: tasks });
expect(result.waves.length).toBe(3);
expect(result.waves[0].tasks).toEqual(expect.arrayContaining(['a', 'b']));
expect(result.waves[1].tasks).toEqual(expect.arrayContaining(['c', 'd']));
expect(result.waves[2].tasks).toEqual(['e']);
});
it('should calculate optimization gain', () => {
const tasks = [
{ id: 'a', dependsOn: [], duration: 10 },
{ id: 'b', dependsOn: [], duration: 10 },
{ id: 'c', dependsOn: ['a', 'b'], duration: 10 },
];
const result = analyzer.analyzeWaves('test-workflow', { customTasks: tasks });
// Sequential: 10 + 10 + 10 = 30min
// Parallel: 10 (wave 1: a,b parallel) + 10 (wave 2: c) = 20min
// Gain: (30 - 20) / 30 = 33%
expect(result.optimizationGain).toBe('33%');
});
it('should return empty result for empty tasks', () => {
const result = analyzer.analyzeWaves('test-workflow', { customTasks: [] });
expect(result.totalTasks).toBe(0);
expect(result.waves).toEqual([]);
expect(result.criticalPath).toEqual([]);
});
it('should handle single task workflow', () => {
const tasks = [{ id: 'a', dependsOn: [], duration: 5 }];
const result = analyzer.analyzeWaves('test-workflow', { customTasks: tasks });
expect(result.totalTasks).toBe(1);
expect(result.waves.length).toBe(1);
expect(result.waves[0].parallel).toBe(false);
});
it('should throw CircularDependencyError for cycles', () => {
const tasks = [
{ id: 'a', dependsOn: ['c'], duration: 5 },
{ id: 'b', dependsOn: ['a'], duration: 5 },
{ id: 'c', dependsOn: ['b'], duration: 5 },
];
expect(() => {
analyzer.analyzeWaves('test-workflow', { customTasks: tasks });
}).toThrow(CircularDependencyError);
});
});
describe('CircularDependencyError', () => {
it('should contain cycle information', () => {
const cycle = ['a', 'b', 'c', 'a'];
const error = new CircularDependencyError(cycle);
expect(error.name).toBe('CircularDependencyError');
expect(error.cycle).toEqual(cycle);
expect(error.message).toContain('a → b → c → a');
});
it('should provide resolution suggestion', () => {
const cycle = ['a', 'b', 'c', 'a'];
const error = new CircularDependencyError(cycle);
const suggestion = error.getSuggestion();
expect(suggestion).toBeTruthy();
expect(typeof suggestion).toBe('string');
});
});
describe('critical path calculation', () => {
it('should find longest path in graph', () => {
const tasks = [
{ id: 'a', dependsOn: [], duration: 5 },
{ id: 'b', dependsOn: ['a'], duration: 20 },
{ id: 'c', dependsOn: ['a'], duration: 3 },
{ id: 'd', dependsOn: ['b', 'c'], duration: 5 },
];
const result = analyzer.analyzeWaves('test-workflow', { customTasks: tasks });
// Critical path should be a -> b -> d (5 + 20 + 5 = 30)
// Not a -> c -> d (5 + 3 + 5 = 13)
expect(result.criticalPath).toEqual(expect.arrayContaining(['a', 'b', 'd']));
});
it('should return empty for empty workflow', () => {
const result = analyzer.analyzeWaves('test-workflow', { customTasks: [] });
expect(result.criticalPath).toEqual([]);
});
});
describe('getCurrentWave', () => {
it('should return current wave context for task', () => {
// Mock the analyzeWaves to return predictable result
const tasks = [
{ id: 'a', dependsOn: [], duration: 5 },
{ id: 'b', dependsOn: ['a'], duration: 10 },
{ id: 'c', dependsOn: ['b'], duration: 5 },
];
const context = analyzer.getCurrentWave('test-workflow', 'b');
// Since we can't easily mock, this tests the error handling path
expect(context.workflowId).toBe('test-workflow');
expect(context.currentTask).toBe('b');
});
});
describe('formatOutput', () => {
const mockAnalysis = {
workflowId: 'test',
totalTasks: 3,
waves: [
{ waveNumber: 1, tasks: ['a', 'b'], parallel: true, estimatedDuration: '5min' },
{ waveNumber: 2, tasks: ['c'], parallel: false, estimatedDuration: '10min' },
],
optimizationGain: '25%',
criticalPath: ['a', 'c'],
metrics: {
sequentialTime: '20min',
parallelTime: '15min',
},
};
it('should format as JSON when json option is true', () => {
const output = analyzer.formatOutput(mockAnalysis, { json: true });
expect(() => JSON.parse(output)).not.toThrow();
const parsed = JSON.parse(output);
expect(parsed.workflowId).toBe('test');
});
it('should include visual representation when visual option is true', () => {
const output = analyzer.formatOutput(mockAnalysis, { visual: true });
expect(output).toContain('Wave 1');
expect(output).toContain('──');
});
it('should include optimization metrics', () => {
const output = analyzer.formatOutput(mockAnalysis);
expect(output).toContain('25%');
expect(output).toContain('Critical Path');
});
});
describe('performance', () => {
it('should analyze small workflow in <10ms', () => {
const tasks = Array.from({ length: 5 }, (_, i) => ({
id: `task-${i}`,
dependsOn: i > 0 ? [`task-${i - 1}`] : [],
duration: 5,
}));
const start = Date.now();
const result = analyzer.analyzeWaves('perf-test', { customTasks: tasks });
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(10);
expect(result.metrics.analysisTime).toBeLessThan(10);
});
it('should analyze medium workflow in <30ms', () => {
const tasks = Array.from({ length: 20 }, (_, i) => ({
id: `task-${i}`,
dependsOn: i > 0 ? [`task-${Math.floor(i / 2)}`] : [],
duration: 5,
}));
const start = Date.now();
const result = analyzer.analyzeWaves('perf-test', { customTasks: tasks });
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(30);
});
it('should analyze large workflow in <50ms', () => {
const tasks = Array.from({ length: 50 }, (_, i) => ({
id: `task-${i}`,
dependsOn: i > 0 ? [`task-${Math.max(0, i - 3)}`] : [],
duration: 5,
}));
const start = Date.now();
const result = analyzer.analyzeWaves('perf-test', { customTasks: tasks });
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(50);
});
});
});
describe('Factory functions', () => {
describe('createWaveAnalyzer', () => {
it('should create WaveAnalyzer instance', () => {
const instance = createWaveAnalyzer();
expect(instance).toBeInstanceOf(WaveAnalyzer);
});
it('should pass options to constructor', () => {
const customDurations = { custom: 15 };
const instance = createWaveAnalyzer({ taskDurations: customDurations });
expect(instance.taskDurations.custom).toBe(15);
});
});
describe('analyzeWaves convenience function', () => {
it('should analyze waves without creating instance manually', () => {
const tasks = [
{ id: 'a', dependsOn: [], duration: 5 },
{ id: 'b', dependsOn: ['a'], duration: 10 },
];
const result = analyzeWaves('test', { customTasks: tasks });
expect(result.workflowId).toBe('test');
expect(result.waves.length).toBe(2);
});
});
});
describe('DEFAULT_TASK_DURATIONS', () => {
it('should have expected default durations', () => {
expect(DEFAULT_TASK_DURATIONS.implement).toBe(30);
expect(DEFAULT_TASK_DURATIONS['write-tests']).toBe(10);
expect(DEFAULT_TASK_DURATIONS.default).toBe(10);
});
});