Files
fusion/packages/core/src/mission-store.test.ts
gsxdsm 8ba375549c feat(FN-1453): fix mission autopilot progression and state refresh
- Fix mission autopilot slice activation when tasks complete
- Fix state refresh in MissionManager to properly reflect autopilot status
- Fix scheduler to correctly trigger autopilot progression events
- Add unit tests for mission store autopilot state transitions
- Add component tests for MissionManager autopilot toggle and state display
- Add e2e tests for mission autopilot lifecycle (enable → task completion → slice progression)
- Update documentation with autopilot state machine details
2026-04-09 16:12:58 -07:00

1924 lines
76 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { MissionStore } from "./mission-store.js";
import { Database } from "./db.js";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { rm } from "node:fs/promises";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-mission-test-"));
}
/** Helper to create a task in the database for foreign key validation */
function createTaskInDb(
database: Database,
taskId: string,
description = "Test task",
status?: string,
): void {
const now = new Date().toISOString();
database.prepare(
`INSERT INTO tasks (id, description, "column", status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`
).run(taskId, description, "triage", status ?? null, now, now);
}
describe("MissionStore", () => {
let tmpDir: string;
let kbDir: string;
let db: Database;
let store: MissionStore;
beforeEach(() => {
tmpDir = makeTmpDir();
kbDir = join(tmpDir, ".fusion");
db = new Database(kbDir);
db.init();
store = new MissionStore(kbDir, db);
});
afterEach(async () => {
try {
db.close();
} catch {
// already closed
}
await rm(tmpDir, { recursive: true, force: true });
});
// ── Mission CRUD Tests ────────────────────────────────────────────────
describe("Mission CRUD", () => {
it("creates a mission with correct defaults", () => {
const mission = store.createMission({
title: "Test Mission",
description: "A test mission",
});
expect(mission.id).toMatch(/^M-/);
expect(mission.title).toBe("Test Mission");
expect(mission.description).toBe("A test mission");
expect(mission.status).toBe("planning");
expect(mission.interviewState).toBe("not_started");
expect(mission.createdAt).toBeTruthy();
expect(mission.updatedAt).toBeTruthy();
});
it("gets a mission by id", () => {
const created = store.createMission({ title: "Get Test" });
const retrieved = store.getMission(created.id);
expect(retrieved).toBeDefined();
expect(retrieved!.id).toBe(created.id);
expect(retrieved!.title).toBe("Get Test");
});
it("returns undefined for non-existent mission", () => {
const result = store.getMission("M-NONEXISTENT");
expect(result).toBeUndefined();
});
it("lists missions ordered by createdAt desc", async () => {
const m1 = store.createMission({ title: "Mission 1" });
await new Promise((r) => setTimeout(r, 10)); // Ensure different timestamps
const m2 = store.createMission({ title: "Mission 2" });
await new Promise((r) => setTimeout(r, 10));
const m3 = store.createMission({ title: "Mission 3" });
const list = store.listMissions();
expect(list).toHaveLength(3);
expect(list[0].id).toBe(m3.id); // Newest first
expect(list[1].id).toBe(m2.id);
expect(list[2].id).toBe(m1.id);
});
it("updates a mission", async () => {
const mission = store.createMission({ title: "Original" });
await new Promise((r) => setTimeout(r, 5)); // Ensure timestamp difference
const updated = store.updateMission(mission.id, {
title: "Updated",
status: "active",
});
expect(updated.title).toBe("Updated");
expect(updated.status).toBe("active");
expect(updated.id).toBe(mission.id);
expect(updated.createdAt).toBe(mission.createdAt);
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThan(
new Date(mission.updatedAt).getTime()
);
});
it("throws when updating non-existent mission", () => {
expect(() => {
store.updateMission("M-NONEXISTENT", { title: "Test" });
}).toThrow("Mission M-NONEXISTENT not found");
});
it("deletes a mission", () => {
const mission = store.createMission({ title: "To Delete" });
store.deleteMission(mission.id);
const retrieved = store.getMission(mission.id);
expect(retrieved).toBeUndefined();
});
it("throws when deleting non-existent mission", () => {
expect(() => {
store.deleteMission("M-NONEXISTENT");
}).toThrow("Mission M-NONEXISTENT not found");
});
it("updates interview state", () => {
const mission = store.createMission({ title: "Interview Test" });
const updated = store.updateMissionInterviewState(mission.id, "in_progress");
expect(updated.interviewState).toBe("in_progress");
});
it("emits mission:created event", () => {
const handler = vi.fn();
store.on("mission:created", handler);
const mission = store.createMission({ title: "Event Test" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mission);
});
it("emits mission:updated event", () => {
const handler = vi.fn();
store.on("mission:updated", handler);
const mission = store.createMission({ title: "Event Test" });
const updated = store.updateMission(mission.id, { title: "Updated" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(updated);
});
it("emits mission:deleted event with id", () => {
const handler = vi.fn();
store.on("mission:deleted", handler);
const mission = store.createMission({ title: "Event Test" });
store.deleteMission(mission.id);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mission.id);
});
});
// ── Mission Summary & Slice Discovery Tests ───────────────────────────
describe("Mission summary helpers", () => {
it("getMissionSummary returns zeros for an empty mission", () => {
const mission = store.createMission({ title: "Empty" });
const summary = store.getMissionSummary(mission.id);
expect(summary).toEqual({
totalMilestones: 0,
completedMilestones: 0,
totalFeatures: 0,
completedFeatures: 0,
progressPercent: 0,
});
});
it("getMissionSummary falls back to milestone progress when no features exist", () => {
const mission = store.createMission({ title: "Milestones only" });
const m1 = store.addMilestone(mission.id, { title: "M1" });
store.addMilestone(mission.id, { title: "M2" });
store.updateMilestone(m1.id, { status: "complete" });
const summary = store.getMissionSummary(mission.id);
expect(summary.totalMilestones).toBe(2);
expect(summary.completedMilestones).toBe(1);
expect(summary.totalFeatures).toBe(0);
expect(summary.completedFeatures).toBe(0);
expect(summary.progressPercent).toBe(50);
});
it("getMissionSummary reports partial feature completion", () => {
const mission = store.createMission({ title: "Partial features" });
const milestone = store.addMilestone(mission.id, { title: "M1" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const f1 = store.addFeature(slice.id, { title: "F1" });
const f2 = store.addFeature(slice.id, { title: "F2" });
store.addFeature(slice.id, { title: "F3" });
store.updateFeature(f1.id, { status: "done" });
store.updateFeature(f2.id, { status: "done" });
const summary = store.getMissionSummary(mission.id);
expect(summary.totalFeatures).toBe(3);
expect(summary.completedFeatures).toBe(2);
expect(summary.progressPercent).toBe(67);
});
it("getMissionSummary reports 100% when all features are done", () => {
const mission = store.createMission({ title: "All done" });
const milestone = store.addMilestone(mission.id, { title: "M1" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const f1 = store.addFeature(slice.id, { title: "F1" });
const f2 = store.addFeature(slice.id, { title: "F2" });
store.updateFeature(f1.id, { status: "done" });
store.updateFeature(f2.id, { status: "done" });
const summary = store.getMissionSummary(mission.id);
expect(summary.progressPercent).toBe(100);
});
it("getMissionSummary rounds progress percent accurately", () => {
const mission = store.createMission({ title: "Rounding" });
const milestone = store.addMilestone(mission.id, { title: "M1" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const f1 = store.addFeature(slice.id, { title: "F1" });
store.addFeature(slice.id, { title: "F2" });
store.addFeature(slice.id, { title: "F3" });
store.updateFeature(f1.id, { status: "done" });
const summary = store.getMissionSummary(mission.id);
expect(summary.progressPercent).toBe(33);
});
it("findNextPendingSlice skips completed slices in earlier milestones", () => {
const mission = store.createMission({ title: "Next pending" });
const m1 = store.addMilestone(mission.id, { title: "M1" });
const m2 = store.addMilestone(mission.id, { title: "M2" });
const completed = store.addSlice(m1.id, { title: "Done slice" });
const pending = store.addSlice(m2.id, { title: "Pending slice" });
store.updateSlice(completed.id, { status: "complete" });
const next = store.findNextPendingSlice(mission.id);
expect(next?.id).toBe(pending.id);
});
it("findNextPendingSlice returns undefined when no pending slices exist", () => {
const mission = store.createMission({ title: "No pending" });
const milestone = store.addMilestone(mission.id, { title: "M1" });
const slice = store.addSlice(milestone.id, { title: "Completed" });
store.updateSlice(slice.id, { status: "complete" });
const next = store.findNextPendingSlice(mission.id);
expect(next).toBeUndefined();
});
it("findNextPendingSlice returns first pending slice in a single-milestone mission", () => {
const mission = store.createMission({ title: "Single" });
const milestone = store.addMilestone(mission.id, { title: "M1" });
const pending = store.addSlice(milestone.id, { title: "Pending" });
const next = store.findNextPendingSlice(mission.id);
expect(next?.id).toBe(pending.id);
});
});
// ── Batched Summary Tests ──────────────────────────────────────────────
describe("listMissionsWithSummaries", () => {
it("returns empty array when no missions exist", () => {
const result = store.listMissionsWithSummaries();
expect(result).toEqual([]);
});
it("returns correct summaries for multiple missions", () => {
// Mission 1: 2 milestones, 1 complete, 2 features, 1 done
const m1 = store.createMission({ title: "Mission 1" });
const ms1a = store.addMilestone(m1.id, { title: "MS1a" });
const ms1b = store.addMilestone(m1.id, { title: "MS1b" });
store.updateMilestone(ms1b.id, { status: "complete" });
const sl1 = store.addSlice(ms1a.id, { title: "SL1" });
const f1 = store.addFeature(sl1.id, { title: "F1" });
store.updateFeature(f1.id, { status: "done" });
const f2 = store.addFeature(sl1.id, { title: "F2" });
// f2 not done
// Mission 2: 1 milestone, 0 features
const m2 = store.createMission({ title: "Mission 2" });
store.addMilestone(m2.id, { title: "MS2" });
// Mission 3: 0 milestones
store.createMission({ title: "Mission 3" });
const result = store.listMissionsWithSummaries();
// Should be sorted by createdAt DESC (m3, m2, m1 based on creation order)
expect(result.length).toBe(3);
// Mission 3: 0 milestones, 0 features → 0%
const mission3 = result.find((m) => m.title === "Mission 3")!;
expect(mission3.summary).toEqual({
totalMilestones: 0,
completedMilestones: 0,
totalFeatures: 0,
completedFeatures: 0,
progressPercent: 0,
});
// Mission 2: 1 milestone, 0 features → 0%
const mission2 = result.find((m) => m.title === "Mission 2")!;
expect(mission2.summary).toEqual({
totalMilestones: 1,
completedMilestones: 0,
totalFeatures: 0,
completedFeatures: 0,
progressPercent: 0,
});
// Mission 1: 2 milestones (1 complete), 2 features (1 done) → 50%
const mission1 = result.find((m) => m.title === "Mission 1")!;
expect(mission1.summary).toEqual({
totalMilestones: 2,
completedMilestones: 1,
totalFeatures: 2,
completedFeatures: 1,
progressPercent: 50,
});
});
it("progress percent matches getMissionSummary behavior", () => {
const mission = store.createMission({ title: "Compare test" });
const milestone = store.addMilestone(mission.id, { title: "M1" });
const slice = store.addSlice(milestone.id, { title: "S1" });
const f1 = store.addFeature(slice.id, { title: "F1" });
store.updateFeature(f1.id, { status: "done" });
const f2 = store.addFeature(slice.id, { title: "F2" });
store.updateFeature(f2.id, { status: "done" });
const f3 = store.addFeature(slice.id, { title: "F3" });
// f3 not done
const singleSummary = store.getMissionSummary(mission.id);
const batchedResult = store.listMissionsWithSummaries().find((m) => m.id === mission.id)!;
expect(batchedResult.summary.totalMilestones).toBe(singleSummary.totalMilestones);
expect(batchedResult.summary.completedMilestones).toBe(singleSummary.completedMilestones);
expect(batchedResult.summary.totalFeatures).toBe(singleSummary.totalFeatures);
expect(batchedResult.summary.completedFeatures).toBe(singleSummary.completedFeatures);
expect(batchedResult.summary.progressPercent).toBe(singleSummary.progressPercent);
});
});
// ── Batched Health Tests ──────────────────────────────────────────────
describe("listMissionsHealth", () => {
it("returns empty map when no missions exist", () => {
const result = store.listMissionsHealth();
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
it("returns correct health for a single empty mission", () => {
const mission = store.createMission({ title: "Empty mission" });
store.updateMission(mission.id, {
autopilotEnabled: true,
autopilotState: "watching",
lastAutopilotActivityAt: "2026-01-01T10:00:00.000Z",
});
const result = store.listMissionsHealth();
expect(result.size).toBe(1);
expect(result.get(mission.id)).toEqual({
missionId: mission.id,
status: "planning",
tasksCompleted: 0,
tasksFailed: 0,
tasksInFlight: 0,
totalTasks: 0,
currentSliceId: undefined,
currentMilestoneId: undefined,
estimatedCompletionPercent: 0,
lastErrorAt: undefined,
lastErrorDescription: undefined,
autopilotState: "watching",
autopilotEnabled: true,
lastActivityAt: "2026-01-01T10:00:00.000Z",
});
});
it("computes correct health for multiple missions with varying states", async () => {
// Mission 1: 1 milestone (active), 1 slice (active), 4 features (1 done, 2 in-flight, 1 failed)
const m1 = store.createMission({ title: "Mission 1" });
store.updateMission(m1.id, { status: "active" });
const ms1 = store.addMilestone(m1.id, { title: "MS1" });
store.updateMilestone(ms1.id, { status: "active" });
const sl1 = store.addSlice(ms1.id, { title: "SL1" });
store.updateSlice(sl1.id, { status: "active" });
const f1Done = store.addFeature(sl1.id, { title: "F1-done" });
store.updateFeature(f1Done.id, { status: "done" });
const f1Triaged = store.addFeature(sl1.id, { title: "F1-triaged" });
store.updateFeature(f1Triaged.id, { status: "triaged" });
const f1Progress = store.addFeature(sl1.id, { title: "F1-progress" });
store.updateFeature(f1Progress.id, { status: "in-progress" });
createTaskInDb(db, "FN-FAILED-1", "Failed task", "failed");
const f1Failed = store.addFeature(sl1.id, { title: "F1-failed" });
store.linkFeatureToTask(f1Failed.id, "FN-FAILED-1");
await new Promise((r) => setTimeout(r, 10));
// Mission 2: 2 milestones (1 complete, 1 active), 0 features
const m2 = store.createMission({ title: "Mission 2" });
store.updateMission(m2.id, { status: "active" });
const ms2a = store.addMilestone(m2.id, { title: "MS2a" });
store.updateMilestone(ms2a.id, { status: "complete" });
const ms2b = store.addMilestone(m2.id, { title: "MS2b" });
store.updateMilestone(ms2b.id, { status: "active" });
const sl2 = store.addSlice(ms2b.id, { title: "SL2" });
store.updateSlice(sl2.id, { status: "active" });
store.logMissionEvent(m1.id, "error", "Error on mission 1");
const result = store.listMissionsHealth();
expect(result.size).toBe(2);
// Mission 1 health
const health1 = result.get(m1.id)!;
expect(health1).toEqual({
missionId: m1.id,
status: "active",
tasksCompleted: 1,
tasksFailed: 1,
tasksInFlight: 3,
totalTasks: 4,
currentSliceId: sl1.id,
currentMilestoneId: ms1.id,
estimatedCompletionPercent: 25,
lastErrorAt: expect.any(String),
lastErrorDescription: "Error on mission 1",
autopilotState: "inactive",
autopilotEnabled: false,
lastActivityAt: undefined,
});
// Mission 2 health: no features, 1/2 milestones complete → 50%
const health2 = result.get(m2.id)!;
expect(health2).toEqual({
missionId: m2.id,
status: "active",
tasksCompleted: 0,
tasksFailed: 0,
tasksInFlight: 0,
totalTasks: 0,
currentSliceId: sl2.id,
currentMilestoneId: ms2b.id,
estimatedCompletionPercent: 50,
lastErrorAt: undefined,
lastErrorDescription: undefined,
autopilotState: "inactive",
autopilotEnabled: false,
lastActivityAt: undefined,
});
});
it("counts failed tasks across missions correctly", () => {
const m1 = store.createMission({ title: "Mission 1" });
const ms1 = store.addMilestone(m1.id, { title: "MS1" });
const sl1 = store.addSlice(ms1.id, { title: "SL1" });
const m2 = store.createMission({ title: "Mission 2" });
const ms2 = store.addMilestone(m2.id, { title: "MS2" });
const sl2 = store.addSlice(ms2.id, { title: "SL2" });
createTaskInDb(db, "FN-FAIL-A", "Task A", "failed");
createTaskInDb(db, "FN-FAIL-B", "Task B", "failed");
createTaskInDb(db, "FN-OK-C", "Task C", "done");
const f1 = store.addFeature(sl1.id, { title: "F1" });
store.linkFeatureToTask(f1.id, "FN-FAIL-A");
const f2 = store.addFeature(sl2.id, { title: "F2" });
store.linkFeatureToTask(f2.id, "FN-FAIL-B");
const f3 = store.addFeature(sl2.id, { title: "F3" });
store.linkFeatureToTask(f3.id, "FN-OK-C");
const result = store.listMissionsHealth();
expect(result.get(m1.id)!.tasksFailed).toBe(1);
expect(result.get(m2.id)!.tasksFailed).toBe(1);
});
it("detects last error per mission independently", () => {
const m1 = store.createMission({ title: "Mission 1" });
const m2 = store.createMission({ title: "Mission 2" });
store.logMissionEvent(m1.id, "error", "Old error on M1");
store.logMissionEvent(m2.id, "error", "Only error on M2");
store.logMissionEvent(m1.id, "error", "Latest error on M1");
const result = store.listMissionsHealth();
expect(result.get(m1.id)!.lastErrorDescription).toBe("Latest error on M1");
expect(result.get(m2.id)!.lastErrorDescription).toBe("Only error on M2");
});
it("produces results consistent with getMissionHealth", () => {
const mission = store.createMission({ title: "Consistency test" });
store.updateMission(mission.id, {
status: "active",
autopilotEnabled: true,
autopilotState: "watching",
lastAutopilotActivityAt: "2026-01-01T10:00:00.000Z",
});
const milestone = store.addMilestone(mission.id, { title: "M1" });
store.updateMilestone(milestone.id, { status: "active" });
const slice = store.addSlice(milestone.id, { title: "S1" });
store.updateSlice(slice.id, { status: "active" });
const f1 = store.addFeature(slice.id, { title: "F1" });
store.updateFeature(f1.id, { status: "done" });
const f2 = store.addFeature(slice.id, { title: "F2" });
store.updateFeature(f2.id, { status: "triaged" });
createTaskInDb(db, "FN-FAILED-X", "Failed task", "failed");
const f3 = store.addFeature(slice.id, { title: "F3" });
store.linkFeatureToTask(f3.id, "FN-FAILED-X");
store.logMissionEvent(mission.id, "error", "Test error");
const singleHealth = store.getMissionHealth(mission.id);
const batchedHealth = store.listMissionsHealth().get(mission.id)!;
// Compare all fields except lastErrorAt (may differ by ms due to separate queries)
expect(batchedHealth.missionId).toBe(singleHealth!.missionId);
expect(batchedHealth.status).toBe(singleHealth!.status);
expect(batchedHealth.tasksCompleted).toBe(singleHealth!.tasksCompleted);
expect(batchedHealth.tasksFailed).toBe(singleHealth!.tasksFailed);
expect(batchedHealth.tasksInFlight).toBe(singleHealth!.tasksInFlight);
expect(batchedHealth.totalTasks).toBe(singleHealth!.totalTasks);
expect(batchedHealth.currentSliceId).toBe(singleHealth!.currentSliceId);
expect(batchedHealth.currentMilestoneId).toBe(singleHealth!.currentMilestoneId);
expect(batchedHealth.estimatedCompletionPercent).toBe(singleHealth!.estimatedCompletionPercent);
expect(batchedHealth.lastErrorDescription).toBe(singleHealth!.lastErrorDescription);
expect(batchedHealth.autopilotState).toBe(singleHealth!.autopilotState);
expect(batchedHealth.autopilotEnabled).toBe(singleHealth!.autopilotEnabled);
});
});
// ── Mission Observability Tests ───────────────────────────────────────
describe("Mission observability", () => {
it("logMissionEvent persists the event and emits mission:event", () => {
const mission = store.createMission({ title: "Observable mission" });
const eventHandler = vi.fn();
store.on("mission:event", eventHandler);
const event = store.logMissionEvent(
mission.id,
"mission_started",
"Mission was started",
{ source: "test" },
);
expect(event.id).toMatch(/^ME-/);
expect(event.missionId).toBe(mission.id);
expect(event.eventType).toBe("mission_started");
expect(event.description).toBe("Mission was started");
expect(event.metadata).toEqual({ source: "test" });
expect(eventHandler).toHaveBeenCalledWith(event);
const events = store.getMissionEvents(mission.id);
expect(events.total).toBe(1);
expect(events.events[0]).toEqual(event);
});
it("getMissionEvents supports pagination, filtering, and newest-first ordering", async () => {
const mission = store.createMission({ title: "Events mission" });
const first = store.logMissionEvent(mission.id, "mission_started", "first");
await new Promise((resolve) => setTimeout(resolve, 5));
const second = store.logMissionEvent(mission.id, "warning", "second warning");
await new Promise((resolve) => setTimeout(resolve, 5));
const third = store.logMissionEvent(mission.id, "error", "third error");
const pageOne = store.getMissionEvents(mission.id, { limit: 2, offset: 0 });
expect(pageOne.total).toBe(3);
expect(pageOne.events).toHaveLength(2);
expect(pageOne.events.map((event) => event.id)).toEqual([third.id, second.id]);
const pageTwo = store.getMissionEvents(mission.id, { limit: 2, offset: 2 });
expect(pageTwo.total).toBe(3);
expect(pageTwo.events).toHaveLength(1);
expect(pageTwo.events[0].id).toBe(first.id);
const filtered = store.getMissionEvents(mission.id, { eventType: "error" });
expect(filtered.total).toBe(1);
expect(filtered.events).toHaveLength(1);
expect(filtered.events[0].eventType).toBe("error");
expect(filtered.events[0].id).toBe(third.id);
});
it("getMissionHealth computes mission metrics and latest error context", () => {
const mission = store.createMission({ title: "Health mission" });
store.updateMission(mission.id, {
status: "active",
autopilotEnabled: true,
autopilotState: "watching",
lastAutopilotActivityAt: "2026-01-01T10:00:00.000Z",
});
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
store.updateMilestone(milestone.id, { status: "active" });
store.updateSlice(slice.id, { status: "active" });
const doneFeature = store.addFeature(slice.id, { title: "Done feature" });
store.updateFeature(doneFeature.id, { status: "done" });
const triagedFeature = store.addFeature(slice.id, { title: "Triaged feature" });
store.updateFeature(triagedFeature.id, { status: "triaged" });
const inProgressFeature = store.addFeature(slice.id, { title: "In progress feature" });
store.updateFeature(inProgressFeature.id, { status: "in-progress" });
createTaskInDb(db, "FN-FAILED", "Failed task", "failed");
const failedFeature = store.addFeature(slice.id, { title: "Failed feature" });
store.linkFeatureToTask(failedFeature.id, "FN-FAILED");
// Keep failed feature out of in-flight count for deterministic assertions.
store.updateFeature(failedFeature.id, { status: "defined" });
store.logMissionEvent(mission.id, "error", "Old error", { at: "old" });
const latestError = store.logMissionEvent(mission.id, "error", "Latest error", { at: "latest" });
const health = store.getMissionHealth(mission.id);
expect(health).toEqual({
missionId: mission.id,
status: "active",
tasksCompleted: 1,
tasksFailed: 1,
tasksInFlight: 2,
totalTasks: 4,
currentSliceId: slice.id,
currentMilestoneId: milestone.id,
estimatedCompletionPercent: 25,
lastErrorAt: latestError.timestamp,
lastErrorDescription: "Latest error",
autopilotState: "watching",
autopilotEnabled: true,
lastActivityAt: "2026-01-01T10:00:00.000Z",
});
});
it("getMissionHealth returns undefined for non-existent mission", () => {
expect(store.getMissionHealth("M-NONEXISTENT")).toBeUndefined();
});
it("getMissionHealth handles an empty mission", () => {
const mission = store.createMission({ title: "Empty health mission" });
const health = store.getMissionHealth(mission.id);
expect(health).toEqual({
missionId: mission.id,
status: "planning",
tasksCompleted: 0,
tasksFailed: 0,
tasksInFlight: 0,
totalTasks: 0,
currentSliceId: undefined,
currentMilestoneId: undefined,
estimatedCompletionPercent: 0,
lastErrorAt: undefined,
lastErrorDescription: undefined,
autopilotState: "inactive",
autopilotEnabled: false,
lastActivityAt: undefined,
});
});
});
// ── Milestone CRUD Tests ──────────────────────────────────────────────
describe("Milestone CRUD", () => {
it("adds a milestone to a mission", () => {
const mission = store.createMission({ title: "Parent Mission" });
const milestone = store.addMilestone(mission.id, {
title: "Test Milestone",
description: "A test milestone",
});
expect(milestone.id).toMatch(/^MS-/);
expect(milestone.missionId).toBe(mission.id);
expect(milestone.title).toBe("Test Milestone");
expect(milestone.description).toBe("A test milestone");
expect(milestone.status).toBe("planning");
expect(milestone.orderIndex).toBe(0);
expect(milestone.dependencies).toEqual([]);
});
it("throws when adding milestone to non-existent mission", () => {
expect(() => {
store.addMilestone("M-NONEXISTENT", { title: "Test" });
}).toThrow("Mission M-NONEXISTENT not found");
});
it("auto-increments orderIndex for multiple milestones", () => {
const mission = store.createMission({ title: "Parent" });
const m1 = store.addMilestone(mission.id, { title: "First" });
const m2 = store.addMilestone(mission.id, { title: "Second" });
const m3 = store.addMilestone(mission.id, { title: "Third" });
expect(m1.orderIndex).toBe(0);
expect(m2.orderIndex).toBe(1);
expect(m3.orderIndex).toBe(2);
});
it("gets a milestone by id", () => {
const mission = store.createMission({ title: "Parent" });
const created = store.addMilestone(mission.id, { title: "Get Test" });
const retrieved = store.getMilestone(created.id);
expect(retrieved).toBeDefined();
expect(retrieved!.id).toBe(created.id);
});
it("returns undefined for non-existent milestone", () => {
const result = store.getMilestone("MS-NONEXISTENT");
expect(result).toBeUndefined();
});
it("lists milestones ordered by orderIndex", () => {
const mission = store.createMission({ title: "Parent" });
const m2 = store.addMilestone(mission.id, { title: "Second" });
const m1 = store.addMilestone(mission.id, { title: "First" });
// Reorder to ensure orderIndex differs from creation order
store.reorderMilestones(mission.id, [m2.id, m1.id]);
const list = store.listMilestones(mission.id);
expect(list[0].id).toBe(m2.id);
expect(list[1].id).toBe(m1.id);
});
it("updates a milestone", () => {
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "Original" });
const updated = store.updateMilestone(milestone.id, {
title: "Updated",
status: "active",
});
expect(updated.title).toBe("Updated");
expect(updated.status).toBe("active");
});
it("deletes a milestone", () => {
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "To Delete" });
store.deleteMilestone(milestone.id);
const retrieved = store.getMilestone(milestone.id);
expect(retrieved).toBeUndefined();
});
it("reorders milestones", () => {
const mission = store.createMission({ title: "Parent" });
const m1 = store.addMilestone(mission.id, { title: "First" });
const m2 = store.addMilestone(mission.id, { title: "Second" });
const m3 = store.addMilestone(mission.id, { title: "Third" });
store.reorderMilestones(mission.id, [m3.id, m1.id, m2.id]);
const list = store.listMilestones(mission.id);
expect(list[0].id).toBe(m3.id);
expect(list[1].id).toBe(m1.id);
expect(list[2].id).toBe(m2.id);
expect(list[0].orderIndex).toBe(0);
expect(list[1].orderIndex).toBe(1);
expect(list[2].orderIndex).toBe(2);
});
it("throws when reordering with invalid milestone id", () => {
const mission = store.createMission({ title: "Parent" });
store.addMilestone(mission.id, { title: "Valid" });
expect(() => {
store.reorderMilestones(mission.id, ["MS-NONEXISTENT"]);
}).toThrow("Milestone MS-NONEXISTENT not found");
});
it("emits milestone events", () => {
const createdHandler = vi.fn();
const deletedHandler = vi.fn();
store.on("milestone:created", createdHandler);
store.on("milestone:deleted", deletedHandler);
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "Test" });
store.deleteMilestone(milestone.id);
expect(createdHandler).toHaveBeenCalledTimes(1);
expect(createdHandler).toHaveBeenCalledWith(milestone);
expect(deletedHandler).toHaveBeenCalledTimes(1);
expect(deletedHandler).toHaveBeenCalledWith(milestone.id);
});
it("accepts dependencies array", () => {
const mission = store.createMission({ title: "Parent" });
const dep1 = store.addMilestone(mission.id, { title: "Dep 1" });
const milestone = store.addMilestone(mission.id, {
title: "Dependent",
dependencies: [dep1.id],
});
expect(milestone.dependencies).toEqual([dep1.id]);
});
});
// ── Slice CRUD Tests ──────────────────────────────────────────────────
describe("Slice CRUD", () => {
it("adds a slice to a milestone", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, {
title: "Test Slice",
description: "A test slice",
});
expect(slice.id).toMatch(/^SL-/);
expect(slice.milestoneId).toBe(milestone.id);
expect(slice.title).toBe("Test Slice");
expect(slice.status).toBe("pending");
expect(slice.orderIndex).toBe(0);
});
it("throws when adding slice to non-existent milestone", () => {
expect(() => {
store.addSlice("MS-NONEXISTENT", { title: "Test" });
}).toThrow("Milestone MS-NONEXISTENT not found");
});
it("auto-increments orderIndex for slices", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const s1 = store.addSlice(milestone.id, { title: "First" });
const s2 = store.addSlice(milestone.id, { title: "Second" });
expect(s1.orderIndex).toBe(0);
expect(s2.orderIndex).toBe(1);
});
it("gets a slice by id", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const created = store.addSlice(milestone.id, { title: "Get Test" });
const retrieved = store.getSlice(created.id);
expect(retrieved).toBeDefined();
expect(retrieved!.id).toBe(created.id);
});
it("lists slices ordered by orderIndex", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const s1 = store.addSlice(milestone.id, { title: "First" });
const s2 = store.addSlice(milestone.id, { title: "Second" });
// Reorder
store.reorderSlices(milestone.id, [s2.id, s1.id]);
const list = store.listSlices(milestone.id);
expect(list[0].id).toBe(s2.id);
expect(list[1].id).toBe(s1.id);
});
it("updates a slice", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Original" });
const updated = store.updateSlice(slice.id, { title: "Updated" });
expect(updated.title).toBe("Updated");
});
it("deletes a slice", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "To Delete" });
store.deleteSlice(slice.id);
const retrieved = store.getSlice(slice.id);
expect(retrieved).toBeUndefined();
});
it("activates a slice", async () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "To Activate" });
const activated = await store.activateSlice(slice.id);
expect(activated.status).toBe("active");
expect(activated.activatedAt).toBeTruthy();
});
it("emits slice:activated event", async () => {
const handler = vi.fn();
store.on("slice:activated", handler);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Test" });
const activated = await store.activateSlice(slice.id);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(activated);
});
it("emits slice:deleted event with id", () => {
const handler = vi.fn();
store.on("slice:deleted", handler);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Test" });
store.deleteSlice(slice.id);
expect(handler).toHaveBeenCalledWith(slice.id);
});
});
// ── Feature CRUD Tests ────────────────────────────────────────────────
describe("Feature CRUD", () => {
it("adds a feature to a slice", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, {
title: "Test Feature",
description: "A test feature",
acceptanceCriteria: "Criteria here",
});
expect(feature.id).toMatch(/^F-/);
expect(feature.sliceId).toBe(slice.id);
expect(feature.title).toBe("Test Feature");
expect(feature.status).toBe("defined");
expect(feature.taskId).toBeUndefined();
});
it("throws when adding feature to non-existent slice", () => {
expect(() => {
store.addFeature("SL-NONEXISTENT", { title: "Test" });
}).toThrow("Slice SL-NONEXISTENT not found");
});
it("gets a feature by id", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const created = store.addFeature(slice.id, { title: "Get Test" });
const retrieved = store.getFeature(created.id);
expect(retrieved).toBeDefined();
expect(retrieved!.id).toBe(created.id);
});
it("lists features for a slice", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const f1 = store.addFeature(slice.id, { title: "Feature 1" });
const f2 = store.addFeature(slice.id, { title: "Feature 2" });
const list = store.listFeatures(slice.id);
expect(list).toHaveLength(2);
expect(list[0].id).toBe(f1.id);
expect(list[1].id).toBe(f2.id);
});
it("updates a feature", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Original" });
const updated = store.updateFeature(feature.id, { title: "Updated" });
expect(updated.title).toBe("Updated");
});
it("deletes a feature", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "To Delete" });
store.deleteFeature(feature.id);
const retrieved = store.getFeature(feature.id);
expect(retrieved).toBeUndefined();
});
it("links a feature to a task and persists missionId/sliceId on the task row", () => {
createTaskInDb(db, "FN-001");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Linkable" });
const linked = store.linkFeatureToTask(feature.id, "FN-001");
const taskRow = db.prepare("SELECT missionId, sliceId FROM tasks WHERE id = ?").get("FN-001") as {
missionId: string | null;
sliceId: string | null;
};
expect(linked.taskId).toBe("FN-001");
expect(linked.status).toBe("triaged");
expect(taskRow.missionId).toBe(mission.id);
expect(taskRow.sliceId).toBe(slice.id);
});
it("emits feature:linked event", () => {
createTaskInDb(db, "FN-001");
const handler = vi.fn();
store.on("feature:linked", handler);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Test" });
const linked = store.linkFeatureToTask(feature.id, "FN-001");
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ feature: linked, taskId: "FN-001" });
});
it("unlinks a feature from a task and clears missionId/sliceId on the task row", () => {
createTaskInDb(db, "FN-001");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Linkable" });
store.linkFeatureToTask(feature.id, "FN-001");
const unlinked = store.unlinkFeatureFromTask(feature.id);
const taskRow = db.prepare("SELECT missionId, sliceId FROM tasks WHERE id = ?").get("FN-001") as {
missionId: string | null;
sliceId: string | null;
};
expect(unlinked.taskId).toBeUndefined();
expect(unlinked.status).toBe("defined");
expect(taskRow.missionId).toBeNull();
expect(taskRow.sliceId).toBeNull();
});
it("finds feature by task id", () => {
createTaskInDb(db, "KB-999");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Findable" });
store.linkFeatureToTask(feature.id, "KB-999");
const found = store.getFeatureByTaskId("KB-999");
expect(found).toBeDefined();
expect(found!.id).toBe(feature.id);
});
it("returns undefined when no feature linked to task", () => {
const result = store.getFeatureByTaskId("FN-NONEXISTENT");
expect(result).toBeUndefined();
});
it("emits feature:deleted event with id", () => {
const handler = vi.fn();
store.on("feature:deleted", handler);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Test" });
store.deleteFeature(feature.id);
expect(handler).toHaveBeenCalledWith(feature.id);
});
});
// ── Cascade Delete Tests ───────────────────────────────────────────────
describe("Cascade Deletes", () => {
it("deletes mission → milestones → slices → features", () => {
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "Child" });
const slice = store.addSlice(milestone.id, { title: "Grandchild" });
const feature = store.addFeature(slice.id, { title: "Great-grandchild" });
store.deleteMission(mission.id);
expect(store.getMission(mission.id)).toBeUndefined();
expect(store.getMilestone(milestone.id)).toBeUndefined();
expect(store.getSlice(slice.id)).toBeUndefined();
expect(store.getFeature(feature.id)).toBeUndefined();
});
it("deletes milestone → slices → features", () => {
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "Child" });
const slice = store.addSlice(milestone.id, { title: "Grandchild" });
const feature = store.addFeature(slice.id, { title: "Great-grandchild" });
store.deleteMilestone(milestone.id);
// Mission should still exist
expect(store.getMission(mission.id)).toBeDefined();
// But everything below should be gone
expect(store.getMilestone(milestone.id)).toBeUndefined();
expect(store.getSlice(slice.id)).toBeUndefined();
expect(store.getFeature(feature.id)).toBeUndefined();
});
it("deletes slice → features", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Feature" });
store.deleteSlice(slice.id);
// Mission and milestone should still exist
expect(store.getMission(mission.id)).toBeDefined();
expect(store.getMilestone(milestone.id)).toBeDefined();
// But slice and feature should be gone
expect(store.getSlice(slice.id)).toBeUndefined();
expect(store.getFeature(feature.id)).toBeUndefined();
});
});
// ── Status Rollup Tests ───────────────────────────────────────────────
describe("Status Rollup", () => {
describe("computeSliceStatus", () => {
it("returns pending when no features", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Empty Slice" });
const status = store.computeSliceStatus(slice.id);
expect(status).toBe("pending");
});
it("returns complete when all features done", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Complete Slice" });
const f1 = store.addFeature(slice.id, { title: "F1" });
const f2 = store.addFeature(slice.id, { title: "F2" });
store.updateFeature(f1.id, { status: "done" });
store.updateFeature(f2.id, { status: "done" });
const status = store.computeSliceStatus(slice.id);
expect(status).toBe("complete");
});
it("returns active when any feature has task linked", () => {
createTaskInDb(db, "FN-001");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Active Slice" });
const feature = store.addFeature(slice.id, { title: "Linked" });
store.linkFeatureToTask(feature.id, "FN-001");
const status = store.computeSliceStatus(slice.id);
expect(status).toBe("active");
});
});
describe("computeMilestoneStatus", () => {
it("returns planning when no slices", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Empty Milestone" });
const status = store.computeMilestoneStatus(milestone.id);
expect(status).toBe("planning");
});
it("returns complete when all slices complete", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Complete Milestone" });
const s1 = store.addSlice(milestone.id, { title: "S1" });
const s2 = store.addSlice(milestone.id, { title: "S2" });
// Make all features done to trigger slice completion
const f1 = store.addFeature(s1.id, { title: "F1" });
const f2 = store.addFeature(s2.id, { title: "F2" });
store.updateFeature(f1.id, { status: "done" });
store.updateFeature(f2.id, { status: "done" });
// Force recompute
store["recomputeSliceStatus"](s1.id);
store["recomputeSliceStatus"](s2.id);
const status = store.computeMilestoneStatus(milestone.id);
expect(status).toBe("complete");
});
it("returns active when any slice is active", () => {
createTaskInDb(db, "FN-001");
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Active Milestone" });
const slice = store.addSlice(milestone.id, { title: "Active Slice" });
const feature = store.addFeature(slice.id, { title: "Linked" });
store.linkFeatureToTask(feature.id, "FN-001");
const status = store.computeMilestoneStatus(milestone.id);
expect(status).toBe("active");
});
});
describe("computeMissionStatus", () => {
it("returns planning when no milestones", () => {
const mission = store.createMission({ title: "Empty Mission" });
const status = store.computeMissionStatus(mission.id);
expect(status).toBe("planning");
});
it("returns complete when all milestones complete", () => {
const mission = store.createMission({ title: "Complete Mission" });
const m1 = store.addMilestone(mission.id, { title: "M1" });
const m2 = store.addMilestone(mission.id, { title: "M2" });
// Complete both milestones
store.updateMilestone(m1.id, { status: "complete" });
store.updateMilestone(m2.id, { status: "complete" });
const status = store.computeMissionStatus(mission.id);
expect(status).toBe("complete");
});
it("returns active when any milestone is active", () => {
const mission = store.createMission({ title: "Active Mission" });
const m1 = store.addMilestone(mission.id, { title: "Active M" });
const m2 = store.addMilestone(mission.id, { title: "Planning M" });
store.updateMilestone(m1.id, { status: "active" });
const status = store.computeMissionStatus(mission.id);
expect(status).toBe("active");
});
});
});
// ── Mission With Hierarchy Tests ──────────────────────────────────────
describe("getMissionWithHierarchy", () => {
it("returns undefined for non-existent mission", () => {
const result = store.getMissionWithHierarchy("M-NONEXISTENT");
expect(result).toBeUndefined();
});
it("returns mission with full hierarchy", () => {
const mission = store.createMission({
title: "Hierarchy Test",
description: "Testing full tree loading",
});
const m1 = store.addMilestone(mission.id, { title: "Milestone 1" });
const m2 = store.addMilestone(mission.id, { title: "Milestone 2" });
const s1 = store.addSlice(m1.id, { title: "Slice 1" });
const s2 = store.addSlice(m1.id, { title: "Slice 2" });
const f1 = store.addFeature(s1.id, { title: "Feature 1" });
const f2 = store.addFeature(s1.id, { title: "Feature 2" });
const withHierarchy = store.getMissionWithHierarchy(mission.id)!;
expect(withHierarchy.id).toBe(mission.id);
expect(withHierarchy.title).toBe("Hierarchy Test");
expect(withHierarchy.milestones).toHaveLength(2);
const m1Data = withHierarchy.milestones.find((m) => m.id === m1.id)!;
expect(m1Data.slices).toHaveLength(2);
const s1Data = m1Data.slices.find((s) => s.id === s1.id)! as import("./mission-types.js").SliceWithFeatures;
expect(s1Data.features).toHaveLength(2);
expect(s1Data.features.find((f: import("./mission-types.js").MissionFeature) => f.id === f1.id)).toBeDefined();
expect(s1Data.features.find((f: import("./mission-types.js").MissionFeature) => f.id === f2.id)).toBeDefined();
});
});
// ── Transaction Tests ────────────────────────────────────────────────
describe("Transaction Handling", () => {
it("rolls back reorder on error", () => {
const mission = store.createMission({ title: "Parent" });
const m1 = store.addMilestone(mission.id, { title: "M1" });
const originalOrder = m1.orderIndex;
expect(() => {
store.reorderMilestones(mission.id, [m1.id, "MS-NONEXISTENT"]);
}).toThrow();
// m1's order should be unchanged due to rollback
const retrieved = store.getMilestone(m1.id);
expect(retrieved!.orderIndex).toBe(originalOrder);
});
it("rolls back slice reorder on error", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const s1 = store.addSlice(milestone.id, { title: "S1" });
const originalOrder = s1.orderIndex;
expect(() => {
store.reorderSlices(milestone.id, [s1.id, "SL-NONEXISTENT"]);
}).toThrow();
const retrieved = store.getSlice(s1.id);
expect(retrieved!.orderIndex).toBe(originalOrder);
});
});
// ── Event Emission Tests ──────────────────────────────────────────────
describe("Event Emissions", () => {
it("emits all mission lifecycle events", () => {
const created = vi.fn();
const updated = vi.fn();
const deleted = vi.fn();
store.on("mission:created", created);
store.on("mission:updated", updated);
store.on("mission:deleted", deleted);
const mission = store.createMission({ title: "Test" });
store.updateMission(mission.id, { title: "Updated" });
store.deleteMission(mission.id);
expect(created).toHaveBeenCalledTimes(1);
expect(updated).toHaveBeenCalledTimes(1);
expect(deleted).toHaveBeenCalledTimes(1);
});
it("emits all milestone lifecycle events", () => {
const created = vi.fn();
const updated = vi.fn();
const deleted = vi.fn();
store.on("milestone:created", created);
store.on("milestone:updated", updated);
store.on("milestone:deleted", deleted);
const mission = store.createMission({ title: "Parent" });
const milestone = store.addMilestone(mission.id, { title: "Test" });
store.updateMilestone(milestone.id, { title: "Updated" });
store.deleteMilestone(milestone.id);
expect(created).toHaveBeenCalledTimes(1);
expect(updated).toHaveBeenCalledTimes(1);
expect(deleted).toHaveBeenCalledTimes(1);
});
it("emits all slice lifecycle events", async () => {
const created = vi.fn();
const updated = vi.fn();
const deleted = vi.fn();
const activated = vi.fn();
store.on("slice:created", created);
store.on("slice:updated", updated);
store.on("slice:deleted", deleted);
store.on("slice:activated", activated);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Test" });
await store.activateSlice(slice.id);
store.deleteSlice(slice.id);
expect(created).toHaveBeenCalledTimes(1);
expect(updated).toHaveBeenCalledTimes(1); // From activateSlice
expect(activated).toHaveBeenCalledTimes(1);
expect(deleted).toHaveBeenCalledTimes(1);
});
it("emits all feature lifecycle events", () => {
createTaskInDb(db, "FN-001");
const created = vi.fn();
const updated = vi.fn();
const deleted = vi.fn();
const linked = vi.fn();
store.on("feature:created", created);
store.on("feature:updated", updated);
store.on("feature:deleted", deleted);
store.on("feature:linked", linked);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Test" });
store.linkFeatureToTask(feature.id, "FN-001");
store.deleteFeature(feature.id);
expect(created).toHaveBeenCalledTimes(1);
// Updated is called twice: once by linkFeatureToTask, once by delete triggering recompute
expect(updated).toHaveBeenCalled();
expect(linked).toHaveBeenCalledTimes(1);
expect(deleted).toHaveBeenCalledTimes(1);
});
it("includes correct data in event payloads", () => {
createTaskInDb(db, "FN-123");
const createdHandler = vi.fn();
const linkedHandler = vi.fn();
store.on("feature:created", createdHandler);
store.on("feature:linked", linkedHandler);
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Test" });
expect(createdHandler).toHaveBeenCalledWith(
expect.objectContaining({
id: feature.id,
title: "Test",
status: "defined",
})
);
store.linkFeatureToTask(feature.id, "FN-123");
expect(linkedHandler).toHaveBeenCalledWith(
expect.objectContaining({
feature: expect.objectContaining({ id: feature.id }),
taskId: "FN-123",
})
);
});
});
describe("triageFeature", () => {
it("throws if TaskStore reference is not available", async () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Feature" });
await expect(store.triageFeature(feature.id)).rejects.toThrow(
"TaskStore reference is required for triage operations",
);
});
it("throws if feature not found", async () => {
// Need a TaskStore reference for this test
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageFeature("F-NONEXISTENT")).rejects.toThrow(
"Feature F-NONEXISTENT not found",
);
});
it("throws if feature is already triaged", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Feature" });
// Triaging once should work
await msWithTs.triageFeature(feature.id);
// Triaging again should fail
const updated = msWithTs.getFeature(feature.id)!;
await expect(msWithTs.triageFeature(updated.id)).rejects.toThrow(
`already triaged`,
);
});
it("creates a task and links it to the feature", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, {
title: "Login Page",
description: "Build a login page",
acceptanceCriteria: "User can log in",
});
const triaged = await msWithTs.triageFeature(feature.id);
// Feature should be triaged with a taskId
expect(triaged.status).toBe("triaged");
expect(triaged.taskId).toBeTruthy();
// Task should exist with correct properties
const task = await ts.getTask(triaged.taskId!);
expect(task).toBeDefined();
expect(task!.title).toBe("Login Page");
expect(task!.description).toContain("Build a login page");
expect(task!.description).toContain("Acceptance Criteria");
expect(task!.sliceId).toBe(slice.id);
expect(task!.missionId).toBe(mission.id);
});
it("uses provided title and description overrides", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Original" });
const triaged = await msWithTs.triageFeature(
feature.id,
"Custom Title",
"Custom description for the task",
);
const task = await ts.getTask(triaged.taskId!);
expect(task!.title).toBe("Custom Title");
expect(task!.description).toBe("Custom description for the task");
});
it("emits feature:linked event", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const linkedHandler = vi.fn();
msWithTs.on("feature:linked", linkedHandler);
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Feature" });
const triaged = await msWithTs.triageFeature(feature.id);
expect(linkedHandler).toHaveBeenCalledWith(
expect.objectContaining({
feature: expect.objectContaining({ id: feature.id }),
taskId: triaged.taskId,
}),
);
});
});
describe("triageSlice", () => {
it("throws if TaskStore reference is not available", async () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
await expect(store.triageSlice(slice.id)).rejects.toThrow(
"TaskStore reference is required for triage operations",
);
});
it("throws if slice not found", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageSlice("SL-NONEXISTENT")).rejects.toThrow(
"Slice SL-NONEXISTENT not found",
);
});
it("triages all defined features in a slice", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const f1 = msWithTs.addFeature(slice.id, { title: "Feature 1" });
const f2 = msWithTs.addFeature(slice.id, { title: "Feature 2" });
const f3 = msWithTs.addFeature(slice.id, { title: "Feature 3" });
const triaged = await msWithTs.triageSlice(slice.id);
expect(triaged).toHaveLength(3);
expect(triaged.every((f) => f.status === "triaged")).toBe(true);
expect(triaged.every((f) => f.taskId)).toBe(true);
// All tasks should exist and be linked to the slice/mission
for (const feature of triaged) {
const task = await ts.getTask(feature.taskId!);
expect(task).toBeDefined();
expect(task!.sliceId).toBe(slice.id);
expect(task!.missionId).toBe(mission.id);
}
});
it("skips already triaged features", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const f1 = msWithTs.addFeature(slice.id, { title: "Feature 1" });
const f2 = msWithTs.addFeature(slice.id, { title: "Feature 2" });
// Triage f1 first
await msWithTs.triageFeature(f1.id);
// Now triage the whole slice — should only triage f2
const triaged = await msWithTs.triageSlice(slice.id);
expect(triaged).toHaveLength(1);
expect(triaged[0].id).toBe(f2.id);
expect(triaged[0].status).toBe("triaged");
});
it("returns empty array if no defined features", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const triaged = await msWithTs.triageSlice(slice.id);
expect(triaged).toEqual([]);
});
});
// ── Auto-Triage on Slice Activation Tests ─────────────────────────────
describe("activateSlice with autoAdvance", () => {
/** Helper to create a MissionStore with a real TaskStore reference */
async function createStoreWithTaskStore(): Promise<{
ts: import("./store.js").TaskStore;
ms: MissionStore;
}> {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const ms = ts.getMissionStore();
return { ts, ms };
}
it("triages features when autoAdvance is true", async () => {
const { ts, ms } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission" });
ms.updateMission(mission.id, { autoAdvance: true });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
const f2 = ms.addFeature(slice.id, { title: "Feature 2" });
const activated = await ms.activateSlice(slice.id);
// Slice should be active
expect(activated.status).toBe("active");
expect(activated.activatedAt).toBeTruthy();
// Both features should be triaged with tasks
const updatedF1 = ms.getFeature(f1.id)!;
const updatedF2 = ms.getFeature(f2.id)!;
expect(updatedF1.status).toBe("triaged");
expect(updatedF1.taskId).toBeTruthy();
expect(updatedF2.status).toBe("triaged");
expect(updatedF2.taskId).toBeTruthy();
// Tasks should exist and be linked to the slice/mission
const task1 = await ts.getTask(updatedF1.taskId!);
const task2 = await ts.getTask(updatedF2.taskId!);
expect(task1).toBeDefined();
expect(task1!.sliceId).toBe(slice.id);
expect(task1!.missionId).toBe(mission.id);
expect(task2).toBeDefined();
expect(task2!.sliceId).toBe(slice.id);
expect(task2!.missionId).toBe(mission.id);
});
it("does not triage features when autoAdvance is false", async () => {
const { ms } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission" });
// autoAdvance defaults to false
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
const activated = await ms.activateSlice(slice.id);
// Slice should be active
expect(activated.status).toBe("active");
// Feature should still be "defined" — not triaged
const updatedF1 = ms.getFeature(f1.id)!;
expect(updatedF1.status).toBe("defined");
expect(updatedF1.taskId).toBeUndefined();
});
it("does not triage features when autoAdvance is unset", async () => {
const { ms } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature" });
const activated = await ms.activateSlice(slice.id);
expect(activated.status).toBe("active");
const updatedFeature = ms.getFeature(feature.id)!;
expect(updatedFeature.status).toBe("defined");
});
it("skips already-triaged features during auto-triage", async () => {
const { ms } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission" });
ms.updateMission(mission.id, { autoAdvance: true });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
const f2 = ms.addFeature(slice.id, { title: "Feature 2" });
// Manually triage f1 first
await ms.triageFeature(f1.id);
const f1TaskId = ms.getFeature(f1.id)!.taskId;
expect(f1TaskId).toBeTruthy();
// Activate the slice — should only triage f2
const activated = await ms.activateSlice(slice.id);
expect(activated.status).toBe("active");
// f1 should keep its existing taskId
const updatedF1 = ms.getFeature(f1.id)!;
expect(updatedF1.taskId).toBe(f1TaskId);
// f2 should now be triaged
const updatedF2 = ms.getFeature(f2.id)!;
expect(updatedF2.status).toBe("triaged");
expect(updatedF2.taskId).toBeTruthy();
});
it("still activates slice even if triage fails", async () => {
const { ms } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission" });
ms.updateMission(mission.id, { autoAdvance: true });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature" });
// Sabotage the TaskStore by removing it to trigger a triage error
// The MissionStore was created via TaskStore, so taskStore is available.
// To make triage fail, we'll delete the task from the DB after it's created.
// Instead, let's use a MissionStore WITHOUT a TaskStore but with autoAdvance.
const storeNoTs = new MissionStore(kbDir, db);
const mission2 = storeNoTs.createMission({ title: "Mission 2" });
storeNoTs.updateMission(mission2.id, { autoAdvance: true });
const milestone2 = storeNoTs.addMilestone(mission2.id, { title: "Milestone 2" });
const slice2 = storeNoTs.addSlice(milestone2.id, { title: "Slice 2" });
storeNoTs.addFeature(slice2.id, { title: "Feature" });
// activateSlice should still succeed even though triageSlice will throw
const activated = await storeNoTs.activateSlice(slice2.id);
expect(activated.status).toBe("active");
expect(activated.activatedAt).toBeTruthy();
});
it("throws meaningful error when slice not found", async () => {
await expect(store.activateSlice("SL-NONEXISTENT")).rejects.toThrow(
"Slice SL-NONEXISTENT not found",
);
});
// ── autopilotEnabled as primary control ──────────────────────────────────
it("triages features when autopilotEnabled is true (autoAdvance false)", async () => {
const { ts, ms } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission" });
// autopilotEnabled is primary control; autoAdvance=false/unset should still work
ms.updateMission(mission.id, { autopilotEnabled: true, autoAdvance: false });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
const f2 = ms.addFeature(slice.id, { title: "Feature 2" });
const activated = await ms.activateSlice(slice.id);
expect(activated.status).toBe("active");
// Both features should be triaged because autopilotEnabled=true
const updatedF1 = ms.getFeature(f1.id)!;
const updatedF2 = ms.getFeature(f2.id)!;
expect(updatedF1.status).toBe("triaged");
expect(updatedF1.taskId).toBeTruthy();
expect(updatedF2.status).toBe("triaged");
expect(updatedF2.taskId).toBeTruthy();
// Tasks should exist and be linked
const task1 = await ts.getTask(updatedF1.taskId!);
const task2 = await ts.getTask(updatedF2.taskId!);
expect(task1).toBeDefined();
expect(task1!.sliceId).toBe(slice.id);
expect(task2).toBeDefined();
expect(task2!.sliceId).toBe(slice.id);
});
it("triages features when autopilotEnabled is true (autoAdvance unset)", async () => {
const { ts, ms } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission" });
// autopilotEnabled=true, autoAdvance undefined (neither true nor false)
ms.updateMission(mission.id, { autopilotEnabled: true });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
await ms.activateSlice(slice.id);
// Feature should be triaged because autopilotEnabled=true
const updatedF1 = ms.getFeature(f1.id)!;
expect(updatedF1.status).toBe("triaged");
expect(updatedF1.taskId).toBeTruthy();
});
it("does not triage features when autopilotEnabled is false and autoAdvance is false", async () => {
const { ms } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission" });
ms.updateMission(mission.id, { autopilotEnabled: false, autoAdvance: false });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
await ms.activateSlice(slice.id);
// Feature should NOT be triaged
const updatedF1 = ms.getFeature(f1.id)!;
expect(updatedF1.status).toBe("defined");
expect(updatedF1.taskId).toBeUndefined();
});
it("triages features when autopilotEnabled is false but autoAdvance is true (legacy compat)", async () => {
const { ms } = await createStoreWithTaskStore();
const mission = ms.createMission({ title: "Mission" });
// Legacy case: autoAdvance=true, autopilotEnabled=false/unset
ms.updateMission(mission.id, { autoAdvance: true });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
await ms.activateSlice(slice.id);
// Feature should be triaged because autoAdvance=true (legacy compat)
const updatedF1 = ms.getFeature(f1.id)!;
expect(updatedF1.status).toBe("triaged");
expect(updatedF1.taskId).toBeTruthy();
});
});
});
// vi import for vitest mocking
import { vi } from "vitest";