fix(practice): handle paused state transitions and add complete phase

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-08 15:47:47 -06:00
parent 1ce448eb0b
commit 36c9ec3301
2 changed files with 209 additions and 48 deletions

View File

@ -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('')
}
})
})
})

View File

@ -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<InteractionPhase, { phase: 'paused' }>
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,
}
}