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:
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user