Add unit tests for KidNumberInput and FlowchartCheckpoint

Tests for KidNumberInput component:
- Rendering with different props and states
- Feedback state styling (correct/incorrect/none)
- Disabled state behavior
- Keypad interaction

Tests for useKidNumberInput hook:
- Initial state
- Adding/removing digits
- Auto-validation on max digits
- Correct/incorrect callbacks
- Clear on correct/incorrect options

Tests for FlowchartCheckpoint:
- KidNumberInput integration for number input type
- Native input preserved for text type
- Two-numbers input mode
- Feedback display
- Keyboard input handling
- Disabled state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2026-01-17 15:57:53 -06:00
parent 7c597e9079
commit 7e98a01a41
4 changed files with 1048 additions and 1 deletions

View File

@ -72,7 +72,8 @@
"Bash(npm view:*)",
"Bash(pnpm install:*)",
"Bash(git ls-files:*)",
"Bash(pnpm panda codegen:*)"
"Bash(pnpm panda codegen:*)",
"Bash(npm run test:*)"
],
"deny": [],
"ask": []

View File

@ -0,0 +1,380 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { FlowchartCheckpoint } from '../FlowchartCheckpoint'
// Mock ThemeContext
vi.mock('@/contexts/ThemeContext', () => ({
useTheme: () => ({ resolvedTheme: 'light' }),
}))
// Mock react-simple-keyboard
vi.mock('react-simple-keyboard', () => ({
default: ({ onKeyPress }: { onKeyPress: (key: string) => void }) => (
<div data-testid="mock-keyboard">
<button data-testid="key-1" onClick={() => onKeyPress('1')}>
1
</button>
<button data-testid="key-2" onClick={() => onKeyPress('2')}>
2
</button>
<button data-testid="key-3" onClick={() => onKeyPress('3')}>
3
</button>
<button data-testid="key-4" onClick={() => onKeyPress('4')}>
4
</button>
<button data-testid="key-5" onClick={() => onKeyPress('5')}>
5
</button>
<button data-testid="key-0" onClick={() => onKeyPress('0')}>
0
</button>
<button data-testid="key-bksp" onClick={() => onKeyPress('{bksp}')}>
</button>
</div>
),
}))
// Mock Panda CSS
vi.mock('../../../../styled-system/css', () => ({
css: vi.fn(() => 'mocked-css-class'),
}))
vi.mock('../../../../styled-system/patterns', () => ({
hstack: vi.fn(() => 'mocked-hstack-class'),
vstack: vi.fn(() => 'mocked-vstack-class'),
}))
describe('FlowchartCheckpoint', () => {
const defaultProps = {
prompt: 'What is 2 + 2?',
inputType: 'number' as const,
onSubmit: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('renders with data attributes', () => {
render(<FlowchartCheckpoint {...defaultProps} />)
const container = screen.getByTestId('checkpoint-container')
expect(container).toBeInTheDocument()
expect(container).toHaveAttribute('data-input-type', 'number')
})
it('renders the prompt', () => {
render(<FlowchartCheckpoint {...defaultProps} prompt="Test prompt" />)
expect(screen.getByTestId('checkpoint-prompt')).toHaveTextContent('Test prompt')
})
it('renders Check button for number input', () => {
render(<FlowchartCheckpoint {...defaultProps} inputType="number" />)
expect(screen.getByTestId('checkpoint-check-button')).toBeInTheDocument()
})
it('renders Check button for text input', () => {
render(<FlowchartCheckpoint {...defaultProps} inputType="text" />)
expect(screen.getByTestId('checkpoint-check-button')).toBeInTheDocument()
})
})
describe('number input type', () => {
it('renders KidNumberInput for number type', () => {
const { container } = render(<FlowchartCheckpoint {...defaultProps} inputType="number" />)
expect(container.querySelector('[data-component="kid-number-input"]')).toBeInTheDocument()
})
it('shows inline keypad for number input', () => {
const { container } = render(<FlowchartCheckpoint {...defaultProps} inputType="number" />)
expect(container.querySelector('[data-element="keypad-inline"]')).toBeInTheDocument()
})
it('accepts digit input via keypad', async () => {
const { container } = render(<FlowchartCheckpoint {...defaultProps} inputType="number" />)
// Click on keypad buttons
fireEvent.click(screen.getAllByTestId('key-4')[0])
// The value should be shown in the display (not the keypad button)
await waitFor(() => {
const display = container.querySelector('[data-element="input-display"]')
expect(display?.textContent).toBe('4')
})
})
it('accepts multiple digits', async () => {
const { container } = render(<FlowchartCheckpoint {...defaultProps} inputType="number" />)
fireEvent.click(screen.getAllByTestId('key-4')[0])
fireEvent.click(screen.getAllByTestId('key-2')[0])
await waitFor(() => {
const display = container.querySelector('[data-element="input-display"]')
expect(display?.textContent).toBe('42')
})
})
it('handles backspace via keypad', async () => {
const { container } = render(<FlowchartCheckpoint {...defaultProps} inputType="number" />)
fireEvent.click(screen.getAllByTestId('key-4')[0])
fireEvent.click(screen.getAllByTestId('key-2')[0])
await waitFor(() => {
const display = container.querySelector('[data-element="input-display"]')
expect(display?.textContent).toBe('42')
})
fireEvent.click(screen.getAllByTestId('key-bksp')[0])
await waitFor(() => {
const display = container.querySelector('[data-element="input-display"]')
expect(display?.textContent).toBe('4')
})
})
it('calls onSubmit with number when Check is clicked', async () => {
const onSubmit = vi.fn()
const { container } = render(
<FlowchartCheckpoint {...defaultProps} inputType="number" onSubmit={onSubmit} />
)
fireEvent.click(screen.getAllByTestId('key-4')[0])
await waitFor(() => {
const display = container.querySelector('[data-element="input-display"]')
expect(display?.textContent).toBe('4')
})
fireEvent.click(screen.getByTestId('checkpoint-check-button'))
expect(onSubmit).toHaveBeenCalledWith(4)
})
it('does not submit when value is empty', () => {
const onSubmit = vi.fn()
render(<FlowchartCheckpoint {...defaultProps} inputType="number" onSubmit={onSubmit} />)
fireEvent.click(screen.getByTestId('checkpoint-check-button'))
expect(onSubmit).not.toHaveBeenCalled()
})
})
describe('text input type', () => {
it('renders native text input for text type', () => {
render(<FlowchartCheckpoint {...defaultProps} inputType="text" />)
expect(screen.getByTestId('checkpoint-input')).toHaveAttribute('type', 'text')
})
it('does not render KidNumberInput for text type', () => {
const { container } = render(<FlowchartCheckpoint {...defaultProps} inputType="text" />)
expect(container.querySelector('[data-component="kid-number-input"]')).not.toBeInTheDocument()
})
it('accepts text input', async () => {
const onSubmit = vi.fn()
render(<FlowchartCheckpoint {...defaultProps} inputType="text" onSubmit={onSubmit} />)
const input = screen.getByTestId('checkpoint-input')
fireEvent.change(input, { target: { value: 'hello' } })
fireEvent.click(screen.getByTestId('checkpoint-check-button'))
expect(onSubmit).toHaveBeenCalledWith('hello')
})
})
describe('two-numbers input type', () => {
const twoNumbersProps = {
...defaultProps,
inputType: 'two-numbers' as const,
inputLabels: ['Numerator', 'Denominator'] as [string, string],
}
it('renders two KidNumberInput components', () => {
const { container } = render(<FlowchartCheckpoint {...twoNumbersProps} />)
const kidInputs = container.querySelectorAll('[data-component="kid-number-input"]')
// 2 displays + 1 keypad
expect(kidInputs.length).toBeGreaterThanOrEqual(2)
})
it('renders input labels', () => {
render(<FlowchartCheckpoint {...twoNumbersProps} />)
expect(screen.getByText('Numerator')).toBeInTheDocument()
expect(screen.getByText('Denominator')).toBeInTheDocument()
})
it('renders shared keypad', () => {
const { container } = render(<FlowchartCheckpoint {...twoNumbersProps} />)
expect(container.querySelector('[data-element="keypad-inline"]')).toBeInTheDocument()
})
it('has clickable input wrappers for focus switching', () => {
render(<FlowchartCheckpoint {...twoNumbersProps} />)
expect(screen.getByTestId('checkpoint-input-1-wrapper')).toBeInTheDocument()
expect(screen.getByTestId('checkpoint-input-2-wrapper')).toBeInTheDocument()
})
it('shows "and" separator between inputs', () => {
render(<FlowchartCheckpoint {...twoNumbersProps} />)
expect(screen.getByText('and')).toBeInTheDocument()
})
})
describe('feedback display', () => {
it('shows correct feedback', () => {
render(
<FlowchartCheckpoint
{...defaultProps}
feedback={{ correct: true, expected: 4, userAnswer: 4 }}
/>
)
expect(screen.getByTestId('checkpoint-feedback')).toHaveAttribute(
'data-feedback-correct',
'true'
)
expect(screen.getByText('Correct!')).toBeInTheDocument()
})
it('shows incorrect feedback with expected value', () => {
render(
<FlowchartCheckpoint
{...defaultProps}
feedback={{ correct: false, expected: 4, userAnswer: 5 }}
/>
)
const feedback = screen.getByTestId('checkpoint-feedback')
expect(feedback).toHaveAttribute('data-feedback-correct', 'false')
expect(feedback).toHaveTextContent('Not quite')
expect(feedback).toHaveTextContent('5')
expect(feedback).toHaveTextContent('4')
})
it('shows two-numbers feedback with partial correctness', () => {
render(
<FlowchartCheckpoint
{...defaultProps}
inputType="two-numbers"
feedback={{
correct: false,
expected: [3, 4] as [number, number],
userAnswer: [3, 5] as [number, number],
}}
/>
)
const feedback = screen.getByTestId('checkpoint-feedback')
expect(feedback).toBeInTheDocument()
// First is correct, second is wrong
expect(screen.getByText(/Second should be 4/)).toBeInTheDocument()
})
})
describe('hint display', () => {
it('shows hint when provided', () => {
render(<FlowchartCheckpoint {...defaultProps} hint="Remember to carry the 1" />)
expect(screen.getByTestId('checkpoint-hint')).toHaveTextContent('Remember to carry the 1')
})
it('does not show hint when not provided', () => {
render(<FlowchartCheckpoint {...defaultProps} />)
expect(screen.queryByTestId('checkpoint-hint')).not.toBeInTheDocument()
})
})
describe('disabled state', () => {
it('disables Check button when disabled', () => {
render(<FlowchartCheckpoint {...defaultProps} disabled />)
expect(screen.getByTestId('checkpoint-check-button')).toBeDisabled()
})
it('does not accept keypad input when disabled', async () => {
const { container } = render(
<FlowchartCheckpoint {...defaultProps} inputType="number" disabled />
)
fireEvent.click(screen.getAllByTestId('key-4')[0])
// Should still show placeholder
const display = container.querySelector('[data-element="input-display"]')
expect(display?.textContent).toBe('?')
})
})
describe('keyboard input', () => {
afterEach(() => {
// Clean up event listeners
})
it('accepts physical keyboard digit input for number type', async () => {
const { container } = render(<FlowchartCheckpoint {...defaultProps} inputType="number" />)
fireEvent.keyDown(window, { key: '5' })
await waitFor(() => {
const display = container.querySelector('[data-element="input-display"]')
expect(display?.textContent).toBe('5')
})
})
it('handles physical keyboard backspace', async () => {
const { container } = render(<FlowchartCheckpoint {...defaultProps} inputType="number" />)
fireEvent.keyDown(window, { key: '5' })
fireEvent.keyDown(window, { key: '2' })
await waitFor(() => {
const display = container.querySelector('[data-element="input-display"]')
expect(display?.textContent).toBe('52')
})
fireEvent.keyDown(window, { key: 'Backspace' })
await waitFor(() => {
const display = container.querySelector('[data-element="input-display"]')
expect(display?.textContent).toBe('5')
})
})
it('does not handle keyboard input when disabled', async () => {
const { container } = render(
<FlowchartCheckpoint {...defaultProps} inputType="number" disabled />
)
fireEvent.keyDown(window, { key: '5' })
// Should still show placeholder
const display = container.querySelector('[data-element="input-display"]')
expect(display?.textContent).toBe('?')
})
it('does not handle keyboard input during feedback', async () => {
const { container } = render(
<FlowchartCheckpoint
{...defaultProps}
inputType="number"
feedback={{ correct: true, expected: 5, userAnswer: 5 }}
/>
)
// Store initial display value
const initialDisplay = container.querySelector('[data-element="input-display"]')
const initialValue = initialDisplay?.textContent
// Keyboard events are ignored during feedback display
fireEvent.keyDown(window, { key: '5' })
// The value should not be updated during feedback
const display = container.querySelector('[data-element="input-display"]')
expect(display?.textContent).toBe(initialValue)
})
})
})

