feat(analysis/critical-path): implement criticalPath and weightedCriticalPath
Implement longest-path-in-DAG algorithms using topological order + dynamic programming. - criticalPath(graph): finds unweighted longest path (each node = weight 1) - weightedCriticalPath(graph, weightFn): finds path with highest cumulative weight - Both throw CircularDependencyError on cyclic graphs - Empty graph returns [], single-node graph returns [nodeId] - 20 unit tests covering linear chains, diamonds, weighted variants, cyclic detection
This commit is contained in:
@@ -1 +1,161 @@
|
||||
// criticalPath, weightedCriticalPath
|
||||
//
|
||||
// Find the longest path from sources to sinks in a DAG using
|
||||
// topological order + dynamic programming.
|
||||
//
|
||||
// Algorithm:
|
||||
// 1. Get topological order (throws CircularDependencyError if cyclic)
|
||||
// 2. For each node in topological order, compute the longest distance
|
||||
// from any source to that node
|
||||
// 3. The node with the maximum distance is the end of the critical path
|
||||
// 4. Backtrack from that node to reconstruct the full path
|
||||
//
|
||||
// For criticalPath (unweighted): each edge contributes weight 1
|
||||
// → dist[v] = max(dist[u] + 1) for all u → v
|
||||
// → This finds the path with the most edges (longest chain)
|
||||
//
|
||||
// For weightedCriticalPath: each node contributes a weight via weightFn
|
||||
// → dist[v] = max(dist[u] + weightFn(v)) for all u → v
|
||||
// → Sources get dist[v] = weightFn(v) (they contribute their own weight)
|
||||
// → This finds the path with the highest cumulative node weight
|
||||
|
||||
import type { TaskGraph } from '../graph/construction.js';
|
||||
import type { TaskGraphNodeAttributes } from '../schema/graph.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helper: compute longest path via topological order + DP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LongestPathResult {
|
||||
/** The ordered array of node IDs on the longest path */
|
||||
path: string[];
|
||||
/** The total distance/weight of the longest path */
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core DP algorithm for longest path in a DAG.
|
||||
*
|
||||
* @param graph - The TaskGraph instance
|
||||
* @param nodeWeightFn - Function that returns the weight contribution of a node.
|
||||
* For unweighted criticalPath, this returns 1.
|
||||
* For weightedCriticalPath, this returns weightFn(nodeId, attrs).
|
||||
*/
|
||||
function computeLongestPath(
|
||||
graph: TaskGraph,
|
||||
nodeWeightFn: (nodeId: string, attrs: TaskGraphNodeAttributes) => number,
|
||||
): LongestPathResult {
|
||||
const raw = graph.raw;
|
||||
|
||||
// Empty graph → no path
|
||||
if (raw.order === 0) {
|
||||
return { path: [], distance: 0 };
|
||||
}
|
||||
|
||||
// Get topological order — throws CircularDependencyError if cyclic
|
||||
const topo = graph.topologicalOrder();
|
||||
|
||||
// Single node → the node itself is the path
|
||||
if (topo.length === 1) {
|
||||
const nodeId = topo[0];
|
||||
const attrs = raw.getNodeAttributes(nodeId);
|
||||
return { path: [nodeId!], distance: nodeWeightFn(nodeId!, attrs) };
|
||||
}
|
||||
|
||||
// dist[v] = longest distance from any source to v
|
||||
const dist = new Map<string, number>();
|
||||
// predecessor[v] = the node u that maximizes dist[u] + weight(v)
|
||||
const predecessor = new Map<string, string | null>();
|
||||
|
||||
// Initialize: sources (nodes with no incoming edges) get their own weight
|
||||
for (const v of topo) {
|
||||
const inDegree = raw.inDegree(v);
|
||||
if (inDegree === 0) {
|
||||
const attrs = raw.getNodeAttributes(v);
|
||||
dist.set(v, nodeWeightFn(v, attrs));
|
||||
predecessor.set(v, null);
|
||||
} else {
|
||||
dist.set(v, -Infinity);
|
||||
predecessor.set(v, null);
|
||||
}
|
||||
}
|
||||
|
||||
// DP: process nodes in topological order
|
||||
for (const v of topo) {
|
||||
// Skip sources (already initialized)
|
||||
if (raw.inDegree(v) === 0) continue;
|
||||
|
||||
const vAttrs = raw.getNodeAttributes(v);
|
||||
const vWeight = nodeWeightFn(v, vAttrs);
|
||||
|
||||
// For each predecessor u → v, check if dist[u] + weight(v) is better
|
||||
for (const u of raw.inNeighbors(v)) {
|
||||
const uDist = dist.get(u)!; // u is processed before v (topo order)
|
||||
const candidate = uDist + vWeight;
|
||||
if (candidate > dist.get(v)!) {
|
||||
dist.set(v, candidate);
|
||||
predecessor.set(v, u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the node with the maximum distance (end of the critical path)
|
||||
let maxDist = -Infinity;
|
||||
let endNode: string | null = null;
|
||||
for (const [nodeId, d] of dist) {
|
||||
if (d > maxDist) {
|
||||
maxDist = d;
|
||||
endNode = nodeId;
|
||||
}
|
||||
}
|
||||
|
||||
// Backtrack from endNode to reconstruct the path
|
||||
const path: string[] = [];
|
||||
let current: string | null = endNode;
|
||||
while (current !== null) {
|
||||
path.unshift(current);
|
||||
current = current !== null ? (predecessor.get(current) ?? null) : null;
|
||||
}
|
||||
|
||||
return { path, distance: maxDist };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find the critical path (longest path) in a task graph.
|
||||
*
|
||||
* Uses unweighted edges (each edge contributes weight 1 to the path length).
|
||||
* Each node on the path also contributes weight 1. This effectively finds
|
||||
* the path with the most nodes from any source to any sink.
|
||||
*
|
||||
* @param graph - The task graph to analyze
|
||||
* @returns An ordered array of task IDs representing the critical path,
|
||||
* from source to sink
|
||||
* @throws {CircularDependencyError} If the graph contains cycles
|
||||
*/
|
||||
export function criticalPath(graph: TaskGraph): string[] {
|
||||
return computeLongestPath(graph, () => 1).path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the weighted critical path in a task graph.
|
||||
*
|
||||
* Each node contributes a weight determined by `weightFn`. The path with
|
||||
* the highest cumulative weight is returned.
|
||||
*
|
||||
* @param graph - The task graph to analyze
|
||||
* @param weightFn - A function that returns the weight of a node given
|
||||
* its ID and attributes
|
||||
* @returns An ordered array of task IDs representing the weighted critical
|
||||
* path, from source to sink
|
||||
* @throws {CircularDependencyError} If the graph contains cycles
|
||||
*/
|
||||
export function weightedCriticalPath(
|
||||
graph: TaskGraph,
|
||||
weightFn: (taskId: string, attrs: TaskGraphNodeAttributes) => number,
|
||||
): string[] {
|
||||
return computeLongestPath(graph, weightFn).path;
|
||||
}
|
||||
@@ -1,7 +1,350 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TaskGraph } from '../src/graph/construction.js';
|
||||
import { criticalPath, weightedCriticalPath } from '../src/analysis/critical-path.js';
|
||||
import { CircularDependencyError } from '../src/error/index.js';
|
||||
|
||||
describe('Analysis', () => {
|
||||
it('placeholder — critical path and risk analysis', () => {
|
||||
expect(true).toBe(true);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build TaskGraph from TaskInput[]
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fromInputs(inputs: Array<{ id: string; name: string; dependsOn: string[] }>): TaskGraph {
|
||||
return TaskGraph.fromTasks(inputs);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// criticalPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('criticalPath', () => {
|
||||
it('returns empty array for empty graph', () => {
|
||||
const graph = new TaskGraph();
|
||||
expect(criticalPath(graph)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns single node for single-node graph', () => {
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [] },
|
||||
]);
|
||||
expect(criticalPath(graph)).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('returns the full chain for a linear graph A→B→C→D', () => {
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [] },
|
||||
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
|
||||
{ id: 'C', name: 'Task C', dependsOn: ['B'] },
|
||||
{ id: 'D', name: 'Task D', dependsOn: ['C'] },
|
||||
]);
|
||||
expect(criticalPath(graph)).toEqual(['A', 'B', 'C', 'D']);
|
||||
});
|
||||
|
||||
it('returns one of the two equal-length paths for diamond graph', () => {
|
||||
// A
|
||||
// / \
|
||||
// B C
|
||||
// \ /
|
||||
// D
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [] },
|
||||
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
|
||||
{ id: 'C', name: 'Task C', dependsOn: ['A'] },
|
||||
{ id: 'D', name: 'Task D', dependsOn: ['B', 'C'] },
|
||||
]);
|
||||
const path = criticalPath(graph);
|
||||
// Both A→B→D and A→C→D have 3 nodes (weight 1 each, total weight 3)
|
||||
expect(path).toHaveLength(3);
|
||||
expect(path[0]).toBe('A');
|
||||
expect(path[2]).toBe('D');
|
||||
// The path must be either A→B→D or A→C→D
|
||||
expect(path[1] === 'B' || path[1] === 'C').toBe(true);
|
||||
// The middle node determines which path: B→D or C→D
|
||||
// path[1] is 'B' → path goes A→B→D
|
||||
// path[1] is 'C' → path goes A→C→D
|
||||
});
|
||||
|
||||
it('returns the longer path when paths differ in length', () => {
|
||||
// A → B → D
|
||||
// A → C
|
||||
// The path A→B→D has 3 nodes, A→C has 2 nodes
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [] },
|
||||
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
|
||||
{ id: 'C', name: 'Task C', dependsOn: ['A'] },
|
||||
{ id: 'D', name: 'Task D', dependsOn: ['B'] },
|
||||
]);
|
||||
expect(criticalPath(graph)).toEqual(['A', 'B', 'D']);
|
||||
});
|
||||
|
||||
it('throws CircularDependencyError on cyclic graph', () => {
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: ['C'] },
|
||||
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
|
||||
{ id: 'C', name: 'Task C', dependsOn: ['B'] },
|
||||
]);
|
||||
expect(() => criticalPath(graph)).toThrow(CircularDependencyError);
|
||||
});
|
||||
|
||||
it('handles graph with multiple sources correctly', () => {
|
||||
// A → C
|
||||
// B → C
|
||||
// Both A and B are sources, C depends on both
|
||||
// Longest path has length 2 (source to C)
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [] },
|
||||
{ id: 'B', name: 'Task B', dependsOn: [] },
|
||||
{ id: 'C', name: 'Task C', dependsOn: ['A', 'B'] },
|
||||
]);
|
||||
const path = criticalPath(graph);
|
||||
expect(path).toHaveLength(2);
|
||||
expect(path[0] === 'A' || path[0] === 'B').toBe(true);
|
||||
expect(path[1]).toBe('C');
|
||||
});
|
||||
|
||||
it('handles graph with multiple sinks correctly', () => {
|
||||
// A → B
|
||||
// A → C
|
||||
// Both B and C are sinks
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [] },
|
||||
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
|
||||
{ id: 'C', name: 'Task C', dependsOn: ['A'] },
|
||||
]);
|
||||
const path = criticalPath(graph);
|
||||
expect(path).toHaveLength(2);
|
||||
expect(path[0]).toBe('A');
|
||||
expect(path[1] === 'B' || path[1] === 'C').toBe(true);
|
||||
});
|
||||
|
||||
it('handles a complex branching graph', () => {
|
||||
// A → B → D → F
|
||||
// A → C → E → F
|
||||
// Both branches have length 4, so either is valid
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [] },
|
||||
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
|
||||
{ id: 'C', name: 'Task C', dependsOn: ['A'] },
|
||||
{ id: 'D', name: 'Task D', dependsOn: ['B'] },
|
||||
{ id: 'E', name: 'Task E', dependsOn: ['C'] },
|
||||
{ id: 'F', name: 'Task F', dependsOn: ['D', 'E'] },
|
||||
]);
|
||||
const path = criticalPath(graph);
|
||||
expect(path).toHaveLength(4);
|
||||
expect(path[0]).toBe('A');
|
||||
expect(path[3]).toBe('F');
|
||||
});
|
||||
|
||||
it('handles the longer branch in an asymmetric graph', () => {
|
||||
// A → B → D → F
|
||||
// A → C → F
|
||||
// The A→B→D→F branch is longer (4 nodes) vs A→C→F (3 nodes)
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [] },
|
||||
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
|
||||
{ id: 'C', name: 'Task C', dependsOn: ['A'] },
|
||||
{ id: 'D', name: 'Task D', dependsOn: ['B'] },
|
||||
{ id: 'F', name: 'Task F', dependsOn: ['D', 'C'] },
|
||||
]);
|
||||
expect(criticalPath(graph)).toEqual(['A', 'B', 'D', 'F']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// weightedCriticalPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('weightedCriticalPath', () => {
|
||||
it('returns empty array for empty graph', () => {
|
||||
const graph = new TaskGraph();
|
||||
expect(weightedCriticalPath(graph, () => 1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns single node for single-node graph', () => {
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [] },
|
||||
]);
|
||||
expect(weightedCriticalPath(graph, (_id, _attrs) => 5)).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('with uniform weight behaves like criticalPath', () => {
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [] },
|
||||
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
|
||||
{ id: 'C', name: 'Task C', dependsOn: ['B'] },
|
||||
{ id: 'D', name: 'Task D', dependsOn: ['C'] },
|
||||
]);
|
||||
const unweighted = criticalPath(graph);
|
||||
const weighted = weightedCriticalPath(graph, () => 1);
|
||||
expect(weighted).toEqual(unweighted);
|
||||
});
|
||||
|
||||
it('selects path with higher cumulative weight', () => {
|
||||
// A (w=1)
|
||||
// / \
|
||||
// B(w=5) C(w=1)
|
||||
// \ /
|
||||
// D(w=1)
|
||||
//
|
||||
// Path A→B→D: total = 1 + 5 + 1 = 7
|
||||
// Path A→C→D: total = 1 + 1 + 1 = 3
|
||||
// Should select A→B→D
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [] },
|
||||
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
|
||||
{ id: 'C', name: 'Task C', dependsOn: ['A'] },
|
||||
{ id: 'D', name: 'Task D', dependsOn: ['B', 'C'] },
|
||||
]);
|
||||
|
||||
const weightMap: Record<string, number> = { A: 1, B: 5, C: 1, D: 1 };
|
||||
const weightFn = (taskId: string) => weightMap[taskId] ?? 1;
|
||||
|
||||
const path = weightedCriticalPath(graph, weightFn);
|
||||
expect(path).toEqual(['A', 'B', 'D']);
|
||||
});
|
||||
|
||||
it('uses scope-based weight function for diverse scope values', () => {
|
||||
// Build a diamond with different scope values:
|
||||
// plan (scope=moderate, cost=3)
|
||||
// / \
|
||||
// impl-A impl-B
|
||||
// (scope=broad, (scope=narrow,
|
||||
// cost=4) cost=2)
|
||||
// \ /
|
||||
// test (scope=narrow, cost=2)
|
||||
//
|
||||
// Path plan→impl-A→test: 3 + 4 + 2 = 9
|
||||
// Path plan→impl-B→test: 3 + 2 + 2 = 7
|
||||
// Should select plan→impl-A→test
|
||||
const graph = TaskGraph.fromTasks([
|
||||
{ id: 'plan', name: 'Planning', dependsOn: [], scope: 'moderate' as const },
|
||||
{ id: 'impl-A', name: 'Impl A', dependsOn: ['plan'], scope: 'broad' as const },
|
||||
{ id: 'impl-B', name: 'Impl B', dependsOn: ['plan'], scope: 'narrow' as const },
|
||||
{ id: 'test', name: 'Testing', dependsOn: ['impl-A', 'impl-B'], scope: 'narrow' as const },
|
||||
]);
|
||||
|
||||
// Use scopeCostEstimate mapping from defaults.ts
|
||||
const scopeCostMap: Record<string, number> = {
|
||||
single: 1.0, narrow: 2.0, moderate: 3.0, broad: 4.0, system: 5.0,
|
||||
};
|
||||
const weightFn = (_taskId: string, attrs: { scope?: string }) =>
|
||||
scopeCostMap[attrs.scope ?? 'narrow'] ?? 2.0;
|
||||
|
||||
const path = weightedCriticalPath(graph, weightFn);
|
||||
expect(path).toEqual(['plan', 'impl-A', 'test']);
|
||||
});
|
||||
|
||||
it('throws CircularDependencyError on cyclic graph', () => {
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: ['C'] },
|
||||
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
|
||||
{ id: 'C', name: 'Task C', dependsOn: ['B'] },
|
||||
]);
|
||||
expect(() => weightedCriticalPath(graph, () => 1)).toThrow(CircularDependencyError);
|
||||
});
|
||||
|
||||
it('weighted path differs from unweighted when weights are non-uniform', () => {
|
||||
// Linear path A→B→C vs shortcut A→C
|
||||
// Unweighted: both paths end at C, A→B→C has 3 nodes vs A→C has 2
|
||||
// With weights: A→C accumulates A.w + C.w; A→B→C accumulates A.w + B.w + C.w
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [] },
|
||||
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
|
||||
{ id: 'C', name: 'Task C', dependsOn: ['A', 'B'] },
|
||||
]);
|
||||
|
||||
// Uniform weight — A→B→C is longer
|
||||
expect(criticalPath(graph)).toEqual(['A', 'B', 'C']);
|
||||
|
||||
// With non-uniform weights where A→C shortcut is heavier:
|
||||
// A→B→C: 1 + 1 + 10 = 12
|
||||
// A→C: 1 + 10 = 11
|
||||
// Still A→B→C
|
||||
const heavyWeightMap: Record<string, number> = { A: 1, B: 1, C: 10 };
|
||||
expect(weightedCriticalPath(graph, (id) => heavyWeightMap[id] ?? 1)).toEqual(['A', 'B', 'C']);
|
||||
|
||||
// With A having huge weight, but still A→B→C is longer:
|
||||
// A→B→C: 10 + 1 + 1 = 12
|
||||
// A→C: 10 + 1 = 11
|
||||
const heavyA: Record<string, number> = { A: 10, B: 1, C: 1 };
|
||||
expect(weightedCriticalPath(graph, (id) => heavyA[id] ?? 1)).toEqual(['A', 'B', 'C']);
|
||||
});
|
||||
|
||||
it('returns correct path with zero-weight nodes', () => {
|
||||
// A(w=10) → B(w=0) → C(w=10)
|
||||
// A→C: 10 + 10 = 20
|
||||
// A→B→C: 10 + 0 + 10 = 20
|
||||
// Both tie — either is valid
|
||||
const graph = fromInputs([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [] },
|
||||
{ id: 'B', name: 'Task B', dependsOn: ['A'] },
|
||||
{ id: 'C', name: 'Task C', dependsOn: ['A', 'B'] },
|
||||
]);
|
||||
|
||||
const weightMap: Record<string, number> = { A: 10, B: 0, C: 10 };
|
||||
const path = weightedCriticalPath(graph, (id) => weightMap[id] ?? 1);
|
||||
// Both paths have weight 20, so either is acceptable
|
||||
expect(path[path.length - 1]).toBe('C');
|
||||
expect(path[0]).toBe('A');
|
||||
});
|
||||
|
||||
it('handles large graph correctly', () => {
|
||||
// Build the large project graph from fixtures
|
||||
const graph = TaskGraph.fromTasks([
|
||||
{ id: 'infra-setup', name: 'Infrastructure setup', dependsOn: [] },
|
||||
{ id: 'db-schema', name: 'Database schema design', dependsOn: [] },
|
||||
{ id: 'auth-design', name: 'Auth system design', dependsOn: [] },
|
||||
{ id: 'auth-impl', name: 'Auth implementation', dependsOn: ['infra-setup', 'auth-design'] },
|
||||
{ id: 'data-layer', name: 'Data access layer', dependsOn: ['db-schema', 'infra-setup'] },
|
||||
{ id: 'api-gateway', name: 'API gateway', dependsOn: ['auth-impl', 'data-layer'] },
|
||||
{ id: 'feature-users', name: 'User management', dependsOn: ['auth-impl', 'data-layer'] },
|
||||
{ id: 'feature-notifications', name: 'Notification system', dependsOn: ['api-gateway', 'data-layer'] },
|
||||
{ id: 'feature-search', name: 'Search functionality', dependsOn: ['data-layer'] },
|
||||
{ id: 'feature-permissions', name: 'Permissions system', dependsOn: ['auth-impl'] },
|
||||
{ id: 'feature-analytics', name: 'Analytics dashboard', dependsOn: ['data-layer', 'api-gateway'] },
|
||||
{ id: 'integrate-auth', name: 'Auth integration test', dependsOn: ['feature-users', 'feature-permissions'] },
|
||||
{ id: 'integrate-api', name: 'API integration test', dependsOn: ['feature-notifications', 'feature-search', 'api-gateway'] },
|
||||
{ id: 'integrate-e2e', name: 'End-to-end integration', dependsOn: ['integrate-auth', 'integrate-api'] },
|
||||
{ id: 'perf-tests', name: 'Performance testing', dependsOn: ['integrate-e2e'] },
|
||||
{ id: 'security-audit', name: 'Security audit', dependsOn: ['auth-impl', 'integrate-auth'] },
|
||||
{ id: 'docs-api', name: 'API documentation', dependsOn: ['api-gateway'] },
|
||||
{ id: 'docs-user', name: 'User documentation', dependsOn: ['feature-users'] },
|
||||
{ id: 'i18n', name: 'Internationalization', dependsOn: ['feature-users', 'feature-notifications'] },
|
||||
{ id: 'accessibility', name: 'Accessibility compliance', dependsOn: ['feature-users', 'feature-analytics'] },
|
||||
{ id: 'error-handling', name: 'Error handling polish', dependsOn: ['api-gateway', 'data-layer'] },
|
||||
{ id: 'config-system', name: 'Configuration system', dependsOn: ['data-layer'] },
|
||||
{ id: 'release', name: 'Production release', dependsOn: ['perf-tests', 'security-audit', 'docs-api', 'docs-user', 'i18n', 'accessibility', 'error-handling', 'config-system'] },
|
||||
]);
|
||||
|
||||
const path = criticalPath(graph);
|
||||
// The critical path should end at 'release' and contain multiple nodes
|
||||
expect(path.length).toBeGreaterThan(5);
|
||||
expect(path[path.length - 1]).toBe('release');
|
||||
// The path should start from a source node
|
||||
expect(path[0] === 'infra-setup' || path[0] === 'db-schema' || path[0] === 'auth-design').toBe(true);
|
||||
// All nodes in the path should be valid task IDs
|
||||
for (const nodeId of path) {
|
||||
expect(graph.getTask(nodeId)).toBeDefined();
|
||||
}
|
||||
// The path should be a valid chain (each consecutive pair has an edge)
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const dependents = graph.dependents(path[i]);
|
||||
expect(dependents).toContain(path[i + 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('weight function receives correct node attributes', () => {
|
||||
const graph = TaskGraph.fromTasks([
|
||||
{ id: 'A', name: 'Task A', dependsOn: [], scope: 'broad' as const },
|
||||
{ id: 'B', name: 'Task B', dependsOn: ['A'], scope: 'narrow' as const },
|
||||
]);
|
||||
|
||||
const accessedAttrs: Array<{ id: string; name: string; scope?: string }> = [];
|
||||
weightedCriticalPath(graph, (taskId, attrs) => {
|
||||
accessedAttrs.push({ id: taskId, name: attrs.name, scope: attrs.scope });
|
||||
return 1;
|
||||
});
|
||||
|
||||
expect(accessedAttrs).toHaveLength(2);
|
||||
expect(accessedAttrs.find((a) => a.id === 'A')?.scope).toBe('broad');
|
||||
expect(accessedAttrs.find((a) => a.id === 'B')?.scope).toBe('narrow');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user