Adds PipelineBranchKind (NONE/APPROVED/DECLINED) on stage definitions
so producers can tag the two routes downstream of a FORMAL-approval
stage. The engine then picks exactly one branch per decision:
• Approve → APPROVED-branch children auto-open, DECLINED-branch
siblings auto-SKIPPED (grayed out, unreachable)
• Request Changes → DECLINED-branch children auto-stamped APPROVED
(passive record of the decline), APPROVED-branch siblings auto-
SKIPPED, then the existing rework edge fires as before
Also fixes a quiet bug in pipeline-template-service.addStage where
approvalType was being dropped from new stages (whitelist didn't
include it).
UI: dropdown on the stage edit sheet + branch-kind badges on the
deliverable detail page. SKIPPED rendering already grays things out.
Smoke test extended: 65/65 passing including the user's split-on-
decision case, N-way split, regression assertion that untagged
pipelines still open all children, and an idempotency check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
853 lines
27 KiB
TypeScript
853 lines
27 KiB
TypeScript
// Self-contained workflow smoke test. No DB needed — exercises the pure
|
|
// state machine + dependency engine against the user's 5-stage pipeline:
|
|
//
|
|
// Inputfile (NONE) → Internal Approval (FORMAL, gate)
|
|
// ├─ Approved
|
|
// │ └─ Delivery
|
|
// └─ Declined (rework edge → Inputfile)
|
|
//
|
|
// Run: npx tsx scripts/test-workflow-logic.ts
|
|
// Each step prints PASS / FAIL; non-zero exit on any failure.
|
|
|
|
import { canTransition } from "@/lib/pipeline/stage-machine";
|
|
import {
|
|
canStageStart,
|
|
getStageIdsToUnblock,
|
|
getBranchStagesToSkipOnApprove,
|
|
getBranchOutcomeOnReject,
|
|
} from "@/lib/pipeline/dependency-engine";
|
|
|
|
type StageStatus =
|
|
| "BLOCKED"
|
|
| "NOT_STARTED"
|
|
| "IN_PROGRESS"
|
|
| "IN_REVIEW"
|
|
| "CHANGES_REQUESTED"
|
|
| "APPROVED"
|
|
| "DELIVERED"
|
|
| "SKIPPED";
|
|
|
|
interface Stage {
|
|
id: string;
|
|
status: StageStatus;
|
|
order: number;
|
|
template: {
|
|
id: string;
|
|
slug: string;
|
|
isCriticalGate: boolean;
|
|
isOptional: boolean;
|
|
dependsOn: { prerequisiteId: string }[];
|
|
};
|
|
stageDefinition: {
|
|
id: string;
|
|
branchKind?: "NONE" | "APPROVED" | "DECLINED" | null;
|
|
dependsOn: { prerequisiteId: string }[];
|
|
} | null;
|
|
}
|
|
|
|
// ─── Pipeline builder ─────────────────────────────────────────────────────
|
|
// Mirrors the user's pipeline graph from the screenshot. Forward edges
|
|
// are encoded in stageDefinition.dependsOn (V2 dynamic). Rework edges
|
|
// (Declined → Inputfile, Delivery → Inputfile) aren't exercised by the
|
|
// engine — those live in PipelineStageRework and route through
|
|
// stage-transition-service.applyRework, which we test separately.
|
|
|
|
function buildPipeline(opts: { tagBranches?: boolean } = {}): Stage[] {
|
|
const stage = (
|
|
slug: string,
|
|
order: number,
|
|
deps: string[],
|
|
extra: Partial<Pick<Stage["template"], "isCriticalGate" | "isOptional">> & {
|
|
branchKind?: "NONE" | "APPROVED" | "DECLINED";
|
|
} = {}
|
|
): Stage => ({
|
|
id: `ds_${slug}`,
|
|
status: "BLOCKED",
|
|
order,
|
|
template: {
|
|
id: `tpl_${slug}`,
|
|
slug,
|
|
isCriticalGate: extra.isCriticalGate ?? false,
|
|
isOptional: extra.isOptional ?? false,
|
|
dependsOn: [],
|
|
},
|
|
stageDefinition: {
|
|
id: `def_${slug}`,
|
|
branchKind: extra.branchKind ?? "NONE",
|
|
dependsOn: deps.map((d) => ({ prerequisiteId: `def_${d}` })),
|
|
},
|
|
});
|
|
|
|
return [
|
|
stage("inputfile", 1, []),
|
|
stage("internal-approval", 2, ["inputfile"], { isCriticalGate: true }),
|
|
stage("approved", 3, ["internal-approval"], {
|
|
branchKind: opts.tagBranches ? "APPROVED" : "NONE",
|
|
}),
|
|
stage("declined", 3, ["internal-approval"], {
|
|
branchKind: opts.tagBranches ? "DECLINED" : "NONE",
|
|
}),
|
|
stage("delivery", 4, ["approved"]),
|
|
];
|
|
}
|
|
|
|
// ─── Test harness ─────────────────────────────────────────────────────────
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
const failures: string[] = [];
|
|
|
|
function assert(name: string, cond: boolean, detail?: string) {
|
|
if (cond) {
|
|
passed++;
|
|
console.log(` ✓ ${name}`);
|
|
} else {
|
|
failed++;
|
|
failures.push(`${name}${detail ? ` — ${detail}` : ""}`);
|
|
console.log(` ✗ ${name}${detail ? ` — ${detail}` : ""}`);
|
|
}
|
|
}
|
|
|
|
function section(name: string) {
|
|
console.log(`\n— ${name} —`);
|
|
}
|
|
|
|
// Simulate the auto-advance + canStageStart flow that stage-service
|
|
// would execute end-to-end on the user's pipeline.
|
|
function simulateTerminalTransition(
|
|
stages: Stage[],
|
|
stageId: string,
|
|
newStatus: StageStatus
|
|
): Stage[] {
|
|
const updated = stages.map((s) =>
|
|
s.id === stageId ? { ...s, status: newStatus } : s
|
|
);
|
|
const just = updated.find((s) => s.id === stageId)!;
|
|
const unblockIds = getStageIdsToUnblock(just, updated);
|
|
return updated.map((s) =>
|
|
unblockIds.includes(s.id) ? { ...s, status: "IN_PROGRESS" } : s
|
|
);
|
|
}
|
|
|
|
// ─── Run ──────────────────────────────────────────────────────────────────
|
|
|
|
section("State machine — NONE stage Mark Complete shortcut");
|
|
{
|
|
const r = canTransition("IN_PROGRESS", "APPROVED");
|
|
assert(
|
|
"IN_PROGRESS → APPROVED is allowed (Mark Complete on NONE stage)",
|
|
r.allowed,
|
|
r.reason
|
|
);
|
|
}
|
|
{
|
|
const r = canTransition("IN_PROGRESS", "IN_REVIEW");
|
|
assert(
|
|
"IN_PROGRESS → IN_REVIEW still allowed (FORMAL/SIMPLE flow)",
|
|
r.allowed
|
|
);
|
|
}
|
|
{
|
|
const r = canTransition("IN_REVIEW", "APPROVED");
|
|
assert("IN_REVIEW → APPROVED allowed (FORMAL approve)", r.allowed);
|
|
}
|
|
{
|
|
const r = canTransition("IN_REVIEW", "CHANGES_REQUESTED");
|
|
assert("IN_REVIEW → CHANGES_REQUESTED allowed (request changes)", r.allowed);
|
|
}
|
|
{
|
|
const r = canTransition("BLOCKED", "APPROVED");
|
|
assert(
|
|
"BLOCKED → APPROVED rejected (must finish prereq first)",
|
|
!r.allowed
|
|
);
|
|
}
|
|
|
|
section("Dependency engine — order-based deps drive the cascade");
|
|
let p = buildPipeline();
|
|
|
|
// Initial state: producer hasn't done anything yet. Inputfile is the only
|
|
// stage without prereqs.
|
|
{
|
|
const inputfile = p.find((s) => s.template.slug === "inputfile")!;
|
|
assert(
|
|
"Inputfile has no prerequisites (it's stage 1)",
|
|
inputfile.stageDefinition!.dependsOn.length === 0
|
|
);
|
|
// Producer manually starts it → IN_PROGRESS
|
|
p = p.map((s) =>
|
|
s.id === inputfile.id ? { ...s, status: "IN_PROGRESS" as StageStatus } : s
|
|
);
|
|
assert("Producer clicks Start Work → Inputfile IN_PROGRESS", true);
|
|
}
|
|
|
|
// Mark Complete on Inputfile → IN_PROGRESS → APPROVED → triggers cascade
|
|
section("Mark Complete on Inputfile (the failing case from the screenshot)");
|
|
{
|
|
const inputfile = p.find((s) => s.template.slug === "inputfile")!;
|
|
const r = canTransition(inputfile.status, "APPROVED");
|
|
assert(
|
|
"Mark Complete (IN_PROGRESS → APPROVED) is now allowed",
|
|
r.allowed,
|
|
r.reason
|
|
);
|
|
|
|
p = simulateTerminalTransition(p, inputfile.id, "APPROVED");
|
|
const ia = p.find((s) => s.template.slug === "internal-approval")!;
|
|
assert(
|
|
"Internal Approval auto-advances to IN_PROGRESS",
|
|
ia.status === "IN_PROGRESS",
|
|
`actual: ${ia.status}`
|
|
);
|
|
|
|
// Downstream-of-downstream stays BLOCKED at this point.
|
|
const approved = p.find((s) => s.template.slug === "approved")!;
|
|
const declined = p.find((s) => s.template.slug === "declined")!;
|
|
assert(
|
|
"Approved stays BLOCKED (waiting on Internal Approval)",
|
|
approved.status === "BLOCKED"
|
|
);
|
|
assert(
|
|
"Declined stays BLOCKED (waiting on Internal Approval)",
|
|
declined.status === "BLOCKED"
|
|
);
|
|
}
|
|
|
|
section("Approve & advance on Internal Approval (FORMAL stage)");
|
|
{
|
|
const ia = p.find((s) => s.template.slug === "internal-approval")!;
|
|
// FORMAL stage goes IN_PROGRESS → IN_REVIEW (Submit) → APPROVED. We
|
|
// simulate the final APPROVED step the StageReviewPanel triggers.
|
|
p = p.map((s) =>
|
|
s.id === ia.id ? { ...s, status: "IN_REVIEW" as StageStatus } : s
|
|
);
|
|
assert(
|
|
"IN_PROGRESS → IN_REVIEW (Submit for review) allowed",
|
|
canTransition("IN_PROGRESS", "IN_REVIEW").allowed
|
|
);
|
|
|
|
// Now the reviewer clicks Approve & advance.
|
|
p = simulateTerminalTransition(p, ia.id, "APPROVED");
|
|
|
|
// Both downstream branches (Approved + Declined) depend on Internal
|
|
// Approval — both should unblock.
|
|
const approved = p.find((s) => s.template.slug === "approved")!;
|
|
const declined = p.find((s) => s.template.slug === "declined")!;
|
|
assert(
|
|
"Approved auto-advances to IN_PROGRESS",
|
|
approved.status === "IN_PROGRESS",
|
|
`actual: ${approved.status}`
|
|
);
|
|
assert(
|
|
"Declined ALSO auto-advances to IN_PROGRESS (shared prereq)",
|
|
declined.status === "IN_PROGRESS",
|
|
`actual: ${declined.status}`
|
|
);
|
|
|
|
// Delivery should still be BLOCKED — it depends on Approved, not on
|
|
// Internal Approval.
|
|
const delivery = p.find((s) => s.template.slug === "delivery")!;
|
|
assert(
|
|
"Delivery still BLOCKED (depends on Approved, not Internal Approval)",
|
|
delivery.status === "BLOCKED",
|
|
`actual: ${delivery.status}`
|
|
);
|
|
}
|
|
|
|
section("Producer marks Approved complete → Delivery cascades");
|
|
{
|
|
const approved = p.find((s) => s.template.slug === "approved")!;
|
|
p = simulateTerminalTransition(p, approved.id, "APPROVED");
|
|
const delivery = p.find((s) => s.template.slug === "delivery")!;
|
|
assert(
|
|
"Delivery auto-advances to IN_PROGRESS once Approved completes",
|
|
delivery.status === "IN_PROGRESS",
|
|
`actual: ${delivery.status}`
|
|
);
|
|
}
|
|
|
|
section("Edge: canStageStart guards work via the engine");
|
|
{
|
|
// Reset to fresh pipeline so we can ask "can Delivery start right now?"
|
|
// before any prereqs are met.
|
|
const fresh = buildPipeline();
|
|
const delivery = fresh.find((s) => s.template.slug === "delivery")!;
|
|
const r = canStageStart(delivery, fresh);
|
|
assert(
|
|
"Fresh Delivery: cannot start — Approved is not terminal",
|
|
!r.allowed,
|
|
r.reason
|
|
);
|
|
}
|
|
{
|
|
const fresh = buildPipeline();
|
|
const inputfile = fresh.find((s) => s.template.slug === "inputfile")!;
|
|
const r = canStageStart(inputfile, fresh);
|
|
assert(
|
|
"Fresh Inputfile: can start — no prerequisites",
|
|
r.allowed,
|
|
r.reason
|
|
);
|
|
}
|
|
|
|
section("Edge: order-fallback deps when no V2 edges are drawn");
|
|
{
|
|
// Build a pipeline with NO stageDefinition deps to verify the
|
|
// order-fallback path. (Producers building a quick pipeline who don't
|
|
// draw forward arrows still get a sensible left-to-right cascade.)
|
|
const fallback = buildPipeline().map((s) => ({
|
|
...s,
|
|
stageDefinition: { id: s.stageDefinition!.id, dependsOn: [] },
|
|
}));
|
|
|
|
const inputfile = fallback.find((s) => s.template.slug === "inputfile")!;
|
|
const ia = fallback.find((s) => s.template.slug === "internal-approval")!;
|
|
inputfile.status = "IN_PROGRESS";
|
|
|
|
const unblock = getStageIdsToUnblock(
|
|
{ ...inputfile, status: "APPROVED" },
|
|
fallback.map((s) => (s.id === inputfile.id ? { ...s, status: "APPROVED" as StageStatus } : s))
|
|
);
|
|
|
|
assert(
|
|
"No V2 deps: order fallback unblocks the next-order stage",
|
|
unblock.includes(ia.id),
|
|
`unblock = ${unblock.join(", ")}`
|
|
);
|
|
}
|
|
|
|
// ─── Generic shape coverage ──────────────────────────────────────────────
|
|
// The engine must work for any pipeline shape, not just the user's
|
|
// canonical 5-stage flow. These build minimal pipelines for each shape
|
|
// pattern and verify the cascade behaves correctly.
|
|
|
|
function pipelineFromGraph(
|
|
edges: Array<[from: string, to: string]>,
|
|
nodeOrder?: Record<string, number>
|
|
): Stage[] {
|
|
const nodes = new Set<string>();
|
|
edges.forEach(([a, b]) => {
|
|
nodes.add(a);
|
|
nodes.add(b);
|
|
});
|
|
const byName = new Map<string, number>();
|
|
Array.from(nodes).forEach((n, i) => byName.set(n, nodeOrder?.[n] ?? i + 1));
|
|
|
|
return Array.from(nodes).map((n) => ({
|
|
id: `ds_${n}`,
|
|
status: "BLOCKED" as StageStatus,
|
|
order: byName.get(n)!,
|
|
template: {
|
|
id: `tpl_${n}`,
|
|
slug: n,
|
|
isCriticalGate: false,
|
|
isOptional: false,
|
|
dependsOn: [],
|
|
},
|
|
stageDefinition: {
|
|
id: `def_${n}`,
|
|
dependsOn: edges
|
|
.filter(([, to]) => to === n)
|
|
.map(([from]) => ({ prerequisiteId: `def_${from}` })),
|
|
},
|
|
}));
|
|
}
|
|
|
|
function complete(stages: Stage[], slug: string): Stage[] {
|
|
const stage = stages.find((s) => s.template.slug === slug);
|
|
if (!stage) throw new Error(`No stage ${slug}`);
|
|
return simulateTerminalTransition(stages, stage.id, "APPROVED");
|
|
}
|
|
|
|
function statusOf(stages: Stage[], slug: string): StageStatus {
|
|
return stages.find((s) => s.template.slug === slug)!.status;
|
|
}
|
|
|
|
section("Shape: linear A → B → C → D");
|
|
{
|
|
let g = pipelineFromGraph([
|
|
["A", "B"],
|
|
["B", "C"],
|
|
["C", "D"],
|
|
]);
|
|
// Producer manually starts the head.
|
|
g = g.map((s) =>
|
|
s.template.slug === "A" ? { ...s, status: "IN_PROGRESS" as StageStatus } : s
|
|
);
|
|
|
|
g = complete(g, "A");
|
|
assert("Linear: A done → B opens", statusOf(g, "B") === "IN_PROGRESS");
|
|
assert("Linear: C stays BLOCKED", statusOf(g, "C") === "BLOCKED");
|
|
assert("Linear: D stays BLOCKED", statusOf(g, "D") === "BLOCKED");
|
|
|
|
g = complete(g, "B");
|
|
assert("Linear: B done → C opens", statusOf(g, "C") === "IN_PROGRESS");
|
|
assert("Linear: D still BLOCKED", statusOf(g, "D") === "BLOCKED");
|
|
|
|
g = complete(g, "C");
|
|
assert("Linear: C done → D opens", statusOf(g, "D") === "IN_PROGRESS");
|
|
}
|
|
|
|
section("Shape: fan-out A → {B, C, D}");
|
|
{
|
|
let g = pipelineFromGraph([
|
|
["A", "B"],
|
|
["A", "C"],
|
|
["A", "D"],
|
|
]);
|
|
g = g.map((s) =>
|
|
s.template.slug === "A" ? { ...s, status: "IN_PROGRESS" as StageStatus } : s
|
|
);
|
|
|
|
g = complete(g, "A");
|
|
assert("Fan-out: B opens", statusOf(g, "B") === "IN_PROGRESS");
|
|
assert("Fan-out: C opens", statusOf(g, "C") === "IN_PROGRESS");
|
|
assert("Fan-out: D opens", statusOf(g, "D") === "IN_PROGRESS");
|
|
}
|
|
|
|
section("Shape: fan-in {A, B, C} → D");
|
|
{
|
|
let g = pipelineFromGraph(
|
|
[
|
|
["A", "D"],
|
|
["B", "D"],
|
|
["C", "D"],
|
|
],
|
|
{ A: 1, B: 1, C: 1, D: 2 }
|
|
);
|
|
g = g.map((s) =>
|
|
["A", "B", "C"].includes(s.template.slug)
|
|
? { ...s, status: "IN_PROGRESS" as StageStatus }
|
|
: s
|
|
);
|
|
|
|
g = complete(g, "A");
|
|
assert("Fan-in: D BLOCKED with only A done", statusOf(g, "D") === "BLOCKED");
|
|
g = complete(g, "B");
|
|
assert("Fan-in: D BLOCKED with A+B done", statusOf(g, "D") === "BLOCKED");
|
|
g = complete(g, "C");
|
|
assert(
|
|
"Fan-in: D opens only after all three predecessors done",
|
|
statusOf(g, "D") === "IN_PROGRESS"
|
|
);
|
|
}
|
|
|
|
section("Shape: diamond A → {B, C} → D");
|
|
{
|
|
let g = pipelineFromGraph(
|
|
[
|
|
["A", "B"],
|
|
["A", "C"],
|
|
["B", "D"],
|
|
["C", "D"],
|
|
],
|
|
{ A: 1, B: 2, C: 2, D: 3 }
|
|
);
|
|
g = g.map((s) =>
|
|
s.template.slug === "A" ? { ...s, status: "IN_PROGRESS" as StageStatus } : s
|
|
);
|
|
|
|
g = complete(g, "A");
|
|
assert("Diamond: A done → B opens", statusOf(g, "B") === "IN_PROGRESS");
|
|
assert("Diamond: A done → C opens", statusOf(g, "C") === "IN_PROGRESS");
|
|
assert("Diamond: D still BLOCKED", statusOf(g, "D") === "BLOCKED");
|
|
|
|
g = complete(g, "B");
|
|
assert(
|
|
"Diamond: only B done → D still BLOCKED (also waits for C)",
|
|
statusOf(g, "D") === "BLOCKED"
|
|
);
|
|
g = complete(g, "C");
|
|
assert(
|
|
"Diamond: B+C done → D opens",
|
|
statusOf(g, "D") === "IN_PROGRESS"
|
|
);
|
|
}
|
|
|
|
section("Shape: long chain (10 stages)");
|
|
{
|
|
const slugs = ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10"];
|
|
const edges: Array<[string, string]> = [];
|
|
for (let i = 0; i < slugs.length - 1; i++) {
|
|
edges.push([slugs[i], slugs[i + 1]]);
|
|
}
|
|
let g = pipelineFromGraph(edges);
|
|
g = g.map((s) =>
|
|
s.template.slug === "s1" ? { ...s, status: "IN_PROGRESS" as StageStatus } : s
|
|
);
|
|
|
|
for (let i = 0; i < slugs.length - 1; i++) {
|
|
g = complete(g, slugs[i]);
|
|
assert(
|
|
`Chain step ${i + 1}: ${slugs[i + 1]} opens after ${slugs[i]} completes`,
|
|
statusOf(g, slugs[i + 1]) === "IN_PROGRESS"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Rework reset planner (mirrors applyRework's where-clause logic) ─────
|
|
// applyRework resets stages where `order > targetOrder && order <=
|
|
// currentOrder && status NOT IN (SKIPPED, NOT_STARTED)` to NOT_STARTED.
|
|
// This pure function reproduces that math so we can verify it from
|
|
// arbitrary pipeline + rework configurations without spinning up a DB.
|
|
|
|
function planReworkReset(
|
|
stages: Stage[],
|
|
sourceSlug: string,
|
|
targetSlug: string
|
|
): string[] {
|
|
const source = stages.find((s) => s.template.slug === sourceSlug)!;
|
|
const target = stages.find((s) => s.template.slug === targetSlug)!;
|
|
if (target.order >= source.order) {
|
|
throw new Error("Target must have lower order than source for rework");
|
|
}
|
|
return stages
|
|
.filter(
|
|
(s) =>
|
|
s.order > target.order &&
|
|
s.order <= source.order &&
|
|
s.status !== "SKIPPED" &&
|
|
s.status !== "NOT_STARTED"
|
|
)
|
|
.map((s) => s.template.slug)
|
|
.sort();
|
|
}
|
|
|
|
section("Rework: linear pipeline, rework from far end back to head");
|
|
{
|
|
let g = pipelineFromGraph([
|
|
["A", "B"],
|
|
["B", "C"],
|
|
["C", "D"],
|
|
]);
|
|
// Walk the whole pipeline to APPROVED, then simulate rework D → A.
|
|
g = g.map((s) =>
|
|
s.template.slug === "A" ? { ...s, status: "IN_PROGRESS" as StageStatus } : s
|
|
);
|
|
g = complete(g, "A");
|
|
g = complete(g, "B");
|
|
g = complete(g, "C");
|
|
g = complete(g, "D");
|
|
assert("Rework setup: all four stages APPROVED", statusOf(g, "A") === "APPROVED" && statusOf(g, "D") === "APPROVED");
|
|
|
|
const toReset = planReworkReset(g, "D", "A");
|
|
assert(
|
|
"Rework D → A: B, C, D reset; A untouched",
|
|
toReset.join(",") === "B,C,D",
|
|
`actual: ${toReset.join(",")}`
|
|
);
|
|
}
|
|
|
|
section("Rework: SKIPPED stages stay SKIPPED across rework");
|
|
{
|
|
let g = pipelineFromGraph([
|
|
["A", "B"],
|
|
["B", "C"],
|
|
["C", "D"],
|
|
]);
|
|
// Simulate: A done, B skipped, C done, D done.
|
|
g = g.map((s) => {
|
|
if (s.template.slug === "A") return { ...s, status: "APPROVED" as StageStatus };
|
|
if (s.template.slug === "B") return { ...s, status: "SKIPPED" as StageStatus };
|
|
if (s.template.slug === "C") return { ...s, status: "APPROVED" as StageStatus };
|
|
if (s.template.slug === "D") return { ...s, status: "APPROVED" as StageStatus };
|
|
return s;
|
|
});
|
|
|
|
const toReset = planReworkReset(g, "D", "A");
|
|
assert(
|
|
"Rework D → A skipping B: only C, D reset; B stays SKIPPED",
|
|
toReset.join(",") === "C,D",
|
|
`actual: ${toReset.join(",")}`
|
|
);
|
|
}
|
|
|
|
section("Rework: short rework D → C only resets D");
|
|
{
|
|
let g = pipelineFromGraph([
|
|
["A", "B"],
|
|
["B", "C"],
|
|
["C", "D"],
|
|
]);
|
|
// All four APPROVED, then short rework from D back to C.
|
|
g = g.map((s) => ({ ...s, status: "APPROVED" as StageStatus }));
|
|
const toReset = planReworkReset(g, "D", "C");
|
|
assert(
|
|
"Rework D → C: only D resets (single-step back)",
|
|
toReset.join(",") === "D",
|
|
`actual: ${toReset.join(",")}`
|
|
);
|
|
}
|
|
|
|
section("Rework: user's pipeline — Declined → Inputfile resets everything between");
|
|
{
|
|
// Match the user's screenshot exactly.
|
|
const stages: Stage[] = [
|
|
{ name: "inputfile", order: 1 },
|
|
{ name: "internal-approval", order: 2 },
|
|
{ name: "approved", order: 3 },
|
|
{ name: "declined", order: 3 },
|
|
{ name: "delivery", order: 4 },
|
|
].map(({ name, order }) => ({
|
|
id: `ds_${name}`,
|
|
status: "APPROVED" as StageStatus, // pretend they've all completed
|
|
order,
|
|
template: {
|
|
id: `tpl_${name}`,
|
|
slug: name,
|
|
isCriticalGate: name === "internal-approval",
|
|
isOptional: false,
|
|
dependsOn: [],
|
|
},
|
|
stageDefinition: { id: `def_${name}`, dependsOn: [] },
|
|
}));
|
|
|
|
const toReset = planReworkReset(stages, "declined", "inputfile");
|
|
assert(
|
|
"User pipeline rework Declined → Inputfile: internal-approval + approved + declined all reset; inputfile spared",
|
|
!toReset.includes("inputfile") &&
|
|
toReset.includes("internal-approval") &&
|
|
toReset.includes("declined"),
|
|
`actual: ${toReset.join(",")}`
|
|
);
|
|
}
|
|
|
|
// ─── Outcome-branch routing ───────────────────────────────────────────────
|
|
// User's pipeline with branch tags: Approved+Declined are downstream of
|
|
// the FORMAL parent, but only one fires per decision. The other goes
|
|
// SKIPPED (visually grayed out, blocked from use).
|
|
|
|
section("Branch routing — APPROVE picks the Approved path, skips Declined");
|
|
{
|
|
// Setup: parent (internal-approval) just hit APPROVED.
|
|
let pipeline = buildPipeline({ tagBranches: true });
|
|
pipeline = pipeline.map((s) =>
|
|
s.template.slug === "inputfile" ? { ...s, status: "APPROVED" as StageStatus } : s
|
|
);
|
|
pipeline = pipeline.map((s) =>
|
|
s.template.slug === "internal-approval"
|
|
? { ...s, status: "APPROVED" as StageStatus }
|
|
: s
|
|
);
|
|
const parent = pipeline.find((s) => s.template.slug === "internal-approval")!;
|
|
|
|
const unblock = getStageIdsToUnblock(parent, pipeline);
|
|
const skip = getBranchStagesToSkipOnApprove(parent, pipeline);
|
|
|
|
// Only Approved branch opens; Declined branch goes SKIPPED.
|
|
const unblockSlugs = pipeline
|
|
.filter((s) => unblock.includes(s.id))
|
|
.map((s) => s.template.slug)
|
|
.sort();
|
|
const skipSlugs = pipeline
|
|
.filter((s) => skip.includes(s.id))
|
|
.map((s) => s.template.slug)
|
|
.sort();
|
|
|
|
assert(
|
|
"APPROVED path: only 'approved' branch unblocks",
|
|
unblockSlugs.length === 1 && unblockSlugs[0] === "approved",
|
|
`unblock=[${unblockSlugs.join(",")}]`
|
|
);
|
|
assert(
|
|
"APPROVED path: 'declined' branch is auto-SKIPPED",
|
|
skipSlugs.length === 1 && skipSlugs[0] === "declined",
|
|
`skip=[${skipSlugs.join(",")}]`
|
|
);
|
|
|
|
// Sanity: without branch tags, BOTH children would unblock (old behaviour).
|
|
const untaggedPipeline = buildPipeline({ tagBranches: false }).map((s) =>
|
|
s.template.slug === "inputfile" || s.template.slug === "internal-approval"
|
|
? { ...s, status: "APPROVED" as StageStatus }
|
|
: s
|
|
);
|
|
const untaggedParent = untaggedPipeline.find(
|
|
(s) => s.template.slug === "internal-approval"
|
|
)!;
|
|
const untaggedUnblock = getStageIdsToUnblock(
|
|
untaggedParent,
|
|
untaggedPipeline
|
|
).map((id) => untaggedPipeline.find((s) => s.id === id)!.template.slug);
|
|
assert(
|
|
"Untagged pipeline opens BOTH children (regression check)",
|
|
untaggedUnblock.includes("approved") &&
|
|
untaggedUnblock.includes("declined"),
|
|
`untaggedUnblock=[${untaggedUnblock.sort().join(",")}]`
|
|
);
|
|
}
|
|
|
|
section("Branch routing — REJECT stamps Declined, skips Approved");
|
|
{
|
|
let pipeline = buildPipeline({ tagBranches: true });
|
|
pipeline = pipeline.map((s) =>
|
|
s.template.slug === "inputfile" ? { ...s, status: "APPROVED" as StageStatus } : s
|
|
);
|
|
// Parent is in flight — Request Changes about to fire.
|
|
pipeline = pipeline.map((s) =>
|
|
s.template.slug === "internal-approval"
|
|
? { ...s, status: "IN_REVIEW" as StageStatus }
|
|
: s
|
|
);
|
|
const parent = pipeline.find((s) => s.template.slug === "internal-approval")!;
|
|
|
|
const { idsToStampApproved, idsToSkip } = getBranchOutcomeOnReject(
|
|
parent,
|
|
pipeline
|
|
);
|
|
const stampedSlugs = pipeline
|
|
.filter((s) => idsToStampApproved.includes(s.id))
|
|
.map((s) => s.template.slug)
|
|
.sort();
|
|
const skippedSlugs = pipeline
|
|
.filter((s) => idsToSkip.includes(s.id))
|
|
.map((s) => s.template.slug)
|
|
.sort();
|
|
|
|
assert(
|
|
"REJECT path: 'declined' branch is auto-stamped APPROVED",
|
|
stampedSlugs.length === 1 && stampedSlugs[0] === "declined",
|
|
`stamped=[${stampedSlugs.join(",")}]`
|
|
);
|
|
assert(
|
|
"REJECT path: 'approved' branch is auto-SKIPPED",
|
|
skippedSlugs.length === 1 && skippedSlugs[0] === "approved",
|
|
`skipped=[${skippedSlugs.join(",")}]`
|
|
);
|
|
}
|
|
|
|
section("Branch routing — generalizes to N-way splits");
|
|
{
|
|
// 3-way split: parent → { approved-a, approved-b, declined }.
|
|
// Two APPROVED branches both open on approve; one DECLINED is skipped.
|
|
const stages: Stage[] = [
|
|
{
|
|
id: "ds_parent",
|
|
status: "APPROVED",
|
|
order: 1,
|
|
template: {
|
|
id: "tpl_parent",
|
|
slug: "parent",
|
|
isCriticalGate: true,
|
|
isOptional: false,
|
|
dependsOn: [],
|
|
},
|
|
stageDefinition: {
|
|
id: "def_parent",
|
|
branchKind: "NONE",
|
|
dependsOn: [],
|
|
},
|
|
},
|
|
...["approved-a", "approved-b"].map<Stage>((slug) => ({
|
|
id: `ds_${slug}`,
|
|
status: "BLOCKED",
|
|
order: 2,
|
|
template: {
|
|
id: `tpl_${slug}`,
|
|
slug,
|
|
isCriticalGate: false,
|
|
isOptional: false,
|
|
dependsOn: [],
|
|
},
|
|
stageDefinition: {
|
|
id: `def_${slug}`,
|
|
branchKind: "APPROVED",
|
|
dependsOn: [{ prerequisiteId: "def_parent" }],
|
|
},
|
|
})),
|
|
{
|
|
id: "ds_declined",
|
|
status: "BLOCKED",
|
|
order: 2,
|
|
template: {
|
|
id: "tpl_declined",
|
|
slug: "declined",
|
|
isCriticalGate: false,
|
|
isOptional: false,
|
|
dependsOn: [],
|
|
},
|
|
stageDefinition: {
|
|
id: "def_declined",
|
|
branchKind: "DECLINED",
|
|
dependsOn: [{ prerequisiteId: "def_parent" }],
|
|
},
|
|
},
|
|
];
|
|
|
|
const parent = stages.find((s) => s.template.slug === "parent")!;
|
|
const unblock = getStageIdsToUnblock(parent, stages)
|
|
.map((id) => stages.find((s) => s.id === id)!.template.slug)
|
|
.sort();
|
|
const skip = getBranchStagesToSkipOnApprove(parent, stages)
|
|
.map((id) => stages.find((s) => s.id === id)!.template.slug)
|
|
.sort();
|
|
|
|
assert(
|
|
"3-way: BOTH APPROVED branches open on approve",
|
|
unblock.length === 2 &&
|
|
unblock.includes("approved-a") &&
|
|
unblock.includes("approved-b"),
|
|
`unblock=[${unblock.join(",")}]`
|
|
);
|
|
assert(
|
|
"3-way: DECLINED branch is skipped on approve",
|
|
skip.length === 1 && skip[0] === "declined",
|
|
`skip=[${skip.join(",")}]`
|
|
);
|
|
}
|
|
|
|
section("Branch routing — already-skipped branches are idempotent");
|
|
{
|
|
let pipeline = buildPipeline({ tagBranches: true });
|
|
pipeline = pipeline.map((s) =>
|
|
s.template.slug === "declined" ? { ...s, status: "SKIPPED" as StageStatus } : s
|
|
);
|
|
pipeline = pipeline.map((s) =>
|
|
s.template.slug === "inputfile" ? { ...s, status: "APPROVED" as StageStatus } : s
|
|
);
|
|
pipeline = pipeline.map((s) =>
|
|
s.template.slug === "internal-approval"
|
|
? { ...s, status: "APPROVED" as StageStatus }
|
|
: s
|
|
);
|
|
const parent = pipeline.find((s) => s.template.slug === "internal-approval")!;
|
|
const skip = getBranchStagesToSkipOnApprove(parent, pipeline);
|
|
assert(
|
|
"Already-SKIPPED branch is not re-targeted",
|
|
skip.length === 0,
|
|
`skip=[${skip.join(",")}]`
|
|
);
|
|
}
|
|
|
|
section("State machine — additional shortcuts");
|
|
{
|
|
// Shortcuts added in 46cd8f8 to declutter producer workflow.
|
|
for (const [from, to, expect] of [
|
|
["IN_PROGRESS", "SKIPPED", true],
|
|
["CHANGES_REQUESTED", "APPROVED", true],
|
|
["BLOCKED", "IN_PROGRESS", true], // when prereqs are now satisfied
|
|
["DELIVERED", "APPROVED", true], // downgrade if mistakenly delivered
|
|
["SKIPPED", "IN_PROGRESS", true], // unskip directly
|
|
["BLOCKED", "APPROVED", false], // still blocked — can't skip work
|
|
["NOT_STARTED", "APPROVED", false], // must start first
|
|
] as const) {
|
|
const r = canTransition(from, to);
|
|
assert(
|
|
`${from} → ${to} is ${expect ? "allowed" : "rejected"}`,
|
|
r.allowed === expect,
|
|
r.reason
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─── Summary ──────────────────────────────────────────────────────────────
|
|
console.log("\n──────────────────────────────────────────────────");
|
|
console.log(
|
|
`Result: ${passed} passed, ${failed} failed (${passed + failed} total)`
|
|
);
|
|
if (failed > 0) {
|
|
console.log("\nFailures:");
|
|
failures.forEach((f) => console.log(` • ${f}`));
|
|
process.exit(1);
|
|
}
|
|
console.log("✓ all workflow logic assertions passed");
|
|
process.exit(0);
|