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:
parent
1ce448eb0b
commit
36c9ec3301
|
|
@ -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('')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue