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:
parent
7c597e9079
commit
7e98a01a41
|
|
@ -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": []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue