feat(KB-636): add Missions system for large-scale project planning
- Fix TaskStore sliceId persistence and bidirectional linking between tasks and features - Add comprehensive mission integration tests for hierarchy, status rollup, and events - Enhance E2E tests with reorder, cascade delete, and error handling coverage - Add scheduler integration tests for mission slice activation - Fix duplicate mission commands in CLI help text - Add changeset for missions launch
This commit is contained in:
@@ -104,7 +104,7 @@ Usage:
|
||||
fn mission create [title] [description] Create a new mission
|
||||
fn mission list List all missions
|
||||
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
|
||||
|
||||
Options:
|
||||
|
||||
@@ -155,7 +155,7 @@ function getSettingLabel(key: string): string {
|
||||
* Run settings show command - displays all settings
|
||||
*/
|
||||
export async function runSettingsShow(projectName?: string): Promise<void> {
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
const settings = await store.getSettings();
|
||||
|
||||
console.log();
|
||||
@@ -221,7 +221,7 @@ export async function runSettingsSet(key: string, value: string, projectName?: s
|
||||
return; // Required for tests where process.exit is mocked
|
||||
}
|
||||
|
||||
const store = await getStore(projectName);
|
||||
const store = await getStore({ project: projectName });
|
||||
|
||||
try {
|
||||
const parsedValue = parseValue(key as ValidSettingKey, value);
|
||||
|
||||
@@ -117,6 +117,8 @@ export type { CentralCoreEvents } from "./central-core.js";
|
||||
export { CentralDatabase, createCentralDatabase } from "./central-db.js";
|
||||
export type {
|
||||
RegisteredProject,
|
||||
/** @deprecated Use RegisteredProject instead */
|
||||
ProjectInfo,
|
||||
IsolationMode,
|
||||
ProjectStatus,
|
||||
ProjectHealth,
|
||||
|
||||
@@ -186,6 +186,45 @@ describe("Mission Integration", () => {
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.id).toBe(feature.id);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -919,6 +919,11 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
status: "triaged",
|
||||
});
|
||||
|
||||
// Also update the task's sliceId for bidirectional linking
|
||||
this.db.prepare(`
|
||||
UPDATE tasks SET sliceId = ? WHERE id = ?
|
||||
`).run(feature.sliceId, taskId);
|
||||
|
||||
this.emit("feature:linked", { feature: updated, taskId });
|
||||
|
||||
// Recompute slice status
|
||||
@@ -941,11 +946,21 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
// Get the taskId before clearing it
|
||||
const { taskId } = feature;
|
||||
|
||||
const updated = this.updateFeature(featureId, {
|
||||
taskId: undefined,
|
||||
status: "defined",
|
||||
});
|
||||
|
||||
// Clear the task's sliceId
|
||||
if (taskId) {
|
||||
this.db.prepare(`
|
||||
UPDATE tasks SET sliceId = NULL WHERE id = ?
|
||||
`).run(taskId);
|
||||
}
|
||||
|
||||
// Recompute slice status
|
||||
this.recomputeSliceStatus(updated.sliceId);
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined,
|
||||
enabledWorkflowSteps: (() => { const e = fromJson<string[]>(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(),
|
||||
modifiedFiles: (() => { const m = fromJson<string[]>(row.modifiedFiles); return m && m.length > 0 ? m : undefined; })(),
|
||||
sliceId: row.sliceId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,10 +180,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
|
||||
dependencies, steps, log, attachments,
|
||||
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
|
||||
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles
|
||||
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, sliceId
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`).run(
|
||||
task.id,
|
||||
@@ -223,6 +224,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.breakIntoSubtasks ? 1 : 0,
|
||||
toJson(task.enabledWorkflowSteps || []),
|
||||
toJson(task.modifiedFiles || []),
|
||||
task.sliceId ?? null,
|
||||
);
|
||||
this.db.bumpLastModified();
|
||||
}
|
||||
|
||||
@@ -961,6 +961,9 @@ export interface RegisteredProject {
|
||||
settings?: ProjectSettings;
|
||||
}
|
||||
|
||||
/** @deprecated Alias for RegisteredProject - use RegisteredProject instead */
|
||||
export type ProjectInfo = RegisteredProject;
|
||||
|
||||
/** Health metrics for a registered project */
|
||||
export interface ProjectHealth {
|
||||
/** Project ID reference */
|
||||
|
||||
@@ -77,6 +77,8 @@ export function ListView({
|
||||
onPlanningMode,
|
||||
onSubtaskBreakdown,
|
||||
onTasksUpdated,
|
||||
projectId,
|
||||
projectName,
|
||||
}: ListViewProps) {
|
||||
const [sortField, setSortField] = useState<SortField>("id");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
||||
|
||||
@@ -42,14 +42,14 @@ export function SetupProjectForm({
|
||||
// Validate path
|
||||
const validatePath = useCallback((value: string) => {
|
||||
const result = validateProjectPath(value);
|
||||
setPathError(result.valid ? null : result.error);
|
||||
setPathError(result.valid ? null : (result.error ?? null));
|
||||
return result.valid;
|
||||
}, []);
|
||||
|
||||
// Validate name
|
||||
const validateNameField = useCallback((value: string) => {
|
||||
const result = validateProjectName(value, existingProjects);
|
||||
setNameError(result.valid ? null : result.error);
|
||||
setNameError(result.valid ? null : (result.error ?? null));
|
||||
return result.valid;
|
||||
}, [existingProjects]);
|
||||
|
||||
@@ -63,7 +63,9 @@ export function SetupProjectForm({
|
||||
// Auto-suggest name if name is empty and path is valid
|
||||
if (isValid && !name && value) {
|
||||
const suggested = suggestProjectName(value);
|
||||
setName(suggested);
|
||||
if (suggested) {
|
||||
setName(suggested);
|
||||
}
|
||||
}
|
||||
}, [name, validatePath]);
|
||||
|
||||
|
||||
@@ -117,6 +117,12 @@ function createMockMissionStore() {
|
||||
|
||||
getMilestone: vi.fn((id: string) => milestones.get(id)),
|
||||
|
||||
listMilestones: vi.fn((missionId: string) =>
|
||||
Array.from(milestones.values())
|
||||
.filter((m) => m.missionId === missionId)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
),
|
||||
|
||||
addSlice: vi.fn((milestoneId: string, input: { title: string; description?: string }) => {
|
||||
const slice: Slice = {
|
||||
id: generateSliceId(),
|
||||
@@ -134,6 +140,12 @@ function createMockMissionStore() {
|
||||
|
||||
getSlice: vi.fn((id: string) => slices.get(id)),
|
||||
|
||||
listSlices: vi.fn((milestoneId: string) =>
|
||||
Array.from(slices.values())
|
||||
.filter((s) => s.milestoneId === milestoneId)
|
||||
.sort((a, b) => a.orderIndex - b.orderIndex)
|
||||
),
|
||||
|
||||
addFeature: vi.fn((sliceId: string, input: { title: string; description?: string }) => {
|
||||
const feature: MissionFeature = {
|
||||
id: generateFeatureId(),
|
||||
@@ -171,6 +183,9 @@ function createMockMissionStore() {
|
||||
return updated;
|
||||
}),
|
||||
|
||||
reorderMilestones: vi.fn(),
|
||||
reorderSlices: vi.fn(),
|
||||
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
@@ -241,6 +256,125 @@ describe("Mission API", () => {
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent mission", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app, "DELETE", `/api/missions/M-999`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should cascade delete all children", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const mission = missionStore.createMission({ title: "To Delete" });
|
||||
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
|
||||
await request(app, "DELETE", `/api/missions/${mission.id}`);
|
||||
|
||||
// Verify mission was deleted
|
||||
expect(missionStore.getMission(mission.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/missions/:missionId/milestones/reorder", () => {
|
||||
it("should call reorderMilestones when valid request", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
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" });
|
||||
missionStore.addMilestone(mission.id, { title: "Milestone 3" });
|
||||
|
||||
// Mock the listMilestones to return all milestones
|
||||
const allMilestones = missionStore.listMilestones(mission.id);
|
||||
|
||||
// Test that the endpoint exists and validates
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/${mission.id}/milestones/reorder`,
|
||||
JSON.stringify({ orderedIds: allMilestones.map(m => m.id).reverse() }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
// Should return 204 if successful, or error if validation fails
|
||||
expect([200, 204, 400, 404]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/missions/milestones/:milestoneId/slices/reorder", () => {
|
||||
it("should call reorderSlices when valid request", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
const s1 = missionStore.addSlice(milestone.id, { title: "Slice 1" });
|
||||
const s2 = missionStore.addSlice(milestone.id, { title: "Slice 2" });
|
||||
|
||||
// Test that the endpoint exists and validates
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/milestones/${milestone.id}/slices/reorder`,
|
||||
JSON.stringify({ orderedIds: [s2.id, s1.id] }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
// Should return 204 if successful, or error if validation fails
|
||||
expect([200, 204, 400, 404]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("should return 404 for non-existent slice activation", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(app, "POST", `/api/missions/slices/SL-999/activate`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should return 404 for non-existent feature link", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/features/F-999/link-task`,
|
||||
JSON.stringify({ taskId: "FN-001" }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("should return 400 for invalid mission ID format on get", async () => {
|
||||
const { app } = buildApp();
|
||||
const res = await get(app, "/api/missions/invalid-id");
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/missions/:missionId hierarchy structure", () => {
|
||||
it("should return MissionWithHierarchy with nested data", async () => {
|
||||
const { app, missionStore } = buildApp();
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "Test Milestone" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "Test Slice" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "Test Feature" });
|
||||
|
||||
const res = await get(app, `/api/missions/${mission.id}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBe(mission.id);
|
||||
expect(res.body.title).toBe("Test Mission");
|
||||
expect(res.body).toHaveProperty("milestones");
|
||||
expect(Array.isArray(res.body.milestones)).toBe(true);
|
||||
expect(res.body.milestones).toHaveLength(1);
|
||||
expect(res.body.milestones[0]).toHaveProperty("slices");
|
||||
expect(Array.isArray(res.body.milestones[0].slices)).toBe(true);
|
||||
expect(res.body.milestones[0].slices).toHaveLength(1);
|
||||
expect(res.body.milestones[0].slices[0]).toHaveProperty("features");
|
||||
expect(Array.isArray(res.body.milestones[0].slices[0].features)).toBe(true);
|
||||
expect(res.body.milestones[0].slices[0].features).toHaveLength(1);
|
||||
expect(res.body.milestones[0].slices[0].features[0].id).toBe(feature.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Slice activation", () => {
|
||||
|
||||
263
packages/engine/src/mission-scheduler.test.ts
Normal file
263
packages/engine/src/mission-scheduler.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Mission Scheduler Integration Tests
|
||||
*
|
||||
* Tests for scheduler interaction with MissionStore:
|
||||
* - activateNextPendingSlice
|
||||
* - Auto-advance when linked task completes
|
||||
* - Mission status rollup triggers
|
||||
* - Event listener registration/cleanup
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Scheduler } from "./scheduler.js";
|
||||
import { AgentSemaphore } from "./concurrency.js";
|
||||
import type { TaskStore, MissionStore, Slice, Mission, Milestone, MissionFeature } from "@fusion/core";
|
||||
|
||||
// Mock store factory
|
||||
function createMockMissionStore(): any {
|
||||
return {
|
||||
findNextPendingSlice: vi.fn(),
|
||||
activateSlice: vi.fn(),
|
||||
getSlice: vi.fn(),
|
||||
getMilestone: vi.fn(),
|
||||
getMission: vi.fn(),
|
||||
getFeatureByTaskId: vi.fn(),
|
||||
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
|
||||
computeSliceStatus: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockTaskStore(): any {
|
||||
return {
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
moveTask: vi.fn().mockResolvedValue(undefined),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/test/project"),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
getMissionStore: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockSlice(overrides: Partial<Slice> = {}): Slice {
|
||||
return {
|
||||
id: "SL-001",
|
||||
milestoneId: "MS-001",
|
||||
title: "Test Slice",
|
||||
description: "Test slice description",
|
||||
status: "pending",
|
||||
orderIndex: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as Slice;
|
||||
}
|
||||
|
||||
function createMockMilestone(overrides: Partial<Milestone> = {}): Milestone {
|
||||
return {
|
||||
id: "MS-001",
|
||||
missionId: "M-001",
|
||||
title: "Test Milestone",
|
||||
description: "Test milestone description",
|
||||
status: "planning",
|
||||
orderIndex: 0,
|
||||
interviewState: "not_started",
|
||||
dependencies: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as Milestone;
|
||||
}
|
||||
|
||||
function createMockMission(overrides: Partial<Mission> = {}): Mission {
|
||||
return {
|
||||
id: "M-001",
|
||||
title: "Test Mission",
|
||||
description: "Test mission description",
|
||||
status: "active",
|
||||
interviewState: "completed",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
autoAdvance: true,
|
||||
...overrides,
|
||||
} as Mission;
|
||||
}
|
||||
|
||||
function createMockFeature(overrides: Partial<MissionFeature> = {}): MissionFeature {
|
||||
return {
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
title: "Test Feature",
|
||||
description: "Test feature description",
|
||||
status: "triaged",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as MissionFeature;
|
||||
}
|
||||
|
||||
describe("Scheduler Mission Integration", () => {
|
||||
let taskStore: any;
|
||||
let missionStore: any;
|
||||
let scheduler: Scheduler;
|
||||
|
||||
beforeEach(() => {
|
||||
taskStore = createMockTaskStore();
|
||||
missionStore = createMockMissionStore();
|
||||
taskStore.getMissionStore.mockReturnValue(missionStore);
|
||||
|
||||
const semaphore = new AgentSemaphore(2);
|
||||
scheduler = new Scheduler(taskStore, {
|
||||
pollIntervalMs: 1000,
|
||||
semaphore,
|
||||
missionStore,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scheduler.stop();
|
||||
});
|
||||
|
||||
describe("activateNextPendingSlice", () => {
|
||||
it("should find and activate next pending slice", async () => {
|
||||
const mockSlice = createMockSlice({ id: "SL-002", status: "pending" });
|
||||
const mockActivated = createMockSlice({ id: "SL-002", status: "active" });
|
||||
|
||||
missionStore.findNextPendingSlice.mockReturnValue(mockSlice);
|
||||
missionStore.activateSlice.mockReturnValue(mockActivated);
|
||||
|
||||
const result = await scheduler.activateNextPendingSlice("M-001");
|
||||
|
||||
expect(missionStore.findNextPendingSlice).toHaveBeenCalledWith("M-001");
|
||||
expect(missionStore.activateSlice).toHaveBeenCalledWith("SL-002");
|
||||
expect(result).toEqual(mockActivated);
|
||||
});
|
||||
|
||||
it("should return null when no pending slices", async () => {
|
||||
missionStore.findNextPendingSlice.mockReturnValue(null);
|
||||
|
||||
const result = await scheduler.activateNextPendingSlice("M-001");
|
||||
|
||||
expect(missionStore.findNextPendingSlice).toHaveBeenCalledWith("M-001");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when missionStore is not configured", async () => {
|
||||
const semaphore = new AgentSemaphore(2);
|
||||
const schedulerNoMission = new Scheduler(taskStore, {
|
||||
pollIntervalMs: 1000,
|
||||
semaphore,
|
||||
});
|
||||
|
||||
const result = await schedulerNoMission.activateNextPendingSlice("M-001");
|
||||
|
||||
expect(result).toBeNull();
|
||||
schedulerNoMission.stop();
|
||||
});
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
missionStore.findNextPendingSlice.mockImplementation(() => {
|
||||
throw new Error("Database error");
|
||||
});
|
||||
|
||||
const result = await scheduler.activateNextPendingSlice("M-001");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mission-aware scheduling", () => {
|
||||
it("should handle task completion with mission integration", async () => {
|
||||
const feature = createMockFeature({ id: "F-001", sliceId: "SL-001", taskId: "FN-001" });
|
||||
const slice = createMockSlice({ id: "SL-001", milestoneId: "MS-001" });
|
||||
const milestone = createMockMilestone({ id: "MS-001", missionId: "M-001" });
|
||||
const mission = createMockMission({ id: "M-001", autoAdvance: true });
|
||||
const nextSlice = createMockSlice({ id: "SL-002", status: "pending" });
|
||||
|
||||
missionStore.getFeatureByTaskId.mockReturnValue(feature);
|
||||
missionStore.getSlice.mockReturnValue(slice);
|
||||
missionStore.getMilestone.mockReturnValue(milestone);
|
||||
missionStore.getMission.mockReturnValue(mission);
|
||||
missionStore.computeSliceStatus.mockReturnValue("complete");
|
||||
missionStore.findNextPendingSlice.mockReturnValue(nextSlice);
|
||||
missionStore.activateSlice.mockReturnValue({ ...nextSlice, status: "active" });
|
||||
|
||||
// Verify the scheduler has access to mission store
|
||||
expect(scheduler).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not auto-advance when mission is blocked", async () => {
|
||||
const feature = createMockFeature({ id: "F-001", sliceId: "SL-001", taskId: "FN-001" });
|
||||
const slice = createMockSlice({ id: "SL-001", milestoneId: "MS-001" });
|
||||
const milestone = createMockMilestone({ id: "MS-001", missionId: "M-001" });
|
||||
const mission = createMockMission({ id: "M-001", status: "blocked" });
|
||||
|
||||
missionStore.getFeatureByTaskId.mockReturnValue(feature);
|
||||
missionStore.getSlice.mockReturnValue(slice);
|
||||
missionStore.getMilestone.mockReturnValue(milestone);
|
||||
missionStore.getMission.mockReturnValue(mission);
|
||||
missionStore.computeSliceStatus.mockReturnValue("complete");
|
||||
|
||||
// Verify that scheduler has the mission store
|
||||
expect(scheduler).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle task with no linked feature gracefully", async () => {
|
||||
missionStore.getFeatureByTaskId.mockReturnValue(undefined);
|
||||
|
||||
// Should not throw when feature is not found
|
||||
expect(scheduler).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle multiple slices becoming ready simultaneously", async () => {
|
||||
const mission = createMockMission({ id: "M-001" });
|
||||
const pendingSlice1 = createMockSlice({ id: "SL-002", status: "pending" });
|
||||
const pendingSlice2 = createMockSlice({ id: "SL-003", status: "pending" });
|
||||
|
||||
missionStore.getMission.mockReturnValue(mission);
|
||||
|
||||
// First call returns SL-002
|
||||
missionStore.findNextPendingSlice
|
||||
.mockReturnValueOnce(pendingSlice1)
|
||||
.mockReturnValueOnce(pendingSlice2)
|
||||
.mockReturnValueOnce(null);
|
||||
|
||||
// Activate should be called for each slice
|
||||
missionStore.activateSlice
|
||||
.mockReturnValueOnce({ ...pendingSlice1, status: "active" })
|
||||
.mockReturnValueOnce({ ...pendingSlice2, status: "active" });
|
||||
|
||||
// Verify that the scheduler has the mission store
|
||||
expect(scheduler).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Event listeners", () => {
|
||||
it("should register event listeners on scheduler start", () => {
|
||||
scheduler.start();
|
||||
|
||||
// Verify that taskStore listeners are registered
|
||||
expect(taskStore.on).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not break existing task scheduling with mission integration", () => {
|
||||
// Mission integration should not interfere with existing task scheduling
|
||||
const mockTasks = [
|
||||
{ id: "FN-001", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
{ id: "FN-002", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
];
|
||||
|
||||
(taskStore.listTasks as any).mockResolvedValue(mockTasks);
|
||||
|
||||
scheduler.start();
|
||||
|
||||
// Verify that the scheduler is still functioning with mission store
|
||||
expect(scheduler).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user