Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions src/__tests__/planLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, it, expect } from "vitest";
import {
deriveLifecycleState,
LIFECYCLE_LABEL,
buildRefactorFleet,
buildRefactorIssues,
type LifecycleSignals,
} from "../lib/planLifecycle";

// ── deriveLifecycleState ──────────────────────────────────────────────────────

describe("deriveLifecycleState (#458 lifecycle state)", () => {
const active = (overrides?: Partial<LifecycleSignals>): LifecycleSignals =>
({ isExisting: true, totalIssues: 10, closedIssues: 0, planGradeScore: 0.8, ...overrides });

it("returns 'new' when the project is not yet published", () => {
expect(deriveLifecycleState({ isExisting: false, totalIssues: 20, closedIssues: 15 })).toBe("new");
});

it("returns 'active' for an existing project with < 50% closure", () => {
expect(deriveLifecycleState(active({ closedIssues: 4 }))).toBe("active"); // 40%
});

it("returns 'near-complete' when ≥75% of issues are closed", () => {
expect(deriveLifecycleState(active({ closedIssues: 8 }))).toBe("near-complete"); // 80%
expect(deriveLifecycleState(active({ closedIssues: 7, totalIssues: 9 }))).toBe("near-complete"); // ~78%
expect(deriveLifecycleState(active({ closedIssues: 75, totalIssues: 100 }))).toBe("near-complete"); // 75%
});

it("returns 'near-complete' at 50%+ closure when the plan grade is B or better (≥0.75)", () => {
expect(deriveLifecycleState(active({ closedIssues: 6, planGradeScore: 0.80 }))).toBe("near-complete"); // 60%+B
expect(deriveLifecycleState(active({ closedIssues: 5, planGradeScore: 0.75 }))).toBe("near-complete"); // 50%+B
});

it("remains 'active' at 50%+ closure when the grade is below B", () => {
expect(deriveLifecycleState(active({ closedIssues: 6, planGradeScore: 0.60 }))).toBe("active"); // 60%+C
expect(deriveLifecycleState(active({ closedIssues: 5, planGradeScore: 0.74 }))).toBe("active"); // 50%+C
});

it("returns 'active' for a plan with no issues (ratio = 0)", () => {
expect(deriveLifecycleState(active({ totalIssues: 0, closedIssues: 0 }))).toBe("active");
});

it("LIFECYCLE_LABEL maps all states", () => {
expect(LIFECYCLE_LABEL["new"]).toBe("drafting");
expect(LIFECYCLE_LABEL["active"]).toBe("expanding");
expect(LIFECYCLE_LABEL["near-complete"]).toBe("near-complete");
});
});

// ── buildRefactorFleet ────────────────────────────────────────────────────────

describe("buildRefactorFleet (#458 refactor fleet shape)", () => {
it("produces two non-overlapping streams per repo (frontend + backend)", () => {
const fleet = buildRefactorFleet(["acme/web"]);
expect(fleet.streams).toHaveLength(2);
const [fe, be] = fleet.streams;
// fe owns src/**, be owns src-tauri/** — guaranteed non-overlapping
expect(fe.owns).toContain("src/**");
expect(be.owns).toContain("src-tauri/**");
// No overlap between the two own globs
expect(fe.owns.some(o => be.owns.includes(o))).toBe(false);
});

it("ids are lowercase-hyphen slugs (valid git branch names)", () => {
const fleet = buildRefactorFleet(["My-Org/My Repo"]);
for (const s of fleet.streams) {
expect(s.id).toMatch(/^[a-z0-9-]+$/);
}
});

it("produces non-overlapping streams across multiple repos", () => {
const fleet = buildRefactorFleet(["acme/web", "acme/api"]);
expect(fleet.streams).toHaveLength(4);
// Each stream owns either src/** or src-tauri/**
// Deduplicated: only two unique globs
const unique = [...new Set(fleet.streams.flatMap(s => s.owns))];
expect(unique).toHaveLength(2); // src/** and src-tauri/**
// No two streams with the SAME repo own the same glob
for (let i = 0; i < fleet.streams.length; i++) {
for (let j = i + 1; j < fleet.streams.length; j++) {
const a = fleet.streams[i], b = fleet.streams[j];
if (a.repo === b.repo) {
expect(a.owns.some(o => b.owns.includes(o))).toBe(false);
}
}
}
});

it("enables the director when there are multiple repos", () => {
expect(buildRefactorFleet(["a/b", "c/d"]).director.enabled).toBe(true);
});

it("enables the director even for a single repo (refactor PRs need a reviewer)", () => {
expect(buildRefactorFleet(["a/b"]).director.enabled).toBe(true);
});

it("all streams have confirm-gate push (human reviews before landing)", () => {
const fleet = buildRefactorFleet(["acme/web"]);
for (const s of fleet.streams) {
expect(s.flow?.push).toBe("push-confirm");
expect(s.flow?.gate).toBe("hard");
}
});

it("returns an empty fleet for an empty repos list", () => {
expect(buildRefactorFleet([]).streams).toHaveLength(0);
});
});

