Merge pull request #1815 from AndyMik90/auto-claude/222-refactor-roadmap-tasks-with-xstate

auto-claude: 222-refactor-roadmap-tasks-with-xstate
This commit is contained in:
Andy
2026-02-20 11:50:09 +01:00
committed by GitHub
9 changed files with 1983 additions and 72 deletions
@@ -3,7 +3,7 @@
* Tests Zustand store for roadmap state management including drag-and-drop actions
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useRoadmapStore, getFeaturesByPhase, getFeaturesByPriority, getFeatureStats } from '../stores/roadmap-store';
import { useRoadmapStore, getFeaturesByPhase, getFeaturesByPriority, getFeatureStats, resetActors } from '../stores/roadmap-store';
import type {
Roadmap,
RoadmapFeature,
@@ -86,6 +86,8 @@ describe('Roadmap Store', () => {
afterEach(() => {
vi.clearAllMocks();
// Reset XState actors to prevent test pollution
resetActors();
});
describe('setRoadmap', () => {
@@ -623,6 +625,165 @@ describe('Roadmap Store', () => {
});
});
describe('setGenerationStatus catch-up logic', () => {
it('should advance from idle to analyzing', () => {
useRoadmapStore.getState().setGenerationStatus({
phase: 'analyzing',
progress: 10,
message: 'Analyzing...'
});
const status = useRoadmapStore.getState().generationStatus;
expect(status.phase).toBe('analyzing');
expect(status.progress).toBe(10);
expect(status.message).toBe('Analyzing...');
});
it('should advance from idle to discovering via catch-up', () => {
useRoadmapStore.getState().setGenerationStatus({
phase: 'discovering',
progress: 30,
message: 'Discovering...'
});
const status = useRoadmapStore.getState().generationStatus;
expect(status.phase).toBe('discovering');
expect(status.progress).toBe(30);
expect(status.message).toBe('Discovering...');
});
it('should advance from idle to generating via catch-up', () => {
useRoadmapStore.getState().setGenerationStatus({
phase: 'generating',
progress: 60,
message: 'Generating...'
});
const status = useRoadmapStore.getState().generationStatus;
expect(status.phase).toBe('generating');
expect(status.progress).toBe(60);
expect(status.message).toBe('Generating...');
});
it('should advance from idle to complete via catch-up', () => {
// First go through active states to build up context, then complete
useRoadmapStore.getState().setGenerationStatus({
phase: 'analyzing',
progress: 10,
message: 'Analyzing...'
});
useRoadmapStore.getState().setGenerationStatus({
phase: 'complete',
progress: 100,
message: 'Done'
});
const status = useRoadmapStore.getState().generationStatus;
expect(status.phase).toBe('complete');
expect(status.progress).toBe(100);
});
it('should advance from idle to error via catch-up', () => {
useRoadmapStore.getState().setGenerationStatus({
phase: 'error',
progress: 0,
message: '',
error: 'Something failed'
});
const status = useRoadmapStore.getState().generationStatus;
expect(status.phase).toBe('error');
expect(status.error).toBe('Something failed');
});
it('should reset from error and start new generation', () => {
// First put into error state
useRoadmapStore.getState().setGenerationStatus({
phase: 'error',
progress: 0,
message: '',
error: 'Failed'
});
expect(useRoadmapStore.getState().generationStatus.phase).toBe('error');
// Now start a new generation from error state
useRoadmapStore.getState().setGenerationStatus({
phase: 'analyzing',
progress: 5,
message: 'Restarting...'
});
const status = useRoadmapStore.getState().generationStatus;
expect(status.phase).toBe('analyzing');
expect(status.progress).toBe(5);
});
it('should send progress updates for active states', () => {
// Move to analyzing
useRoadmapStore.getState().setGenerationStatus({
phase: 'analyzing',
progress: 0,
message: 'Starting...'
});
// Update progress in analyzing
useRoadmapStore.getState().setGenerationStatus({
phase: 'analyzing',
progress: 50,
message: 'Halfway...'
});
const status = useRoadmapStore.getState().generationStatus;
expect(status.phase).toBe('analyzing');
expect(status.progress).toBe(50);
expect(status.message).toBe('Halfway...');
});
it('should be idempotent for idle-to-idle transitions', () => {
useRoadmapStore.getState().setGenerationStatus({
phase: 'idle',
progress: 0,
message: ''
});
const status = useRoadmapStore.getState().generationStatus;
expect(status.phase).toBe('idle');
expect(status.progress).toBe(0);
});
it('should handle complete-to-idle reset', () => {
useRoadmapStore.getState().setGenerationStatus({
phase: 'complete',
progress: 100,
message: 'Done'
});
expect(useRoadmapStore.getState().generationStatus.phase).toBe('complete');
useRoadmapStore.getState().setGenerationStatus({
phase: 'idle',
progress: 0,
message: ''
});
expect(useRoadmapStore.getState().generationStatus.phase).toBe('idle');
});
it('should preserve startedAt from persisted status on reload', () => {
const persistedStartedAt = new Date('2025-06-01T12:00:00Z');
useRoadmapStore.getState().setGenerationStatus({
phase: 'generating',
progress: 70,
message: 'Generating...',
startedAt: persistedStartedAt,
lastActivityAt: new Date()
});
const status = useRoadmapStore.getState().generationStatus;
expect(status.phase).toBe('generating');
expect(status.startedAt).toBeDefined();
expect(status.startedAt!.getTime()).toBe(persistedStartedAt.getTime());
});
});
describe('clearRoadmap', () => {
it('should clear roadmap and reset status', () => {
useRoadmapStore.setState({
@@ -1,4 +1,7 @@
/// <reference types="vite/client" />
import { create } from 'zustand';
import { createActor } from 'xstate';
import type { Actor } from 'xstate';
import type {
Competitor,
CompetitorAnalysis,
@@ -10,6 +13,118 @@ import type {
TaskOutcome,
FeatureSource
} from '../../shared/types';
import {
roadmapGenerationMachine,
roadmapFeatureMachine,
mapGenerationStateToPhase,
mapFeatureStateToStatus,
type RoadmapGenerationEvent,
type RoadmapFeatureEvent
} from '@shared/state-machines';
// ---------------------------------------------------------------------------
// Module-level XState actor singletons
// ---------------------------------------------------------------------------
let generationActor: Actor<typeof roadmapGenerationMachine> | null = null;
const featureActors = new Map<string, Actor<typeof roadmapFeatureMachine>>();
/**
* Reset all actors to clean state.
* Use this in tests (afterEach) and HMR dispose handlers to avoid stale actors.
*/
export function resetActors(): void {
if (generationActor) {
generationActor.stop();
generationActor = null;
}
featureActors.forEach((actor) => actor.stop());
featureActors.clear();
}
/**
* Get or create the singleton generation actor.
* Optionally provide an initial state and context to restore from persisted data.
*/
function getOrCreateGenerationActor(
initialState?: RoadmapGenerationStatus['phase'],
initialContext?: Partial<{ progress: number; message: string; error: string; startedAt: number; completedAt: number; lastActivityAt: number }>
): Actor<typeof roadmapGenerationMachine> {
// Invalidate cached actor if its state doesn't match the expected value
if (generationActor && initialState) {
const currentValue = String(generationActor.getSnapshot().value);
if (currentValue !== initialState) {
generationActor.stop();
generationActor = null;
}
}
if (!generationActor) {
if (initialState) {
const resolvedSnapshot = roadmapGenerationMachine.resolveState({
value: initialState,
context: {
progress: initialContext?.progress ?? 0,
message: initialContext?.message,
error: initialContext?.error,
startedAt: initialContext?.startedAt,
completedAt: initialContext?.completedAt,
lastActivityAt: initialContext?.lastActivityAt
}
});
generationActor = createActor(roadmapGenerationMachine, { snapshot: resolvedSnapshot });
} else {
generationActor = createActor(roadmapGenerationMachine);
}
generationActor.start();
}
return generationActor;
}
/**
* Get or create a feature actor for a given feature ID.
* Optionally provide an initial state to restore from persisted data.
*/
function getOrCreateFeatureActor(
featureId: string,
initialState?: RoadmapFeatureStatus,
initialContext?: Partial<{ linkedSpecId: string; taskOutcome: TaskOutcome; previousStatus: RoadmapFeatureStatus }>
): Actor<typeof roadmapFeatureMachine> {
let actor = featureActors.get(featureId);
// Invalidate cached actor if its state or context doesn't match the expected values
if (actor && initialState) {
const snapshot = actor.getSnapshot();
const currentValue = String(snapshot.value);
const ctx = snapshot.context;
const contextMismatch = initialContext && (
ctx.taskOutcome !== (initialContext.taskOutcome ?? undefined) ||
ctx.previousStatus !== (initialContext.previousStatus ?? undefined) ||
ctx.linkedSpecId !== (initialContext.linkedSpecId ?? undefined)
);
if (currentValue !== initialState || contextMismatch) {
actor.stop();
featureActors.delete(featureId);
actor = undefined;
}
}
if (!actor) {
if (initialState) {
const resolvedSnapshot = roadmapFeatureMachine.resolveState({
value: initialState,
context: {
linkedSpecId: initialContext?.linkedSpecId ?? undefined,
taskOutcome: initialContext?.taskOutcome ?? undefined,
previousStatus: initialContext?.previousStatus ?? undefined
}
});
actor = createActor(roadmapFeatureMachine, { snapshot: resolvedSnapshot });
} else {
actor = createActor(roadmapFeatureMachine);
}
actor.start();
featureActors.set(featureId, actor);
}
return actor;
}
/**
* Migrate roadmap data to latest schema
@@ -79,6 +194,23 @@ const initialGenerationStatus: RoadmapGenerationStatus = {
message: ''
};
/**
* Derive RoadmapGenerationStatus from the generation actor's current snapshot.
*/
function deriveGenerationStatus(actor: Actor<typeof roadmapGenerationMachine>): RoadmapGenerationStatus {
const snapshot = actor.getSnapshot();
const phase = mapGenerationStateToPhase(String(snapshot.value));
const ctx = snapshot.context;
return {
phase,
progress: ctx.progress,
message: ctx.message ?? '',
error: ctx.error,
startedAt: ctx.startedAt ? new Date(ctx.startedAt) : undefined,
lastActivityAt: ctx.lastActivityAt ? new Date(ctx.lastActivityAt) : undefined
};
}
export const useRoadmapStore = create<RoadmapState>((set) => ({
// Initial state
roadmap: null,
@@ -87,93 +219,284 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
currentProjectId: null,
// Actions
setRoadmap: (roadmap) => set({ roadmap }),
setRoadmap: (roadmap) => {
// Prune stale actors: stop and remove actors for features not in the new roadmap
if (roadmap) {
const newFeatureIds = new Set(roadmap.features.map((f) => f.id));
for (const [featureId, actor] of featureActors.entries()) {
if (!newFeatureIds.has(featureId)) {
actor.stop();
featureActors.delete(featureId);
}
}
} else {
// No roadmap → cleanup all actors
featureActors.forEach((actor) => actor.stop());
featureActors.clear();
}
return set({ roadmap });
},
setCompetitorAnalysis: (analysis) => set({ competitorAnalysis: analysis }),
setGenerationStatus: (status) =>
set((state) => {
const now = new Date();
const isStartingGeneration =
state.generationStatus.phase === 'idle' && status.phase !== 'idle';
const isStoppingGeneration = status.phase === 'idle' || status.phase === 'complete' || status.phase === 'error';
setGenerationStatus: (status) => {
const actor = getOrCreateGenerationActor(
status.phase !== 'idle' ? status.phase : undefined,
status.phase !== 'idle' ? {
progress: status.progress,
message: status.message,
error: status.error,
startedAt: status.startedAt?.getTime(),
lastActivityAt: status.lastActivityAt?.getTime()
} : undefined
);
return {
generationStatus: {
...status,
// Set startedAt when transitioning from idle to active, but preserve passed timestamp if provided (for restoring persisted state)
startedAt: isStartingGeneration
? (status.startedAt ?? now)
: isStoppingGeneration
? undefined
: status.startedAt ?? state.generationStatus.startedAt,
// Update lastActivityAt on any status change, but preserve passed timestamp if provided (for restoring persisted state)
lastActivityAt: isStoppingGeneration ? undefined : (status.lastActivityAt ?? now)
// Map the incoming status phase to an XState event
let event: RoadmapGenerationEvent | null = null;
switch (status.phase) {
case 'analyzing': {
const currentState = String(actor.getSnapshot().value);
if (currentState === 'idle') {
event = { type: 'START_GENERATION' };
} else if (currentState === 'complete' || currentState === 'error') {
actor.send({ type: 'RESET' });
event = { type: 'START_GENERATION' };
}
};
}),
break;
}
case 'discovering': {
// NOTE: Backward transitions (e.g., generating→discovering) are intentionally
// unsupported. The generation pipeline is strictly forward-progressing, so XState
// will silently drop the event if the actor is already past this phase.
const cs = String(actor.getSnapshot().value);
if (cs === 'idle') {
actor.send({ type: 'START_GENERATION' });
} else if (cs === 'complete' || cs === 'error') {
actor.send({ type: 'RESET' });
actor.send({ type: 'START_GENERATION' });
}
event = { type: 'DISCOVERY_STARTED' };
break;
}
case 'generating': {
const cs = String(actor.getSnapshot().value);
if (cs === 'idle') {
actor.send({ type: 'START_GENERATION' });
actor.send({ type: 'DISCOVERY_STARTED' });
} else if (cs === 'analyzing') {
actor.send({ type: 'DISCOVERY_STARTED' });
} else if (cs === 'complete' || cs === 'error') {
actor.send({ type: 'RESET' });
actor.send({ type: 'START_GENERATION' });
actor.send({ type: 'DISCOVERY_STARTED' });
}
event = { type: 'GENERATION_STARTED' };
break;
}
case 'complete': {
const cs = String(actor.getSnapshot().value);
// Catch-up logic: advance actor to 'generating' state before sending GENERATION_COMPLETE
if (cs === 'idle') {
actor.send({ type: 'START_GENERATION' });
actor.send({ type: 'DISCOVERY_STARTED' });
actor.send({ type: 'GENERATION_STARTED' });
} else if (cs === 'analyzing') {
actor.send({ type: 'DISCOVERY_STARTED' });
actor.send({ type: 'GENERATION_STARTED' });
} else if (cs === 'discovering') {
actor.send({ type: 'GENERATION_STARTED' });
} else if (cs === 'error') {
actor.send({ type: 'RESET' });
actor.send({ type: 'START_GENERATION' });
actor.send({ type: 'DISCOVERY_STARTED' });
actor.send({ type: 'GENERATION_STARTED' });
}
event = { type: 'GENERATION_COMPLETE' };
break;
}
case 'error': {
const cs = String(actor.getSnapshot().value);
// Catch-up logic: GENERATION_ERROR is only handled in analyzing, discovering,
// and generating states. Advance from idle/complete so the event isn't dropped.
if (cs === 'idle') {
actor.send({ type: 'START_GENERATION' });
} else if (cs === 'complete' || cs === 'error') {
actor.send({ type: 'RESET' });
actor.send({ type: 'START_GENERATION' });
}
event = { type: 'GENERATION_ERROR', error: status.error ?? 'Unknown error' };
break;
}
case 'idle': {
// Stop or reset depending on current state
const currentState = String(actor.getSnapshot().value);
if (currentState === 'complete' || currentState === 'error') {
event = { type: 'RESET' };
} else if (currentState !== 'idle') {
event = { type: 'STOP' };
}
break;
}
}
if (event) {
actor.send(event);
}
// Send progress updates for active states
const currentState = String(actor.getSnapshot().value);
if (currentState === 'analyzing' || currentState === 'discovering' || currentState === 'generating') {
actor.send({ type: 'PROGRESS_UPDATE', progress: status.progress, message: status.message });
}
// Derive store state from the actor snapshot
set({ generationStatus: deriveGenerationStatus(actor) });
},
setCurrentProjectId: (projectId) => set({ currentProjectId: projectId }),
updateFeatureStatus: (featureId, status) =>
set((state) => {
if (!state.roadmap) return state;
updateFeatureStatus: (featureId, status) => {
// NOTE: getState() is called outside set() because XState actors are external
// side effects that cannot run inside Zustand's synchronous updater. The feature
// lookup and actor state restoration use this snapshot, with the actual state
// write deferred to the set() call below. This is intentional architecture.
const state = useRoadmapStore.getState();
if (!state.roadmap) return;
const updatedFeatures = state.roadmap.features.map((feature) =>
feature.id === featureId
? { ...feature, status, ...(status !== 'done' ? { taskOutcome: undefined, previousStatus: undefined } : {}) }
: feature
const feature = state.roadmap.features.find((f) => f.id === featureId);
if (!feature) return;
// Determine the XState event based on target status
const eventMap: Record<RoadmapFeatureStatus, RoadmapFeatureEvent> = {
planned: { type: 'PLAN' },
in_progress: { type: 'START_PROGRESS' },
done: { type: 'MARK_DONE' },
under_review: { type: 'MOVE_TO_REVIEW' }
};
const actor = getOrCreateFeatureActor(featureId, feature.status, {
linkedSpecId: feature.linkedSpecId,
taskOutcome: feature.taskOutcome,
previousStatus: feature.previousStatus
});
actor.send(eventMap[status]);
const snapshot = actor.getSnapshot();
const derivedStatus = mapFeatureStateToStatus(String(snapshot.value));
const ctx = snapshot.context;
// Skip store write if XState silently ignored the event (no-op transition)
if (derivedStatus === feature.status && ctx.taskOutcome === feature.taskOutcome && ctx.previousStatus === feature.previousStatus) return;
set((s) => {
if (!s.roadmap) return s;
const updatedFeatures = s.roadmap.features.map((f) =>
f.id === featureId
? {
...f,
status: derivedStatus,
taskOutcome: ctx.taskOutcome,
previousStatus: ctx.previousStatus
}
: f
);
return {
roadmap: {
...state.roadmap,
features: updatedFeatures,
updatedAt: new Date()
}
roadmap: { ...s.roadmap, features: updatedFeatures, updatedAt: new Date() }
};
}),
});
},
// Mark feature as done when its linked task completes
markFeatureDoneBySpecId: (specId: string, taskOutcome: TaskOutcome = 'completed') =>
set((state) => {
if (!state.roadmap) return state;
markFeatureDoneBySpecId: (specId: string, taskOutcome: TaskOutcome = 'completed') => {
const state = useRoadmapStore.getState();
if (!state.roadmap) return;
const updatedFeatures = state.roadmap.features.map((feature) =>
feature.linkedSpecId === specId
? { ...feature, status: 'done' as RoadmapFeatureStatus, taskOutcome, previousStatus: feature.status !== 'done' ? feature.status : feature.previousStatus }
: feature
);
// Determine the XState event based on task outcome
const outcomeEventMap: Record<TaskOutcome, RoadmapFeatureEvent> = {
completed: { type: 'TASK_COMPLETED' },
deleted: { type: 'TASK_DELETED' },
archived: { type: 'TASK_ARCHIVED' }
};
const event = outcomeEventMap[taskOutcome];
// Process actors outside set() — collect derived state per feature
const featureUpdates = new Map<string, { status: RoadmapFeatureStatus; taskOutcome?: TaskOutcome; previousStatus?: RoadmapFeatureStatus }>();
for (const feature of state.roadmap.features) {
if (feature.linkedSpecId !== specId) continue;
const actor = getOrCreateFeatureActor(feature.id, feature.status, {
linkedSpecId: feature.linkedSpecId,
taskOutcome: feature.taskOutcome,
previousStatus: feature.previousStatus
});
actor.send(event);
const snapshot = actor.getSnapshot();
const ctx = snapshot.context;
featureUpdates.set(feature.id, {
status: mapFeatureStateToStatus(String(snapshot.value)),
taskOutcome: ctx.taskOutcome,
previousStatus: ctx.previousStatus
});
}
if (featureUpdates.size === 0) return;
set((s) => {
if (!s.roadmap) return s;
const updatedFeatures = s.roadmap.features.map((f) => {
const update = featureUpdates.get(f.id);
return update ? { ...f, ...update } : f;
});
return {
roadmap: {
...state.roadmap,
features: updatedFeatures,
updatedAt: new Date()
}
roadmap: { ...s.roadmap, features: updatedFeatures, updatedAt: new Date() }
};
}),
});
},
updateFeatureLinkedSpec: (featureId, specId) =>
set((state) => {
if (!state.roadmap) return state;
updateFeatureLinkedSpec: (featureId, specId) => {
const state = useRoadmapStore.getState();
if (!state.roadmap) return;
const updatedFeatures = state.roadmap.features.map((feature) =>
feature.id === featureId
? { ...feature, linkedSpecId: specId, status: 'in_progress' as RoadmapFeatureStatus }
: feature
const feature = state.roadmap.features.find((f) => f.id === featureId);
if (!feature) return;
const actor = getOrCreateFeatureActor(featureId, feature.status, {
linkedSpecId: feature.linkedSpecId,
taskOutcome: feature.taskOutcome,
previousStatus: feature.previousStatus
});
actor.send({ type: 'LINK_SPEC', specId } satisfies RoadmapFeatureEvent);
const snapshot = actor.getSnapshot();
const derivedStatus = mapFeatureStateToStatus(String(snapshot.value));
const ctx = snapshot.context;
// Skip store write if nothing changed (same linkedSpecId and status)
if (ctx.linkedSpecId === feature.linkedSpecId && derivedStatus === feature.status) return;
set((s) => {
if (!s.roadmap) return s;
const updatedFeatures = s.roadmap.features.map((f) =>
f.id === featureId
? { ...f, linkedSpecId: ctx.linkedSpecId, status: derivedStatus }
: f
);
return {
roadmap: {
...state.roadmap,
features: updatedFeatures,
updatedAt: new Date()
}
roadmap: { ...s.roadmap, features: updatedFeatures, updatedAt: new Date() }
};
}),
});
},
deleteFeature: (featureId) => {
// Stop and remove the feature's actor outside set()
const actor = featureActors.get(featureId);
if (actor) {
actor.stop();
featureActors.delete(featureId);
}
deleteFeature: (featureId) =>
set((state) => {
if (!state.roadmap) return state;
@@ -188,15 +511,27 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
updatedAt: new Date()
}
};
}),
});
},
clearRoadmap: () =>
set({
clearRoadmap: () => {
// Stop all actors and clear Maps
if (generationActor) {
generationActor.stop();
generationActor = null;
}
featureActors.forEach((actor) => {
actor.stop();
});
featureActors.clear();
return set({
roadmap: null,
competitorAnalysis: null,
generationStatus: initialGenerationStatus,
currentProjectId: null
}),
});
},
// Reorder features within a phase
reorderFeatures: (phaseId, featureIds) =>
@@ -349,24 +684,28 @@ async function reconcileLinkedFeatures(projectId: string, roadmap: Roadmap): Pro
let hasChanges = false;
for (const feature of featuresNeedingReconciliation) {
const task = taskMap.get(feature.linkedSpecId!);
// Safe: linkedSpecId is guaranteed to exist by the filter above
const linkedSpecId = feature.linkedSpecId;
if (!linkedSpecId) continue;
const task = taskMap.get(linkedSpecId);
if (!task) {
// Task no longer exists → mark as done with deleted outcome
if (feature.status !== 'done' || feature.taskOutcome !== 'deleted') {
store.markFeatureDoneBySpecId(feature.linkedSpecId!, 'deleted');
store.markFeatureDoneBySpecId(linkedSpecId, 'deleted');
hasChanges = true;
}
} else if (task.status === 'done' || task.status === 'pr_created') {
// Task is completed → mark feature as done
if (feature.status !== 'done' || !feature.taskOutcome) {
store.markFeatureDoneBySpecId(feature.linkedSpecId!, 'completed');
store.markFeatureDoneBySpecId(linkedSpecId, 'completed');
hasChanges = true;
}
} else if (task.metadata?.archivedAt) {
// Task is archived → mark feature as done with archived outcome
if (feature.status !== 'done' || feature.taskOutcome !== 'archived') {
store.markFeatureDoneBySpecId(feature.linkedSpecId!, 'archived');
store.markFeatureDoneBySpecId(linkedSpecId, 'archived');
hasChanges = true;
}
}
@@ -578,3 +917,10 @@ export function getFeatureStats(roadmap: Roadmap | null): {
byComplexity
};
}
// HMR cleanup: reset actors on hot module replacement
if (import.meta.hot) {
import.meta.hot.dispose(() => {
resetActors();
});
}
@@ -0,0 +1,399 @@
import { describe, it, expect } from 'vitest';
import { createActor } from 'xstate';
import {
roadmapFeatureMachine,
type RoadmapFeatureEvent
} from '../roadmap-feature-machine';
/**
* Helper to run a sequence of events and get the final snapshot
*/
function runEvents(events: RoadmapFeatureEvent[], initialState?: string) {
const actor = initialState
? createActor(roadmapFeatureMachine, {
snapshot: roadmapFeatureMachine.resolveState({
value: initialState,
context: {}
})
})
: createActor(roadmapFeatureMachine);
actor.start();
for (const event of events) {
actor.send(event);
}
const snapshot = actor.getSnapshot();
actor.stop();
return snapshot;
}
describe('roadmapFeatureMachine', () => {
describe('initial state', () => {
it('should start in under_review state', () => {
const actor = createActor(roadmapFeatureMachine);
actor.start();
expect(actor.getSnapshot().value).toBe('under_review');
actor.stop();
});
it('should have empty context initially', () => {
const actor = createActor(roadmapFeatureMachine);
actor.start();
const { context } = actor.getSnapshot();
expect(context.linkedSpecId).toBeUndefined();
expect(context.taskOutcome).toBeUndefined();
expect(context.previousStatus).toBeUndefined();
actor.stop();
});
});
describe('status transitions: under_review → planned → in_progress → done', () => {
it('should transition under_review → planned via PLAN', () => {
const snapshot = runEvents([{ type: 'PLAN' }]);
expect(snapshot.value).toBe('planned');
});
it('should transition planned → in_progress via START_PROGRESS', () => {
const snapshot = runEvents([{ type: 'PLAN' }, { type: 'START_PROGRESS' }]);
expect(snapshot.value).toBe('in_progress');
});
it('should transition in_progress → done via MARK_DONE', () => {
const snapshot = runEvents([
{ type: 'PLAN' },
{ type: 'START_PROGRESS' },
{ type: 'MARK_DONE' }
]);
expect(snapshot.value).toBe('done');
});
it('should transition under_review → in_progress via START_PROGRESS', () => {
const snapshot = runEvents([{ type: 'START_PROGRESS' }]);
expect(snapshot.value).toBe('in_progress');
});
it('should transition under_review → done via MARK_DONE', () => {
const snapshot = runEvents([{ type: 'MARK_DONE' }]);
expect(snapshot.value).toBe('done');
expect(snapshot.context.previousStatus).toBe('under_review');
});
it('should transition planned → done via MARK_DONE', () => {
const snapshot = runEvents([{ type: 'PLAN' }, { type: 'MARK_DONE' }]);
expect(snapshot.value).toBe('done');
expect(snapshot.context.previousStatus).toBe('planned');
});
it('should allow reverse: planned → under_review via MOVE_TO_REVIEW', () => {
const snapshot = runEvents([{ type: 'PLAN' }, { type: 'MOVE_TO_REVIEW' }]);
expect(snapshot.value).toBe('under_review');
});
it('should allow reverse: in_progress → planned via PLAN', () => {
const snapshot = runEvents([{ type: 'START_PROGRESS' }, { type: 'PLAN' }]);
expect(snapshot.value).toBe('planned');
});
it('should allow reverse: in_progress → under_review via MOVE_TO_REVIEW', () => {
const snapshot = runEvents([
{ type: 'START_PROGRESS' },
{ type: 'MOVE_TO_REVIEW' }
]);
expect(snapshot.value).toBe('under_review');
});
});
describe('LINK_SPEC', () => {
it('should set linkedSpecId and auto-transition to in_progress from under_review', () => {
const snapshot = runEvents([{ type: 'LINK_SPEC', specId: 'spec-42' }]);
expect(snapshot.value).toBe('in_progress');
expect(snapshot.context.linkedSpecId).toBe('spec-42');
});
it('should set linkedSpecId and auto-transition to in_progress from planned', () => {
const snapshot = runEvents([
{ type: 'PLAN' },
{ type: 'LINK_SPEC', specId: 'spec-99' }
]);
expect(snapshot.value).toBe('in_progress');
expect(snapshot.context.linkedSpecId).toBe('spec-99');
});
it('should update linkedSpecId without changing state when already in_progress', () => {
const snapshot = runEvents([
{ type: 'START_PROGRESS' },
{ type: 'LINK_SPEC', specId: 'spec-7' }
]);
expect(snapshot.value).toBe('in_progress');
expect(snapshot.context.linkedSpecId).toBe('spec-7');
});
it('should be ignored from done state (no LINK_SPEC transition defined)', () => {
const snapshot = runEvents([
{ type: 'MARK_DONE' },
{ type: 'LINK_SPEC', specId: 'spec-1' }
]);
expect(snapshot.value).toBe('done');
expect(snapshot.context.linkedSpecId).toBeUndefined();
});
});
describe('TASK_COMPLETED from in_progress', () => {
it('should transition to done with taskOutcome="completed"', () => {
const snapshot = runEvents([
{ type: 'START_PROGRESS' },
{ type: 'TASK_COMPLETED' }
]);
expect(snapshot.value).toBe('done');
expect(snapshot.context.taskOutcome).toBe('completed');
expect(snapshot.context.previousStatus).toBe('in_progress');
});
});
describe('TASK_DELETED from in_progress', () => {
it('should transition to done with taskOutcome="deleted"', () => {
const snapshot = runEvents([
{ type: 'START_PROGRESS' },
{ type: 'TASK_DELETED' }
]);
expect(snapshot.value).toBe('done');
expect(snapshot.context.taskOutcome).toBe('deleted');
expect(snapshot.context.previousStatus).toBe('in_progress');
});
});
describe('TASK_ARCHIVED from in_progress', () => {
it('should transition to done with taskOutcome="archived"', () => {
const snapshot = runEvents([
{ type: 'START_PROGRESS' },
{ type: 'TASK_ARCHIVED' }
]);
expect(snapshot.value).toBe('done');
expect(snapshot.context.taskOutcome).toBe('archived');
expect(snapshot.context.previousStatus).toBe('in_progress');
});
});
describe('REVERT from done', () => {
it('should revert to in_progress when previousStatus was in_progress', () => {
const snapshot = runEvents([
{ type: 'START_PROGRESS' },
{ type: 'MARK_DONE' },
{ type: 'REVERT' }
]);
expect(snapshot.value).toBe('in_progress');
});
it('should revert to planned when previousStatus was planned', () => {
const snapshot = runEvents([
{ type: 'PLAN' },
{ type: 'MARK_DONE' },
{ type: 'REVERT' }
]);
expect(snapshot.value).toBe('planned');
});
it('should revert to under_review when previousStatus was under_review', () => {
const snapshot = runEvents([{ type: 'MARK_DONE' }, { type: 'REVERT' }]);
expect(snapshot.value).toBe('under_review');
});
it('should revert to under_review when no previousStatus is set (fallback)', () => {
// Use state restoration to put in done without previousStatus
const actor = createActor(roadmapFeatureMachine, {
snapshot: roadmapFeatureMachine.resolveState({
value: 'done',
context: { previousStatus: undefined }
})
});
actor.start();
actor.send({ type: 'REVERT' });
expect(actor.getSnapshot().value).toBe('under_review');
actor.stop();
});
});
describe('moving away from done clears taskOutcome and previousStatus', () => {
it('should clear context on REVERT', () => {
const snapshot = runEvents([
{ type: 'START_PROGRESS' },
{ type: 'TASK_COMPLETED' },
{ type: 'REVERT' }
]);
expect(snapshot.value).toBe('in_progress');
expect(snapshot.context.taskOutcome).toBeUndefined();
expect(snapshot.context.previousStatus).toBeUndefined();
});
it('should clear context on MOVE_TO_REVIEW from done', () => {
const snapshot = runEvents([
{ type: 'START_PROGRESS' },
{ type: 'TASK_COMPLETED' },
{ type: 'MOVE_TO_REVIEW' }
]);
expect(snapshot.value).toBe('under_review');
expect(snapshot.context.taskOutcome).toBeUndefined();
expect(snapshot.context.previousStatus).toBeUndefined();
});
it('should clear context on PLAN from done', () => {
const snapshot = runEvents([
{ type: 'START_PROGRESS' },
{ type: 'TASK_DELETED' },
{ type: 'PLAN' }
]);
expect(snapshot.value).toBe('planned');
expect(snapshot.context.taskOutcome).toBeUndefined();
expect(snapshot.context.previousStatus).toBeUndefined();
});
it('should clear context on START_PROGRESS from done', () => {
const snapshot = runEvents([
{ type: 'START_PROGRESS' },
{ type: 'TASK_ARCHIVED' },
{ type: 'START_PROGRESS' }
]);
expect(snapshot.value).toBe('in_progress');
expect(snapshot.context.taskOutcome).toBeUndefined();
expect(snapshot.context.previousStatus).toBeUndefined();
});
});
describe('redundant status transitions', () => {
it('should ignore MOVE_TO_REVIEW when already in under_review (no-op)', () => {
const actor = createActor(roadmapFeatureMachine);
actor.start();
// MOVE_TO_REVIEW is not defined on under_review, so it's ignored
actor.send({ type: 'MOVE_TO_REVIEW' });
expect(actor.getSnapshot().value).toBe('under_review');
actor.stop();
});
it('should ignore PLAN when already in planned (no-op)', () => {
const snapshot = runEvents([{ type: 'PLAN' }, { type: 'PLAN' }]);
expect(snapshot.value).toBe('planned');
});
it('should ignore START_PROGRESS when already in in_progress (no-op)', () => {
const snapshot = runEvents([
{ type: 'START_PROGRESS' },
{ type: 'START_PROGRESS' }
]);
expect(snapshot.value).toBe('in_progress');
});
it('should handle MARK_DONE in done state (self-transition)', () => {
const snapshot = runEvents([{ type: 'MARK_DONE' }, { type: 'MARK_DONE' }]);
expect(snapshot.value).toBe('done');
});
});
describe('task events from various states', () => {
it('should transition TASK_COMPLETED from under_review to done', () => {
const snapshot = runEvents([{ type: 'TASK_COMPLETED' }]);
expect(snapshot.value).toBe('done');
expect(snapshot.context.taskOutcome).toBe('completed');
expect(snapshot.context.previousStatus).toBe('under_review');
});
it('should transition TASK_DELETED from under_review to done', () => {
const snapshot = runEvents([{ type: 'TASK_DELETED' }]);
expect(snapshot.value).toBe('done');
expect(snapshot.context.taskOutcome).toBe('deleted');
expect(snapshot.context.previousStatus).toBe('under_review');
});
it('should transition TASK_ARCHIVED from under_review to done', () => {
const snapshot = runEvents([{ type: 'TASK_ARCHIVED' }]);
expect(snapshot.value).toBe('done');
expect(snapshot.context.taskOutcome).toBe('archived');
expect(snapshot.context.previousStatus).toBe('under_review');
});
it('should transition TASK_DELETED from planned to done', () => {
const snapshot = runEvents([{ type: 'PLAN' }, { type: 'TASK_DELETED' }]);
expect(snapshot.value).toBe('done');
expect(snapshot.context.taskOutcome).toBe('deleted');
expect(snapshot.context.previousStatus).toBe('planned');
});
it('should handle TASK_ARCHIVED from done (update outcome)', () => {
const snapshot = runEvents([
{ type: 'MARK_DONE' },
{ type: 'TASK_ARCHIVED' }
]);
expect(snapshot.value).toBe('done');
expect(snapshot.context.taskOutcome).toBe('archived');
});
});
describe('state restoration from snapshot', () => {
it('should restore to planned state', () => {
const actor = createActor(roadmapFeatureMachine, {
snapshot: roadmapFeatureMachine.resolveState({
value: 'planned',
context: {}
})
});
actor.start();
expect(actor.getSnapshot().value).toBe('planned');
actor.stop();
});
it('should restore to in_progress with linkedSpecId', () => {
const actor = createActor(roadmapFeatureMachine, {
snapshot: roadmapFeatureMachine.resolveState({
value: 'in_progress',
context: { linkedSpecId: 'spec-123' }
})
});
actor.start();
const { value, context } = actor.getSnapshot();
expect(value).toBe('in_progress');
expect(context.linkedSpecId).toBe('spec-123');
actor.stop();
});
it('should restore to done with full context and allow revert', () => {
const actor = createActor(roadmapFeatureMachine, {
snapshot: roadmapFeatureMachine.resolveState({
value: 'done',
context: {
linkedSpecId: 'spec-5',
taskOutcome: 'completed',
previousStatus: 'in_progress'
}
})
});
actor.start();
expect(actor.getSnapshot().value).toBe('done');
expect(actor.getSnapshot().context.taskOutcome).toBe('completed');
actor.send({ type: 'REVERT' });
expect(actor.getSnapshot().value).toBe('in_progress');
expect(actor.getSnapshot().context.taskOutcome).toBeUndefined();
actor.stop();
});
});
describe('moving away from in_progress clears context', () => {
it('should clear taskOutcome and previousStatus when moving to planned', () => {
const snapshot = runEvents([
{ type: 'START_PROGRESS' },
{ type: 'PLAN' }
]);
expect(snapshot.context.taskOutcome).toBeUndefined();
expect(snapshot.context.previousStatus).toBeUndefined();
});
it('should clear taskOutcome and previousStatus when moving to under_review', () => {
const snapshot = runEvents([
{ type: 'START_PROGRESS' },
{ type: 'MOVE_TO_REVIEW' }
]);
expect(snapshot.context.taskOutcome).toBeUndefined();
expect(snapshot.context.previousStatus).toBeUndefined();
});
});
});
@@ -0,0 +1,461 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createActor } from 'xstate';
import {
roadmapGenerationMachine,
type RoadmapGenerationEvent,
} from '../roadmap-generation-machine';
/**
* Helper to run a sequence of events and get the final state
*/
function runEvents(events: RoadmapGenerationEvent[]) {
const actor = createActor(roadmapGenerationMachine);
actor.start();
for (const event of events) {
actor.send(event);
}
const snapshot = actor.getSnapshot();
actor.stop();
return snapshot;
}
describe('roadmapGenerationMachine', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
describe('initial state', () => {
it('should start in idle state', () => {
const actor = createActor(roadmapGenerationMachine);
actor.start();
expect(actor.getSnapshot().value).toBe('idle');
actor.stop();
});
it('should have default context initially', () => {
const actor = createActor(roadmapGenerationMachine);
actor.start();
const snapshot = actor.getSnapshot();
expect(snapshot.context.progress).toBe(0);
expect(snapshot.context.message).toBeUndefined();
expect(snapshot.context.error).toBeUndefined();
expect(snapshot.context.startedAt).toBeUndefined();
expect(snapshot.context.completedAt).toBeUndefined();
actor.stop();
});
});
describe('happy path: idle → analyzing → discovering → generating → complete', () => {
it('should transition through the standard workflow', () => {
const events: RoadmapGenerationEvent[] = [
{ type: 'START_GENERATION' },
{ type: 'PROGRESS_UPDATE', progress: 20, message: 'Analyzing...' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'PROGRESS_UPDATE', progress: 50, message: 'Discovering...' },
{ type: 'GENERATION_STARTED' },
{ type: 'PROGRESS_UPDATE', progress: 80, message: 'Generating...' },
{ type: 'GENERATION_COMPLETE' },
];
const snapshot = runEvents(events);
expect(snapshot.value).toBe('complete');
expect(snapshot.context.progress).toBe(100);
expect(snapshot.context.completedAt).toBeDefined();
});
it('should transition from idle to analyzing on START_GENERATION', () => {
const snapshot = runEvents([{ type: 'START_GENERATION' }]);
expect(snapshot.value).toBe('analyzing');
});
it('should transition from analyzing to discovering on DISCOVERY_STARTED', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
]);
expect(snapshot.value).toBe('discovering');
});
it('should transition from discovering to generating on GENERATION_STARTED', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_STARTED' },
]);
expect(snapshot.value).toBe('generating');
});
it('should transition from generating to complete on GENERATION_COMPLETE', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_STARTED' },
{ type: 'GENERATION_COMPLETE' },
]);
expect(snapshot.value).toBe('complete');
});
});
describe('PROGRESS_UPDATE updates context in all active states', () => {
it('should update progress in analyzing state', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'PROGRESS_UPDATE', progress: 25, message: 'Analyzing codebase' },
]);
expect(snapshot.value).toBe('analyzing');
expect(snapshot.context.progress).toBe(25);
expect(snapshot.context.message).toBe('Analyzing codebase');
});
it('should update progress in discovering state', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'PROGRESS_UPDATE', progress: 50, message: 'Discovering features' },
]);
expect(snapshot.value).toBe('discovering');
expect(snapshot.context.progress).toBe(50);
expect(snapshot.context.message).toBe('Discovering features');
});
it('should update progress in generating state', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_STARTED' },
{ type: 'PROGRESS_UPDATE', progress: 80, message: 'Generating roadmap' },
]);
expect(snapshot.value).toBe('generating');
expect(snapshot.context.progress).toBe(80);
expect(snapshot.context.message).toBe('Generating roadmap');
});
});
describe('error flow: GENERATION_ERROR from any active state → error', () => {
it('should transition to error from analyzing', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'GENERATION_ERROR', error: 'Analysis failed' },
]);
expect(snapshot.value).toBe('error');
expect(snapshot.context.error).toBe('Analysis failed');
});
it('should transition to error from discovering', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_ERROR', error: 'Discovery failed' },
]);
expect(snapshot.value).toBe('error');
expect(snapshot.context.error).toBe('Discovery failed');
});
it('should transition to error from generating', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_STARTED' },
{ type: 'GENERATION_ERROR', error: 'Generation failed' },
]);
expect(snapshot.value).toBe('error');
expect(snapshot.context.error).toBe('Generation failed');
});
it('should preserve progress when transitioning to error', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'PROGRESS_UPDATE', progress: 60, message: 'Processing...' },
{ type: 'GENERATION_ERROR', error: 'Something went wrong' },
]);
expect(snapshot.value).toBe('error');
expect(snapshot.context.progress).toBe(60);
expect(snapshot.context.error).toBe('Something went wrong');
});
});
describe('stop flow: STOP from analyzing/discovering/generating → idle', () => {
it('should transition to idle from analyzing on STOP', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'STOP' },
]);
expect(snapshot.value).toBe('idle');
expect(snapshot.context.progress).toBe(0);
expect(snapshot.context.startedAt).toBeUndefined();
});
it('should transition to idle from discovering on STOP', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'STOP' },
]);
expect(snapshot.value).toBe('idle');
expect(snapshot.context.progress).toBe(0);
});
it('should transition to idle from generating on STOP', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_STARTED' },
{ type: 'STOP' },
]);
expect(snapshot.value).toBe('idle');
expect(snapshot.context.progress).toBe(0);
});
});
describe('RESET from complete/error → idle', () => {
it('should transition from complete to idle on RESET', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_STARTED' },
{ type: 'GENERATION_COMPLETE' },
{ type: 'RESET' },
]);
expect(snapshot.value).toBe('idle');
expect(snapshot.context.progress).toBe(0);
expect(snapshot.context.completedAt).toBeUndefined();
});
it('should transition from error to idle on RESET', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'GENERATION_ERROR', error: 'Some error' },
{ type: 'RESET' },
]);
expect(snapshot.value).toBe('idle');
expect(snapshot.context.error).toBeUndefined();
expect(snapshot.context.progress).toBe(0);
});
});
describe('guard: START_GENERATION rejected when not idle', () => {
it('should ignore START_GENERATION in analyzing state', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'START_GENERATION' },
]);
expect(snapshot.value).toBe('analyzing');
});
it('should ignore START_GENERATION in discovering state', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'START_GENERATION' },
]);
expect(snapshot.value).toBe('discovering');
});
it('should ignore START_GENERATION in generating state', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_STARTED' },
{ type: 'START_GENERATION' },
]);
expect(snapshot.value).toBe('generating');
});
it('should ignore START_GENERATION in complete state', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_STARTED' },
{ type: 'GENERATION_COMPLETE' },
{ type: 'START_GENERATION' },
]);
expect(snapshot.value).toBe('complete');
});
it('should ignore START_GENERATION in error state', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'GENERATION_ERROR', error: 'err' },
{ type: 'START_GENERATION' },
]);
expect(snapshot.value).toBe('error');
});
});
describe('stale events ignored after complete/error', () => {
it('should ignore PROGRESS_UPDATE in complete state', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_STARTED' },
{ type: 'GENERATION_COMPLETE' },
{ type: 'PROGRESS_UPDATE', progress: 50, message: 'stale' },
]);
expect(snapshot.value).toBe('complete');
expect(snapshot.context.progress).toBe(100);
});
it('should ignore PROGRESS_UPDATE in error state', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'GENERATION_ERROR', error: 'err' },
{ type: 'PROGRESS_UPDATE', progress: 50, message: 'stale' },
]);
expect(snapshot.value).toBe('error');
expect(snapshot.context.progress).toBe(0);
});
it('should ignore STOP in complete state', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_STARTED' },
{ type: 'GENERATION_COMPLETE' },
{ type: 'STOP' },
]);
expect(snapshot.value).toBe('complete');
});
it('should ignore STOP in error state', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'GENERATION_ERROR', error: 'err' },
{ type: 'STOP' },
]);
expect(snapshot.value).toBe('error');
});
it('should ignore GENERATION_ERROR in complete state', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_STARTED' },
{ type: 'GENERATION_COMPLETE' },
{ type: 'GENERATION_ERROR', error: 'late error' },
]);
expect(snapshot.value).toBe('complete');
expect(snapshot.context.error).toBeUndefined();
});
});
describe('timestamp tracking', () => {
it('should set startedAt on START_GENERATION', () => {
const snapshot = runEvents([{ type: 'START_GENERATION' }]);
expect(snapshot.context.startedAt).toBe(Date.now());
});
it('should clear startedAt when returning to idle via STOP', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'STOP' },
]);
expect(snapshot.context.startedAt).toBeUndefined();
});
it('should clear startedAt when returning to idle via RESET', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'GENERATION_ERROR', error: 'err' },
{ type: 'RESET' },
]);
expect(snapshot.context.startedAt).toBeUndefined();
});
it('should set completedAt on GENERATION_COMPLETE', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_STARTED' },
{ type: 'GENERATION_COMPLETE' },
]);
expect(snapshot.context.completedAt).toBe(Date.now());
});
it('should clear completedAt on RESET from complete', () => {
const snapshot = runEvents([
{ type: 'START_GENERATION' },
{ type: 'DISCOVERY_STARTED' },
{ type: 'GENERATION_STARTED' },
{ type: 'GENERATION_COMPLETE' },
{ type: 'RESET' },
]);
expect(snapshot.context.completedAt).toBeUndefined();
});
it('should reset startedAt on new START_GENERATION', () => {
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
const actor = createActor(roadmapGenerationMachine);
actor.start();
actor.send({ type: 'START_GENERATION' });
const firstStartedAt = actor.getSnapshot().context.startedAt;
actor.send({ type: 'GENERATION_ERROR', error: 'err' });
actor.send({ type: 'RESET' });
vi.setSystemTime(new Date('2025-01-01T01:00:00Z'));
actor.send({ type: 'START_GENERATION' });
const secondStartedAt = actor.getSnapshot().context.startedAt;
expect(firstStartedAt).toBeDefined();
expect(secondStartedAt).toBeDefined();
if (firstStartedAt && secondStartedAt) {
expect(secondStartedAt).toBeGreaterThan(firstStartedAt);
}
actor.stop();
});
});
describe('state restoration from snapshot', () => {
it('should restore to correct state from snapshot', () => {
const testStates = ['idle', 'analyzing', 'discovering', 'generating', 'complete', 'error'];
for (const state of testStates) {
const actor = createActor(roadmapGenerationMachine, {
snapshot: roadmapGenerationMachine.resolveState({
value: state,
context: {
progress: 0,
message: undefined,
error: undefined,
startedAt: undefined,
completedAt: undefined,
},
}),
});
actor.start();
expect(actor.getSnapshot().value).toBe(state);
actor.stop();
}
});
it('should restore context from snapshot', () => {
const actor = createActor(roadmapGenerationMachine, {
snapshot: roadmapGenerationMachine.resolveState({
value: 'generating',
context: {
progress: 75,
message: 'Almost done',
error: undefined,
startedAt: 1000,
completedAt: undefined,
},
}),
});
actor.start();
const snapshot = actor.getSnapshot();
expect(snapshot.value).toBe('generating');
expect(snapshot.context.progress).toBe(75);
expect(snapshot.context.message).toBe('Almost done');
expect(snapshot.context.startedAt).toBe(1000);
actor.stop();
});
});
});
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import {
GENERATION_STATE_NAMES,
FEATURE_STATE_NAMES,
mapGenerationStateToPhase,
mapFeatureStateToStatus,
} from '../roadmap-state-utils';
import { roadmapGenerationMachine } from '../roadmap-generation-machine';
import { roadmapFeatureMachine } from '../roadmap-feature-machine';
describe('mapGenerationStateToPhase', () => {
it('should map every GENERATION_STATE_NAMES entry to a non-default phase', () => {
for (const state of GENERATION_STATE_NAMES) {
const phase = mapGenerationStateToPhase(state);
// Each known state should map to itself (identity mapping), NOT the default 'idle'
expect(phase).toBe(state);
}
});
it('should map each generation state to a valid phase value', () => {
const validPhases = new Set(['idle', 'analyzing', 'discovering', 'generating', 'complete', 'error']);
for (const state of GENERATION_STATE_NAMES) {
const phase = mapGenerationStateToPhase(state);
expect(validPhases.has(phase)).toBe(true);
}
});
it('should return idle for unknown states', () => {
expect(mapGenerationStateToPhase('nonexistent')).toBe('idle');
expect(mapGenerationStateToPhase('')).toBe('idle');
});
it('should have a case for every generation state (no silent fallthrough)', () => {
// Verify that no known state falls through to the default case
// by checking that each maps to its own name (identity)
const defaultValue = mapGenerationStateToPhase('__unknown_sentinel__');
for (const state of GENERATION_STATE_NAMES) {
if (state === defaultValue) continue; // 'idle' is both a valid state and the default
const result = mapGenerationStateToPhase(state);
expect(result).not.toBe(defaultValue);
}
});
it('should include every machine state in GENERATION_STATE_NAMES (reverse direction)', () => {
const machineStates = Object.keys(roadmapGenerationMachine.config.states ?? {});
const stateNameSet = new Set<string>(GENERATION_STATE_NAMES);
for (const machineState of machineStates) {
expect(stateNameSet.has(machineState), `Machine state '${machineState}' missing from GENERATION_STATE_NAMES`).toBe(true);
}
});
});
describe('mapFeatureStateToStatus', () => {
it('should map every FEATURE_STATE_NAMES entry to a non-default status', () => {
for (const state of FEATURE_STATE_NAMES) {
const status = mapFeatureStateToStatus(state);
// Each known state should map to itself (identity mapping), NOT the default 'under_review'
expect(status).toBe(state);
}
});
it('should map each feature state to a valid status value', () => {
const validStatuses = new Set(['under_review', 'planned', 'in_progress', 'done']);
for (const state of FEATURE_STATE_NAMES) {
const status = mapFeatureStateToStatus(state);
expect(validStatuses.has(status)).toBe(true);
}
});
it('should return under_review for unknown states', () => {
expect(mapFeatureStateToStatus('nonexistent')).toBe('under_review');
expect(mapFeatureStateToStatus('')).toBe('under_review');
});
it('should have a case for every feature state (no silent fallthrough)', () => {
// Verify that no known state falls through to the default case
// by checking that each maps to its own name (identity)
const defaultValue = mapFeatureStateToStatus('__unknown_sentinel__');
for (const state of FEATURE_STATE_NAMES) {
if (state === defaultValue) continue; // 'under_review' is both valid and default
const result = mapFeatureStateToStatus(state);
expect(result).not.toBe(defaultValue);
}
});
it('should include every machine state in FEATURE_STATE_NAMES (reverse direction)', () => {
const machineStates = Object.keys(roadmapFeatureMachine.config.states ?? {});
const stateNameSet = new Set<string>(FEATURE_STATE_NAMES);
for (const machineState of machineStates) {
expect(stateNameSet.has(machineState), `Machine state '${machineState}' missing from FEATURE_STATE_NAMES`).toBe(true);
}
});
});
@@ -7,3 +7,28 @@ export {
mapStateToLegacy,
} from './task-state-utils';
export type { TaskStateName } from './task-state-utils';
export { roadmapGenerationMachine } from './roadmap-generation-machine';
export type {
RoadmapGenerationContext,
RoadmapGenerationEvent,
} from './roadmap-generation-machine';
export { roadmapFeatureMachine } from './roadmap-feature-machine';
export type {
RoadmapFeatureContext,
RoadmapFeatureEvent,
} from './roadmap-feature-machine';
export {
GENERATION_STATE_NAMES,
FEATURE_STATE_NAMES,
GENERATION_SETTLED_STATES,
FEATURE_SETTLED_STATES,
mapGenerationStateToPhase,
mapFeatureStateToStatus,
} from './roadmap-state-utils';
export type {
GenerationStateName,
FeatureStateName,
} from './roadmap-state-utils';
@@ -0,0 +1,189 @@
import { assign, createMachine } from 'xstate';
import type { TaskOutcome, RoadmapFeatureStatus } from '../types/roadmap';
export interface RoadmapFeatureContext {
linkedSpecId?: string;
taskOutcome?: TaskOutcome;
previousStatus?: RoadmapFeatureStatus;
}
export type RoadmapFeatureEvent =
| { type: 'PLAN' }
| { type: 'START_PROGRESS' }
| { type: 'MARK_DONE' }
| { type: 'LINK_SPEC'; specId: string }
| { type: 'TASK_COMPLETED' }
| { type: 'TASK_DELETED' }
| { type: 'TASK_ARCHIVED' }
| { type: 'REVERT' }
| { type: 'MOVE_TO_REVIEW' };
export const roadmapFeatureMachine = createMachine(
{
id: 'roadmapFeature',
initial: 'under_review',
types: {} as {
context: RoadmapFeatureContext;
events: RoadmapFeatureEvent;
},
context: {
linkedSpecId: undefined,
taskOutcome: undefined,
previousStatus: undefined
},
states: {
under_review: {
on: {
PLAN: 'planned',
START_PROGRESS: 'in_progress',
LINK_SPEC: {
target: 'in_progress',
actions: 'setLinkedSpec'
},
MARK_DONE: {
target: 'done',
actions: 'savePreviousUnderReview'
},
TASK_COMPLETED: {
target: 'done',
actions: ['savePreviousUnderReview', 'setTaskOutcomeCompleted']
},
TASK_DELETED: {
target: 'done',
actions: ['savePreviousUnderReview', 'setTaskOutcomeDeleted']
},
TASK_ARCHIVED: {
target: 'done',
actions: ['savePreviousUnderReview', 'setTaskOutcomeArchived']
}
}
},
planned: {
on: {
START_PROGRESS: 'in_progress',
LINK_SPEC: {
target: 'in_progress',
actions: 'setLinkedSpec'
},
MARK_DONE: {
target: 'done',
actions: 'savePreviousPlanned'
},
TASK_COMPLETED: {
target: 'done',
actions: ['savePreviousPlanned', 'setTaskOutcomeCompleted']
},
TASK_DELETED: {
target: 'done',
actions: ['savePreviousPlanned', 'setTaskOutcomeDeleted']
},
TASK_ARCHIVED: {
target: 'done',
actions: ['savePreviousPlanned', 'setTaskOutcomeArchived']
},
MOVE_TO_REVIEW: 'under_review'
}
},
in_progress: {
on: {
TASK_COMPLETED: {
target: 'done',
actions: ['savePreviousInProgress', 'setTaskOutcomeCompleted']
},
TASK_DELETED: {
target: 'done',
actions: ['savePreviousInProgress', 'setTaskOutcomeDeleted']
},
TASK_ARCHIVED: {
target: 'done',
actions: ['savePreviousInProgress', 'setTaskOutcomeArchived']
},
MARK_DONE: {
target: 'done',
actions: 'savePreviousInProgress'
},
MOVE_TO_REVIEW: {
target: 'under_review',
actions: 'clearDoneContext'
},
PLAN: {
target: 'planned',
actions: 'clearDoneContext'
},
LINK_SPEC: {
actions: 'setLinkedSpec'
}
}
},
done: {
on: {
REVERT: [
{
target: 'in_progress',
guard: 'previousWasInProgress',
actions: 'clearDoneContext'
},
{
target: 'planned',
guard: 'previousWasPlanned',
actions: 'clearDoneContext'
},
{
target: 'under_review',
actions: 'clearDoneContext'
}
],
MARK_DONE: {
target: 'done'
},
TASK_COMPLETED: {
target: 'done',
actions: 'setTaskOutcomeCompleted'
},
TASK_DELETED: {
target: 'done',
actions: 'setTaskOutcomeDeleted'
},
TASK_ARCHIVED: {
target: 'done',
actions: 'setTaskOutcomeArchived'
},
MOVE_TO_REVIEW: {
target: 'under_review',
actions: 'clearDoneContext'
},
PLAN: {
target: 'planned',
actions: 'clearDoneContext'
},
START_PROGRESS: {
target: 'in_progress',
actions: 'clearDoneContext'
}
}
}
}
},
{
guards: {
previousWasInProgress: ({ context }) => context.previousStatus === 'in_progress',
previousWasPlanned: ({ context }) => context.previousStatus === 'planned'
},
actions: {
setLinkedSpec: assign({
linkedSpecId: ({ event }) =>
event.type === 'LINK_SPEC' ? event.specId : undefined
}),
savePreviousUnderReview: assign({ previousStatus: () => 'under_review' as const }),
savePreviousPlanned: assign({ previousStatus: () => 'planned' as const }),
savePreviousInProgress: assign({ previousStatus: () => 'in_progress' as const }),
setTaskOutcomeCompleted: assign({ taskOutcome: () => 'completed' as const }),
setTaskOutcomeDeleted: assign({ taskOutcome: () => 'deleted' as const }),
setTaskOutcomeArchived: assign({ taskOutcome: () => 'archived' as const }),
clearDoneContext: assign({
taskOutcome: () => undefined,
previousStatus: () => undefined
})
}
}
);
@@ -0,0 +1,117 @@
import { assign, createMachine } from 'xstate';
export interface RoadmapGenerationContext {
progress: number;
message?: string;
error?: string;
startedAt?: number;
completedAt?: number;
lastActivityAt?: number;
}
export type RoadmapGenerationEvent =
| { type: 'START_GENERATION' }
| { type: 'PROGRESS_UPDATE'; progress: number; message: string }
| { type: 'DISCOVERY_STARTED' }
| { type: 'GENERATION_STARTED' }
| { type: 'GENERATION_COMPLETE' }
| { type: 'GENERATION_ERROR'; error: string }
| { type: 'STOP' }
| { type: 'RESET' };
export const roadmapGenerationMachine = createMachine(
{
id: 'roadmapGeneration',
initial: 'idle',
types: {} as {
context: RoadmapGenerationContext;
events: RoadmapGenerationEvent;
},
context: {
progress: 0,
message: undefined,
error: undefined,
startedAt: undefined,
completedAt: undefined,
lastActivityAt: undefined,
},
states: {
idle: {
on: {
START_GENERATION: { target: 'analyzing', actions: 'setStarted' },
},
},
analyzing: {
on: {
PROGRESS_UPDATE: { actions: 'updateProgress' },
DISCOVERY_STARTED: 'discovering',
GENERATION_ERROR: { target: 'error', actions: 'setError' },
STOP: { target: 'idle', actions: 'resetContext' },
},
},
discovering: {
on: {
PROGRESS_UPDATE: { actions: 'updateProgress' },
GENERATION_STARTED: 'generating',
GENERATION_ERROR: { target: 'error', actions: 'setError' },
STOP: { target: 'idle', actions: 'resetContext' },
},
},
generating: {
on: {
PROGRESS_UPDATE: { actions: 'updateProgress' },
GENERATION_COMPLETE: { target: 'complete', actions: 'setCompleted' },
GENERATION_ERROR: { target: 'error', actions: 'setError' },
STOP: { target: 'idle', actions: 'resetContext' },
},
},
complete: {
on: {
RESET: { target: 'idle', actions: 'resetContext' },
},
},
error: {
on: {
RESET: { target: 'idle', actions: 'resetContext' },
},
},
},
},
{
actions: {
setStarted: assign({
progress: () => 0,
message: () => undefined,
error: () => undefined,
startedAt: () => Date.now(),
completedAt: () => undefined,
lastActivityAt: () => Date.now(),
}),
updateProgress: assign({
progress: ({ event }) =>
event.type === 'PROGRESS_UPDATE' ? Math.min(100, Math.max(0, event.progress)) : 0,
message: ({ event }) =>
event.type === 'PROGRESS_UPDATE' ? event.message : undefined,
lastActivityAt: () => Date.now(),
}),
setCompleted: assign({
progress: () => 100,
completedAt: () => Date.now(),
lastActivityAt: () => Date.now(),
}),
setError: assign({
error: ({ event }) =>
event.type === 'GENERATION_ERROR' ? event.error : undefined,
lastActivityAt: () => Date.now(),
}),
resetContext: assign({
progress: () => 0,
message: () => undefined,
error: () => undefined,
startedAt: () => undefined,
completedAt: () => undefined,
lastActivityAt: () => undefined,
}),
},
}
);
@@ -0,0 +1,120 @@
/**
* Shared XState roadmap state utilities.
*
* Provides type-safe state names, settled states, and mapping helpers
* derived from the roadmap machine definitions. Used by roadmap-store
* and roadmap hooks to avoid duplicate constants.
*/
import type { StateValueFrom } from 'xstate';
import type { RoadmapGenerationStatus, RoadmapFeatureStatus } from '../types/roadmap';
import { roadmapGenerationMachine } from './roadmap-generation-machine';
import { roadmapFeatureMachine } from './roadmap-feature-machine';
/**
* All XState generation state names.
*
* IMPORTANT: These must match the state keys in roadmap-generation-machine.ts.
* If you add/remove a state in the machine, update this array.
*/
export const GENERATION_STATE_NAMES = [
'idle', 'analyzing', 'discovering', 'generating', 'complete', 'error'
] as const;
export type GenerationStateName = typeof GENERATION_STATE_NAMES[number];
// Compile-time assertion: ensures every element in GENERATION_STATE_NAMES is a valid machine state.
// The reverse direction (every machine state is in the array) is enforced by the exhaustive switch
// in mapGenerationStateToPhase below — adding a new machine state without a switch case will cause
// it to silently map to 'idle' via default, which the mapGenerationStateToPhase tests will catch.
const _genCheck: readonly StateValueFrom<typeof roadmapGenerationMachine>[] = GENERATION_STATE_NAMES;
/**
* All XState feature state names.
*
* IMPORTANT: These must match the state keys in roadmap-feature-machine.ts.
* If you add/remove a state in the machine, update this array.
*/
export const FEATURE_STATE_NAMES = [
'under_review', 'planned', 'in_progress', 'done'
] as const;
export type FeatureStateName = typeof FEATURE_STATE_NAMES[number];
// Compile-time assertion: ensures every element in FEATURE_STATE_NAMES is a valid machine state.
// Reverse direction enforced by mapFeatureStateToStatus switch exhaustiveness (see comment above).
const _featCheck: readonly StateValueFrom<typeof roadmapFeatureMachine>[] = FEATURE_STATE_NAMES;
/**
* Generation states where the machine has settled — the generation is
* complete or has errored. Stale progress events should NOT overwrite
* these states, as XState is the source of truth.
*
* NOTE: Exported for future consumer use (e.g., UI components that need to
* check if generation is settled before allowing user actions). Currently
* unused but intentionally retained as public API for roadmap state checking.
*/
export const GENERATION_SETTLED_STATES: ReadonlySet<string> = new Set<GenerationStateName>([
'complete', 'error'
]);
/**
* Feature states where the machine has settled — the feature is done.
* Stale task lifecycle events should NOT overwrite this state.
*
* NOTE: Exported for future consumer use (e.g., UI components that need to
* check if feature is settled before allowing drag-and-drop or status changes).
* Currently unused but intentionally retained as public API for feature state checking.
*/
export const FEATURE_SETTLED_STATES: ReadonlySet<string> = new Set<FeatureStateName>([
'done'
]);
/**
* Maps an XState generation state to the RoadmapGenerationStatus phase.
*
* The generation machine states map 1:1 to the phase union type, so this
* is a type-safe identity mapping with a fallback for unknown states.
*/
export function mapGenerationStateToPhase(
state: string
): RoadmapGenerationStatus['phase'] {
switch (state) {
case 'idle':
return 'idle';
case 'analyzing':
return 'analyzing';
case 'discovering':
return 'discovering';
case 'generating':
return 'generating';
case 'complete':
return 'complete';
case 'error':
return 'error';
default:
return 'idle';
}
}
/**
* Maps an XState feature state to the RoadmapFeatureStatus type.
*
* The feature machine states map 1:1 to the status union type, so this
* is a type-safe identity mapping with a fallback for unknown states.
*/
export function mapFeatureStateToStatus(
state: string
): RoadmapFeatureStatus {
switch (state) {
case 'under_review':
return 'under_review';
case 'planned':
return 'planned';
case 'in_progress':
return 'in_progress';
case 'done':
return 'done';
default:
return 'under_review';
}
}