test: include vitest test import

This commit is contained in:
Mikael Hugo 2026-05-02 05:38:37 +02:00
parent df03312fa5
commit 0e769dbf13
9 changed files with 673 additions and 862 deletions

View file

@ -1,5 +1,5 @@
// SF — Provider Capabilities Registry Tests (ADR-005 Phase 1)
import { describe } from 'vitest';
import { describe, test } from 'vitest';
import assert from "node:assert/strict";
import {

View file

@ -180,7 +180,10 @@ export function inspectMilestoneRoadmapMarkdown(
};
const blockingIssue = getVisionAlignmentBlockingIssue(meeting);
if (blockingIssue) issues.push(blockingIssue);
if (blockingIssue) {
console.log(`[sf:debug] blockingIssue: ${blockingIssue}`);
issues.push(blockingIssue);
}
return { issues };
}

View file

@ -1,23 +1,11 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it } from "vitest";
import assert from "node:assert/strict";
import { deriveState } from "../state.js";
let passed = 0;
let failed = 0;
function assertEq<T>(actual: T, expected: T, message: string): void {
if (JSON.stringify(actual) === JSON.stringify(expected)) {
passed++;
} else {
failed++;
console.error(
` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
// ─── Fixture Helpers ───────────────────────────────────────────────────────
function createFixtureBase(): string {
@ -80,16 +68,13 @@ function cleanup(base: string): void {
}
// ═══════════════════════════════════════════════════════════════════════════
// Test Groups
// Tests
// ═══════════════════════════════════════════════════════════════════════════
async function main(): Promise<void> {
// ─── Test 1: CONTEXT-DRAFT.md only → needs-discussion ──────────────────
console.log("\n=== CONTEXT-DRAFT.md only → needs-discussion ===");
{
describe("deriveState draft-aware", () => {
it("CONTEXT-DRAFT.md only → needs-discussion", async () => {
const base = createFixtureBase();
try {
// M001 directory with only CONTEXT-DRAFT.md — no CONTEXT.md, no ROADMAP.md
writeContextDraft(
base,
"M001",
@ -98,31 +83,23 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(state.phase, "needs-discussion", "phase is needs-discussion");
assertEq(state.activeMilestone?.id, "M001", "activeMilestone id is M001");
assertEq(state.activeSlice, null, "activeSlice is null");
assertEq(state.activeTask, null, "activeTask is null");
assertEq(
state.registry[0]?.status,
"active",
"registry[0] status is active",
);
assertEq(
assert.strictEqual(state.phase, "needs-discussion");
assert.strictEqual(state.activeMilestone?.id, "M001");
assert.strictEqual(state.activeSlice, null);
assert.strictEqual(state.activeTask, null);
assert.strictEqual(state.registry[0]?.status, "active");
assert.ok(
state.nextAction.includes("Discuss"),
true,
"nextAction mentions Discuss",
);
} finally {
cleanup(base);
}
}
});
// ─── Test 2: CONTEXT.md only → pre-planning (unchanged) ───────────────
console.log("\n=== CONTEXT.md only → pre-planning (unchanged) ===");
{
it("CONTEXT.md only → pre-planning (unchanged)", async () => {
const base = createFixtureBase();
try {
// M001 directory with CONTEXT.md but no ROADMAP.md
writeContext(
base,
"M001",
@ -131,30 +108,19 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(
state.phase,
"pre-planning",
"phase is pre-planning with CONTEXT.md",
);
assertEq(state.activeMilestone?.id, "M001", "activeMilestone id is M001");
assertEq(state.activeSlice, null, "activeSlice is null");
assertEq(state.activeTask, null, "activeTask is null");
assertEq(
state.registry[0]?.status,
"active",
"registry[0] status is active",
);
assert.strictEqual(state.phase, "pre-planning");
assert.strictEqual(state.activeMilestone?.id, "M001");
assert.strictEqual(state.activeSlice, null);
assert.strictEqual(state.activeTask, null);
assert.strictEqual(state.registry[0]?.status, "active");
} finally {
cleanup(base);
}
}
});
// ─── Test 3: Both CONTEXT.md and CONTEXT-DRAFT.md → CONTEXT wins ──────
console.log("\n=== both CONTEXT.md and CONTEXT-DRAFT.md → CONTEXT wins ===");
{
it("both CONTEXT.md and CONTEXT-DRAFT.md → CONTEXT wins", async () => {
const base = createFixtureBase();
try {
// M001 has both files — CONTEXT.md should take precedence
writeContext(
base,
"M001",
@ -164,30 +130,17 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(
state.phase,
"pre-planning",
"phase is pre-planning when CONTEXT.md exists",
);
assertEq(state.activeMilestone?.id, "M001", "activeMilestone id is M001");
assertEq(
state.registry[0]?.status,
"active",
"registry[0] status is active",
);
assert.strictEqual(state.phase, "pre-planning");
assert.strictEqual(state.activeMilestone?.id, "M001");
assert.strictEqual(state.registry[0]?.status, "active");
} finally {
cleanup(base);
}
}
});
// ─── Test 4: M001 complete, M002 has CONTEXT-DRAFT → M002 needs-discussion ──
console.log(
"\n=== M001 complete, M002 has CONTEXT-DRAFT → M002 needs-discussion ===",
);
{
it("M001 complete, M002 has CONTEXT-DRAFT → M002 needs-discussion", async () => {
const base = createFixtureBase();
try {
// M001: complete (roadmap with all slices done + summary)
writeRoadmap(
base,
"M001",
@ -208,36 +161,26 @@ async function main(): Promise<void> {
"# M001 Summary\n\nFirst milestone complete.",
);
// M002: only CONTEXT-DRAFT.md
writeContextDraft(base, "M002", "# Draft for M002\n\nSeed material.");
const state = await deriveState(base);
assertEq(
state.phase,
"needs-discussion",
"phase is needs-discussion for M002",
);
assertEq(state.activeMilestone?.id, "M002", "activeMilestone id is M002");
assertEq(state.activeSlice, null, "activeSlice is null");
assertEq(state.registry.length, 2, "registry has 2 entries");
assertEq(state.registry[0]?.status, "complete", "M001 is complete");
assertEq(state.registry[1]?.status, "active", "M002 is active");
assertEq(state.progress?.milestones?.done, 1, "milestones done = 1");
assertEq(state.progress?.milestones?.total, 2, "milestones total = 2");
assert.strictEqual(state.phase, "needs-discussion");
assert.strictEqual(state.activeMilestone?.id, "M002");
assert.strictEqual(state.activeSlice, null);
assert.strictEqual(state.registry.length, 2);
assert.strictEqual(state.registry[0]?.status, "complete");
assert.strictEqual(state.registry[1]?.status, "active");
assert.strictEqual(state.progress?.milestones?.done, 1);
assert.strictEqual(state.progress?.milestones?.total, 2);
} finally {
cleanup(base);
}
}
});
// ─── Test 5: Multi-milestone: M001 complete, M002 CONTEXT-DRAFT, M003 pending ──
console.log(
"\n=== multi-milestone: M001 complete, M002 draft, M003 pending ===",
);
{
it("multi-milestone: M001 complete, M002 draft, M003 pending", async () => {
const base = createFixtureBase();
try {
// M001: complete
writeRoadmap(
base,
"M001",
@ -254,10 +197,8 @@ async function main(): Promise<void> {
writeMilestoneValidation(base, "M001");
writeMilestoneSummary(base, "M001", "# M001 Summary\n\nComplete.");
// M002: draft only — should become active with needs-discussion
writeContextDraft(base, "M002", "# M002 Draft\n\nSeed.");
// M003: milestone directory with CONTEXT — should be pending
mkdirSync(join(base, ".sf", "milestones", "M003"), { recursive: true });
writeFileSync(
join(base, ".sf", "milestones", "M003", "M003-CONTEXT.md"),
@ -266,30 +207,20 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(
state.phase,
"needs-discussion",
"phase is needs-discussion for M002",
);
assertEq(state.activeMilestone?.id, "M002", "activeMilestone is M002");
assertEq(state.registry.length, 3, "registry has 3 entries");
assertEq(state.registry[0]?.status, "complete", "M001 is complete");
assertEq(state.registry[1]?.status, "active", "M002 is active");
assertEq(state.registry[2]?.status, "pending", "M003 is pending");
assert.strictEqual(state.phase, "needs-discussion");
assert.strictEqual(state.activeMilestone?.id, "M002");
assert.strictEqual(state.registry.length, 3);
assert.strictEqual(state.registry[0]?.status, "complete");
assert.strictEqual(state.registry[1]?.status, "active");
assert.strictEqual(state.registry[2]?.status, "pending");
} finally {
cleanup(base);
}
}
});
// ─── Test 6: Milestone with ROADMAP + CONTEXT-DRAFT → ROADMAP takes precedence ──
console.log(
"\n=== milestone with ROADMAP + CONTEXT-DRAFT → normal execution ===",
);
{
it("milestone with ROADMAP + CONTEXT-DRAFT → ROADMAP takes precedence", async () => {
const base = createFixtureBase();
try {
// M001 has ROADMAP.md (active slice, incomplete tasks) and CONTEXT-DRAFT.md
// The ROADMAP should take precedence — we're past the draft phase
writeRoadmap(
base,
"M001",
@ -309,7 +240,6 @@ async function main(): Promise<void> {
"# Draft\n\nThis should be ignored — roadmap exists.",
);
// Add a plan so it goes to executing phase
writePlan(
base,
"M001",
@ -326,93 +256,49 @@ async function main(): Promise<void> {
const state = await deriveState(base);
assertEq(
state.phase,
"executing",
"phase is executing (ROADMAP takes precedence over CONTEXT-DRAFT)",
);
assertEq(state.activeMilestone?.id, "M001", "activeMilestone is M001");
assertEq(state.activeSlice?.id, "S01", "activeSlice is S01");
assertEq(state.activeTask?.id, "T01", "activeTask is T01");
assert.strictEqual(state.phase, "executing");
assert.strictEqual(state.activeMilestone?.id, "M001");
assert.strictEqual(state.activeSlice?.id, "S01");
assert.strictEqual(state.activeTask?.id, "T01");
} finally {
cleanup(base);
}
}
});
// ─── Test 7: Empty milestone dir (ghost — no files at all) → skipped ───
console.log("\n=== empty milestone dir (ghost) → skipped, pre-planning ===");
{
it("empty milestone dir (ghost) → skipped, pre-planning", async () => {
const base = createFixtureBase();
try {
// M001: just a directory, no files at all — ghost milestone, skipped
mkdirSync(join(base, ".sf", "milestones", "M001"), { recursive: true });
const state = await deriveState(base);
assertEq(
state.phase,
"pre-planning",
"phase is pre-planning for ghost milestone",
);
assertEq(
state.activeMilestone,
null,
"activeMilestone is null (ghost skipped)",
);
assertEq(state.registry.length, 0, "registry is empty (ghost skipped)");
assert.strictEqual(state.phase, "pre-planning");
assert.strictEqual(state.activeMilestone, null);
assert.strictEqual(state.registry.length, 0);
} finally {
cleanup(base);
}
}
});
// ─── Test 8: CONTEXT-DRAFT on non-first active milestone ──────────────
// M001 has no summary and no roadmap (active), M002 has CONTEXT-DRAFT
// M001 should be active (pre-planning), M002 should be pending
console.log("\n=== CONTEXT-DRAFT on non-active milestone → pending ===");
{
it("CONTEXT-DRAFT on non-active milestone → pending", async () => {
const base = createFixtureBase();
try {
// M001: has CONTEXT but no roadmap/summary → becomes active first
mkdirSync(join(base, ".sf", "milestones", "M001"), { recursive: true });
writeFileSync(
join(base, ".sf", "milestones", "M001", "M001-CONTEXT.md"),
"# M001\n\nFirst milestone.",
);
// M002: has CONTEXT-DRAFT but isn't active (M001 is first)
writeContextDraft(base, "M002", "# M002 Draft\n\nSeed.");
const state = await deriveState(base);
assertEq(
state.phase,
"pre-planning",
"phase is pre-planning (M001 is active, not M002)",
);
assertEq(state.activeMilestone?.id, "M001", "activeMilestone is M001");
assertEq(state.registry[0]?.status, "active", "M001 is active");
assertEq(state.registry[1]?.status, "pending", "M002 is pending");
assert.strictEqual(state.phase, "pre-planning");
assert.strictEqual(state.activeMilestone?.id, "M001");
assert.strictEqual(state.registry[0]?.status, "active");
assert.strictEqual(state.registry[1]?.status, "pending");
} finally {
cleanup(base);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Summary
// ═══════════════════════════════════════════════════════════════════════════
console.log(`\n${"═".repeat(60)}`);
console.log(
`Draft-aware state derivation tests: ${passed} passed, ${failed} failed`,
);
console.log("═".repeat(60));
if (failed > 0) {
process.exit(1);
}
}
main().catch((err) => {
console.error("Test suite error:", err);
process.exit(1);
});
});

View file

@ -9,104 +9,99 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createTestContext } from "./test-helpers.ts";
const { assertTrue, report } = createTestContext();
import { describe, it } from "vitest";
import assert from "node:assert/strict";
const phasesPath = join(import.meta.dirname, "..", "auto", "phases.ts");
const phasesSrc = readFileSync(phasesPath, "utf-8");
console.log("\n=== #2766: Non-MergeConflictError stops auto mode ===");
describe("#2766: Non-MergeConflictError stops auto mode", () => {
it("phases.ts file exists and is readable", () => {
assert.ok(
phasesPath.length > 0 && phasesPath.endsWith("phases.ts"),
"phases.ts file exists and is readable",
);
});
// ── Test 1: phases.ts calls logError for non-conflict merge errors ──────
it("all mergeAndExit call sites have catch (mergeErr) blocks", () => {
const mergeErrCatchCount = [...phasesSrc.matchAll(/\} catch \(mergeErr\)/g)]
.length;
assert.ok(
mergeErrCatchCount >= 3,
`all mergeAndExit call sites have catch (mergeErr) blocks (found ${mergeErrCatchCount}, expected >= 3)`,
);
});
assertTrue(
phasesPath.length > 0 && phasesPath.endsWith("phases.ts"),
"phases.ts file exists and is readable",
);
it("every mergeErr catch block handles non-MergeConflictError", () => {
const catchPattern = /\} catch \(mergeErr\) \{/g;
let match: RegExpExecArray | null;
let blocksWithNonConflictHandling = 0;
let blocksTotal = 0;
// Count all mergeAndExit catch blocks by finding "} catch (mergeErr)" patterns
const _mergeErrCatches = [...phasesPath.matchAll(/\} catch \(mergeErr\)/g)];
// Use the source itself for matching
const mergeErrCatchCount = [...phasesSrc.matchAll(/\} catch \(mergeErr\)/g)]
.length;
assertTrue(
mergeErrCatchCount >= 3,
`all mergeAndExit call sites have catch (mergeErr) blocks (found ${mergeErrCatchCount}, expected >= 3)`,
);
match = catchPattern.exec(phasesSrc);
while (match !== null) {
blocksTotal++;
const afterCatch = phasesSrc.slice(match.index, match.index + 1200);
// ── Test 2: Every mergeErr catch block handles non-MergeConflictError ───
const hasInstanceofCheck = afterCatch.includes(
"instanceof MergeConflictError",
);
const hasNonConflictStop = afterCatch.includes('reason: "merge-failed"');
const hasStopAuto = afterCatch.includes("stopAuto");
const hasLogError = afterCatch.includes("logError");
// Find each catch block and verify it has the non-conflict error handling pattern
const catchPattern = /\} catch \(mergeErr\) \{/g;
let match: RegExpExecArray | null;
let blocksWithNonConflictHandling = 0;
let blocksTotal = 0;
if (hasInstanceofCheck && hasNonConflictStop && hasStopAuto && hasLogError) {
blocksWithNonConflictHandling++;
}
match = catchPattern.exec(phasesSrc);
}
match = catchPattern.exec(phasesSrc);
while (match !== null) {
blocksTotal++;
// Look at the ~800 chars after the catch to find both the MergeConflictError
// instanceof check AND the non-conflict handling
const afterCatch = phasesSrc.slice(match.index, match.index + 1200);
assert.strictEqual(
blocksWithNonConflictHandling,
blocksTotal,
`all ${blocksTotal} mergeAndExit catch blocks stop auto on non-conflict errors (${blocksWithNonConflictHandling}/${blocksTotal})`,
);
assert.ok(
blocksTotal >= 3,
`expected at least 3 catch blocks, found ${blocksTotal}`,
);
});
const hasInstanceofCheck = afterCatch.includes(
"instanceof MergeConflictError",
);
const hasNonConflictStop = afterCatch.includes('reason: "merge-failed"');
const hasStopAuto = afterCatch.includes("stopAuto");
const hasLogError = afterCatch.includes("logError");
it("non-conflict handler returns break (does not continue)", () => {
const mergeFailedReasons = [...phasesSrc.matchAll(/reason: "merge-failed"/g)]
.length;
assert.ok(
mergeFailedReasons >= 3,
`all catch blocks return reason: "merge-failed" (found ${mergeFailedReasons}, expected >= 3)`,
);
});
if (hasInstanceofCheck && hasNonConflictStop && hasStopAuto && hasLogError) {
blocksWithNonConflictHandling++;
}
match = catchPattern.exec(phasesSrc);
}
it("non-conflict handler notifies user", () => {
const notifyErrorPattern =
/Merge failed:.*Resolve and run \/sf autonomous to resume/g;
const notifyCount = [...phasesSrc.matchAll(notifyErrorPattern)].length;
assert.ok(
notifyCount >= 3,
`all catch blocks notify user about merge failure (found ${notifyCount}, expected >= 3)`,
);
});
assertTrue(
blocksWithNonConflictHandling === blocksTotal && blocksTotal >= 3,
`all ${blocksTotal} mergeAndExit catch blocks stop auto on non-conflict errors (${blocksWithNonConflictHandling}/${blocksTotal})`,
);
it("logError replaces logWarning for non-conflict merge errors", () => {
const logWarningMergePattern =
/logWarning\(.*Milestone merge failed with non-conflict error/g;
const logWarningCount = [...phasesSrc.matchAll(logWarningMergePattern)].length;
assert.strictEqual(
logWarningCount,
0,
"logWarning is no longer used for non-conflict merge errors (replaced by logError)",
);
// ── Test 3: Non-conflict handler returns break (does not continue) ──────
// Verify the pattern: after the MergeConflictError instanceof block,
// the non-conflict path returns { action: "break", reason: "merge-failed" }
const mergeFailedReasons = [...phasesSrc.matchAll(/reason: "merge-failed"/g)]
.length;
assertTrue(
mergeFailedReasons >= 3,
`all catch blocks return reason: "merge-failed" (found ${mergeFailedReasons}, expected >= 3)`,
);
// ── Test 4: Non-conflict handler notifies user ──────────────────────────
// Each non-conflict block should call ctx.ui.notify with error severity
const notifyErrorPattern =
/Merge failed:.*Resolve and run \/sf autonomous to resume/g;
const notifyCount = [...phasesSrc.matchAll(notifyErrorPattern)].length;
assertTrue(
notifyCount >= 3,
`all catch blocks notify user about merge failure (found ${notifyCount}, expected >= 3)`,
);
// ── Test 5: logError replaces logWarning for non-conflict merge errors ──
// The old code used logWarning — verify logError is used instead
const logWarningMergePattern =
/logWarning\(.*Milestone merge failed with non-conflict error/g;
const logWarningCount = [...phasesSrc.matchAll(logWarningMergePattern)].length;
assertTrue(
logWarningCount === 0,
"logWarning is no longer used for non-conflict merge errors (replaced by logError)",
);
const logErrorMergePattern =
/logError\(.*Milestone merge failed with non-conflict error/g;
const logErrorCount = [...phasesSrc.matchAll(logErrorMergePattern)].length;
assertTrue(
logErrorCount >= 3,
`logError is used for non-conflict merge errors (found ${logErrorCount}, expected >= 3)`,
);
report();
const logErrorMergePattern =
/logError\(.*Milestone merge failed with non-conflict error/g;
const logErrorCount = [...phasesSrc.matchAll(logErrorMergePattern)].length;
assert.ok(
logErrorCount >= 3,
`logError is used for non-conflict merge errors (found ${logErrorCount}, expected >= 3)`,
);
});
});

View file

@ -1,17 +1,13 @@
import { describe, it } from "vitest";
import assert from "node:assert/strict";
import {
validateSlicePlanContent,
validateTaskPlanContent,
} from "../observability-validator.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
// ═══════════════════════════════════════════════════════════════════════════
// validateTaskPlanContent — empty/missing Steps section
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== validateTaskPlanContent: empty Steps section ===");
{
const content = `# T01: Some Task
describe("validateTaskPlanContent", () => {
it("empty Steps section produces empty_steps_section issue", () => {
const content = `# T01: Some Task
## Description
@ -24,31 +20,28 @@ Do something useful.
- Run the tests and confirm output.
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const stepsIssues = issues.filter((i) => i.ruleId === "empty_steps_section");
assertTrue(
stepsIssues.length >= 1,
"empty Steps section produces empty_steps_section issue",
);
if (stepsIssues.length > 0) {
assertEq(
stepsIssues[0].severity,
"warning",
"empty_steps_section severity is warning",
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const stepsIssues = issues.filter((i) => i.ruleId === "empty_steps_section");
assert.ok(
stepsIssues.length >= 1,
"empty Steps section produces empty_steps_section issue",
);
assertEq(
stepsIssues[0].scope,
"task-plan",
"empty_steps_section scope is task-plan",
);
}
}
if (stepsIssues.length > 0) {
assert.strictEqual(
stepsIssues[0].severity,
"warning",
"empty_steps_section severity is warning",
);
assert.strictEqual(
stepsIssues[0].scope,
"task-plan",
"empty_steps_section scope is task-plan",
);
}
});
console.log(
"\n=== validateTaskPlanContent: missing Steps section entirely ===",
);
{
const content = `# T01: Some Task
it("missing Steps section entirely produces empty_steps_section issue", () => {
const content = `# T01: Some Task
## Description
@ -59,21 +52,16 @@ Do something useful.
- Run the tests.
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const stepsIssues = issues.filter((i) => i.ruleId === "empty_steps_section");
assertTrue(
stepsIssues.length >= 1,
"missing Steps section produces empty_steps_section issue",
);
}
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const stepsIssues = issues.filter((i) => i.ruleId === "empty_steps_section");
assert.ok(
stepsIssues.length >= 1,
"missing Steps section produces empty_steps_section issue",
);
});
// ═══════════════════════════════════════════════════════════════════════════
// validateTaskPlanContent — placeholder-only Verification
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== validateTaskPlanContent: placeholder-only Verification ===");
{
const content = `# T01: Some Task
it("placeholder-only Verification produces placeholder_verification issue", () => {
const content = `# T01: Some Task
## Steps
@ -86,33 +74,30 @@ console.log("\n=== validateTaskPlanContent: placeholder-only Verification ===");
- {{another placeholder}}
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const verifyIssues = issues.filter(
(i) => i.ruleId === "placeholder_verification",
);
assertTrue(
verifyIssues.length >= 1,
"placeholder-only Verification produces placeholder_verification issue",
);
if (verifyIssues.length > 0) {
assertEq(
verifyIssues[0].severity,
"warning",
"placeholder_verification severity is warning",
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const verifyIssues = issues.filter(
(i) => i.ruleId === "placeholder_verification",
);
assertEq(
verifyIssues[0].scope,
"task-plan",
"placeholder_verification scope is task-plan",
assert.ok(
verifyIssues.length >= 1,
"placeholder-only Verification produces placeholder_verification issue",
);
}
}
if (verifyIssues.length > 0) {
assert.strictEqual(
verifyIssues[0].severity,
"warning",
"placeholder_verification severity is warning",
);
assert.strictEqual(
verifyIssues[0].scope,
"task-plan",
"placeholder_verification scope is task-plan",
);
}
});
console.log(
"\n=== validateTaskPlanContent: Verification with only template text ===",
);
{
const content = `# T01: Some Task
it("template-text-only Verification produces placeholder_verification issue", () => {
const content = `# T01: Some Task
## Steps
@ -123,99 +108,18 @@ console.log(
{{whatWasVerifiedAndHow commands run, tests passed, behavior confirmed}}
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const verifyIssues = issues.filter(
(i) => i.ruleId === "placeholder_verification",
);
assertTrue(
verifyIssues.length >= 1,
"template-text-only Verification produces placeholder_verification issue",
);
}
// ═══════════════════════════════════════════════════════════════════════════
// validateSlicePlanContent — empty inline task entries
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== validateSlicePlanContent: empty inline task entries ===");
{
const content = `# S01: Some Slice
**Goal:** Build the thing.
**Demo:** It works.
## Tasks
- [ ] **T01: First Task** \`est:20m\`
- [ ] **T02: Second Task** \`est:15m\`
## Verification
- Run the tests.
`;
const issues = validateSlicePlanContent("S01-PLAN.md", content);
const emptyTaskIssues = issues.filter((i) => i.ruleId === "empty_task_entry");
assertTrue(
emptyTaskIssues.length >= 1,
"task entries with no description produce empty_task_entry issue",
);
if (emptyTaskIssues.length > 0) {
assertEq(
emptyTaskIssues[0].severity,
"warning",
"empty_task_entry severity is warning",
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const verifyIssues = issues.filter(
(i) => i.ruleId === "placeholder_verification",
);
assertEq(
emptyTaskIssues[0].scope,
"slice-plan",
"empty_task_entry scope is slice-plan",
assert.ok(
verifyIssues.length >= 1,
"template-text-only Verification produces placeholder_verification issue",
);
}
}
});
console.log(
"\n=== validateSlicePlanContent: task entries with content are fine ===",
);
{
const content = `# S01: Some Slice
**Goal:** Build the thing.
**Demo:** It works.
## Tasks
- [ ] **T01: First Task** \`est:20m\`
- Why: Because it matters.
- Files: \`src/index.ts\`
- Do: Implement the feature.
- [ ] **T02: Second Task** \`est:15m\`
- Why: Also important.
- Do: Add tests.
## Verification
- Run the tests.
`;
const issues = validateSlicePlanContent("S01-PLAN.md", content);
const emptyTaskIssues = issues.filter((i) => i.ruleId === "empty_task_entry");
assertEq(
emptyTaskIssues.length,
0,
"task entries with description content produce no empty_task_entry issues",
);
}
// ═══════════════════════════════════════════════════════════════════════════
// validateTaskPlanContent — scope_estimate over threshold
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== validateTaskPlanContent: scope_estimate over threshold ===");
{
const content = `---
it("scope_estimate over threshold produces scope issues", () => {
const content = `---
estimated_steps: 12
estimated_files: 15
---
@ -233,49 +137,44 @@ estimated_files: 15
- Check it works.
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const stepsOverIssues = issues.filter(
(i) => i.ruleId === "scope_estimate_steps_high",
);
const filesOverIssues = issues.filter(
(i) => i.ruleId === "scope_estimate_files_high",
);
assertTrue(
stepsOverIssues.length >= 1,
"estimated_steps=12 (>=10) produces scope_estimate_steps_high issue",
);
assertTrue(
filesOverIssues.length >= 1,
"estimated_files=15 (>=12) produces scope_estimate_files_high issue",
);
if (stepsOverIssues.length > 0) {
assertEq(
stepsOverIssues[0].severity,
"warning",
"scope_estimate_steps_high severity is warning",
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const stepsOverIssues = issues.filter(
(i) => i.ruleId === "scope_estimate_steps_high",
);
assertEq(
stepsOverIssues[0].scope,
"task-plan",
"scope_estimate_steps_high scope is task-plan",
const filesOverIssues = issues.filter(
(i) => i.ruleId === "scope_estimate_files_high",
);
}
if (filesOverIssues.length > 0) {
assertEq(
filesOverIssues[0].severity,
"warning",
"scope_estimate_files_high severity is warning",
assert.ok(
stepsOverIssues.length >= 1,
"estimated_steps=12 (>=10) produces scope_estimate_steps_high issue",
);
}
}
assert.ok(
filesOverIssues.length >= 1,
"estimated_files=15 (>=12) produces scope_estimate_files_high issue",
);
if (stepsOverIssues.length > 0) {
assert.strictEqual(
stepsOverIssues[0].severity,
"warning",
"scope_estimate_steps_high severity is warning",
);
assert.strictEqual(
stepsOverIssues[0].scope,
"task-plan",
"scope_estimate_steps_high scope is task-plan",
);
}
if (filesOverIssues.length > 0) {
assert.strictEqual(
filesOverIssues[0].severity,
"warning",
"scope_estimate_files_high severity is warning",
);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// validateTaskPlanContent — scope_estimate within limits
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== validateTaskPlanContent: scope_estimate within limits ===");
{
const content = `---
it("scope_estimate within limits produces no scope issues", () => {
const content = `---
estimated_steps: 4
estimated_files: 6
---
@ -291,26 +190,21 @@ estimated_files: 6
- Verify it works.
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const scopeIssues = issues.filter(
(i) =>
i.ruleId === "scope_estimate_steps_high" ||
i.ruleId === "scope_estimate_files_high",
);
assertEq(
scopeIssues.length,
0,
"scope_estimate within limits produces no scope issues",
);
}
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const scopeIssues = issues.filter(
(i) =>
i.ruleId === "scope_estimate_steps_high" ||
i.ruleId === "scope_estimate_files_high",
);
assert.strictEqual(
scopeIssues.length,
0,
"scope_estimate within limits produces no scope issues",
);
});
// ═══════════════════════════════════════════════════════════════════════════
// validateTaskPlanContent — missing scope_estimate (no warning)
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== validateTaskPlanContent: missing scope_estimate ===");
{
const content = `# T01: No Frontmatter Task
it("missing scope_estimate produces no scope issues", () => {
const content = `# T01: No Frontmatter Task
## Steps
@ -321,24 +215,21 @@ console.log("\n=== validateTaskPlanContent: missing scope_estimate ===");
- Verify it works.
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const scopeIssues = issues.filter(
(i) =>
i.ruleId === "scope_estimate_steps_high" ||
i.ruleId === "scope_estimate_files_high",
);
assertEq(
scopeIssues.length,
0,
"missing scope_estimate produces no scope issues",
);
}
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const scopeIssues = issues.filter(
(i) =>
i.ruleId === "scope_estimate_steps_high" ||
i.ruleId === "scope_estimate_files_high",
);
assert.strictEqual(
scopeIssues.length,
0,
"missing scope_estimate produces no scope issues",
);
});
console.log(
"\n=== validateTaskPlanContent: frontmatter without scope keys ===",
);
{
const content = `---
it("frontmatter without scope keys produces no scope issues", () => {
const content = `---
id: T01
parent: S01
---
@ -354,26 +245,21 @@ parent: S01
- Verify it works.
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const scopeIssues = issues.filter(
(i) =>
i.ruleId === "scope_estimate_steps_high" ||
i.ruleId === "scope_estimate_files_high",
);
assertEq(
scopeIssues.length,
0,
"frontmatter without scope keys produces no scope issues",
);
}
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const scopeIssues = issues.filter(
(i) =>
i.ruleId === "scope_estimate_steps_high" ||
i.ruleId === "scope_estimate_files_high",
);
assert.strictEqual(
scopeIssues.length,
0,
"frontmatter without scope keys produces no scope issues",
);
});
// ═══════════════════════════════════════════════════════════════════════════
// Clean plans — no false positives
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== Clean task plan: no plan-quality issues ===");
{
const content = `---
it("clean task plan produces no plan-quality issues", () => {
const content = `---
estimated_steps: 5
estimated_files: 3
---
@ -409,24 +295,221 @@ A real task with real content.
- Failure state exposed: exit code 1 + error message on invalid input
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const planQualityIssues = issues.filter(
(i) =>
i.ruleId === "empty_steps_section" ||
i.ruleId === "placeholder_verification" ||
i.ruleId === "scope_estimate_steps_high" ||
i.ruleId === "scope_estimate_files_high",
);
assertEq(
planQualityIssues.length,
0,
"clean task plan produces no plan-quality issues",
);
}
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const planQualityIssues = issues.filter(
(i) =>
i.ruleId === "empty_steps_section" ||
i.ruleId === "placeholder_verification" ||
i.ruleId === "scope_estimate_steps_high" ||
i.ruleId === "scope_estimate_files_high",
);
assert.strictEqual(
planQualityIssues.length,
0,
"clean task plan produces no plan-quality issues",
);
});
console.log("\n=== Clean slice plan: no plan-quality issues ===");
{
const content = `# S01: Well-Formed Slice
it("Expected Output without file paths triggers missing_output_file_paths", () => {
const content = `# T01: Some Task
## Description
Do something.
## Steps
1. Do the thing
## Verification
- Check it works
## Expected Output
This task produces the main output.
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const outputIssues = issues.filter(
(i) => i.ruleId === "missing_output_file_paths",
);
assert.ok(
outputIssues.length >= 1,
"Expected Output without file paths triggers missing_output_file_paths",
);
});
it("Expected Output with file paths does not trigger warning", () => {
const content = `# T01: Some Task
## Description
Do something.
## Steps
1. Do the thing
## Verification
- Check it works
## Expected Output
- \`src/types.ts\` — New type definitions
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const outputIssues = issues.filter(
(i) => i.ruleId === "missing_output_file_paths",
);
assert.strictEqual(
outputIssues.length,
0,
"Expected Output with file paths does not trigger warning",
);
});
it("Inputs without file paths triggers missing_input_file_paths with info severity", () => {
const content = `# T01: Some Task
## Description
Do something.
## Steps
1. Do the thing
## Verification
- Check it works
## Inputs
Prior task summary insights about the architecture.
## Expected Output
- \`src/output.ts\` — Output file
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const inputIssues = issues.filter(
(i) => i.ruleId === "missing_input_file_paths",
);
assert.ok(
inputIssues.length >= 1,
"Inputs without file paths triggers missing_input_file_paths",
);
if (inputIssues.length > 0) {
assert.strictEqual(
inputIssues[0].severity,
"info",
"missing_input_file_paths is info severity (not warning)",
);
}
});
it("missing Expected Output section triggers missing_output_file_paths", () => {
const content = `# T01: Some Task
## Description
Do something.
## Steps
1. Do the thing
## Verification
- Check it works
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const outputIssues = issues.filter(
(i) => i.ruleId === "missing_output_file_paths",
);
assert.ok(
outputIssues.length >= 1,
"Missing Expected Output section triggers missing_output_file_paths",
);
});
});
describe("validateSlicePlanContent", () => {
it("task entries with no description produce empty_task_entry issue", () => {
const content = `# S01: Some Slice
**Goal:** Build the thing.
**Demo:** It works.
## Tasks
- [ ] **T01: First Task** \`est:20m\`
- [ ] **T02: Second Task** \`est:15m\`
## Verification
- Run the tests.
`;
const issues = validateSlicePlanContent("S01-PLAN.md", content);
const emptyTaskIssues = issues.filter((i) => i.ruleId === "empty_task_entry");
assert.ok(
emptyTaskIssues.length >= 1,
"task entries with no description produce empty_task_entry issue",
);
if (emptyTaskIssues.length > 0) {
assert.strictEqual(
emptyTaskIssues[0].severity,
"warning",
"empty_task_entry severity is warning",
);
assert.strictEqual(
emptyTaskIssues[0].scope,
"slice-plan",
"empty_task_entry scope is slice-plan",
);
}
});
it("task entries with description content produce no empty_task_entry issues", () => {
const content = `# S01: Some Slice
**Goal:** Build the thing.
**Demo:** It works.
## Tasks
- [ ] **T01: First Task** \`est:20m\`
- Why: Because it matters.
- Files: \`src/index.ts\`
- Do: Implement the feature.
- [ ] **T02: Second Task** \`est:15m\`
- Why: Also important.
- Do: Add tests.
## Verification
- Run the tests.
`;
const issues = validateSlicePlanContent("S01-PLAN.md", content);
const emptyTaskIssues = issues.filter((i) => i.ruleId === "empty_task_entry");
assert.strictEqual(
emptyTaskIssues.length,
0,
"task entries with description content produce no empty_task_entry issues",
);
});
it("clean slice plan produces no empty_task_entry issues", () => {
const content = `# S01: Well-Formed Slice
**Goal:** Build a complete feature.
**Demo:** Run the test suite and see all green.
@ -458,155 +541,14 @@ console.log("\n=== Clean slice plan: no plan-quality issues ===");
- Redaction constraints: none
`;
const issues = validateSlicePlanContent("S01-PLAN.md", content);
const planQualityIssues = issues.filter(
(i) => i.ruleId === "empty_task_entry",
);
assertEq(
planQualityIssues.length,
0,
"clean slice plan produces no empty_task_entry issues",
);
}
// ═══════════════════════════════════════════════════════════════════════════
// validateTaskPlanContent — missing output file paths
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== validateTaskPlanContent: missing output file paths ===");
{
const content = `# T01: Some Task
## Description
Do something.
## Steps
1. Do the thing
## Verification
- Check it works
## Expected Output
This task produces the main output.
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const outputIssues = issues.filter(
(i) => i.ruleId === "missing_output_file_paths",
);
assertTrue(
outputIssues.length >= 1,
"Expected Output without file paths triggers missing_output_file_paths",
);
}
console.log("\n=== validateTaskPlanContent: valid output file paths ===");
{
const content = `# T01: Some Task
## Description
Do something.
## Steps
1. Do the thing
## Verification
- Check it works
## Expected Output
- \`src/types.ts\` — New type definitions
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const outputIssues = issues.filter(
(i) => i.ruleId === "missing_output_file_paths",
);
assertEq(
outputIssues.length,
0,
"Expected Output with file paths does not trigger warning",
);
}
console.log(
"\n=== validateTaskPlanContent: missing input file paths (info severity) ===",
);
{
const content = `# T01: Some Task
## Description
Do something.
## Steps
1. Do the thing
## Verification
- Check it works
## Inputs
Prior task summary insights about the architecture.
## Expected Output
- \`src/output.ts\` — Output file
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const inputIssues = issues.filter(
(i) => i.ruleId === "missing_input_file_paths",
);
assertTrue(
inputIssues.length >= 1,
"Inputs without file paths triggers missing_input_file_paths",
);
if (inputIssues.length > 0) {
assertEq(
inputIssues[0].severity,
"info",
"missing_input_file_paths is info severity (not warning)",
const issues = validateSlicePlanContent("S01-PLAN.md", content);
const planQualityIssues = issues.filter(
(i) => i.ruleId === "empty_task_entry",
);
}
}
console.log(
"\n=== validateTaskPlanContent: no Expected Output section at all ===",
);
{
const content = `# T01: Some Task
## Description
Do something.
## Steps
1. Do the thing
## Verification
- Check it works
`;
const issues = validateTaskPlanContent("T01-PLAN.md", content);
const outputIssues = issues.filter(
(i) => i.ruleId === "missing_output_file_paths",
);
assertTrue(
outputIssues.length >= 1,
"Missing Expected Output section triggers missing_output_file_paths",
);
}
report();
assert.strictEqual(
planQualityIssues.length,
0,
"clean slice plan produces no empty_task_entry issues",
);
});
});

View file

@ -1,6 +1,7 @@
// Tests for critical path algorithm.
// Tests computeCriticalPath with known DAG structures.
import { describe, it } from "vitest";
import assert from "node:assert/strict";
import type { VisualizerMilestone } from "../visualizer-data.js";
import { computeCriticalPath } from "../visualizer-data.js";
@ -26,178 +27,154 @@ function makeSlice(id: string, done: boolean, depends: string[] = []) {
};
}
// ─── Linear chain ───────────────────────────────────────────────────────────
describe("computeCriticalPath", () => {
it("linear chain has critical path", () => {
// M001 -> M002 -> M003
const milestones = [
makeMs("M001", "complete", []),
makeMs(
"M002",
"active",
["M001"],
[makeSlice("S01", true), makeSlice("S02", false, ["S01"])],
),
makeMs("M003", "pending", ["M002"]),
];
console.log("\n=== Critical Path: Linear Chain ===");
const cp = computeCriticalPath(milestones);
assert.ok(cp.milestonePath.length > 0, "linear chain has critical path");
assert.ok(cp.milestonePath.includes("M002"), "M002 is on critical path");
assert.ok(cp.milestonePath.includes("M003"), "M003 is on critical path");
assert.deepStrictEqual(
cp.milestoneSlack.get("M002"),
0,
"M002 has zero slack",
);
assert.deepStrictEqual(
cp.milestoneSlack.get("M003"),
0,
"M003 has zero slack",
);
});
{
// M001 -> M002 -> M003
const milestones = [
makeMs("M001", "complete", []),
makeMs(
"M002",
"active",
["M001"],
[makeSlice("S01", true), makeSlice("S02", false, ["S01"])],
),
makeMs("M003", "pending", ["M002"]),
];
it("diamond DAG has critical path through heavier branch", () => {
// M001 -> M002 -> M004
// M001 -> M003 -> M004
// M002 has 3 incomplete slices, M003 has 1 incomplete slice
const milestones = [
makeMs("M001", "complete", []),
makeMs(
"M002",
"active",
["M001"],
[
makeSlice("S01", false),
makeSlice("S02", false),
makeSlice("S03", false),
],
),
makeMs("M003", "pending", ["M001"], [makeSlice("S01", false)]),
makeMs("M004", "pending", ["M002", "M003"]),
];
const cp = computeCriticalPath(milestones);
assert.ok(cp.milestonePath.length > 0, "linear chain has critical path");
assert.ok(cp.milestonePath.includes("M002"), "M002 is on critical path");
assert.ok(cp.milestonePath.includes("M003"), "M003 is on critical path");
assert.deepStrictEqual(
cp.milestoneSlack.get("M002"),
0,
"M002 has zero slack",
);
assert.deepStrictEqual(
cp.milestoneSlack.get("M003"),
0,
"M003 has zero slack",
);
}
const cp = computeCriticalPath(milestones);
assert.ok(cp.milestonePath.length >= 2, "diamond DAG has critical path");
// M002 has weight 3 (3 incomplete), M003 has weight 1
// Critical path should go through M002 (longer)
assert.ok(
cp.milestonePath.includes("M002"),
"M002 (heavier) is on critical path",
);
// ─── Diamond DAG ────────────────────────────────────────────────────────────
// M003 should have non-zero slack since it's lighter
const m003Slack = cp.milestoneSlack.get("M003") ?? -1;
assert.ok(m003Slack > 0, "M003 has positive slack (lighter branch)");
});
console.log("\n=== Critical Path: Diamond DAG ===");
it("independent branches have at least one critical node", () => {
// M001 (no deps), M002 (no deps), M003 (no deps)
const milestones = [
makeMs("M001", "active", [], [makeSlice("S01", false)]),
makeMs(
"M002",
"pending",
[],
[makeSlice("S01", false), makeSlice("S02", false)],
),
makeMs("M003", "pending", [], [makeSlice("S01", false)]),
];
{
// M001 -> M002 -> M004
// M001 -> M003 -> M004
// M002 has 3 incomplete slices, M003 has 1 incomplete slice
const milestones = [
makeMs("M001", "complete", []),
makeMs(
"M002",
"active",
["M001"],
[
makeSlice("S01", false),
makeSlice("S02", false),
makeSlice("S03", false),
],
),
makeMs("M003", "pending", ["M001"], [makeSlice("S01", false)]),
makeMs("M004", "pending", ["M002", "M003"]),
];
const cp = computeCriticalPath(milestones);
assert.ok(
cp.milestonePath.length >= 1,
"independent branches have at least one critical node",
);
// M002 has the most incomplete slices, should be critical
assert.ok(
cp.milestonePath.includes("M002"),
"M002 (longest) is on critical path",
);
});
const cp = computeCriticalPath(milestones);
assert.ok(cp.milestonePath.length >= 2, "diamond DAG has critical path");
// M002 has weight 3 (3 incomplete), M003 has weight 1
// Critical path should go through M002 (longer)
assert.ok(
cp.milestonePath.includes("M002"),
"M002 (heavier) is on critical path",
);
it("slice-level critical path is computed correctly", () => {
// Active milestone with slice dependencies: S01 -> S02 -> S04, S01 -> S03
const milestones = [
makeMs(
"M001",
"active",
[],
[
makeSlice("S01", true),
makeSlice("S02", false, ["S01"]),
makeSlice("S03", false, ["S01"]),
makeSlice("S04", false, ["S02"]),
],
),
];
// M003 should have non-zero slack since it's lighter
const m003Slack = cp.milestoneSlack.get("M003") ?? -1;
assert.ok(m003Slack > 0, "M003 has positive slack (lighter branch)");
}
const cp = computeCriticalPath(milestones);
assert.ok(cp.slicePath.length > 0, "has slice-level critical path");
assert.ok(cp.slicePath.includes("S02"), "S02 is on slice critical path");
assert.ok(cp.slicePath.includes("S04"), "S04 is on slice critical path");
// ─── Independent branches ───────────────────────────────────────────────────
// S03 should have non-zero slack (it's a shorter branch)
const s03Slack = cp.sliceSlack.get("S03") ?? -1;
assert.ok(s03Slack > 0, "S03 has positive slack (shorter branch)");
});
console.log("\n=== Critical Path: Independent Branches ===");
it("empty milestones produce empty path", () => {
const cp = computeCriticalPath([]);
assert.deepStrictEqual(
cp.milestonePath.length,
0,
"empty milestones produce empty path",
);
assert.deepStrictEqual(
cp.slicePath.length,
0,
"empty milestones produce empty slice path",
);
});
{
// M001 (no deps), M002 (no deps), M003 (no deps)
const milestones = [
makeMs("M001", "active", [], [makeSlice("S01", false)]),
makeMs(
"M002",
"pending",
[],
[makeSlice("S01", false), makeSlice("S02", false)],
),
makeMs("M003", "pending", [], [makeSlice("S01", false)]),
];
it("single milestone is its own critical path", () => {
const milestones = [
makeMs(
"M001",
"active",
[],
[makeSlice("S01", false), makeSlice("S02", false)],
),
];
const cp = computeCriticalPath(milestones);
assert.ok(
cp.milestonePath.length >= 1,
"independent branches have at least one critical node",
);
// M002 has the most incomplete slices, should be critical
assert.ok(
cp.milestonePath.includes("M002"),
"M002 (longest) is on critical path",
);
}
// ─── Slice-level critical path ──────────────────────────────────────────────
console.log("\n=== Critical Path: Slice-level ===");
{
// Active milestone with slice dependencies: S01 -> S02 -> S04, S01 -> S03
const milestones = [
makeMs(
const cp = computeCriticalPath(milestones);
assert.ok(
cp.milestonePath.length === 1,
"single milestone is its own critical path",
);
assert.deepStrictEqual(
cp.milestonePath[0],
"M001",
"active",
[],
[
makeSlice("S01", true),
makeSlice("S02", false, ["S01"]),
makeSlice("S03", false, ["S01"]),
makeSlice("S04", false, ["S02"]),
],
),
];
const cp = computeCriticalPath(milestones);
assert.ok(cp.slicePath.length > 0, "has slice-level critical path");
assert.ok(cp.slicePath.includes("S02"), "S02 is on slice critical path");
assert.ok(cp.slicePath.includes("S04"), "S04 is on slice critical path");
// S03 should have non-zero slack (it's a shorter branch)
const s03Slack = cp.sliceSlack.get("S03") ?? -1;
assert.ok(s03Slack > 0, "S03 has positive slack (shorter branch)");
}
// ─── Empty milestones ───────────────────────────────────────────────────────
console.log("\n=== Critical Path: Empty ===");
{
const cp = computeCriticalPath([]);
assert.deepStrictEqual(
cp.milestonePath.length,
0,
"empty milestones produce empty path",
);
assert.deepStrictEqual(
cp.slicePath.length,
0,
"empty milestones produce empty slice path",
);
}
// ─── Single milestone ───────────────────────────────────────────────────────
console.log("\n=== Critical Path: Single Milestone ===");
{
const milestones = [
makeMs(
"M001",
"active",
[],
[makeSlice("S01", false), makeSlice("S02", false)],
),
];
const cp = computeCriticalPath(milestones);
assert.ok(
cp.milestonePath.length === 1,
"single milestone is its own critical path",
);
assert.deepStrictEqual(
cp.milestonePath[0],
"M001",
"M001 is the critical node",
);
}
// ─── Report ─────────────────────────────────────────────────────────────────
"M001 is the critical node",
);
});
});

View file

@ -20,6 +20,9 @@ import { test, afterEach } from 'vitest';
const SEARCH_ENV_KEYS = [
"TAVILY_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CODE_PLAN_KEY",
"MINIMAX_CODING_API_KEY",
"BRAVE_API_KEY",
"SERPER_API_KEY",
"EXA_API_KEY",
@ -243,27 +246,28 @@ test('direct arg "auto" sets preference and notifies', async (t) => {
// 4. No arg — shows select UI, user picks one
// ═══════════════════════════════════════════════════════════════════════════
test("no arg shows select UI with 7 options, user picks brave", async () => {
test("no arg shows select UI with 8 options, user picks brave", async () => {
const cmd = await loadCommand();
await withEnv(
{ TAVILY_API_KEY: "tvly-test", BRAVE_API_KEY: "BSA-test" },
{ TAVILY_API_KEY: "tvly-test", BRAVE_API_KEY: "BSA-test", MINIMAX_API_KEY: undefined, MINIMAX_CODE_PLAN_KEY: undefined, MINIMAX_CODING_API_KEY: undefined },
async () => {
const ctx = makeMockCtx("brave (key: ✓)");
await cmd.handler("", ctx);
// Select UI shown
assert.equal(ctx.ui.selectCalls.length, 1, "should show select UI");
assert.equal(ctx.ui.selectCalls[0].options.length, 7);
assert.equal(ctx.ui.selectCalls[0].options.length, 8);
// Options show key status
assert.match(ctx.ui.selectCalls[0].options[0], /tavily \(key: ✓\)/);
assert.match(ctx.ui.selectCalls[0].options[1], /brave \(key: ✓\)/);
assert.match(ctx.ui.selectCalls[0].options[2], /serper \(key:/);
assert.match(ctx.ui.selectCalls[0].options[3], /exa \(key:/);
assert.match(ctx.ui.selectCalls[0].options[4], /ollama \(key:/);
assert.match(ctx.ui.selectCalls[0].options[5], /combosearch \(/);
assert.equal(ctx.ui.selectCalls[0].options[6], "auto");
assert.match(ctx.ui.selectCalls[0].options[1], /minimax \(key:/);
assert.match(ctx.ui.selectCalls[0].options[2], /brave \(key: ✓\)/);
assert.match(ctx.ui.selectCalls[0].options[3], /serper \(key:/);
assert.match(ctx.ui.selectCalls[0].options[4], /exa \(key:/);
assert.match(ctx.ui.selectCalls[0].options[5], /ollama \(key:/);
assert.match(ctx.ui.selectCalls[0].options[6], /combosearch \(/);
assert.equal(ctx.ui.selectCalls[0].options[7], "auto");
// Title shows current preference
assert.match(ctx.ui.selectCalls[0].title, /current:/);
@ -346,19 +350,20 @@ test('invalid arg "google" falls back to interactive select', async () => {
// 7. Tab completion — all options when prefix is empty
// ═══════════════════════════════════════════════════════════════════════════
test("tab completion returns all 7 options when prefix is empty", async () => {
test("tab completion returns all 8 options when prefix is empty", async () => {
const cmd = await loadCommand();
await withEnv(
{ TAVILY_API_KEY: "tvly-test", BRAVE_API_KEY: "BSA-test" },
{ TAVILY_API_KEY: "tvly-test", BRAVE_API_KEY: "BSA-test", MINIMAX_API_KEY: undefined, MINIMAX_CODE_PLAN_KEY: undefined, MINIMAX_CODING_API_KEY: undefined },
() => {
const items = cmd.getArgumentCompletions!("");
assert.ok(items, "completions should not be null");
assert.equal(items!.length, 7);
assert.equal(items!.length, 8);
const values = items!.map((i: any) => i.value);
assert.deepEqual(values, [
"tavily",
"minimax",
"brave",
"serper",
"exa",
@ -434,6 +439,9 @@ test('notify message shows "none" when no API keys available', async () => {
{
TAVILY_API_KEY: undefined,
BRAVE_API_KEY: undefined,
MINIMAX_API_KEY: undefined,
MINIMAX_CODE_PLAN_KEY: undefined,
MINIMAX_CODING_API_KEY: undefined,
OLLAMA_API_KEY: undefined,
},
async () => {
@ -454,14 +462,14 @@ test("select options show key unavailability with ✗", async () => {
const cmd = await loadCommand();
await withEnv(
{ TAVILY_API_KEY: undefined, BRAVE_API_KEY: undefined },
{ TAVILY_API_KEY: undefined, BRAVE_API_KEY: undefined, MINIMAX_API_KEY: undefined, MINIMAX_CODE_PLAN_KEY: undefined, MINIMAX_CODING_API_KEY: undefined },
async () => {
const ctx = makeMockCtx("auto");
await cmd.handler("", ctx);
assert.equal(ctx.ui.selectCalls.length, 1);
assert.match(ctx.ui.selectCalls[0].options[0], /tavily \(key: ✗\)/);
assert.match(ctx.ui.selectCalls[0].options[1], /brave \(key: ✗\)/);
assert.match(ctx.ui.selectCalls[0].options[2], /brave \(key: ✗\)/);
},
);
});

View file

@ -1,4 +1,4 @@
import { describe } from 'vitest';
import { describe, test } from 'vitest';
import assert from "node:assert/strict";
/**

View file

@ -1,4 +1,4 @@
import { describe, beforeEach, afterEach } from 'vitest';
import { describe, test, beforeEach, afterEach } from 'vitest';
import assert from "node:assert/strict";
import {