dow-prod-tracker/scripts/test-workflow-logic.ts
DJP e80ce5b32f Add workflow logic smoke test (19/19 passing)
Self-contained tsx script that exercises the pure state-machine +
dependency-engine logic against the user's exact 5-stage pipeline
shape (Inputfile → Internal Approval ⇉ Approved/Declined → Delivery)
— no DB needed.

Catches the regression from earlier today where `Cannot transition
from IN_PROGRESS to APPROVED` blocked the NONE-stage Mark Complete
shortcut, plus verifies:
  - Order-based dependency fallback (when no V2 edges are drawn)
  - Multi-branch unblock (approving Internal Approval opens BOTH
    Approved and Declined simultaneously)
  - Downstream-of-downstream stays correctly BLOCKED until its
    specific parent completes (Delivery waits for Approved, not for
    Internal Approval)

Run: npx tsx scripts/test-workflow-logic.ts
Non-zero exit on any failure. 19/19 assertions pass against current
HEAD.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:28:14 -04:00

320 lines
10 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,
} 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;
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(): Stage[] {
const stage = (
slug: string,
order: number,
deps: string[],
extra: Partial<Pick<Stage["template"], "isCriticalGate" | "isOptional">> = {}
): 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}`,
dependsOn: deps.map((d) => ({ prerequisiteId: `def_${d}` })),
},
});
return [
stage("inputfile", 1, []),
stage("internal-approval", 2, ["inputfile"], { isCriticalGate: true }),
stage("approved", 3, ["internal-approval"]),
stage("declined", 3, ["internal-approval"]),
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(", ")}`
);
}
// ─── 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);