diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md index 8845eb60ca..5ef9ef1f55 100644 --- a/packages/gittensory-engine/README.md +++ b/packages/gittensory-engine/README.md @@ -540,6 +540,7 @@ dashboard progress summaries: - `isPlanProgressComplete(plan)` — every step is `completed` or `skipped` (empty plans are not complete; mirrors `planProgress`'s `completed` status) - `resolvePlanOverallStatus(plan)` — coarse status (`pending` | `running` | `completed` | `failed` | `blocked`); mirrors `planProgress`'s `status` - `hasPlanReadySteps(plan)` — any step is runnable now (`pending` with satisfied dependencies; mirrors `nextReadySteps(plan).length > 0`) +- `countPlanReadySteps(plan)` — how many steps are runnable now (`pending` with satisfied dependencies; mirrors `nextReadySteps(plan).length`) - `isPlanTerminated(plan)` — plan reached a terminal outcome (`failed` step or every step `completed`/`skipped`; empty plans are not terminated) ## Opportunity competition diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 05ec54a5cf..72b786b42e 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -149,7 +149,7 @@ export { hasPlanCompletedSteps } from "./plan-completed.js"; export { isPlanBlocked } from "./plan-blocked.js"; export { isPlanProgressComplete } from "./plan-progress-complete.js"; export { resolvePlanOverallStatus, type PlanOverallStatus } from "./plan-overall-status.js"; -export { hasPlanReadySteps } from "./plan-ready.js"; +export { hasPlanReadySteps, countPlanReadySteps } from "./plan-ready.js"; export { isPlanTerminated } from "./plan-terminated.js"; export * from "./plan-templates.js"; export * from "./portfolio/queue.js"; diff --git a/packages/gittensory-engine/src/plan-ready.ts b/packages/gittensory-engine/src/plan-ready.ts index a3081be483..5089760c99 100644 --- a/packages/gittensory-engine/src/plan-ready.ts +++ b/packages/gittensory-engine/src/plan-ready.ts @@ -16,3 +16,11 @@ function nextReadySteps(plan: PlanDag): PlanStep[] { export function hasPlanReadySteps(plan: PlanDag): boolean { return nextReadySteps(plan).length > 0; } + +/** + * Return how many steps are runnable now: `pending` with every dependency `completed` or `skipped`. Mirrors hosted + * `nextReadySteps(plan).length`. Pure. + */ +export function countPlanReadySteps(plan: PlanDag): number { + return nextReadySteps(plan).length; +} diff --git a/test/unit/plan-ready-count.test.ts b/test/unit/plan-ready-count.test.ts new file mode 100644 index 0000000000..35962fc719 --- /dev/null +++ b/test/unit/plan-ready-count.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import { countPlanReadySteps } from "../../packages/gittensory-engine/src/plan-ready"; +import type { PlanStep } from "../../packages/gittensory-engine/src/plan-export"; +import { nextReadySteps } from "../../src/services/plan-dag"; + +function step(over: Partial & { id: string; title: string }): PlanStep { + return { + actionClass: undefined, + dependsOn: [], + status: "pending", + attempts: 0, + maxAttempts: 3, + lastError: null, + ...over, + }; +} + +describe("countPlanReadySteps", () => { + it("returns zero for an empty plan", () => { + expect(countPlanReadySteps({ steps: [] })).toBe(0); + }); + + it("returns one when a single pending step has no dependencies", () => { + expect( + countPlanReadySteps({ + steps: [step({ id: "a", title: "Build", status: "pending" })], + }), + ).toBe(1); + }); + + it("returns two when two independent pending steps are ready", () => { + expect( + countPlanReadySteps({ + steps: [ + step({ id: "a", title: "Build", status: "pending" }), + step({ id: "b", title: "Test", status: "pending" }), + ], + }), + ).toBe(2); + }); + + it("returns one when only the root pending step is ready in a chain", () => { + expect( + countPlanReadySteps({ + steps: [ + step({ id: "a", title: "Build", status: "pending" }), + step({ id: "b", title: "Test", status: "pending", dependsOn: ["a"] }), + ], + }), + ).toBe(1); + }); + + it("returns one when a pending step's dependencies are satisfied", () => { + expect( + countPlanReadySteps({ + steps: [ + step({ id: "a", title: "Build", status: "completed" }), + step({ id: "b", title: "Test", status: "pending", dependsOn: ["a"] }), + ], + }), + ).toBe(1); + }); + + it("returns zero for a cyclic deadlock with no ready steps", () => { + expect( + countPlanReadySteps({ + steps: [ + step({ id: "a", title: "A", dependsOn: ["b"] }), + step({ id: "b", title: "B", dependsOn: ["a"] }), + ], + }), + ).toBe(0); + }); + + it("returns zero when a pending step depends on a missing step id", () => { + expect( + countPlanReadySteps({ + steps: [step({ id: "a", title: "A", dependsOn: ["ghost"] })], + }), + ).toBe(0); + }); + + it("returns zero when every step is completed or skipped", () => { + expect( + countPlanReadySteps({ + steps: [ + step({ id: "a", title: "Build", status: "completed" }), + step({ id: "b", title: "Deploy", status: "skipped" }), + ], + }), + ).toBe(0); + }); + + it("matches hosted nextReadySteps(plan).length", () => { + const plan = { + steps: [ + step({ id: "a", title: "Build", status: "completed" }), + step({ id: "b", title: "Test", status: "pending", dependsOn: ["a"] }), + step({ id: "c", title: "Deploy", status: "pending" }), + ], + }; + expect(countPlanReadySteps(plan)).toBe(nextReadySteps(plan).length); + }); + + it("is exported from the package barrel", async () => { + const barrel = await import("../../packages/gittensory-engine/src/index"); + expect(typeof barrel.countPlanReadySteps).toBe("function"); + expect( + barrel.countPlanReadySteps({ + steps: [step({ id: "a", title: "A", status: "pending" })], + }), + ).toBe(1); + }); +});