import { describe, it, expect } from "vitest"; import { riskPath, riskDistribution } from "../src/analysis/risk.js"; import { shouldDecomposeTask } from "../src/analysis/decompose.js"; import { TaskGraph } from "../src/graph/construction.js"; import { riskWeight, impactWeight } from "../src/analysis/defaults.js"; import { CircularDependencyError } from "../src/error/index.js"; // --------------------------------------------------------------------------- // riskPath // --------------------------------------------------------------------------- describe("riskPath", () => { it("returns empty path and zero totalRisk for empty graph", () => { const graph = new TaskGraph(); const result = riskPath(graph); expect(result.path).toEqual([]); expect(result.totalRisk).toBe(0); }); it("returns single node for graph with one task", () => { const graph = TaskGraph.fromTasks([ { id: "A", name: "Task A", dependsOn: [], risk: "high", impact: "phase" }, ]); const result = riskPath(graph); expect(result.path).toEqual(["A"]); // totalRisk = riskWeight("high") * impactWeight("phase") = 0.35 * 2.0 = 0.70 expect(result.totalRisk).toBeCloseTo(0.35 * 2.0); }); it("finds the highest-risk path in a simple chain", () => { // A (medium, isolated) → B (high, phase) → C (critical, project) const graph = TaskGraph.fromTasks([ { id: "A", name: "Task A", dependsOn: [], risk: "medium", impact: "isolated" }, { id: "B", name: "Task B", dependsOn: ["A"], risk: "high", impact: "phase" }, { id: "C", name: "Task C", dependsOn: ["B"], risk: "critical", impact: "project" }, ]); const result = riskPath(graph); // Only one chain: A → B → C expect(result.path).toEqual(["A", "B", "C"]); // totalRisk = sum of riskWeight * impactWeight for each node const wA = riskWeight("medium") * impactWeight("isolated"); // 0.20 * 1.0 = 0.20 const wB = riskWeight("high") * impactWeight("phase"); // 0.35 * 2.0 = 0.70 const wC = riskWeight("critical") * impactWeight("project"); // 0.50 * 3.0 = 1.50 expect(result.totalRisk).toBeCloseTo(wA + wB + wC); }); it("picks the path with highest cumulative risk in a diamond graph", () => { // A (critical, isolated) // / \ // B C // (low, (high, project) // isolated) // \ / // D (medium, component) const graph = TaskGraph.fromTasks([ { id: "A", name: "Task A", dependsOn: [], risk: "critical", impact: "isolated" }, { id: "B", name: "Task B", dependsOn: ["A"], risk: "low", impact: "isolated" }, { id: "C", name: "Task C", dependsOn: ["A"], risk: "high", impact: "project" }, { id: "D", name: "Task D", dependsOn: ["B", "C"], risk: "medium", impact: "component" }, ]); const result = riskPath(graph); // Path through C should have higher risk: // A → C → D: 0.50*1.0 + 0.35*3.0 + 0.20*1.5 = 0.50 + 1.05 + 0.30 = 1.85 // A → B → D: 0.50*1.0 + 0.10*1.0 + 0.20*1.5 = 0.50 + 0.10 + 0.30 = 0.90 // The weightedCriticalPath should pick A → C → D expect(result.path).toEqual(["A", "C", "D"]); const wA = riskWeight("critical") * impactWeight("isolated"); // 0.50 * 1.0 = 0.50 const wC = riskWeight("high") * impactWeight("project"); // 0.35 * 3.0 = 1.05 const wD = riskWeight("medium") * impactWeight("component"); // 0.20 * 1.5 = 0.30 expect(result.totalRisk).toBeCloseTo(wA + wC + wD); }); it("uses default risk/impact when not specified on nodes", () => { // Nodes without risk/impact should use defaults: medium risk, isolated impact const graph = TaskGraph.fromTasks([ { id: "A", name: "Task A", dependsOn: [] }, { id: "B", name: "Task B", dependsOn: ["A"] }, ]); const result = riskPath(graph); expect(result.path).toEqual(["A", "B"]); // Both use defaults: riskWeight("medium") * impactWeight("isolated") = 0.20 * 1.0 = 0.20 each expect(result.totalRisk).toBeCloseTo(0.20 + 0.20); }); it("throws CircularDependencyError for cyclic graph", () => { const tg = new TaskGraph(); tg.addTask("A", { name: "Task A" }); tg.addTask("B", { name: "Task B" }); tg.addTask("C", { name: "Task C" }); tg.addDependency("A", "B"); tg.addDependency("B", "C"); tg.addDependency("C", "A"); expect(() => riskPath(tg)).toThrow(CircularDependencyError); }); }); // --------------------------------------------------------------------------- // riskDistribution // --------------------------------------------------------------------------- describe("riskDistribution", () => { it("returns all empty buckets for empty graph", () => { const graph = new TaskGraph(); const result = riskDistribution(graph); expect(result).toEqual({ trivial: [], low: [], medium: [], high: [], critical: [], unspecified: [], }); }); it("groups tasks by their risk attribute", () => { const graph = TaskGraph.fromTasks([ { id: "A", name: "Task A", dependsOn: [], risk: "trivial" }, { id: "B", name: "Task B", dependsOn: [], risk: "low" }, { id: "C", name: "Task C", dependsOn: [], risk: "medium" }, { id: "D", name: "Task D", dependsOn: [], risk: "high" }, { id: "E", name: "Task E", dependsOn: [], risk: "critical" }, ]); const result = riskDistribution(graph); expect(result.trivial).toEqual(["A"]); expect(result.low).toEqual(["B"]); expect(result.medium).toEqual(["C"]); expect(result.high).toEqual(["D"]); expect(result.critical).toEqual(["E"]); expect(result.unspecified).toEqual([]); }); it("puts tasks without risk attribute in unspecified bucket", () => { const graph = TaskGraph.fromTasks([ { id: "A", name: "Task A", dependsOn: [], risk: "high" }, { id: "B", name: "Task B", dependsOn: [] }, // no risk { id: "C", name: "Task C", dependsOn: [], risk: "low" }, ]); const result = riskDistribution(graph); expect(result.high).toEqual(["A"]); expect(result.unspecified).toEqual(["B"]); expect(result.low).toEqual(["C"]); }); it("puts all unspecified tasks in one bucket when no tasks have risk", () => { const graph = TaskGraph.fromTasks([ { id: "A", name: "Task A", dependsOn: [] }, { id: "B", name: "Task B", dependsOn: ["A"] }, ]); const result = riskDistribution(graph); expect(result.unspecified).toEqual(["A", "B"]); expect(result.trivial).toEqual([]); expect(result.low).toEqual([]); expect(result.medium).toEqual([]); expect(result.high).toEqual([]); expect(result.critical).toEqual([]); }); it("no duplicate task IDs in any bucket", () => { // Even if a task appears once in the graph, it should only appear once const graph = TaskGraph.fromTasks([ { id: "auth", name: "Auth module", dependsOn: [], risk: "high" }, { id: "db", name: "Database setup", dependsOn: [], risk: "medium" }, { id: "api", name: "API layer", dependsOn: ["auth", "db"] }, // no risk { id: "tests", name: "Test suite", dependsOn: ["api"], risk: "low" }, { id: "deploy", name: "Deploy pipeline", dependsOn: ["tests"], risk: "critical" }, ]); const result = riskDistribution(graph); // Each bucket has unique entries const allIds = [ ...result.trivial, ...result.low, ...result.medium, ...result.high, ...result.critical, ...result.unspecified, ]; expect(new Set(allIds).size).toBe(allIds.length); // no duplicates }); it("handles mixed category fixture graph", () => { // auth (high), db (medium), api (unspecified), tests (low), deploy (critical) const graph = TaskGraph.fromTasks([ { id: "auth", name: "Auth module", dependsOn: [], risk: "high", scope: "broad", impact: "phase", status: "pending" }, { id: "db", name: "Database setup", dependsOn: [], risk: "medium", scope: "moderate", impact: null, status: "completed" }, { id: "api", name: "API layer", dependsOn: ["auth", "db"], risk: null, scope: null, impact: "component", status: null }, { id: "tests", name: "Test suite", dependsOn: ["api"], risk: "low", scope: null, impact: null, status: null }, { id: "deploy", name: "Deploy pipeline", dependsOn: ["tests"], risk: "critical", scope: "system", impact: "project", status: "blocked" }, ]); const result = riskDistribution(graph); expect(result.high).toEqual(["auth"]); expect(result.medium).toEqual(["db"]); expect(result.low).toEqual(["tests"]); expect(result.critical).toEqual(["deploy"]); expect(result.unspecified).toEqual(["api"]); }); }); // --------------------------------------------------------------------------- // shouldDecomposeTask // --------------------------------------------------------------------------- describe("shouldDecomposeTask", () => { it("flags high risk tasks for decomposition", () => { const result = shouldDecomposeTask({ name: "Auth module", risk: "high" as const }); expect(result.shouldDecompose).toBe(true); expect(result.reasons).toHaveLength(1); expect(result.reasons[0]).toContain("risk: high"); expect(result.reasons[0]).toContain("failure probability"); // high risk: p=0.65, failure probability = 1 - 0.65 = 0.35 expect(result.reasons[0]).toContain("0.35"); }); it("flags critical risk tasks for decomposition", () => { const result = shouldDecomposeTask({ name: "Deploy pipeline", risk: "critical" as const }); expect(result.shouldDecompose).toBe(true); expect(result.reasons).toHaveLength(1); expect(result.reasons[0]).toContain("risk: critical"); // critical risk: p=0.50, failure probability = 0.50 expect(result.reasons[0]).toContain("0.50"); }); it("flags broad scope tasks for decomposition", () => { const result = shouldDecomposeTask({ name: "Refactor", scope: "broad" as const }); expect(result.shouldDecompose).toBe(true); expect(result.reasons).toHaveLength(1); expect(result.reasons[0]).toContain("scope: broad"); // broad scope: costEstimate = 4.0 expect(result.reasons[0]).toContain("4.0"); }); it("flags system scope tasks for decomposition", () => { const result = shouldDecomposeTask({ name: "Migration", scope: "system" as const }); expect(result.shouldDecompose).toBe(true); expect(result.reasons).toHaveLength(1); expect(result.reasons[0]).toContain("scope: system"); // system scope: costEstimate = 5.0 expect(result.reasons[0]).toContain("5.0"); }); it("flags both high risk AND broad scope with two reasons", () => { const result = shouldDecomposeTask({ name: "Auth implementation", risk: "high" as const, scope: "broad" as const, }); expect(result.shouldDecompose).toBe(true); expect(result.reasons).toHaveLength(2); expect(result.reasons[0]).toContain("risk: high"); expect(result.reasons[0]).toContain("failure probability 0.35"); expect(result.reasons[1]).toContain("scope: broad"); expect(result.reasons[1]).toContain("cost estimate 4.0"); }); it("does NOT flag medium risk tasks", () => { const result = shouldDecomposeTask({ name: "Normal task", risk: "medium" as const }); expect(result.shouldDecompose).toBe(false); expect(result.reasons).toEqual([]); }); it("does NOT flag low risk tasks", () => { const result = shouldDecomposeTask({ name: "Safe task", risk: "low" as const }); expect(result.shouldDecompose).toBe(false); expect(result.reasons).toEqual([]); }); it("does NOT flag trivial risk tasks", () => { const result = shouldDecomposeTask({ name: "Trivial task", risk: "trivial" as const }); expect(result.shouldDecompose).toBe(false); expect(result.reasons).toEqual([]); }); it("does NOT flag narrow scope tasks", () => { const result = shouldDecomposeTask({ name: "Narrow task", scope: "narrow" as const }); expect(result.shouldDecompose).toBe(false); expect(result.reasons).toEqual([]); }); it("does NOT flag moderate scope tasks", () => { const result = shouldDecomposeTask({ name: "Moderate task", scope: "moderate" as const }); expect(result.shouldDecompose).toBe(false); expect(result.reasons).toEqual([]); }); it("does NOT flag single scope tasks", () => { const result = shouldDecomposeTask({ name: "Single task", scope: "single" as const }); expect(result.shouldDecompose).toBe(false); expect(result.reasons).toEqual([]); }); it("does NOT flag unassessed tasks (null/undefined risk and scope)", () => { // Unassessed tasks use defaults: risk=medium, scope=narrow // Both are below threshold, so not flagged const result = shouldDecomposeTask({ name: "Unassessed task" }); expect(result.shouldDecompose).toBe(false); expect(result.reasons).toEqual([]); }); it("does NOT flag tasks with only medium risk and narrow scope (defaults)", () => { const result = shouldDecomposeTask({ name: "Default-ish task", risk: "medium" as const, scope: "narrow" as const, }); expect(result.shouldDecompose).toBe(false); expect(result.reasons).toEqual([]); }); it("flags high risk even when scope is narrow", () => { const result = shouldDecomposeTask({ name: "Risky narrow task", risk: "high" as const, scope: "narrow" as const, }); expect(result.shouldDecompose).toBe(true); expect(result.reasons).toHaveLength(1); expect(result.reasons[0]).toContain("risk: high"); }); it("flags broad scope even when risk is low", () => { const result = shouldDecomposeTask({ name: "Low-risk broad task", risk: "low" as const, scope: "broad" as const, }); expect(result.shouldDecompose).toBe(true); expect(result.reasons).toHaveLength(1); expect(result.reasons[0]).toContain("scope: broad"); }); it("reasons include specific numeric values from risk and scope", () => { const result = shouldDecomposeTask({ name: "Complex task", risk: "critical" as const, scope: "system" as const, impact: "project" as const, }); expect(result.shouldDecompose).toBe(true); expect(result.reasons).toHaveLength(2); // critical: failure probability = 1 - 0.50 = 0.50 expect(result.reasons[0]).toBe("risk: critical — failure probability 0.50"); // system: cost estimate = 5.0 expect(result.reasons[1]).toBe("scope: system — cost estimate 5.0"); }); it("is a pure function — does not depend on a graph", () => { // shouldDecomposeTask takes attributes only, not a graph const attrs = { name: "Standalone task", risk: "high" as const }; const result = shouldDecomposeTask(attrs); expect(result.shouldDecompose).toBe(true); // No graph needed — pure function on attributes }); });