From 36c9ec3301f70b9b4437e766f0537c6fc14d875c Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 8 Dec 2025 15:47:47 -0600 Subject: [PATCH] fix(practice): handle paused state transitions and add complete phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix edge cases in the state machine: - completeSubmit now works while paused (updates resumePhase) - completeTransition now works while paused (updates resumePhase) - Add 'complete' phase for session completion - Allow enterHelpMode from helpMode (navigate between terms) - Add transformActivePhase helper for paused state handling - Add markComplete action and isComplete predicate - Prevent pausing from complete phase Add 5 new tests for these edge cases. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../__tests__/useInteractionPhase.test.ts | 143 +++++++++++++++++- .../practice/hooks/useInteractionPhase.ts | 114 ++++++++------ 2 files changed, 209 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/practice/hooks/__tests__/useInteractionPhase.test.ts b/apps/web/src/components/practice/hooks/__tests__/useInteractionPhase.test.ts index a79b9170..85ba3d5e 100644 --- a/apps/web/src/components/practice/hooks/__tests__/useInteractionPhase.test.ts +++ b/apps/web/src/components/practice/hooks/__tests__/useInteractionPhase.test.ts @@ -1,5 +1,8 @@ +/** + * @vitest-environment jsdom + */ import { act, renderHook } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GeneratedProblem } from '@/db/schema/session-plans' import { computeHelpContext, @@ -1026,4 +1029,142 @@ describe('useInteractionPhase', () => { expect(result.current.phase.phase).toBe('submitting') }) }) + + // =========================================================================== + // Complete phase + // =========================================================================== + + describe('markComplete', () => { + it('transitions to complete phase', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.markComplete() + }) + + expect(result.current.phase.phase).toBe('complete') + expect(result.current.isComplete).toBe(true) + }) + + it('cannot pause from complete phase', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.markComplete() + }) + + act(() => { + result.current.pause() + }) + + expect(result.current.phase.phase).toBe('complete') + }) + }) + + // =========================================================================== + // Paused phase transitions + // =========================================================================== + + describe('completeSubmit while paused', () => { + it('updates resumePhase from submitting to showingFeedback', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.startSubmit() + }) + + act(() => { + result.current.pause() + }) + + expect(result.current.phase.phase).toBe('paused') + + // Complete submit while paused + act(() => { + result.current.completeSubmit('correct') + }) + + // Should still be paused but resumePhase updated + expect(result.current.phase.phase).toBe('paused') + if (result.current.phase.phase === 'paused') { + expect(result.current.phase.resumePhase.phase).toBe('showingFeedback') + if (result.current.phase.resumePhase.phase === 'showingFeedback') { + expect(result.current.phase.resumePhase.result).toBe('correct') + } + } + }) + }) + + describe('completeTransition while paused', () => { + it('updates resumePhase from transitioning to inputting', () => { + const { result } = renderHook(() => useInteractionPhase()) + const nextProblem = createTestProblem([7, 8]) + + // Get to transitioning phase + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.handleDigit('1') + result.current.handleDigit('2') + result.current.startSubmit() + }) + + act(() => { + result.current.completeSubmit('correct') + }) + + act(() => { + result.current.startTransition(nextProblem, 1) + }) + + // Now pause during transition + act(() => { + result.current.pause() + }) + + expect(result.current.phase.phase).toBe('paused') + if (result.current.phase.phase === 'paused') { + expect(result.current.phase.resumePhase.phase).toBe('transitioning') + } + + // Complete transition while paused + act(() => { + result.current.completeTransition() + }) + + // Should still be paused but resumePhase updated + expect(result.current.phase.phase).toBe('paused') + if (result.current.phase.phase === 'paused') { + expect(result.current.phase.resumePhase.phase).toBe('inputting') + } + }) + }) + + describe('enterHelpMode from helpMode', () => { + it('allows navigating to a different term', () => { + const { result } = renderHook(() => useInteractionPhase()) + + act(() => { + result.current.loadProblem(simpleProblem, 0, 0) + result.current.enterHelpMode(1) // Enter help for term at index 1 + }) + + expect(result.current.phase.phase).toBe('helpMode') + if (result.current.phase.phase === 'helpMode') { + expect(result.current.phase.helpContext.termIndex).toBe(1) + } + + // Navigate to different term + act(() => { + result.current.enterHelpMode(2) // Switch to term at index 2 + }) + + expect(result.current.phase.phase).toBe('helpMode') + if (result.current.phase.phase === 'helpMode') { + expect(result.current.phase.helpContext.termIndex).toBe(2) + // Answer should be cleared when switching terms + expect(result.current.phase.attempt.userAnswer).toBe('') + } + }) + }) }) diff --git a/apps/web/src/components/practice/hooks/useInteractionPhase.ts b/apps/web/src/components/practice/hooks/useInteractionPhase.ts index 69bfd7b1..02e1f3c2 100644 --- a/apps/web/src/components/practice/hooks/useInteractionPhase.ts +++ b/apps/web/src/components/practice/hooks/useInteractionPhase.ts @@ -54,51 +54,28 @@ export interface OutgoingAttempt { result: 'correct' | 'incorrect' } +/** + * Non-paused phases (used for resumePhase type) + */ +export type ActivePhase = + | { phase: 'loading' } + | { phase: 'inputting'; attempt: AttemptInput } + | { phase: 'helpMode'; attempt: AttemptInput; helpContext: HelpContext } + | { phase: 'submitting'; attempt: AttemptInput } + | { phase: 'showingFeedback'; attempt: AttemptInput; result: 'correct' | 'incorrect' } + | { phase: 'transitioning'; outgoing: OutgoingAttempt; incoming: AttemptInput } + | { phase: 'complete' } + /** * Discriminated union representing all possible interaction phases. * Each phase carries exactly the data needed for that phase. */ export type InteractionPhase = - // No problem loaded yet, waiting for initialization - | { phase: 'loading' } - - // Student is actively entering digits for their answer - | { - phase: 'inputting' - attempt: AttemptInput - } - - // Student triggered help mode by entering a prefix sum - | { - phase: 'helpMode' - attempt: AttemptInput - helpContext: HelpContext - } - - // Answer submitted, waiting for server response - | { - phase: 'submitting' - attempt: AttemptInput - } - - // Showing feedback (correct/incorrect) after submission - | { - phase: 'showingFeedback' - attempt: AttemptInput - result: 'correct' | 'incorrect' - } - - // Animating transition to next problem - | { - phase: 'transitioning' - outgoing: OutgoingAttempt - incoming: AttemptInput - } - + | ActivePhase // Session paused - remembers what phase to return to | { phase: 'paused' - resumePhase: Exclude + resumePhase: ActivePhase } /** Threshold for correction count before requiring manual submit */ @@ -108,6 +85,30 @@ export const MANUAL_SUBMIT_THRESHOLD = 2 // Helper Functions // ============================================================================= +/** + * Gets the active (non-paused) phase, unwrapping if paused. + */ +export function getActivePhase(phase: InteractionPhase): ActivePhase { + return phase.phase === 'paused' ? phase.resumePhase : phase +} + +/** + * Applies a transformation to the active phase, preserving paused wrapper if present. + * If the transform returns null, the phase is unchanged. + */ +export function transformActivePhase( + phase: InteractionPhase, + transform: (active: ActivePhase) => ActivePhase | null +): InteractionPhase { + if (phase.phase === 'paused') { + const newResumePhase = transform(phase.resumePhase) + if (newResumePhase === null) return phase + return { phase: 'paused', resumePhase: newResumePhase } + } + const newPhase = transform(phase) + return newPhase === null ? phase : newPhase +} + /** * Creates a fresh attempt input for a new problem */ @@ -242,10 +243,14 @@ export interface UseInteractionPhaseReturn { completeTransition: () => void /** Clear to loading state */ clearToLoading: () => void + /** Mark session as complete */ + markComplete: () => void /** Pause session (* → paused) */ pause: () => void /** Resume session (paused → resumePhase) */ resume: () => void + /** Is the session complete? */ + isComplete: boolean } export function useInteractionPhase( @@ -415,7 +420,8 @@ export function useInteractionPhase( const enterHelpMode = useCallback((termIndex: number) => { setPhase((prev) => { - if (prev.phase !== 'inputting') return prev + // Allow entering help mode from inputting or helpMode (to navigate to a different term) + if (prev.phase !== 'inputting' && prev.phase !== 'helpMode') return prev const helpContext = computeHelpContext(prev.attempt.problem.terms, termIndex) const updatedAttempt = { ...prev.attempt, userAnswer: '' } @@ -439,10 +445,12 @@ export function useInteractionPhase( }, []) const completeSubmit = useCallback((result: 'correct' | 'incorrect') => { - setPhase((prev) => { - if (prev.phase !== 'submitting') return prev - return { phase: 'showingFeedback', attempt: prev.attempt, result } - }) + setPhase((prev) => + transformActivePhase(prev, (active) => { + if (active.phase !== 'submitting') return null + return { phase: 'showingFeedback', attempt: active.attempt, result } + }) + ) }, []) const startTransition = useCallback((nextProblem: GeneratedProblem, nextSlotIndex: number) => { @@ -463,19 +471,26 @@ export function useInteractionPhase( }, []) const completeTransition = useCallback(() => { - setPhase((prev) => { - if (prev.phase !== 'transitioning') return prev - return { phase: 'inputting', attempt: prev.incoming } - }) + setPhase((prev) => + transformActivePhase(prev, (active) => { + if (active.phase !== 'transitioning') return null + return { phase: 'inputting', attempt: active.incoming } + }) + ) }, []) const clearToLoading = useCallback(() => { setPhase({ phase: 'loading' }) }, []) + const markComplete = useCallback(() => { + setPhase({ phase: 'complete' }) + }, []) + const pause = useCallback(() => { setPhase((prev) => { - if (prev.phase === 'paused' || prev.phase === 'loading') return prev + if (prev.phase === 'paused' || prev.phase === 'loading' || prev.phase === 'complete') + return prev return { phase: 'paused', resumePhase: prev } }) }, []) @@ -487,6 +502,9 @@ export function useInteractionPhase( }) }, []) + // Is the session complete? + const isComplete = phase.phase === 'complete' + return { phase, canAcceptInput, @@ -509,7 +527,9 @@ export function useInteractionPhase( startTransition, completeTransition, clearToLoading, + markComplete, pause, resume, + isComplete, } }