diff --git a/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts b/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts index 251d924c..e5b9cd8f 100644 --- a/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts +++ b/apps/frontend/src/renderer/__tests__/roadmap-store.test.ts @@ -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({ diff --git a/apps/frontend/src/renderer/stores/roadmap-store.ts b/apps/frontend/src/renderer/stores/roadmap-store.ts index 9b4c1bcc..05a9fce0 100644 --- a/apps/frontend/src/renderer/stores/roadmap-store.ts +++ b/apps/frontend/src/renderer/stores/roadmap-store.ts @@ -1,4 +1,7 @@ +/// 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 | null = null; +const featureActors = new Map>(); + +/** + * 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 { + // 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 { + 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): 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((set) => ({ // Initial state roadmap: null, @@ -87,93 +219,284 @@ export const useRoadmapStore = create((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 = { + 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 = { + 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(); + 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((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(); + }); +} diff --git a/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts new file mode 100644 index 00000000..02712e27 --- /dev/null +++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-feature-machine.test.ts @@ -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(); + }); + }); +}); diff --git a/apps/frontend/src/shared/state-machines/__tests__/roadmap-generation-machine.test.ts b/apps/frontend/src/shared/state-machines/__tests__/roadmap-generation-machine.test.ts new file mode 100644 index 00000000..28012519 --- /dev/null +++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-generation-machine.test.ts @@ -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(); + }); + }); +}); diff --git a/apps/frontend/src/shared/state-machines/__tests__/roadmap-state-utils.test.ts b/apps/frontend/src/shared/state-machines/__tests__/roadmap-state-utils.test.ts new file mode 100644 index 00000000..6cb9dacb --- /dev/null +++ b/apps/frontend/src/shared/state-machines/__tests__/roadmap-state-utils.test.ts @@ -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(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(FEATURE_STATE_NAMES); + for (const machineState of machineStates) { + expect(stateNameSet.has(machineState), `Machine state '${machineState}' missing from FEATURE_STATE_NAMES`).toBe(true); + } + }); +}); diff --git a/apps/frontend/src/shared/state-machines/index.ts b/apps/frontend/src/shared/state-machines/index.ts index 68cd1f3a..2a92e180 100644 --- a/apps/frontend/src/shared/state-machines/index.ts +++ b/apps/frontend/src/shared/state-machines/index.ts @@ -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'; diff --git a/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts b/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts new file mode 100644 index 00000000..277970e1 --- /dev/null +++ b/apps/frontend/src/shared/state-machines/roadmap-feature-machine.ts @@ -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 + }) + } + } +); diff --git a/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts b/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts new file mode 100644 index 00000000..606a577e --- /dev/null +++ b/apps/frontend/src/shared/state-machines/roadmap-generation-machine.ts @@ -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, + }), + }, + } +); diff --git a/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts b/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts new file mode 100644 index 00000000..1f8626a8 --- /dev/null +++ b/apps/frontend/src/shared/state-machines/roadmap-state-utils.ts @@ -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[] = 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[] = 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 = new Set([ + '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 = new Set([ + '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'; + } +}