Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
304 lines
9.6 KiB
JavaScript
304 lines
9.6 KiB
JavaScript
/**
|
|
* Dependency Graph Tests - Sprint 1 (ADR-026)
|
|
* Testing cycle detection and topological sorting for formula variables
|
|
*
|
|
* 🔴 RED → 🟢 GREEN → 🔵 REFACTOR
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach } from 'vitest';
|
|
import { DependencyGraph } from '../dependencyGraph.js';
|
|
|
|
// ============================================================
|
|
// TESTS
|
|
// ============================================================
|
|
|
|
describe('DependencyGraph (ADR-026)', () => {
|
|
let graph;
|
|
|
|
beforeEach(() => {
|
|
graph = new DependencyGraph();
|
|
});
|
|
|
|
// ============================================================
|
|
// 🔴 RED PHASE: addDependency
|
|
// ============================================================
|
|
|
|
describe('addDependency()', () => {
|
|
/**
|
|
* BEHAVIOR: When adding a dependency
|
|
* GIVEN two variable names
|
|
* THEN the dependency is recorded
|
|
*/
|
|
test('should add single dependency', () => {
|
|
graph.addDependency('$margin', '$revenue');
|
|
|
|
expect(graph.getDependencies('$margin')).toContain('$revenue');
|
|
});
|
|
|
|
test('should add multiple dependencies for same variable', () => {
|
|
graph.addDependency('$profit', '$revenue');
|
|
graph.addDependency('$profit', '$costs');
|
|
|
|
const deps = graph.getDependencies('$profit');
|
|
expect(deps).toContain('$revenue');
|
|
expect(deps).toContain('$costs');
|
|
});
|
|
|
|
test('should handle variables with no dependencies', () => {
|
|
graph.addNode('$constant');
|
|
|
|
expect(graph.getDependencies('$constant')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 🔴 RED PHASE: hasCycle
|
|
// ============================================================
|
|
|
|
describe('hasCycle()', () => {
|
|
/**
|
|
* BEHAVIOR: Detect circular dependencies
|
|
* GIVEN a graph with cycles
|
|
* THEN hasCycle returns true
|
|
*/
|
|
test('should return false for empty graph', () => {
|
|
expect(graph.hasCycle()).toBe(false);
|
|
});
|
|
|
|
test('should return false for graph without cycles', () => {
|
|
// $margin depends on $revenue and $costs
|
|
graph.addDependency('$margin', '$revenue');
|
|
graph.addDependency('$margin', '$costs');
|
|
// $profit depends on $margin
|
|
graph.addDependency('$profit', '$margin');
|
|
|
|
expect(graph.hasCycle()).toBe(false);
|
|
});
|
|
|
|
test('should detect direct cycle (A → B → A)', () => {
|
|
graph.addDependency('$a', '$b');
|
|
graph.addDependency('$b', '$a');
|
|
|
|
expect(graph.hasCycle()).toBe(true);
|
|
});
|
|
|
|
test('should detect indirect cycle (A → B → C → A)', () => {
|
|
graph.addDependency('$a', '$b');
|
|
graph.addDependency('$b', '$c');
|
|
graph.addDependency('$c', '$a');
|
|
|
|
expect(graph.hasCycle()).toBe(true);
|
|
});
|
|
|
|
test('should detect self-referencing cycle (A → A)', () => {
|
|
graph.addDependency('$recursive', '$recursive');
|
|
|
|
expect(graph.hasCycle()).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 🔴 RED PHASE: findCycle
|
|
// ============================================================
|
|
|
|
describe('findCycle()', () => {
|
|
/**
|
|
* BEHAVIOR: Find the cycle path
|
|
* GIVEN a graph with cycles
|
|
* THEN findCycle returns the cycle path
|
|
*/
|
|
test('should return null for graph without cycles', () => {
|
|
graph.addDependency('$a', '$b');
|
|
graph.addDependency('$b', '$c');
|
|
|
|
expect(graph.findCycle()).toBeNull();
|
|
});
|
|
|
|
test('should return cycle path', () => {
|
|
graph.addDependency('$a', '$b');
|
|
graph.addDependency('$b', '$a');
|
|
|
|
const cycle = graph.findCycle();
|
|
expect(cycle).not.toBeNull();
|
|
expect(Array.isArray(cycle)).toBe(true);
|
|
expect(cycle.length).toBeGreaterThan(1);
|
|
});
|
|
|
|
test('should return self-reference cycle', () => {
|
|
graph.addDependency('$x', '$x');
|
|
|
|
const cycle = graph.findCycle();
|
|
expect(cycle).not.toBeNull();
|
|
expect(cycle).toContain('$x');
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 🔴 RED PHASE: topologicalSort
|
|
// ============================================================
|
|
|
|
describe('topologicalSort()', () => {
|
|
/**
|
|
* BEHAVIOR: Sort variables by dependency order
|
|
* GIVEN a DAG (directed acyclic graph)
|
|
* THEN return variables in calculation order
|
|
*/
|
|
test('should return empty array for empty graph', () => {
|
|
const sorted = graph.topologicalSort();
|
|
expect(sorted).toEqual([]);
|
|
});
|
|
|
|
test('should sort single variable', () => {
|
|
graph.addNode('$constant');
|
|
|
|
const sorted = graph.topologicalSort();
|
|
expect(sorted).toEqual(['$constant']);
|
|
});
|
|
|
|
test('should sort linear dependencies correctly', () => {
|
|
// $c depends on $b, $b depends on $a
|
|
graph.addDependency('$c', '$b');
|
|
graph.addDependency('$b', '$a');
|
|
graph.addNode('$a'); // No dependencies
|
|
|
|
const sorted = graph.topologicalSort();
|
|
|
|
// $a should come before $b, $b before $c
|
|
const indexA = sorted.indexOf('$a');
|
|
const indexB = sorted.indexOf('$b');
|
|
const indexC = sorted.indexOf('$c');
|
|
|
|
expect(indexA).toBeLessThan(indexB);
|
|
expect(indexB).toBeLessThan(indexC);
|
|
});
|
|
|
|
test('should sort complex dependencies correctly', () => {
|
|
// $total = $revenue - $costs
|
|
// $margin = $total * 0.3
|
|
// $tax = $margin * $tax_rate
|
|
graph.addDependency('$total', '$revenue');
|
|
graph.addDependency('$total', '$costs');
|
|
graph.addDependency('$margin', '$total');
|
|
graph.addDependency('$tax', '$margin');
|
|
graph.addDependency('$tax', '$tax_rate');
|
|
graph.addNode('$revenue');
|
|
graph.addNode('$costs');
|
|
graph.addNode('$tax_rate');
|
|
|
|
const sorted = graph.topologicalSort();
|
|
|
|
// Check order constraints
|
|
expect(sorted.indexOf('$revenue')).toBeLessThan(sorted.indexOf('$total'));
|
|
expect(sorted.indexOf('$costs')).toBeLessThan(sorted.indexOf('$total'));
|
|
expect(sorted.indexOf('$total')).toBeLessThan(sorted.indexOf('$margin'));
|
|
expect(sorted.indexOf('$margin')).toBeLessThan(sorted.indexOf('$tax'));
|
|
});
|
|
|
|
test('should return null for cyclic graph', () => {
|
|
graph.addDependency('$a', '$b');
|
|
graph.addDependency('$b', '$a');
|
|
|
|
const sorted = graph.topologicalSort();
|
|
expect(sorted).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 🔴 RED PHASE: getCalculationStreams
|
|
// ============================================================
|
|
|
|
describe('getCalculationStreams()', () => {
|
|
/**
|
|
* BEHAVIOR: Group variables into parallel calculation streams
|
|
* GIVEN a DAG
|
|
* THEN return map of stream number to variable arrays
|
|
*/
|
|
test('should return empty map for empty graph', () => {
|
|
const streams = graph.getCalculationStreams();
|
|
expect(streams.size).toBe(0);
|
|
});
|
|
|
|
test('should put independent variables in stream 1', () => {
|
|
graph.addNode('$a');
|
|
graph.addNode('$b');
|
|
graph.addNode('$c');
|
|
|
|
const streams = graph.getCalculationStreams();
|
|
|
|
expect(streams.get(1)).toContain('$a');
|
|
expect(streams.get(1)).toContain('$b');
|
|
expect(streams.get(1)).toContain('$c');
|
|
});
|
|
|
|
test('should put dependent variables in later streams', () => {
|
|
// Stream 1: $revenue, $costs (no deps)
|
|
// Stream 2: $total (depends on stream 1)
|
|
// Stream 3: $margin (depends on stream 2)
|
|
graph.addNode('$revenue');
|
|
graph.addNode('$costs');
|
|
graph.addDependency('$total', '$revenue');
|
|
graph.addDependency('$total', '$costs');
|
|
graph.addDependency('$margin', '$total');
|
|
|
|
const streams = graph.getCalculationStreams();
|
|
|
|
expect(streams.get(1)).toContain('$revenue');
|
|
expect(streams.get(1)).toContain('$costs');
|
|
expect(streams.get(2)).toContain('$total');
|
|
expect(streams.get(3)).toContain('$margin');
|
|
});
|
|
|
|
test('should return null for cyclic graph', () => {
|
|
graph.addDependency('$a', '$b');
|
|
graph.addDependency('$b', '$a');
|
|
|
|
const streams = graph.getCalculationStreams();
|
|
expect(streams).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 🔴 RED PHASE: getAllNodes
|
|
// ============================================================
|
|
|
|
describe('getAllNodes()', () => {
|
|
test('should return all nodes in graph', () => {
|
|
graph.addNode('$a');
|
|
graph.addNode('$b');
|
|
graph.addDependency('$c', '$a');
|
|
|
|
const nodes = graph.getAllNodes();
|
|
|
|
expect(nodes).toContain('$a');
|
|
expect(nodes).toContain('$b');
|
|
expect(nodes).toContain('$c');
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 🔴 RED PHASE: getDependents
|
|
// ============================================================
|
|
|
|
describe('getDependents()', () => {
|
|
/**
|
|
* BEHAVIOR: Find variables that depend on this one
|
|
* GIVEN a variable name
|
|
* THEN return variables that depend on it (reverse deps)
|
|
*/
|
|
test('should return empty array for leaf node', () => {
|
|
graph.addDependency('$a', '$b');
|
|
|
|
const dependents = graph.getDependents('$a');
|
|
expect(dependents).toEqual([]);
|
|
});
|
|
|
|
test('should return dependents', () => {
|
|
graph.addDependency('$margin', '$revenue');
|
|
graph.addDependency('$profit', '$revenue');
|
|
|
|
const dependents = graph.getDependents('$revenue');
|
|
expect(dependents).toContain('$margin');
|
|
expect(dependents).toContain('$profit');
|
|
});
|
|
});
|
|
});
|