test(KB-636): expand mission integration coverage

- Add deeper MissionStore integration tests for hierarchy integrity, rollups, reorders, and task link cleanup
- Extend mission API end-to-end coverage for feature patching, validation, and cascade scenarios
- Fix TaskStore task upsert placeholders to preserve mission slice linking fields after merge resolution
- Tidy mission CLI help text and include the published package changeset for the missions launch
This commit is contained in:
gsxdsm
2026-04-01 14:30:04 -07:00
parent ce0bf248b1
commit 03c3743300
5 changed files with 292 additions and 612 deletions

View File

@@ -23,4 +23,3 @@ The Missions system provides a hierarchical planning structure:
- Press Cmd/Ctrl+Shift+M in dashboard to open missions - Press Cmd/Ctrl+Shift+M in dashboard to open missions
- Use interview mode for AI-assisted mission planning - Use interview mode for AI-assisted mission planning
- Link features to tasks for automatic progress tracking - Link features to tasks for automatic progress tracking

View File

@@ -104,7 +104,7 @@ Usage:
fn mission create [title] [description...] Create a new mission fn mission create [title] [description...] Create a new mission
fn mission list List all missions fn mission list List all missions
fn mission show <id> Show mission with hierarchy fn mission show <id> Show mission with hierarchy
fn mission delete <id> [--force] Delete a mission fn mission delete <id> [--force] Delete mission
fn mission activate-slice <slice-id> Activate a pending slice fn mission activate-slice <slice-id> Activate a pending slice
Options: Options:

View File