// ── buildRefactorIssues ───────────────────────────────────────────────────────

describe("buildRefactorIssues (#458 agent-ready refactor issues)", () => {
it("produces agent-ready issues with acceptance criteria and owned files", () => {
const issues = buildRefactorIssues(["acme/web"]);
expect(issues.length).toBeGreaterThan(0);
for (const i of issues) {
expect(i.acceptance.length).toBeGreaterThan(0);
expect(i.owns.length).toBeGreaterThan(0);
expect(i.stream).toBeTruthy();
expect(i.phase).toBe("Refactor");
}
});

it("assigns frontend issues to the -fe stream and backend to -be stream", () => {
const issues = buildRefactorIssues(["acme/web"]);
const feIssues = issues.filter(i => i.stream?.endsWith("-refactor-fe"));
const beIssues = issues.filter(i => i.stream?.endsWith("-refactor-be"));
expect(feIssues.length).toBeGreaterThan(0);
expect(beIssues.length).toBeGreaterThan(0);
for (const i of feIssues) expect(i.owns).toContain("src/**");
for (const i of beIssues) expect(i.owns).toContain("src-tauri/**");
});

it("scales to multiple repos — separate issues per repo", () => {
const single = buildRefactorIssues(["acme/web"]);
const multi = buildRefactorIssues(["acme/web", "acme/api"]);
expect(multi.length).toBe(single.length * 2);
});

it("issue refs are stable and unique", () => {
const issues = buildRefactorIssues(["acme/web", "acme/api"]);
const refs = issues.map(i => i.ref);
expect(new Set(refs).size).toBe(refs.length);
});
});
239 changes: 239 additions & 0 deletions src/__tests__/planSeamGraph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { describe, it, expect } from "vitest";
import { buildSeamGraph, type SeamGraph } from "../lib/planSeamGraph";
import type { PlanIssue } from "../screens/projects/planIssues";

// ── helpers ───────────────────────────────────────────────────────────────────

function issue(ref: string, overrides: Partial<PlanIssue> = {}): PlanIssue {
return {
ref,
title: `Issue ${ref}`,
acceptance: [],
owns: [],
dependsOn: [],
labels: [],
...overrides,
};
}

function layersOf(g: SeamGraph): Map<string, number> {
return new Map(g.nodes.map(n => [n.id, n.layer]));
}

// ── buildSeamGraph ────────────────────────────────────────────────────────────

