fixed regressed parsing, tool sanitation
This commit is contained in:
674
tests/dag-construction.test.ts
Normal file
674
tests/dag-construction.test.ts
Normal file
@@ -0,0 +1,674 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import type { Project, Task } from "../src/types";
|
||||
import {
|
||||
buildExecutionPlan,
|
||||
buildSequentialPlan,
|
||||
getBlockedTasks,
|
||||
detectCycles,
|
||||
getCriticalPath,
|
||||
formatDependencyChain,
|
||||
formatExecutionPlan,
|
||||
} from "../src/dag";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeProject(overrides?: Partial<Project>): Project {
|
||||
return {
|
||||
tasks: [],
|
||||
dependencies: {},
|
||||
sourcePath: "/tmp/test.md",
|
||||
sourceDir: "/tmp",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function task(
|
||||
id: string,
|
||||
dependencies: string[] = [],
|
||||
status: Task["status"] = "pending",
|
||||
parallelGroup?: number,
|
||||
): Task {
|
||||
return { id, title: `Task ${id}`, status, dependencies, parallelGroup };
|
||||
}
|
||||
|
||||
function tasksFrom(...args: Task[]): Task[] {
|
||||
return args;
|
||||
}
|
||||
|
||||
// ─── Basic DAG Construction ──────────────────────────────────────────────────
|
||||
|
||||
describe("buildExecutionPlan (Kahn's algorithm)", () => {
|
||||
it("handles empty task list", () => {
|
||||
const project = makeProject({ tasks: [] });
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
expect(plan.batches).toEqual([]);
|
||||
expect(plan.totalTasks).toBe(0);
|
||||
});
|
||||
|
||||
it("puts all root tasks in batch 0", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(task("01"), task("02"), task("03")),
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
expect(plan.batches).toHaveLength(1);
|
||||
expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual([
|
||||
"01",
|
||||
"02",
|
||||
"03",
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds correct linear dependency chain", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["02"]),
|
||||
task("04", ["03"]),
|
||||
),
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
expect(plan.batches).toHaveLength(4);
|
||||
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||
expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["02"]);
|
||||
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]);
|
||||
expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["04"]);
|
||||
});
|
||||
|
||||
it("groups parallelizable tasks in the same batch", () => {
|
||||
// Diamond: 01 -> 02, 03 -> 04
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["01"]),
|
||||
task("04", ["02", "03"]),
|
||||
),
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
// Batch 0: [01], Batch 1: [02, 03], Batch 2: [04]
|
||||
expect(plan.batches).toHaveLength(3);
|
||||
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]);
|
||||
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]);
|
||||
});
|
||||
|
||||
it("assigns correct batchIndex values", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["01"]),
|
||||
task("04", ["02", "03"]),
|
||||
),
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
expect(plan.batches[0].batchIndex).toBe(0);
|
||||
expect(plan.batches[1].batchIndex).toBe(1);
|
||||
expect(plan.batches[2].batchIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("skips completed tasks and includes them in skippedTasks", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01", [], "completed"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["02"]),
|
||||
),
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set(["01"]));
|
||||
expect(plan.totalTasks).toBe(2);
|
||||
expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]);
|
||||
expect(plan.batches).toHaveLength(2);
|
||||
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["02"]);
|
||||
expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["03"]);
|
||||
});
|
||||
|
||||
it("throws on dependency cycle", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01", ["03"]),
|
||||
task("02", ["01"]),
|
||||
task("03", ["02"]),
|
||||
),
|
||||
});
|
||||
expect(() => buildExecutionPlan(project, new Set())).toThrow(
|
||||
/dependency cycle/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks tasks that depend on failed tasks", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["02"]),
|
||||
task("04", ["03"]),
|
||||
),
|
||||
});
|
||||
const plan = buildExecutionPlan(
|
||||
project,
|
||||
new Set(),
|
||||
undefined,
|
||||
new Set(["01"]),
|
||||
);
|
||||
// 01 is excluded from pending (failed). 02, 03, 04 are pending but
|
||||
// transitively blocked — they don't appear in batches.
|
||||
expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]);
|
||||
expect(plan.totalTasks).toBe(3); // 02, 03, 04 are pending but blocked
|
||||
expect(plan.batches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("blocks immediate dependents when task fails", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["01"]),
|
||||
task("04"), // independent
|
||||
),
|
||||
});
|
||||
const plan = buildExecutionPlan(
|
||||
project,
|
||||
new Set(),
|
||||
undefined,
|
||||
new Set(["01"]),
|
||||
);
|
||||
// 01 is excluded from pending (failed). 02, 03 are pending but blocked
|
||||
// (depend on 01). 04 is independent and ready.
|
||||
expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]);
|
||||
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["04"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Complex DAGs ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("Complex DAG batching", () => {
|
||||
it("builds the OAuth PRD example correctly", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["02"]),
|
||||
task("04", ["01"]),
|
||||
task("05", ["03", "04"]),
|
||||
task("06", ["03", "04"]),
|
||||
task("07", ["03"]),
|
||||
task("08", ["05", "06", "07"]),
|
||||
),
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
// Expected batches: [01], [02,04], [03], [05,06,07], [08]
|
||||
expect(plan.batches).toHaveLength(5);
|
||||
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "04"]);
|
||||
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]);
|
||||
expect(plan.batches[3].tasks.map((t) => t.id).sort()).toEqual([
|
||||
"05",
|
||||
"06",
|
||||
"07",
|
||||
]);
|
||||
expect(plan.batches[4].tasks.map((t) => t.id)).toEqual(["08"]);
|
||||
});
|
||||
|
||||
it("builds the Design Token PRD example correctly", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["01"]),
|
||||
task("04", ["02", "03"]),
|
||||
task("05", ["04", "01"]),
|
||||
),
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
// Expected batches: [01], [02,03], [04], [05]
|
||||
expect(plan.batches).toHaveLength(4);
|
||||
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]);
|
||||
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]);
|
||||
expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["05"]);
|
||||
});
|
||||
|
||||
it("handles a 3-tier diamond", () => {
|
||||
// 01
|
||||
// / \
|
||||
// 02 03
|
||||
// / \ / \
|
||||
// 04 05 06
|
||||
// \ | /
|
||||
// 07
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["01"]),
|
||||
task("04", ["02"]),
|
||||
task("05", ["02", "03"]),
|
||||
task("06", ["03"]),
|
||||
task("07", ["04", "05", "06"]),
|
||||
),
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
expect(plan.batches).toHaveLength(4);
|
||||
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]);
|
||||
expect(plan.batches[2].tasks.map((t) => t.id).sort()).toEqual([
|
||||
"04",
|
||||
"05",
|
||||
"06",
|
||||
]);
|
||||
expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["07"]);
|
||||
});
|
||||
|
||||
it("handles a wide fan-out with delayed convergence", () => {
|
||||
// 01 -> 02,03,04,05,06
|
||||
// 02,03 -> 07
|
||||
// 04,05 -> 08
|
||||
// 06 -> 09
|
||||
// 07,08,09 -> 10
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["01"]),
|
||||
task("04", ["01"]),
|
||||
task("05", ["01"]),
|
||||
task("06", ["01"]),
|
||||
task("07", ["02", "03"]),
|
||||
task("08", ["04", "05"]),
|
||||
task("09", ["06"]),
|
||||
task("10", ["07", "08", "09"]),
|
||||
),
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
expect(plan.batches).toHaveLength(4);
|
||||
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual([
|
||||
"02",
|
||||
"03",
|
||||
"04",
|
||||
"05",
|
||||
"06",
|
||||
]);
|
||||
expect(plan.batches[2].tasks.map((t) => t.id).sort()).toEqual([
|
||||
"07",
|
||||
"08",
|
||||
"09",
|
||||
]);
|
||||
expect(plan.batches[3].tasks.map((t) => t.id)).toEqual(["10"]);
|
||||
});
|
||||
|
||||
it("handles multiple independent subgraphs", () => {
|
||||
// Two completely independent chains:
|
||||
// Chain A: 01 -> 02 -> 03
|
||||
// Chain B: 04 -> 05
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["02"]),
|
||||
task("04"),
|
||||
task("05", ["04"]),
|
||||
),
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
// Batch 0: [01, 04] (both roots)
|
||||
// Batch 1: [02, 05]
|
||||
// Batch 2: [03]
|
||||
expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual(["01", "04"]);
|
||||
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "05"]);
|
||||
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["03"]);
|
||||
});
|
||||
|
||||
it("batches tasks respecting fan-in convergence", () => {
|
||||
// 01 -> 03, 02 -> 03 (03 depends on both 01 AND 02)
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(task("01"), task("02"), task("03", ["01", "02"])),
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
expect(plan.batches[0].tasks.map((t) => t.id).sort()).toEqual(["01", "02"]);
|
||||
expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["03"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Sequential Plan ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildSequentialPlan", () => {
|
||||
it("puts each task in its own batch", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])),
|
||||
});
|
||||
const plan = buildSequentialPlan(project, new Set());
|
||||
expect(plan.batches).toHaveLength(3);
|
||||
plan.batches.forEach((b, i) => {
|
||||
expect(b.tasks).toHaveLength(1);
|
||||
expect(b.batchIndex).toBe(i);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips completed tasks and blocks transitively failed tasks", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["02"]),
|
||||
task("04"),
|
||||
),
|
||||
});
|
||||
const plan = buildSequentialPlan(project, new Set(["01"]), new Set(["01"]));
|
||||
// 01 failed => 02, 03 blocked. 04 independent, runs.
|
||||
expect(plan.skippedTasks.map((t) => t.id).sort()).toEqual([
|
||||
"01",
|
||||
"02",
|
||||
"03",
|
||||
]);
|
||||
expect(plan.totalTasks).toBe(3);
|
||||
});
|
||||
|
||||
it("maintains task order in sequential batches", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])),
|
||||
});
|
||||
const plan = buildSequentialPlan(project, new Set());
|
||||
expect(plan.batches.map((b) => b.tasks[0].id)).toEqual(["01", "02", "03"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getBlockedTasks ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("getBlockedTasks", () => {
|
||||
it("returns direct dependents of failed tasks", () => {
|
||||
const pending = tasksFrom(task("01"), task("02", ["01"]), task("03"));
|
||||
const blocked = getBlockedTasks(pending, new Set(["01"]));
|
||||
expect([...blocked]).toEqual(["02"]);
|
||||
});
|
||||
|
||||
it("returns transitive dependents (chain reaction)", () => {
|
||||
const pending = tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["02"]),
|
||||
task("04", ["03"]),
|
||||
);
|
||||
const blocked = getBlockedTasks(pending, new Set(["01"]));
|
||||
expect([...blocked].sort()).toEqual(["02", "03", "04"]);
|
||||
});
|
||||
|
||||
it("does not affect tasks in separate subgraphs", () => {
|
||||
const pending = tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("10"),
|
||||
task("11", ["10"]),
|
||||
);
|
||||
const blocked = getBlockedTasks(pending, new Set(["01"]));
|
||||
expect([...blocked].sort()).toEqual(["02"]);
|
||||
});
|
||||
|
||||
it("returns empty set when no tasks depend on failed tasks", () => {
|
||||
const pending = tasksFrom(task("01"), task("02"), task("03"));
|
||||
const blocked = getBlockedTasks(pending, new Set(["99"]));
|
||||
expect(blocked.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── detectCycles ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("detectCycles", () => {
|
||||
it("returns empty for acyclic graph", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["02"])),
|
||||
});
|
||||
expect(detectCycles(project)).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects a 3-node cycle", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01", ["03"]),
|
||||
task("02", ["01"]),
|
||||
task("03", ["02"]),
|
||||
),
|
||||
});
|
||||
const cycles = detectCycles(project);
|
||||
expect(cycles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects a self-loop", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(task("01", ["01"])),
|
||||
});
|
||||
const cycles = detectCycles(project);
|
||||
expect(cycles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("detects cycle in disconnected subgraph", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"), // isolated
|
||||
task("02", ["03"]),
|
||||
task("03", ["02"]), // cycle
|
||||
),
|
||||
});
|
||||
const cycles = detectCycles(project);
|
||||
expect(cycles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns empty for graph with only diamond patterns", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["01"]),
|
||||
task("04", ["02", "03"]),
|
||||
),
|
||||
});
|
||||
expect(detectCycles(project)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getCriticalPath ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("getCriticalPath", () => {
|
||||
it("returns the longest path through the DAG", () => {
|
||||
// 01 -> 02 -> 03 -> 04 (long = 4)
|
||||
// 01 -> 05 -> 04 (short = 3)
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["02"]),
|
||||
task("04", ["03", "05"]),
|
||||
task("05", ["01"]),
|
||||
),
|
||||
});
|
||||
const path = getCriticalPath(project);
|
||||
expect(path.length).toBe(4);
|
||||
expect(path[0].id).toBe("01");
|
||||
expect(path[path.length - 1].id).toBe("04");
|
||||
});
|
||||
|
||||
it("returns single-node path for roots", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(task("01"), task("02"), task("03")),
|
||||
});
|
||||
const path = getCriticalPath(project);
|
||||
expect(path.length).toBe(1);
|
||||
});
|
||||
|
||||
it("handles complex branching by picking the longest chain", () => {
|
||||
// 01 -> 02 -> 03 -> 04 -> 05 (long = 5)
|
||||
// 01 -> 06 -> 05 (short = 3)
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["02"]),
|
||||
task("04", ["03"]),
|
||||
task("05", ["04", "06"]),
|
||||
task("06", ["01"]),
|
||||
),
|
||||
});
|
||||
const path = getCriticalPath(project);
|
||||
// Should pick 01 -> 02 -> 03 -> 04 -> 05 (length 5)
|
||||
expect(path.length).toBe(5);
|
||||
expect(path.map((t) => t.id)).toEqual(["01", "02", "03", "04", "05"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatDependencyChain ───────────────────────────────────────────────────
|
||||
|
||||
describe("formatDependencyChain", () => {
|
||||
it("renders a simple tree", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(task("01"), task("02", ["01"])),
|
||||
});
|
||||
const formatted = formatDependencyChain(project);
|
||||
expect(formatted).toContain("01");
|
||||
expect(formatted).toContain("02");
|
||||
});
|
||||
|
||||
it("mentions root tasks", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(task("01"), task("02")),
|
||||
});
|
||||
const formatted = formatDependencyChain(project);
|
||||
expect(formatted).toMatch(/01.*root|root.*01/i);
|
||||
});
|
||||
|
||||
it("handles empty task list", () => {
|
||||
const project = makeProject({ tasks: [] });
|
||||
const formatted = formatDependencyChain(project);
|
||||
expect(formatted).toContain("no tasks");
|
||||
});
|
||||
|
||||
it("shows orphan tasks when dependencies reference non-existent IDs", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(task("01", ["99"])),
|
||||
});
|
||||
const formatted = formatDependencyChain(project);
|
||||
expect(formatted).toMatch(/orphan|unreached/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatExecutionPlan ─────────────────────────────────────────────────────
|
||||
|
||||
describe("formatExecutionPlan", () => {
|
||||
it("displays task counts and batches", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(task("01"), task("02", ["01"])),
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
const formatted = formatExecutionPlan(plan);
|
||||
expect(formatted).toContain("Total tasks");
|
||||
expect(formatted).toContain("Batches");
|
||||
expect(formatted).toContain("01");
|
||||
expect(formatted).toContain("02");
|
||||
});
|
||||
|
||||
it("shows skipped tasks", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(task("01", [], "completed"), task("02", ["01"])),
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set(["01"]));
|
||||
const formatted = formatExecutionPlan(plan);
|
||||
expect(formatted).toContain("completed");
|
||||
});
|
||||
|
||||
it("shows parallel group annotations when provided", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(task("01"), task("02", ["01"]), task("03", ["01"])),
|
||||
parallelGroups: [{ index: 0, label: "UI sprint", taskIds: ["02", "03"] }],
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
const formatted = formatExecutionPlan(plan, project.parallelGroups);
|
||||
expect(formatted).toContain("UI sprint");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Group-Aware Batching ────────────────────────────────────────────────────
|
||||
|
||||
describe("Parallel group batching", () => {
|
||||
it("builds batches when parallel groups are defined", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["01"]),
|
||||
task("04", ["02", "03"]),
|
||||
),
|
||||
parallelGroups: [
|
||||
{ index: 0, label: "Frontend", taskIds: ["01", "02", "03", "04"] },
|
||||
],
|
||||
});
|
||||
// Should route through buildGroupAwareBatches
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
expect(plan.batches.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("respects intra-group dependencies in parallel groups", () => {
|
||||
// Tasks: 01 -> 02, 01 -> 03, 02 -> 04, 03 -> 04
|
||||
// With parallel groups, there are no cross-group dependencies by definition.
|
||||
// Intra-group deps are respected by Kahn's algorithm.
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01"),
|
||||
task("02", ["01"]),
|
||||
task("03", ["01"]),
|
||||
task("04", ["02", "03"]),
|
||||
),
|
||||
parallelGroups: [
|
||||
{ index: 0, label: "All", taskIds: ["01", "02", "03", "04"] },
|
||||
],
|
||||
});
|
||||
const plan = buildExecutionPlan(project, new Set());
|
||||
// Batch 0: [01], Batch 1: [02, 03], Batch 2: [04]
|
||||
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["01"]);
|
||||
expect(plan.batches[1].tasks.map((t) => t.id).sort()).toEqual(["02", "03"]);
|
||||
expect(plan.batches[2].tasks.map((t) => t.id)).toEqual(["04"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Real-World Scenario: Resume with completed tasks ───────────────────────
|
||||
|
||||
describe("Real-world resume scenarios", () => {
|
||||
it("buildExecutionPlan correctly excludes file-based [x] completions", () => {
|
||||
// Design Token PRD resume: 01,02,03 [x] in file, 04 [~], 05 [ ]
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01", [], "completed"),
|
||||
task("02", ["01"], "completed"),
|
||||
task("03", ["01"], "completed"),
|
||||
task("04", ["02", "03"], "in_progress"),
|
||||
task("05", ["04", "01"], "pending"),
|
||||
),
|
||||
});
|
||||
// buildCompletedSet in index.ts produces {01, 02, 03} from file + progress
|
||||
// This simulates what happens after buildCompletedSet is called
|
||||
const completedFromFile = new Set(
|
||||
project.tasks.filter((t) => t.status === "completed").map((t) => t.id),
|
||||
);
|
||||
const plan = buildExecutionPlan(project, completedFromFile);
|
||||
|
||||
// Only 04 and 05 should be pending
|
||||
expect(plan.totalTasks).toBe(2);
|
||||
expect(plan.batches).toHaveLength(2);
|
||||
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["04"]);
|
||||
expect(plan.batches[1].tasks.map((t) => t.id)).toEqual(["05"]);
|
||||
});
|
||||
|
||||
it("skipsTasks includes both progress-completed and file-completed tasks", () => {
|
||||
const project = makeProject({
|
||||
tasks: tasksFrom(
|
||||
task("01", [], "completed"),
|
||||
task("02", ["01"], "pending"),
|
||||
),
|
||||
});
|
||||
// Simulate: 01 completed in file AND in progress
|
||||
const plan = buildExecutionPlan(project, new Set(["01"]));
|
||||
expect(plan.skippedTasks.map((t) => t.id)).toEqual(["01"]);
|
||||
expect(plan.batches[0].tasks.map((t) => t.id)).toEqual(["02"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user