@@ -1,665 +1,353 @@
/** import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
* Mission Integration Tests import { mkdtempSync } from "node:fs";
* import { rm } from "node:fs/promises";
* Comprehensive integration tests for MissionStore working with TaskStore.
* Tests mission hierarchy, status rollup, cascade operations, and event emissions.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore } from "./store.js"; import { TaskStore } from "./store.js";
import { MissionStore } from "./mission-store.js"; import { Database } from "./db.js";
import type { Mission, Milestone, Slice, MissionFeature } from "./mission-types.js";
// Helper to create temp directory function makeTmpDir(): string {
function createTempDir(): string { return mkdtempSync(join(tmpdir(), "kb-mission-integration-"));
return mkdtempSync(join(tmpdir(), "mission-integration-"));
} }
// Helper to cleanup temp directory function getPrivateDb(store: TaskStore): Database | null {
function cleanupTempDir(dir: string): void { return (store as unknown as { _db: Database | null })._db;
try { }
rmSync(dir, { recursive: true, force: true });
} catch { function assertHierarchyIntegrity(
// Ignore cleanup errors hierarchy: NonNullable<ReturnType<ReturnType<TaskStore["getMissionStore"]>["getMissionWithHierarchy"]>>,
) {
expect(hierarchy.milestones.every((milestone, index) => milestone.orderIndex === index)).toBe(true);
expect(new Set(hierarchy.milestones.map((milestone) => milestone.id)).size).toBe(hierarchy.milestones.length);
for (const milestone of hierarchy.milestones) {
expect(milestone.slices.every((slice, index) => slice.orderIndex === index)).toBe(true);
expect(new Set(milestone.slices.map((slice) => slice.id)).size).toBe(milestone.slices.length);
for (const slice of milestone.slices) {
expect(slice.milestoneId).toBe(milestone.id);
expect(new Set(slice.features.map((feature) => feature.id)).size).toBe(slice.features.length);
for (const feature of slice.features) {
expect(feature.sliceId).toBe(slice.id);
}
}
} }
} }
describe("Mission Integration", () => { /**
let tempDir: string; * Creates a mission hierarchy large enough to exercise rollups, reorder logic,
* and cascade deletions in integration scenarios.
*/
async function createHierarchy(store: TaskStore) {
const missionStore = store.getMissionStore();
const mission = missionStore.createMission({
title: "Launch authentication",
description: "Mission hierarchy integration test",
});
const milestones = Array.from({ length: 3 }, (_, milestoneIndex) => {
const milestone = missionStore.addMilestone(mission.id, {
title: `Milestone ${milestoneIndex + 1}`,
description: `Phase ${milestoneIndex + 1}`,
});
const slices = Array.from({ length: 2 }, (_, sliceIndex) => {
const slice = missionStore.addSlice(milestone.id, {
title: `Slice ${milestoneIndex + 1}.${sliceIndex + 1}`,
description: `Slice ${milestoneIndex + 1}.${sliceIndex + 1}`,
});
const features = Array.from({ length: 3 }, (_, featureIndex) =>
missionStore.addFeature(slice.id, {
title: `Feature ${milestoneIndex + 1}.${sliceIndex + 1}.${featureIndex + 1}`,
description: "Feature description",
acceptanceCriteria: "criterion",
}),
);
return { ...slice, features };
});
return { ...milestone, slices };
});
return { missionStore, mission, milestones };
}
/**
* MissionStore integration tests verify the missions hierarchy when it shares
* the same SQLite database as TaskStore. These scenarios cover linking tasks
* to features, rollup state transitions, hierarchy integrity after reorders and
* deletions, foreign-key cleanup, and event emissions that other packages rely on.
*/
describe("MissionStore integration with TaskStore", () => {
let rootDir: string;
let taskStore: TaskStore; let taskStore: TaskStore;
let missionStore: MissionStore;
beforeEach(async () => { beforeEach(async () => {
tempDir = createTempDir(); vi.useFakeTimers();
taskStore = new TaskStore(tempDir); vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z"));
rootDir = makeTmpDir();
taskStore = new TaskStore(rootDir);
await taskStore.init(); await taskStore.init();
missionStore = taskStore.getMissionStore();
}); });
afterEach(() => { afterEach(async () => {
cleanupTempDir(tempDir); vi.useRealTimers();
await rm(rootDir, { recursive: true, force: true });
}); });
describe("Mission Creation and Hierarchy", () => { it("creates and retrieves a full hierarchy through the shared MissionStore", async () => {
it("should create mission with complete hierarchy", () => { const { missionStore, mission } = await createHierarchy(taskStore);
// Create mission
const mission = missionStore.createMission({
title: "Build Auth System",
description: "Complete authentication system with login, signup, and password reset",
});
expect(mission.id).toMatch(/^M-/); const fullMission = missionStore.getMissionWithHierarchy(mission.id);
expect(mission.title).toBe("Build Auth System");
expect(mission.status).toBe("planning");
expect(mission.interviewState).toBe("not_started");
// Add milestones expect(fullMission).toBeDefined();
const milestone1 = missionStore.addMilestone(mission.id, { expect(fullMission?.milestones).toHaveLength(3);
title: "Database Schema", expect(fullMission?.milestones.every((milestone) => milestone.slices.length === 2)).toBe(true);
description: "Design and implement database tables", expect(
}); fullMission?.milestones.every((milestone) =>
milestone.slices.every((slice) => {
const hierarchySlice = slice as typeof slice & { features: Array<{ id: string }> };
return hierarchySlice.features.length === 3;
}),
),
).toBe(true);
});
const milestone2 = missionStore.addMilestone(mission.id, { it("links features to real TaskStore tasks and updates sliceId without populating missionId", async () => {
title: "API Endpoints", const { missionStore, mission, milestones } = await createHierarchy(taskStore);
description: "Build REST API endpoints", const feature = milestones[0].slices[0].features[0];
});
expect(milestone1.orderIndex).toBe(0); const linkedTask = await taskStore.createTask({
expect(milestone2.orderIndex).toBe(1); title: "Build login form",
description: "Implement the login form task used for mission linking.",
// Add slices to first milestone column: "todo",
const slice1 = missionStore.addSlice(milestone1.id, {
title: "User Tables",
description: "Create user and session tables",
});
const slice2 = missionStore.addSlice(milestone1.id, {
title: "Token Storage",
description: "Implement refresh token storage",
});
expect(slice1.orderIndex).toBe(0);
expect(slice2.orderIndex).toBe(1);
// Add features to first slice
const feature1 = missionStore.addFeature(slice1.id, {
title: "User model",
description: "Define user database schema",
acceptanceCriteria: "Users can be created with email and password hash",
});
const feature2 = missionStore.addFeature(slice1.id, {
title: "Session table",
description: "Create session management table",
});
expect(feature1.status).toBe("defined");
expect(feature2.status).toBe("defined");
// Verify full hierarchy
const fullMission = missionStore.getMissionWithHierarchy(mission.id);
expect(fullMission).toBeDefined();
expect(fullMission!.milestones).toHaveLength(2);
expect(fullMission!.milestones[0].slices).toHaveLength(2);
expect(fullMission!.milestones[0].slices[0].features).toHaveLength(2);
}); });
it("should compute orderIndex correctly for multiple items", () => { // TaskStore persists tasks to disk first, so create a DB-backed snapshot
const mission = missionStore.createMission({ title: "Test Mission" }); // using a normal update path before MissionStore writes the linkage field.
await taskStore.moveTask(linkedTask.id, "in-progress");
// Add 3 milestones const linkedFeature = missionStore.linkFeatureToTask(feature.id, linkedTask.id);
const ms1 = missionStore.addMilestone(mission.id, { title: "Milestone 1" }); const storedTask = await taskStore.getTask(linkedTask.id);
const ms2 = missionStore.addMilestone(mission.id, { title: "Milestone 2" }); const taskRow = getPrivateDb(taskStore)?.prepare(
const ms3 = missionStore.addMilestone(mission.id, { title: "Milestone 3" }); "SELECT missionId, sliceId FROM tasks WHERE id = ?",
).get(linkedTask.id) as { missionId: string | null; sliceId: string | null } | undefined;
expect(ms1.orderIndex).toBe(0); expect(linkedFeature.taskId).toBe(linkedTask.id);
expect(ms2.orderIndex).toBe(1); expect(linkedFeature.status).toBe("triaged");
expect(ms3.orderIndex).toBe(2); expect(storedTask.sliceId).toBe(milestones[0].slices[0].id);
expect(taskRow?.sliceId).toBe(milestones[0].slices[0].id);
expect(taskRow?.missionId).toBeNull();
// Add slices to first milestone const linkedHierarchy = missionStore.getMissionWithHierarchy(mission.id);
const sl1 = missionStore.addSlice(ms1.id, { title: "Slice 1" }); expect(linkedHierarchy?.milestones[0].slices[0].features[0].taskId).toBe(linkedTask.id);
const sl2 = missionStore.addSlice(ms1.id, { title: "Slice 2" });
expect(sl1.orderIndex).toBe(0);
expect(sl2.orderIndex).toBe(1);
});
}); });
describe("Task Linking and Feature Status", () => { it("rolls up status from features to slices, milestones, and mission", async () => {
it("should update feature status when linked to task", async () => { const { missionStore, mission, milestones } = await createHierarchy(taskStore);
const mission = missionStore.createMission({ title: "Test Mission" }); const [firstMilestone] = milestones;
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" }); const [firstSlice] = firstMilestone.slices;
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
// Create a task const linkedFeatures: { featureId: string; taskId: string }[] = [];
for (const feature of firstSlice.features) {
const task = await taskStore.createTask({ const task = await taskStore.createTask({
description: "Implement feature", title: feature.title,
title: "Feature implementation", description: `Task for ${feature.title}`,
}); column: "todo",
// Link feature to task
const updatedFeature = missionStore.linkFeatureToTask(feature.id, task.id);
expect(updatedFeature.taskId).toBe(task.id);
expect(updatedFeature.status).toBe("triaged");
});
it("should update slice status when feature is linked", async () => {
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
// Slice starts as pending
expect(slice.status).toBe("pending");
// Create and link task
const task = await taskStore.createTask({
description: "Implement feature",
title: "Feature implementation",
}); });
await taskStore.moveTask(task.id, "in-progress");
missionStore.linkFeatureToTask(feature.id, task.id); missionStore.linkFeatureToTask(feature.id, task.id);
linkedFeatures.push({ featureId: feature.id, taskId: task.id });
}
// Slice should now be active let updatedSlice = missionStore.getSlice(firstSlice.id);
const updatedSlice = missionStore.getSlice(slice.id); let updatedMilestone = missionStore.getMilestone(firstMilestone.id);
expect(updatedSlice!.status).toBe("active"); let updatedMission = missionStore.getMission(mission.id);
});
it("should find feature by task ID", async () => { expect(updatedSlice?.status).toBe("active");
const mission = missionStore.createMission({ title: "Test Mission" }); expect(updatedMilestone?.status).toBe("active");
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" }); expect(updatedMission?.status).toBe("active");
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
const task = await taskStore.createTask({ for (const { featureId, taskId } of linkedFeatures) {
description: "Implement feature", await taskStore.moveTask(taskId, "in-review");
title: "Feature implementation", await taskStore.moveTask(taskId, "done");
}); missionStore.updateFeature(featureId, { taskId, status: "done" });
}
missionStore.linkFeatureToTask(feature.id, task.id); updatedSlice = missionStore.getSlice(firstSlice.id);
updatedMilestone = missionStore.getMilestone(firstMilestone.id);
updatedMission = missionStore.getMission(mission.id);
const found = missionStore.getFeatureByTaskId(task.id); expect(updatedSlice?.status).toBe("complete");
expect(found).toBeDefined(); expect(updatedMilestone?.status).toBe("active");
expect(found!.id).toBe(feature.id); expect(updatedMission?.status).toBe("active");
});
it("should set task.sliceId when linking feature to task", async () => {
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
const task = await taskStore.createTask({
description: "Implement feature",
title: "Feature implementation",
});
// Link feature to task
missionStore.linkFeatureToTask(feature.id, task.id);
// Reload task and verify sliceId was set
const reloaded = await taskStore.getTask(task.id);
expect(reloaded.sliceId).toBe(slice.id);
});
it("should clear task.sliceId when unlinking feature from task", async () => {
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
const task = await taskStore.createTask({
description: "Implement feature",
title: "Feature implementation",
});
// Link and then unlink
missionStore.linkFeatureToTask(feature.id, task.id);
missionStore.unlinkFeatureFromTask(feature.id);
// Reload task and verify sliceId was cleared
const reloaded = await taskStore.getTask(task.id);
expect(reloaded.sliceId).toBeUndefined();
});
}); });
describe("Status Rollup", () => { it("cascades mission deletion across milestones, slices, and features", async () => {
it("should compute slice status based on features", async () => { const { missionStore, mission, milestones } = await createHierarchy(taskStore);
const mission = missionStore.createMission({ title: "Test Mission" }); const milestoneIds = milestones.map((milestone) => milestone.id);
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" }); const sliceIds = milestones.flatMap((milestone) => milestone.slices.map((slice) => slice.id));
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" }); const featureIds = milestones.flatMap((milestone) =>
milestone.slices.flatMap((slice) => slice.features.map((feature) => feature.id)),
);
// Empty slice should be pending missionStore.deleteMission(mission.id);
expect(missionStore.computeSliceStatus(slice.id)).toBe("pending");
// Add features expect(missionStore.getMission(mission.id)).toBeUndefined();
const f1 = missionStore.addFeature(slice.id, { title: "Feature 1" }); expect(milestoneIds.every((id) => missionStore.getMilestone(id) === undefined)).toBe(true);
const f2 = missionStore.addFeature(slice.id, { title: "Feature 2" }); expect(sliceIds.every((id) => missionStore.getSlice(id) === undefined)).toBe(true);
expect(featureIds.every((id) => missionStore.getFeature(id) === undefined)).toBe(true);
// Still pending (no tasks linked)
expect(missionStore.computeSliceStatus(slice.id)).toBe("pending");
// Link f1 to a task (makes it triaged/active)
const task1 = await taskStore.createTask({
description: "Implement feature 1",
title: "Feature 1 implementation",
});
missionStore.linkFeatureToTask(f1.id, task1.id);
// Should now be active since f1 has a task link
expect(missionStore.computeSliceStatus(slice.id)).toBe("active");
// Mark both as done
missionStore.updateFeatureStatus(f1.id, "done");
missionStore.updateFeatureStatus(f2.id, "done");
// Should be complete
expect(missionStore.computeSliceStatus(slice.id)).toBe("complete");
});
it("should compute milestone status based on slices", () => {
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
// Empty milestone should be planning
expect(missionStore.computeMilestoneStatus(milestone.id)).toBe("planning");
// Add slices
const s1 = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const s2 = missionStore.addSlice(milestone.id, { title: "Slice 2" });
// Still planning (all slices pending)
expect(missionStore.computeMilestoneStatus(milestone.id)).toBe("planning");
// Activate first slice
missionStore.activateSlice(s1.id);
// Should be active
expect(missionStore.computeMilestoneStatus(milestone.id)).toBe("active");
// Complete first slice by marking features done
const f1 = missionStore.addFeature(s1.id, { title: "Feature 1" });
missionStore.updateFeatureStatus(f1.id, "done");
// Activate and complete second slice
missionStore.activateSlice(s2.id);
const f2 = missionStore.addFeature(s2.id, { title: "Feature 2" });
missionStore.updateFeatureStatus(f2.id, "done");
// Milestone should be complete
const updatedMilestone = missionStore.getMilestone(milestone.id);
expect(updatedMilestone!.status).toBe("complete");
});
it("should compute mission status based on milestones", () => {
const mission = missionStore.createMission({ title: "Test Mission" });
// Empty mission should be planning
expect(missionStore.computeMissionStatus(mission.id)).toBe("planning");
// Add milestones
const m1 = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const m2 = missionStore.addMilestone(mission.id, { title: "Milestone 2" });
// Still planning
expect(missionStore.computeMissionStatus(mission.id)).toBe("planning");
// Complete first milestone via slice activation and feature completion
const s1 = missionStore.addSlice(m1.id, { title: "Slice 1" });
const f1 = missionStore.addFeature(s1.id, { title: "Feature 1" });
missionStore.activateSlice(s1.id);
missionStore.updateFeatureStatus(f1.id, "done");
// Mission should be active (one milestone active/complete)
expect(missionStore.computeMissionStatus(mission.id)).toBe("active");
// Complete second milestone
const s2 = missionStore.addSlice(m2.id, { title: "Slice 2" });
const f2 = missionStore.addFeature(s2.id, { title: "Feature 2" });
missionStore.activateSlice(s2.id);
missionStore.updateFeatureStatus(f2.id, "done");
// Mission should be complete
const updatedMission = missionStore.getMission(mission.id);
expect(updatedMission!.status).toBe("complete");
});
}); });
describe("Cascade Delete", () => { it("recomputes order indexes after deleting a middle milestone and preserves child integrity after reorder", async () => {
it("should delete mission and all children", () => { const { missionStore, mission, milestones } = await createHierarchy(taskStore);
const mission = missionStore.createMission({ title: "Test Mission" }); const [firstMilestone, middleMilestone, lastMilestone] = milestones;
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
// Delete mission missionStore.deleteMilestone(middleMilestone.id);
missionStore.deleteMission(mission.id);
// Everything should be gone const afterDelete = missionStore.listMilestones(mission.id);
expect(missionStore.getMission(mission.id)).toBeUndefined(); expect(afterDelete.map((milestone) => milestone.id)).toEqual([firstMilestone.id, lastMilestone.id]);
expect(missionStore.getMilestone(milestone.id)).toBeUndefined(); missionStore.reorderMilestones(mission.id, [firstMilestone.id, lastMilestone.id]);
expect(missionStore.getSlice(slice.id)).toBeUndefined();
expect(missionStore.getFeature(feature.id)).toBeUndefined();
});
it("should delete milestone and its slices/features", () => { const afterRecompute = missionStore.listMilestones(mission.id);
const mission = missionStore.createMission({ title: "Test Mission" }); expect(afterRecompute.map((milestone) => milestone.orderIndex)).toEqual([0, 1]);
const m1 = missionStore.addMilestone(mission.id, { title: "Milestone 1" }); expect(missionStore.getSlice(middleMilestone.slices[0].id)).toBeUndefined();
const m2 = missionStore.addMilestone(mission.id, { title: "Milestone 2" }); expect(missionStore.getFeature(middleMilestone.slices[0].features[0].id)).toBeUndefined();
const slice = missionStore.addSlice(m1.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
// Delete first milestone missionStore.reorderMilestones(mission.id, [lastMilestone.id, firstMilestone.id]);
missionStore.deleteMilestone(m1.id); const reordered = missionStore.getMissionWithHierarchy(mission.id);
// Second milestone and mission should still exist expect(reordered?.milestones.map((milestone) => milestone.id)).toEqual([
expect(missionStore.getMission(mission.id)).toBeDefined(); lastMilestone.id,
expect(missionStore.getMilestone(m2.id)).toBeDefined(); firstMilestone.id,
]);
// First milestone's children should be gone expect(reordered?.milestones[0].slices.map((slice) => slice.id)).toEqual(
expect(missionStore.getMilestone(m1.id)).toBeUndefined(); lastMilestone.slices.map((slice) => slice.id),
expect(missionStore.getSlice(slice.id)).toBeUndefined(); );
expect(missionStore.getFeature(feature.id)).toBeUndefined(); expect(reordered?.milestones[1].slices[0].features.map((feature) => feature.id)).toEqual(
}); firstMilestone.slices[0].features.map((feature) => feature.id),
);
it("should delete slice and its features", () => { expect(reordered).toBeDefined();
const mission = missionStore.createMission({ title: "Test Mission" }); assertHierarchyIntegrity(reordered!);
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const s1 = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const s2 = missionStore.addSlice(milestone.id, { title: "Slice 2" });
const f1 = missionStore.addFeature(s1.id, { title: "Feature 1" });
// Delete first slice
missionStore.deleteSlice(s1.id);
// Milestone and second slice should still exist
expect(missionStore.getMilestone(milestone.id)).toBeDefined();
expect(missionStore.getSlice(s2.id)).toBeDefined();
// First slice and its feature should be gone
expect(missionStore.getSlice(s1.id)).toBeUndefined();
expect(missionStore.getFeature(f1.id)).toBeUndefined();
});
}); });
describe("Events", () => { it("emits mission lifecycle events for creation, linking, and slice activation in order", async () => {
it("should emit mission:created event", () => { const missionStore = taskStore.getMissionStore();
const handler = vi.fn(); const events: string[] = [];
missionStore.on("mission:created", handler);
const mission = missionStore.createMission({ title: "Test Mission" }); missionStore.on("mission:created", () => events.push("mission:created"));
missionStore.on("feature:linked", () => events.push("feature:linked"));
missionStore.on("slice:activated", () => events.push("slice:activated"));
expect(handler).toHaveBeenCalledTimes(1); const mission = missionStore.createMission({ title: "Event mission" });
expect(handler).toHaveBeenCalledWith(mission); const milestone = missionStore.addMilestone(mission.id, { title: "Event milestone" });
const slice = missionStore.addSlice(milestone.id, { title: "Event slice" });
const feature = missionStore.addFeature(slice.id, { title: "Event feature" });
const task = await taskStore.createTask({
title: "Event task",
description: "Task for event assertions",
column: "todo",
}); });
await taskStore.moveTask(task.id, "in-progress");
it("should emit milestone:created event", () => { missionStore.linkFeatureToTask(feature.id, task.id);
const mission = missionStore.createMission({ title: "Test Mission" }); missionStore.activateSlice(slice.id);
const handler = vi.fn();
missionStore.on("milestone:created", handler);
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" }); expect(events).toEqual(["mission:created", "feature:linked", "slice:activated"]);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(milestone);
});
it("should emit slice:activated event", () => {
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const handler = vi.fn();
missionStore.on("slice:activated", handler);
const activated = missionStore.activateSlice(slice.id);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(activated);
});
it("should emit feature:linked event", async () => {
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
const handler = vi.fn();
missionStore.on("feature:linked", handler);
const task = await taskStore.createTask({
description: "Implement feature",
title: "Feature implementation",
});
const updated = missionStore.linkFeatureToTask(feature.id, task.id);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ feature: updated, taskId: task.id });
});
it("should emit delete events", () => {
const mission = missionStore.createMission({ title: "Test Mission" });
const handler = vi.fn();
missionStore.on("mission:deleted", handler);
missionStore.deleteMission(mission.id);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(mission.id);
});
}); });
describe("Complex Scenarios", () => { it("uses the same Database instance for TaskStore and MissionStore", () => {
it("should handle 3 milestones with 2 slices each with 3 features", () => { const missionStore = taskStore.getMissionStore();
const mission = missionStore.createMission({ title: "Complex Mission" }); const db = getPrivateDb(taskStore);
const missionStoreDb = (missionStore as unknown as { db: Database }).db;
// Create 3 milestones, each with 2 slices, each with 3 features expect(db).toBeDefined();
for (let m = 0; m < 3; m++) { expect(missionStoreDb).toBe(db);
const milestone = missionStore.addMilestone(mission.id, { });
title: `Milestone ${m + 1}`,
});
for (let s = 0; s < 2; s++) { it("keeps hierarchy retrievable after repeated deterministic reorder operations", async () => {
const slice = missionStore.addSlice(milestone.id, { const { missionStore, mission, milestones } = await createHierarchy(taskStore);
title: `Milestone ${m + 1} - Slice ${s + 1}`,
});
for (let f = 0; f < 3; f++) { missionStore.reorderMilestones(mission.id, [milestones[2].id, milestones[0].id, milestones[1].id]);
missionStore.addFeature(slice.id, { missionStore.reorderMilestones(mission.id, [milestones[1].id, milestones[2].id, milestones[0].id]);
title: `Milestone ${m + 1} - Slice ${s + 1} - Feature ${f + 1}`,
}); for (const milestone of missionStore.listMilestones(mission.id)) {
} const slices = missionStore.listSlices(milestone.id);
missionStore.reorderSlices(
milestone.id,
slices
.map((slice) => slice.id)
.reverse(),
);
}
const hierarchy = missionStore.getMissionWithHierarchy(mission.id);
expect(hierarchy?.milestones).toHaveLength(3);
expect(hierarchy).toBeDefined();
assertHierarchyIntegrity(hierarchy!);
});
it("keeps hierarchy valid under overlapping reorder and lookup operations", async () => {
const { missionStore, mission, milestones } = await createHierarchy(taskStore);
await Promise.all([
Promise.resolve().then(() =>
missionStore.reorderMilestones(mission.id, [milestones[1].id, milestones[2].id, milestones[0].id]),
),
Promise.resolve().then(() => {
const slices = missionStore.listSlices(milestones[0].id);
missionStore.reorderSlices(milestones[0].id, slices.map((slice) => slice.id).reverse());
}),
Promise.resolve().then(() => missionStore.getMissionWithHierarchy(mission.id)),
Promise.resolve().then(() => missionStore.listMissions()),
]);
const hierarchy = missionStore.getMissionWithHierarchy(mission.id);
expect(hierarchy).toBeDefined();
assertHierarchyIntegrity(hierarchy!);
});
it("keeps all descendants retrievable after bulk feature completion updates", async () => {
const { missionStore, mission } = await createHierarchy(taskStore);
const hierarchy = missionStore.getMissionWithHierarchy(mission.id)!;
for (const milestone of hierarchy.milestones) {
for (const slice of milestone.slices) {
for (const feature of slice.features) {
missionStore.updateFeature(feature.id, { status: "done" });
} }
} }
}
// Verify full hierarchy const refreshed = missionStore.getMissionWithHierarchy(mission.id)!;
const fullMission = missionStore.getMissionWithHierarchy(mission.id); expect(refreshed.milestones).toHaveLength(3);
expect(fullMission!.milestones).toHaveLength(3); expect(
expect(fullMission!.milestones[0].slices).toHaveLength(2); refreshed.milestones.every((milestone) =>
expect(fullMission!.milestones[0].slices[0].features).toHaveLength(3); milestone.slices.every((slice) => {
const hierarchySlice = slice as typeof slice & { features: Array<{ status: string }> };
// Total features return hierarchySlice.features.every((feature) => feature.status === "done");
let totalFeatures = 0; }),
for (const m of fullMission!.milestones) { ),
for (const s of m.slices) { ).toBe(true);
totalFeatures += s.features.length;
}
}
expect(totalFeatures).toBe(18); // 3 milestones × 2 slices × 3 features
});
it("should handle middle milestone deletion with orderIndex recomputation", () => {
const mission = missionStore.createMission({ title: "Test Mission" });
const m1 = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const m2 = missionStore.addMilestone(mission.id, { title: "Milestone 2" });
const m3 = missionStore.addMilestone(mission.id, { title: "Milestone 3" });
expect(m1.orderIndex).toBe(0);
expect(m2.orderIndex).toBe(1);
expect(m3.orderIndex).toBe(2);
// Delete middle milestone
missionStore.deleteMilestone(m2.id);
// Remaining milestones should still be accessible
const remaining = missionStore.listMilestones(mission.id);
expect(remaining).toHaveLength(2);
// Reordering doesn't happen automatically - orderIndex stays as is
// until explicit reorder is called
expect(remaining[0].id).toBe(m1.id);
expect(remaining[1].id).toBe(m3.id);
});
it("should handle reordering and verify integrity", () => {
const mission = missionStore.createMission({ title: "Test Mission" });
const m1 = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const m2 = missionStore.addMilestone(mission.id, { title: "Milestone 2" });
const m3 = missionStore.addMilestone(mission.id, { title: "Milestone 3" });
// Add slices with features to first milestone
const s1 = missionStore.addSlice(m1.id, { title: "Slice 1" });
const f1 = missionStore.addFeature(s1.id, { title: "Feature 1" });
// Reorder milestones: reverse order
missionStore.reorderMilestones(mission.id, [m3.id, m2.id, m1.id]);
// Verify orderIndex updated
const milestones = missionStore.listMilestones(mission.id);
expect(milestones[0].id).toBe(m3.id);
expect(milestones[0].orderIndex).toBe(0);
expect(milestones[1].id).toBe(m2.id);
expect(milestones[1].orderIndex).toBe(1);
expect(milestones[2].id).toBe(m1.id);
expect(milestones[2].orderIndex).toBe(2);
// Verify slice and feature still intact
const slices = missionStore.listSlices(m1.id);
expect(slices).toHaveLength(1);
expect(slices[0].id).toBe(s1.id);
const features = missionStore.listFeatures(s1.id);
expect(features).toHaveLength(1);
expect(features[0].id).toBe(f1.id);
});
}); });
describe("Concurrent Operations", () => { it("clears mission feature task links when a linked task is deleted", async () => {
it("should handle rapid sequential modifications", async () => { const { missionStore, milestones } = await createHierarchy(taskStore);
const mission = missionStore.createMission({ title: "Test Mission" }); const feature = milestones[0].slices[0].features[0];
const task = await taskStore.createTask({
// Rapidly add many milestones title: "Delete linked task",
const promises: Promise<Milestone>[] = []; description: "Task used to verify foreign key cleanup.",
for (let i = 0; i < 10; i++) { column: "todo",
promises.push(
Promise.resolve(
missionStore.addMilestone(mission.id, { title: `Milestone ${i}` })
)
);
}
const milestones = await Promise.all(promises);
// All should have unique orderIndex values
const orderIndices = milestones.map((m) => m.orderIndex);
const uniqueIndices = new Set(orderIndices);
expect(uniqueIndices.size).toBe(10);
}); });
await taskStore.moveTask(task.id, "in-progress");
missionStore.linkFeatureToTask(feature.id, task.id);
it("should find next pending slice correctly", () => { await taskStore.deleteTask(task.id);
const mission = missionStore.createMission({ title: "Test Mission" });
const m1 = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const m2 = missionStore.addMilestone(mission.id, { title: "Milestone 2" });
const s1 = missionStore.addSlice(m1.id, { title: "Slice 1" }); const refreshed = missionStore.getFeature(feature.id);
const s2 = missionStore.addSlice(m1.id, { title: "Slice 2" }); expect(refreshed?.taskId).toBeUndefined();
const s3 = missionStore.addSlice(m2.id, { title: "Slice 3" });
// First pending should be s1
const first = missionStore.findNextPendingSlice(mission.id);
expect(first!.id).toBe(s1.id);
// Activate s1
missionStore.activateSlice(s1.id);
// Next pending should be s2
const second = missionStore.findNextPendingSlice(mission.id);
expect(second!.id).toBe(s2.id);
// Mark s2 as complete
const f2 = missionStore.addFeature(s2.id, { title: "Feature" });
missionStore.updateFeatureStatus(f2.id, "done");
// Next pending should be s3
const third = missionStore.findNextPendingSlice(mission.id);
expect(third!.id).toBe(s3.id);
});
});
describe("Edge Cases", () => {
it("should handle mission with no milestones", () => {
const mission = missionStore.createMission({ title: "Empty Mission" });
expect(missionStore.computeMissionStatus(mission.id)).toBe("planning");
const fullMission = missionStore.getMissionWithHierarchy(mission.id);
expect(fullMission!.milestones).toHaveLength(0);
});
it("should handle milestone with no slices", () => {
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Empty Milestone" });
expect(missionStore.computeMilestoneStatus(milestone.id)).toBe("planning");
expect(missionStore.listSlices(milestone.id)).toHaveLength(0);
});
it("should handle slice with no features", () => {
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Empty Slice" });
expect(missionStore.computeSliceStatus(slice.id)).toBe("pending");
expect(missionStore.listFeatures(slice.id)).toHaveLength(0);
});
it("should reject empty feature title", () => {
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
// Adding feature with empty title should still work at store level
// API layer handles validation
const feature = missionStore.addFeature(slice.id, { title: "" });
expect(feature.title).toBe("");
});
it("should handle unlinking feature from task", async () => {
const mission = missionStore.createMission({ title: "Test Mission" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
const task = await taskStore.createTask({
description: "Implement feature",
title: "Feature implementation",
});
// Link
missionStore.linkFeatureToTask(feature.id, task.id);
let updated = missionStore.getFeature(feature.id);
expect(updated!.taskId).toBe(task.id);
expect(updated!.status).toBe("triaged");
// Unlink
missionStore.unlinkFeatureFromTask(feature.id);
updated = missionStore.getFeature(feature.id);
expect(updated!.taskId).toBeUndefined();
expect(updated!.status).toBe("defined");
});
}); });
}); });