View File

@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { KidNumberInput } from '../KidNumberInput'
// Mock ThemeContext
vi.mock('@/contexts/ThemeContext', () => ({
useTheme: () => ({ resolvedTheme: 'light' }),
}))
// Mock react-simple-keyboard
vi.mock('react-simple-keyboard', () => ({
default: ({ onKeyPress, layout }: { onKeyPress: (key: string) => void; layout: any }) => (
<div data-testid="mock-keyboard">
{/* Render clickable buttons for testing */}
<button data-testid="key-1" onClick={() => onKeyPress('1')}>
1
</button>
<button data-testid="key-2" onClick={() => onKeyPress('2')}>
2
</button>
<button data-testid="key-3" onClick={() => onKeyPress('3')}>
3
</button>
<button data-testid="key-0" onClick={() => onKeyPress('0')}>
0
</button>
<button data-testid="key-bksp" onClick={() => onKeyPress('{bksp}')}>
</button>
</div>
),
}))
// Mock Panda CSS
vi.mock('../../../../../styled-system/css', () => ({
css: vi.fn(() => 'mocked-css-class'),
}))
describe('KidNumberInput', () => {
const defaultProps = {
value: '',
onDigit: vi.fn(),
onBackspace: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('renders the component with data attribute', () => {
const { container } = render(<KidNumberInput {...defaultProps} />)
expect(container.querySelector('[data-component="kid-number-input"]')).toBeInTheDocument()
})
it('renders display area with placeholder when value is empty', () => {
render(<KidNumberInput {...defaultProps} value="" placeholder="?" />)
const display = screen.getByText('?')
expect(display).toBeInTheDocument()
})
it('renders display area with value when provided', () => {
render(<KidNumberInput {...defaultProps} value="42" />)
const display = screen.getByText('42')
expect(display).toBeInTheDocument()
})
it('renders keypad when showKeypad is true (default)', () => {
render(<KidNumberInput {...defaultProps} />)
// Fixed mode renders both portrait and landscape keyboards
const keyboards = screen.getAllByTestId('mock-keyboard')
expect(keyboards.length).toBeGreaterThanOrEqual(1)
})
it('does not render keypad when showKeypad is false', () => {
render(<KidNumberInput {...defaultProps} showKeypad={false} />)
expect(screen.queryByTestId('mock-keyboard')).not.toBeInTheDocument()
})
})
describe('feedback states', () => {
it('has feedback="none" data attribute by default', () => {
const { container } = render(<KidNumberInput {...defaultProps} />)
expect(container.querySelector('[data-feedback="none"]')).toBeInTheDocument()
})
it('has feedback="correct" data attribute when correct', () => {
const { container } = render(<KidNumberInput {...defaultProps} feedback="correct" />)
expect(container.querySelector('[data-feedback="correct"]')).toBeInTheDocument()
})
it('has feedback="incorrect" data attribute when incorrect', () => {
const { container } = render(<KidNumberInput {...defaultProps} feedback="incorrect" />)
expect(container.querySelector('[data-feedback="incorrect"]')).toBeInTheDocument()
})
})
describe('disabled state', () => {
it('has disabled=false data attribute by default', () => {
const { container } = render(<KidNumberInput {...defaultProps} />)
expect(container.querySelector('[data-disabled="false"]')).toBeInTheDocument()
})
it('has disabled=true data attribute when disabled', () => {
const { container } = render(<KidNumberInput {...defaultProps} disabled />)
expect(container.querySelector('[data-disabled="true"]')).toBeInTheDocument()
})
it('does not call onDigit when disabled and key is pressed', () => {
const onDigit = vi.fn()
render(<KidNumberInput {...defaultProps} onDigit={onDigit} disabled />)
// Use first keyboard found (fixed mode has multiple)
fireEvent.click(screen.getAllByTestId('key-1')[0])
expect(onDigit).not.toHaveBeenCalled()
})
it('does not call onBackspace when disabled and backspace is pressed', () => {
const onBackspace = vi.fn()
render(<KidNumberInput {...defaultProps} onBackspace={onBackspace} disabled />)
// Use first keyboard found
fireEvent.click(screen.getAllByTestId('key-bksp')[0])
expect(onBackspace).not.toHaveBeenCalled()
})
})
describe('keypad interaction', () => {
it('calls onDigit when a number key is pressed', () => {
const onDigit = vi.fn()
render(<KidNumberInput {...defaultProps} onDigit={onDigit} />)
// Use first keyboard found (fixed mode has multiple)
fireEvent.click(screen.getAllByTestId('key-1')[0])
expect(onDigit).toHaveBeenCalledWith('1')
fireEvent.click(screen.getAllByTestId('key-2')[0])
expect(onDigit).toHaveBeenCalledWith('2')
})
it('calls onBackspace when backspace key is pressed', () => {
const onBackspace = vi.fn()
render(<KidNumberInput {...defaultProps} onBackspace={onBackspace} />)
// Use first keyboard found
fireEvent.click(screen.getAllByTestId('key-bksp')[0])
expect(onBackspace).toHaveBeenCalled()
})
})
describe('display sizes', () => {
it('renders with default size xl', () => {
render(<KidNumberInput {...defaultProps} />)
// Size is handled by CSS, just verify it renders
const display = screen.getByText('?')
expect(display).toBeInTheDocument()
})
it('renders with size sm', () => {
render(<KidNumberInput {...defaultProps} displaySize="sm" />)
const display = screen.getByText('?')
expect(display).toBeInTheDocument()
})
it('renders with size md', () => {
render(<KidNumberInput {...defaultProps} displaySize="md" />)
const display = screen.getByText('?')
expect(display).toBeInTheDocument()
})
it('renders with size lg', () => {
render(<KidNumberInput {...defaultProps} displaySize="lg" />)
const display = screen.getByText('?')
expect(display).toBeInTheDocument()
})
})
describe('keypad modes', () => {
it('renders fixed keypad by default', () => {
const { container } = render(<KidNumberInput {...defaultProps} keypadMode="fixed" />)
// Fixed mode renders portrait and landscape containers
expect(container.querySelector('[data-element="keypad-portrait"]')).toBeInTheDocument()
expect(container.querySelector('[data-element="keypad-landscape"]')).toBeInTheDocument()
})
it('renders inline keypad when mode is inline', () => {
const { container } = render(<KidNumberInput {...defaultProps} keypadMode="inline" />)
expect(container.querySelector('[data-element="keypad-inline"]')).toBeInTheDocument()
expect(container.querySelector('[data-element="keypad-portrait"]')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,474 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useKidNumberInput } from '../useKidNumberInput'
describe('useKidNumberInput', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
describe('initial state', () => {
it('starts with empty value', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 5,
onCorrect: vi.fn(),
})
)
expect(result.current.state.value).toBe('')
})
it('starts with null startTime', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 5,
onCorrect: vi.fn(),
})
)
expect(result.current.state.startTime).toBeNull()
})
it('reflects disabled prop', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 5,
onCorrect: vi.fn(),
disabled: true,
})
)
expect(result.current.state.disabled).toBe(true)
})
})
describe('addDigit', () => {
it('adds a digit to the value', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 42,
onCorrect: vi.fn(),
})
)
act(() => {
result.current.actions.addDigit('1')
})
expect(result.current.state.value).toBe('1')
})
it('appends multiple digits', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 123,
onCorrect: vi.fn(),
})
)
act(() => {
result.current.actions.addDigit('1')
result.current.actions.addDigit('2')
})
expect(result.current.state.value).toBe('12')
})
it('does not add digit when disabled', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 5,
onCorrect: vi.fn(),
disabled: true,
})
)
act(() => {
result.current.actions.addDigit('1')
})
expect(result.current.state.value).toBe('')
})
it('ignores non-digit characters', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 42,
onCorrect: vi.fn(),
})
)
act(() => {
result.current.actions.addDigit('a')
result.current.actions.addDigit('!')
result.current.actions.addDigit('1')
})
expect(result.current.state.value).toBe('1')
})
it('respects maxDigits based on correctAnswer length', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 5, // 1 digit
onCorrect: vi.fn(),
})
)
act(() => {
result.current.actions.addDigit('1')
result.current.actions.addDigit('2') // Should be ignored
})
expect(result.current.state.value).toBe('1')
})
it('respects custom maxDigits', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 5,
onCorrect: vi.fn(),
maxDigits: 3,
})
)
act(() => {
result.current.actions.addDigit('1')
result.current.actions.addDigit('2')
result.current.actions.addDigit('3')
result.current.actions.addDigit('4') // Should be ignored
})
expect(result.current.state.value).toBe('123')
})
it('sets startTime on first digit', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 42,
onCorrect: vi.fn(),
})
)
expect(result.current.state.startTime).toBeNull()
act(() => {
result.current.actions.addDigit('1')
})
expect(result.current.state.startTime).not.toBeNull()
})
})
describe('backspace', () => {
it('removes the last digit', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 123,
onCorrect: vi.fn(),
})
)
act(() => {
result.current.actions.addDigit('1')
result.current.actions.addDigit('2')
result.current.actions.backspace()
})
expect(result.current.state.value).toBe('1')
})
it('does nothing when value is empty', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 5,
onCorrect: vi.fn(),
})
)
act(() => {
result.current.actions.backspace()
})
expect(result.current.state.value).toBe('')
})
it('does nothing when disabled', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 42,
onCorrect: vi.fn(),
disabled: true,
})
)
// Start with some value by enabling then disabling
const { result: enabledResult } = renderHook(() =>
useKidNumberInput({
correctAnswer: 42,
onCorrect: vi.fn(),
disabled: false,
})
)
act(() => {
enabledResult.current.actions.addDigit('1')
})
// Now test disabled behavior
act(() => {
result.current.actions.backspace()
})
expect(result.current.state.value).toBe('')
})
})
describe('clear', () => {
it('clears the value', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 123,
onCorrect: vi.fn(),
})
)
act(() => {
result.current.actions.addDigit('1')
result.current.actions.addDigit('2')
result.current.actions.clear()
})
expect(result.current.state.value).toBe('')
})
it('resets startTime', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 42,
onCorrect: vi.fn(),
})
)
act(() => {
result.current.actions.addDigit('1')
})
expect(result.current.state.startTime).not.toBeNull()
act(() => {
result.current.actions.clear()
})
expect(result.current.state.startTime).toBeNull()
})
})
describe('reset', () => {
it('clears value and startTime', () => {
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 42,
onCorrect: vi.fn(),
})
)
act(() => {
result.current.actions.addDigit('1')
})
expect(result.current.state.value).toBe('1')
expect(result.current.state.startTime).not.toBeNull()
act(() => {
result.current.actions.reset()
})
expect(result.current.state.value).toBe('')
expect(result.current.state.startTime).toBeNull()
})
})
describe('validation', () => {
it('calls onCorrect when correct answer is entered', async () => {
const onCorrect = vi.fn()
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 5,
onCorrect,
})
)
act(() => {
result.current.actions.addDigit('5')
})
// Wait for setTimeout in validateAndSubmit
await act(async () => {
vi.advanceTimersByTime(100)
})
expect(onCorrect).toHaveBeenCalledWith(5, expect.any(Number))
})
it('calls onIncorrect when wrong answer is entered', async () => {
const onCorrect = vi.fn()
const onIncorrect = vi.fn()
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 5,
onCorrect,
onIncorrect,
})
)
act(() => {
result.current.actions.addDigit('3')
})
await act(async () => {
vi.advanceTimersByTime(100)
})
expect(onIncorrect).toHaveBeenCalledWith(3, 5, expect.any(Number))
expect(onCorrect).not.toHaveBeenCalled()
})
it('clears value after correct answer by default', async () => {
const onCorrect = vi.fn()
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 5,
onCorrect,
})
)
act(() => {
result.current.actions.addDigit('5')
})
await act(async () => {
vi.advanceTimersByTime(100)
})
expect(result.current.state.value).toBe('')
})
it('keeps value after correct when clearOnCorrect is false', async () => {
const onCorrect = vi.fn()
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 5,
onCorrect,
clearOnCorrect: false,
})
)
act(() => {
result.current.actions.addDigit('5')
})
await act(async () => {
vi.advanceTimersByTime(100)
})
expect(result.current.state.value).toBe('5')
})
it('clears value after incorrect answer by default', async () => {
const onCorrect = vi.fn()
const onIncorrect = vi.fn()
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 5,
onCorrect,
onIncorrect,
})
)
act(() => {
result.current.actions.addDigit('3')
})
await act(async () => {
vi.advanceTimersByTime(100)
})
expect(result.current.state.value).toBe('')
})
it('keeps value after incorrect when clearOnIncorrect is false', async () => {
const onCorrect = vi.fn()
const onIncorrect = vi.fn()
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 5,
onCorrect,
onIncorrect,
clearOnIncorrect: false,
})
)
act(() => {
result.current.actions.addDigit('3')
})
await act(async () => {
vi.advanceTimersByTime(100)
})
expect(result.current.state.value).toBe('3')
})
it('validates multi-digit answers', async () => {
const onCorrect = vi.fn()
const { result } = renderHook(() =>
useKidNumberInput({
correctAnswer: 42,
onCorrect,
})
)
act(() => {
result.current.actions.addDigit('4')
result.current.actions.addDigit('2')
})
await act(async () => {
vi.advanceTimersByTime(100)
})
expect(onCorrect).toHaveBeenCalledWith(42, expect.any(Number))
})
})
describe('correctAnswer changes', () => {
it('resets when correctAnswer changes', () => {
const { result, rerender } = renderHook(
({ correctAnswer }) =>
useKidNumberInput({
correctAnswer,
onCorrect: vi.fn(),
}),
{ initialProps: { correctAnswer: 5 } }
)
act(() => {
result.current.actions.addDigit('3')
})
expect(result.current.state.value).toBe('3')
// Change correctAnswer (new question)
rerender({ correctAnswer: 10 })
expect(result.current.state.value).toBe('')
})
})
})