describe("buildSeamGraph (#294 seam graph builder)", () => {

it("returns an empty graph for an empty issue list", () => {
const g = buildSeamGraph([]);
expect(g.nodes).toHaveLength(0);
expect(g.edges).toHaveLength(0);
expect(g.layerCount).toBe(0);
expect(g.danglingCount).toBe(0);
});

it("places a single node with no deps in layer 0", () => {
const g = buildSeamGraph([issue("A")]);
expect(g.nodes).toHaveLength(1);
expect(g.nodes[0].layer).toBe(0);
expect(g.nodes[0].order).toBe(0);
expect(g.edges).toHaveLength(0);
});

it("builds a linear chain with increasing layers", () => {
// A → B → C: A is layer 0, B is layer 1, C is layer 2.
const issues = [
issue("A"),
issue("B", { dependsOn: ["A"] }),
issue("C", { dependsOn: ["B"] }),
];
const g = buildSeamGraph(issues);
const layers = layersOf(g);
expect(layers.get("A")).toBe(0);
expect(layers.get("B")).toBe(1);
expect(layers.get("C")).toBe(2);
expect(g.layerCount).toBe(3);
expect(g.danglingCount).toBe(0);
});

it("uses the longest path (critical path) for diamond DAGs", () => {
// A → B, A → C, B → D, C → D.
// Shortest path to D via A→B→D is 2; via A→C→D is also 2. Layer of D = 2.
const issues = [
issue("A"),
issue("B", { dependsOn: ["A"] }),
issue("C", { dependsOn: ["A"] }),
issue("D", { dependsOn: ["B", "C"] }),
];
const g = buildSeamGraph(issues);
const layers = layersOf(g);
expect(layers.get("A")).toBe(0);
expect(layers.get("B")).toBe(1);
expect(layers.get("C")).toBe(1);
expect(layers.get("D")).toBe(2);
expect(g.layerCount).toBe(3);
});

it("uses the longest path when branches have unequal depth", () => {
// A → B → C → E, A → D → E.
// Depth of E via A→B→C: 3; via A→D: 2. Longest path places E at layer 3.
const issues = [
issue("A"),
issue("B", { dependsOn: ["A"] }),
issue("C", { dependsOn: ["B"] }),
issue("D", { dependsOn: ["A"] }),
issue("E", { dependsOn: ["C", "D"] }),
];
const g = buildSeamGraph(issues);
const layers = layersOf(g);
expect(layers.get("E")).toBe(3);
expect(layers.get("D")).toBe(1);
});

it("marks dangling deps and counts them", () => {
// B depends on X which is not in the issue list.
const g = buildSeamGraph([issue("A"), issue("B", { dependsOn: ["X"] })]);
expect(g.danglingCount).toBe(1);
const dangling = g.edges.filter(e => e.dangling);
expect(dangling).toHaveLength(1);
expect(dangling[0].from).toBe("X");
expect(dangling[0].to).toBe("B");
});

it("dangling deps do not affect layer assignment (B still lands at layer 0)", () => {
// B has a dep on absent X; without incoming non-dangling edges B is a source.
const g = buildSeamGraph([issue("A"), issue("B", { dependsOn: ["X"] })]);
const layers = layersOf(g);
expect(layers.get("B")).toBe(0);
});

it("multiple dangling deps accumulate in danglingCount", () => {
const g = buildSeamGraph([issue("A", { dependsOn: ["X", "Y", "Z"] })]);
expect(g.danglingCount).toBe(3);
});

it("filterRepo restricts nodes to the specified repo", () => {
const issues = [
issue("A", { repo: "org/web" }),
issue("B", { repo: "org/api" }),
issue("C", { repo: "org/web" }),
];
const g = buildSeamGraph(issues, "org/web");
expect(g.nodes.map(n => n.id).sort()).toEqual(["A", "C"]);
});

it("filterRepo marks cross-repo deps as dangling", () => {
// C (web) depends on B (api); when filtered to web, dep on B is dangling.
const issues = [
issue("A", { repo: "org/web" }),
issue("B", { repo: "org/api" }),
issue("C", { repo: "org/web", dependsOn: ["B"] }),
];
const g = buildSeamGraph(issues, "org/web");
expect(g.danglingCount).toBe(1);
expect(g.edges[0].dangling).toBe(true);
});

it("with no filterRepo, all issues are included and cross-repo deps resolve", () => {
const issues = [
issue("A", { repo: "org/web" }),
issue("B", { repo: "org/api", dependsOn: ["A"] }),
];
const g = buildSeamGraph(issues);
expect(g.nodes).toHaveLength(2);
expect(g.danglingCount).toBe(0);
const layers = layersOf(g);
expect(layers.get("A")).toBe(0);
expect(layers.get("B")).toBe(1);
});

it("carries owns and acceptance onto each SeamNode for drill-down", () => {
const accepts = ["criterion one", "criterion two"];
const owns = ["src/foo/**"];
const g = buildSeamGraph([issue("A", { acceptance: accepts, owns })]);
const n = g.nodes.find(n => n.id === "A")!;
expect(n.owns).toEqual(owns);
expect(n.acceptance).toEqual(accepts);
});

it("derives maturity: done when labels contain 'done'", () => {
const g = buildSeamGraph([issue("A", { labels: ["done"] })]);
expect(g.nodes[0].maturity).toBe("done");
});

it("derives maturity: done for 'closed' label", () => {
const g = buildSeamGraph([issue("A", { labels: ["closed"] })]);
expect(g.nodes[0].maturity).toBe("done");
});

it("derives maturity: active when ≥2 acceptance criteria AND has owns", () => {
const g = buildSeamGraph([issue("A", { acceptance: ["a", "b"], owns: ["src/**"] })]);
expect(g.nodes[0].maturity).toBe("active");
});

it("derives maturity: backlog when only acceptance criteria exist (no owns)", () => {
const g = buildSeamGraph([issue("A", { acceptance: ["a"] })]);
expect(g.nodes[0].maturity).toBe("backlog");
});

it("derives maturity: backlog when only owns exist (no acceptance)", () => {
const g = buildSeamGraph([issue("A", { owns: ["src/**"] })]);
expect(g.nodes[0].maturity).toBe("backlog");
});

it("derives maturity: stub when neither acceptance nor owns", () => {
const g = buildSeamGraph([issue("A")]);
expect(g.nodes[0].maturity).toBe("stub");
});

it("nodes within the same layer are ordered stably (by stream, then ref)", () => {
const issues = [
issue("C", { stream: "alpha" }),
issue("A", { stream: "beta" }),
issue("B", { stream: "alpha" }),
];
const g = buildSeamGraph(issues);
// All in layer 0. Sorted: alpha/B, alpha/C, beta/A → orders 0,1,2
const orderByRef = new Map(g.nodes.map(n => [n.id, n.order]));
expect(orderByRef.get("B")).toBeLessThan(orderByRef.get("C")!);
expect(orderByRef.get("C")!).toBeLessThan(orderByRef.get("A")!);
});

it("carries stream and phase through to the node", () => {
const g = buildSeamGraph([issue("A", { stream: "my-stream", phase: 2 })]);
const n = g.nodes[0];
expect(n.stream).toBe("my-stream");
expect(n.phase).toBe(2);
});

it("disconnected subgraphs both land in their own layer assignments", () => {
// Two independent chains: A→B and C→D.
const issues = [
issue("A"),
issue("B", { dependsOn: ["A"] }),
issue("C"),
issue("D", { dependsOn: ["C"] }),
];
const g = buildSeamGraph(issues);
const layers = layersOf(g);
expect(layers.get("A")).toBe(0);
expect(layers.get("B")).toBe(1);
expect(layers.get("C")).toBe(0);
expect(layers.get("D")).toBe(1);
expect(g.layerCount).toBe(2);
});

it("nodes in a cycle fall back to layer 0 (no infinite loop)", () => {
// A → B → A (cycle). Kahn cannot process them, so both fall to layer 0.
const issues = [
issue("A", { dependsOn: ["B"] }),
issue("B", { dependsOn: ["A"] }),
];
// Must terminate without hanging.
const g = buildSeamGraph(issues);
expect(g.nodes).toHaveLength(2);
const layers = layersOf(g);
expect(layers.get("A")).toBe(0);
expect(layers.get("B")).toBe(0);
});
});
Loading
Loading