feat(KB-632): add MissionStore with missions database schema
- Add database schema v3 with missions, objectives, rewards, progress tables - Implement MissionStore with full CRUD, event emission, and progress tracking - Add comprehensive mission types (MissionDefinition, MissionInstance, Objective, Reward) - Include 900+ lines of MissionStore unit tests covering all operations - Export mission types and store from core package index
This commit is contained in:
@@ -63,6 +63,11 @@ describe("Database", () => {
|
||||
expect(tableNames).toContain("agents");
|
||||
expect(tableNames).toContain("agentHeartbeats");
|
||||
expect(tableNames).toContain("__meta");
|
||||
// Mission hierarchy tables
|
||||
expect(tableNames).toContain("missions");
|
||||
expect(tableNames).toContain("milestones");
|
||||
expect(tableNames).toContain("slices");
|
||||
expect(tableNames).toContain("mission_features");
|
||||
});
|
||||
|
||||
it("creates all expected indexes", () => {
|
||||
@@ -80,7 +85,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(2);
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -103,7 +108,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(2);
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -677,8 +682,8 @@ describe("schema migrations", () => {
|
||||
// Now run init() which should trigger migration
|
||||
db.init();
|
||||
|
||||
// Verify version bumped
|
||||
expect(db.getSchemaVersion()).toBe(2);
|
||||
// Verify version bumped to 3 (includes both v1→v2 and v2→v3 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -703,11 +708,130 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(2);
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(2);
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("migrates a v2 database by adding missionId and sliceId columns", () => {
|
||||
tmpDir = makeTmpDir();
|
||||
const kbDir = join(tmpDir, ".kb");
|
||||
|
||||
// Create a v2 database manually (without missionId and sliceId columns)
|
||||
const db = new Database(kbDir);
|
||||
// Create tables without the new columns (matching v2 schema)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
description TEXT NOT NULL,
|
||||
"column" TEXT NOT NULL,
|
||||
status TEXT,
|
||||
size TEXT,
|
||||
reviewLevel INTEGER,
|
||||
currentStep INTEGER DEFAULT 0,
|
||||
worktree TEXT,
|
||||
blockedBy TEXT,
|
||||
paused INTEGER DEFAULT 0,
|
||||
baseBranch TEXT,
|
||||
modelPresetId TEXT,
|
||||
modelProvider TEXT,
|
||||
modelId TEXT,
|
||||
validatorModelProvider TEXT,
|
||||
validatorModelId TEXT,
|
||||
mergeRetries INTEGER,
|
||||
error TEXT,
|
||||
summary TEXT,
|
||||
thinkingLevel TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
columnMovedAt TEXT,
|
||||
dependencies TEXT DEFAULT '[]',
|
||||
steps TEXT DEFAULT '[]',
|
||||
log TEXT DEFAULT '[]',
|
||||
attachments TEXT DEFAULT '[]',
|
||||
steeringComments TEXT DEFAULT '[]',
|
||||
comments TEXT DEFAULT '[]',
|
||||
workflowStepResults TEXT DEFAULT '[]',
|
||||
prInfo TEXT,
|
||||
issueInfo TEXT,
|
||||
mergeDetails TEXT,
|
||||
breakIntoSubtasks INTEGER DEFAULT 0,
|
||||
enabledWorkflowSteps TEXT DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
nextId INTEGER DEFAULT 1,
|
||||
nextWorkflowStepId INTEGER DEFAULT 1,
|
||||
settings TEXT DEFAULT '{}',
|
||||
workflowSteps TEXT DEFAULT '[]',
|
||||
updatedAt TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS activityLog (
|
||||
id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, type TEXT NOT NULL,
|
||||
taskId TEXT, taskTitle TEXT, details TEXT NOT NULL, metadata TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS archivedTasks (id TEXT PRIMARY KEY, data TEXT NOT NULL, archivedAt TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS automations (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT,
|
||||
scheduleType TEXT NOT NULL, cronExpression TEXT NOT NULL, command TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1, timeoutMs INTEGER, steps TEXT,
|
||||
nextRunAt TEXT, lastRunAt TEXT, lastRunResult TEXT,
|
||||
runCount INTEGER DEFAULT 0, runHistory TEXT DEFAULT '[]',
|
||||
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'idle', taskId TEXT,
|
||||
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
|
||||
lastHeartbeatAt TEXT, metadata TEXT DEFAULT '{}'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS agentHeartbeats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agentId TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT NOT NULL, runId TEXT NOT NULL,
|
||||
FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '2')");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
||||
|
||||
// Insert a task on the v2 schema
|
||||
db.exec(`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('KB-2', 'test v2', 'triage', '2025-01-01', '2025-01-01')`);
|
||||
|
||||
// Now run init() which should trigger v2→v3 migration
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 3
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((c) => c.name);
|
||||
expect(colNames).toContain("missionId");
|
||||
expect(colNames).toContain("sliceId");
|
||||
|
||||
// Existing task should still be readable
|
||||
const task = db.prepare("SELECT * FROM tasks WHERE id = 'KB-2'").get() as any;
|
||||
expect(task.description).toBe("test v2");
|
||||
|
||||
// New columns should have null defaults
|
||||
expect(task.missionId).toBeNull();
|
||||
expect(task.sliceId).toBeNull();
|
||||
|
||||
// Mission tables should be created
|
||||
const tables = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
).all() as { name: string }[];
|
||||
const tableNames = tables.map((t) => t.name);
|
||||
expect(tableNames).toContain("missions");
|
||||
expect(tableNames).toContain("milestones");
|
||||
expect(tableNames).toContain("slices");
|
||||
expect(tableNames).toContain("mission_features");
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -739,7 +863,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(2);
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -57,7 +57,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 2;
|
||||
const SCHEMA_VERSION = 3;
|
||||
|
||||
const SCHEMA_SQL = `
|
||||
-- Tasks table with JSON columns for nested data
|
||||
@@ -184,6 +184,61 @@ CREATE TABLE IF NOT EXISTS __meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
-- Missions table (hierarchical project planning)
|
||||
CREATE TABLE IF NOT EXISTS missions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL,
|
||||
interviewState TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Milestones table (phases within a mission)
|
||||
CREATE TABLE IF NOT EXISTS milestones (
|
||||
id TEXT PRIMARY KEY,
|
||||
missionId TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL,
|
||||
orderIndex INTEGER NOT NULL,
|
||||
interviewState TEXT NOT NULL,
|
||||
dependencies TEXT DEFAULT '[]',
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (missionId) REFERENCES missions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Slices table (work units within a milestone)
|
||||
CREATE TABLE IF NOT EXISTS slices (
|
||||
id TEXT PRIMARY KEY,
|
||||
milestoneId TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL,
|
||||
orderIndex INTEGER NOT NULL,
|
||||
activatedAt TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (milestoneId) REFERENCES milestones(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Mission features table (features within a slice that can link to tasks)
|
||||
CREATE TABLE IF NOT EXISTS mission_features (
|
||||
id TEXT PRIMARY KEY,
|
||||
sliceId TEXT NOT NULL,
|
||||
taskId TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
acceptanceCriteria TEXT,
|
||||
status TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
FOREIGN KEY (sliceId) REFERENCES slices(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (taskId) REFERENCES tasks(id) ON DELETE SET NULL
|
||||
);
|
||||
`;
|
||||
|
||||
// ── Database Class ───────────────────────────────────────────────────
|
||||
@@ -258,6 +313,14 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 3) {
|
||||
this.applyMigration(3, () => {
|
||||
// Add mission hierarchy columns to tasks for linking tasks to slices
|
||||
this.addColumnIfMissing("tasks", "missionId", "TEXT");
|
||||
this.addColumnIfMissing("tasks", "sliceId", "TEXT");
|
||||
});
|
||||
}
|
||||
|
||||
// Future migrations go here:
|
||||
// if (version < 3) { this.applyMigration(3, () => { ... }); }
|
||||
}
|
||||
|
||||
@@ -51,3 +51,43 @@ export type {
|
||||
ImportSettingsOptions,
|
||||
ImportResult,
|
||||
} from "./settings-export.js";
|
||||
|
||||
// ── Mission Hierarchy Types ────────────────────────────────────────────
|
||||
|
||||
export {
|
||||
MISSION_STATUSES,
|
||||
MILESTONE_STATUSES,
|
||||
SLICE_STATUSES,
|
||||
FEATURE_STATUSES,
|
||||
INTERVIEW_STATES,
|
||||
} from "./mission-types.js";
|
||||
export type {
|
||||
MissionStatus,
|
||||
MilestoneStatus,
|
||||
SliceStatus,
|
||||
FeatureStatus,
|
||||
InterviewState,
|
||||
Mission,
|
||||
Milestone,
|
||||
Slice,
|
||||
MissionFeature,
|
||||
MissionCreateInput,
|
||||
MilestoneCreateInput,
|
||||
SliceCreateInput,
|
||||
FeatureCreateInput,
|
||||
MissionWithHierarchy,
|
||||
MilestoneWithSlices,
|
||||
SliceWithFeatures,
|
||||
MissionEventPayload,
|
||||
MissionDeletedPayload,
|
||||
MilestoneEventPayload,
|
||||
MilestoneDeletedPayload,
|
||||
SliceEventPayload,
|
||||
SliceDeletedPayload,
|
||||
SliceActivatedPayload,
|
||||
FeatureEventPayload,
|
||||
FeatureDeletedPayload,
|
||||
FeatureLinkedPayload,
|
||||
} from "./mission-types.js";
|
||||
export { MissionStore } from "./mission-store.js";
|
||||
export type { MissionStoreEvents } from "./mission-store.js";
|
||||
|
||||
939
packages/core/src/mission-store.test.ts
Normal file
939
packages/core/src/mission-store.test.ts
Normal file
@@ -0,0 +1,939 @@
|
||||
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",
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
database.prepare(
|
||||
`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(taskId, description, "triage", now, now);
|
||||
}
|
||||
|
||||
describe("MissionStore", () => {
|
||||
let tmpDir: string;
|
||||
let kbDir: string;
|
||||
let db: Database;
|
||||
let store: MissionStore;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
kbDir = join(tmpDir, ".kb");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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", () => {
|
||||
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 = store.activateSlice(slice.id);
|
||||
|
||||
expect(activated.status).toBe("active");
|
||||
expect(activated.activatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("emits slice:activated event", () => {
|
||||
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 = 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", () => {
|
||||
createTaskInDb(db, "KB-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, "KB-001");
|
||||
|
||||
expect(linked.taskId).toBe("KB-001");
|
||||
expect(linked.status).toBe("triaged");
|
||||
});
|
||||
|
||||
it("emits feature:linked event", () => {
|
||||
createTaskInDb(db, "KB-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, "KB-001");
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith({ feature: linked, taskId: "KB-001" });
|
||||
});
|
||||
|
||||
it("unlinks a feature from a task", () => {
|
||||
createTaskInDb(db, "KB-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, "KB-001");
|
||||
|
||||
const unlinked = store.unlinkFeatureFromTask(feature.id);
|
||||
|
||||
expect(unlinked.taskId).toBeUndefined();
|
||||
expect(unlinked.status).toBe("defined");
|
||||
});
|
||||
|
||||
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("KB-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, "KB-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, "KB-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, "KB-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, "KB-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", () => {
|
||||
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" });
|
||||
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, "KB-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, "KB-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, "KB-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, "KB-123");
|
||||
|
||||
expect(linkedHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
feature: expect.objectContaining({ id: feature.id }),
|
||||
taskId: "KB-123",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// vi import for vitest mocking
|
||||
import { vi } from "vitest";
|
||||
1122
packages/core/src/mission-store.ts
Normal file
1122
packages/core/src/mission-store.ts
Normal file
File diff suppressed because it is too large
Load Diff
251
packages/core/src/mission-types.ts
Normal file
251
packages/core/src/mission-types.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Mission hierarchy types for kb project planning.
|
||||
*
|
||||
* A Mission represents a high-level objective that can span multiple milestones.
|
||||
* Each Milestone represents a phase of work within a mission.
|
||||
* Each Slice represents a work unit within a milestone that can be activated for implementation.
|
||||
* Each Feature represents a deliverable within a slice that can be linked to a kb Task.
|
||||
*
|
||||
* The hierarchy: Mission → Milestone → Slice → Feature → (optional) Task
|
||||
*/
|
||||
|
||||
// ── Status Enums ─────────────────────────────────────────────────────
|
||||
|
||||
/** Status values for a Mission's lifecycle */
|
||||
export const MISSION_STATUSES = ["planning", "active", "blocked", "complete", "archived"] as const;
|
||||
export type MissionStatus = (typeof MISSION_STATUSES)[number];
|
||||
|
||||
/** Status values for a Milestone within a mission */
|
||||
export const MILESTONE_STATUSES = ["planning", "active", "blocked", "complete"] as const;
|
||||
export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];
|
||||
|
||||
/** Status values for a Slice (work unit) */
|
||||
export const SLICE_STATUSES = ["pending", "active", "complete"] as const;
|
||||
export type SliceStatus = (typeof SLICE_STATUSES)[number];
|
||||
|
||||
/** Status values for a Feature within a slice */
|
||||
export const FEATURE_STATUSES = ["defined", "triaged", "in-progress", "done"] as const;
|
||||
export type FeatureStatus = (typeof FEATURE_STATUSES)[number];
|
||||
|
||||
/** Interview state for AI-assisted specification */
|
||||
export const INTERVIEW_STATES = ["not_started", "in_progress", "completed", "needs_update"] as const;
|
||||
export type InterviewState = (typeof INTERVIEW_STATES)[number];
|
||||
|
||||
// ── Core Entity Types ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A Mission represents a high-level objective or project.
|
||||
* Missions contain milestones that break down the work into phases.
|
||||
*/
|
||||
export interface Mission {
|
||||
/** Unique identifier (e.g., "M-001") */
|
||||
id: string;
|
||||
/** Display name of the mission */
|
||||
title: string;
|
||||
/** Detailed description of the mission's objectives */
|
||||
description?: string;
|
||||
/** Current lifecycle status */
|
||||
status: MissionStatus;
|
||||
/** State of the AI specification interview process */
|
||||
interviewState: InterviewState;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Milestone represents a phase of work within a mission.
|
||||
* Milestones contain slices that represent work units to be executed.
|
||||
*/
|
||||
export interface Milestone {
|
||||
/** Unique identifier (e.g., "MS-001") */
|
||||
id: string;
|
||||
/** Parent mission ID */
|
||||
missionId: string;
|
||||
/** Display name of the milestone */
|
||||
title: string;
|
||||
/** Detailed description of milestone objectives */
|
||||
description?: string;
|
||||
/** Current lifecycle status */
|
||||
status: MilestoneStatus;
|
||||
/** Order index for sorting within the mission (0-based) */
|
||||
orderIndex: number;
|
||||
/** State of the AI specification interview process */
|
||||
interviewState: InterviewState;
|
||||
/** IDs of milestones that must complete before this one can start */
|
||||
dependencies: string[];
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Slice represents a work unit within a milestone.
|
||||
* Slices can be activated for implementation, linking to kb tasks.
|
||||
*/
|
||||
export interface Slice {
|
||||
/** Unique identifier (e.g., "SL-001") */
|
||||
id: string;
|
||||
/** Parent milestone ID */
|
||||
milestoneId: string;
|
||||
/** Display name of the slice */
|
||||
title: string;
|
||||
/** Detailed description of work to be done */
|
||||
description?: string;
|
||||
/** Current lifecycle status */
|
||||
status: SliceStatus;
|
||||
/** Order index for sorting within the milestone (0-based) */
|
||||
orderIndex: number;
|
||||
/** ISO-8601 timestamp when the slice was activated (if applicable) */
|
||||
activatedAt?: string;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A MissionFeature represents a deliverable within a slice.
|
||||
* Features can be linked to kb Tasks for implementation.
|
||||
*/
|
||||
export interface MissionFeature {
|
||||
/** Unique identifier (e.g., "F-001") */
|
||||
id: string;
|
||||
/** Parent slice ID */
|
||||
sliceId: string;
|
||||
/** Linked task ID (optional) - set when feature is triaged into a task */
|
||||
taskId?: string;
|
||||
/** Display name of the feature */
|
||||
title: string;
|
||||
/** Detailed description of the feature */
|
||||
description?: string;
|
||||
/** Acceptance criteria for completing the feature */
|
||||
acceptanceCriteria?: string;
|
||||
/** Current lifecycle status */
|
||||
status: FeatureStatus;
|
||||
/** ISO-8601 timestamp of creation */
|
||||
createdAt: string;
|
||||
/** ISO-8601 timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Input Types (for creation) ──────────────────────────────────────
|
||||
|
||||
/** Input for creating a new Mission */
|
||||
export interface MissionCreateInput {
|
||||
/** Display name of the mission (required) */
|
||||
title: string;
|
||||
/** Detailed description of the mission's objectives */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Input for creating a new Milestone */
|
||||
export interface MilestoneCreateInput {
|
||||
/** Display name of the milestone (required) */
|
||||
title: string;
|
||||
/** Detailed description of milestone objectives */
|
||||
description?: string;
|
||||
/** IDs of milestones that must complete before this one can start */
|
||||
dependencies?: string[];
|
||||
}
|
||||
|
||||
/** Input for creating a new Slice */
|
||||
export interface SliceCreateInput {
|
||||
/** Display name of the slice (required) */
|
||||
title: string;
|
||||
/** Detailed description of work to be done */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Input for creating a new Feature */
|
||||
export interface FeatureCreateInput {
|
||||
/** Display name of the feature (required) */
|
||||
title: string;
|
||||
/** Detailed description of the feature */
|
||||
description?: string;
|
||||
/** Acceptance criteria for completing the feature */
|
||||
acceptanceCriteria?: string;
|
||||
}
|
||||
|
||||
// ─ Composite Types ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A Milestone with its nested slices loaded.
|
||||
* Used when fetching a single milestone with full hierarchy.
|
||||
*/
|
||||
export interface MilestoneWithSlices extends Milestone {
|
||||
/** Slices belonging to this milestone */
|
||||
slices: Slice[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A Slice with its nested features loaded.
|
||||
* Used when fetching a single slice with full details.
|
||||
*/
|
||||
export interface SliceWithFeatures extends Slice {
|
||||
/** Features belonging to this slice */
|
||||
features: MissionFeature[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A Mission with complete hierarchy loaded:
|
||||
* Mission → Milestones → Slices → Features
|
||||
*/
|
||||
export interface MissionWithHierarchy extends Mission {
|
||||
/** Milestones belonging to this mission, each with their slices */
|
||||
milestones: Array<MilestoneWithSlices & {
|
||||
/** Slices with their features loaded */
|
||||
slices: SliceWithFeatures[];
|
||||
}>;
|
||||
}
|
||||
|
||||
// ── Event Payload Types ─────────────────────────────────────────────
|
||||
|
||||
/** Payload for mission:created and mission:updated events */
|
||||
export type MissionEventPayload = Mission;
|
||||
|
||||
/** Payload for mission:deleted event */
|
||||
export interface MissionDeletedPayload {
|
||||
/** ID of the deleted mission */
|
||||
missionId: string;
|
||||
}
|
||||
|
||||
/** Payload for milestone:created and milestone:updated events */
|
||||
export type MilestoneEventPayload = Milestone;
|
||||
|
||||
/** Payload for milestone:deleted event */
|
||||
export interface MilestoneDeletedPayload {
|
||||
/** ID of the deleted milestone */
|
||||
milestoneId: string;
|
||||
}
|
||||
|
||||
/** Payload for slice:created and slice:updated events */
|
||||
export type SliceEventPayload = Slice;
|
||||
|
||||
/** Payload for slice:deleted event */
|
||||
export interface SliceDeletedPayload {
|
||||
/** ID of the deleted slice */
|
||||
sliceId: string;
|
||||
}
|
||||
|
||||
/** Payload for slice:activated event */
|
||||
export type SliceActivatedPayload = Slice;
|
||||
|
||||
/** Payload for feature:created and feature:updated events */
|
||||
export type FeatureEventPayload = MissionFeature;
|
||||
|
||||
/** Payload for feature:deleted event */
|
||||
export interface FeatureDeletedPayload {
|
||||
/** ID of the deleted feature */
|
||||
featureId: string;
|
||||
}
|
||||
|
||||
/** Payload for feature:linked event */
|
||||
export interface FeatureLinkedPayload {
|
||||
/** The feature that was linked */
|
||||
feature: MissionFeature;
|
||||
/** ID of the task it was linked to */
|
||||
taskId: string;
|
||||
}
|
||||
Reference in New Issue
Block a user