View File

@@ -74,6 +74,8 @@ export function ListView({
onPlanningMode, onPlanningMode,
onSubtaskBreakdown, onSubtaskBreakdown,
onTasksUpdated, onTasksUpdated,
projectId,
projectName,
}: ListViewProps) { }: ListViewProps) {
const [sortField, setSortField] = useState<SortField>("id"); const [sortField, setSortField] = useState<SortField>("id");
const [sortDirection, setSortDirection] = useState<SortDirection>("desc"); const [sortDirection, setSortDirection] = useState<SortDirection>("desc");

View File

@@ -287,14 +287,10 @@ describe("Mission API", () => {
it("should cascade delete all children", async () => { it("should cascade delete all children", async () => {
const { app, missionStore } = buildApp(); const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "To Delete" }); const mission = missionStore.createMission({ title: "To Delete" });
const milestone = missionStore.addMilestone(mission.id, { title: "Milestone 1" }); missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const slice = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const feature = missionStore.addFeature(slice.id, { title: "Feature 1" });
// Delete mission
await request(app, "DELETE", `/api/missions/${mission.id}`); await request(app, "DELETE", `/api/missions/${mission.id}`);
// Verify mission was deleted
expect(missionStore.getMission(mission.id)).toBeUndefined(); expect(missionStore.getMission(mission.id)).toBeUndefined();
}); });
}); });
@@ -303,23 +299,20 @@ describe("Mission API", () => {
it("should call reorderMilestones when valid request", async () => { it("should call reorderMilestones when valid request", async () => {
const { app, missionStore } = buildApp(); const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Test Mission" }); const mission = missionStore.createMission({ title: "Test Mission" });
const m1 = missionStore.addMilestone(mission.id, { title: "Milestone 1" }); missionStore.addMilestone(mission.id, { title: "Milestone 1" });
const m2 = missionStore.addMilestone(mission.id, { title: "Milestone 2" }); missionStore.addMilestone(mission.id, { title: "Milestone 2" });
missionStore.addMilestone(mission.id, { title: "Milestone 3" }); missionStore.addMilestone(mission.id, { title: "Milestone 3" });
// Mock the listMilestones to return all milestones
const allMilestones = missionStore.listMilestones(mission.id); const allMilestones = missionStore.listMilestones(mission.id);
// Test that the endpoint exists and validates
const res = await request( const res = await request(
app, app,
"POST", "POST",
`/api/missions/${mission.id}/milestones/reorder`, `/api/missions/${mission.id}/milestones/reorder`,
JSON.stringify({ orderedIds: allMilestones.map(m => m.id).reverse() }), JSON.stringify({ orderedIds: allMilestones.map((m) => m.id).reverse() }),
{ "content-type": "application/json" } { "content-type": "application/json" }
); );
// Should return 204 if successful, or error if validation fails
expect([200, 204, 400, 404]).toContain(res.status); expect([200, 204, 400, 404]).toContain(res.status);
}); });
}); });
@@ -332,7 +325,6 @@ describe("Mission API", () => {
const s1 = missionStore.addSlice(milestone.id, { title: "Slice 1" }); const s1 = missionStore.addSlice(milestone.id, { title: "Slice 1" });
const s2 = missionStore.addSlice(milestone.id, { title: "Slice 2" }); const s2 = missionStore.addSlice(milestone.id, { title: "Slice 2" });
// Test that the endpoint exists and validates
const res = await request( const res = await request(
app, app,
"POST", "POST",
@@ -341,7 +333,6 @@ describe("Mission API", () => {
{ "content-type": "application/json" } { "content-type": "application/json" }
); );
// Should return 204 if successful, or error if validation fails
expect([200, 204, 400, 404]).toContain(res.status); expect([200, 204, 400, 404]).toContain(res.status);
}); });
}); });