import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { InputNumber } from './input-number'; const spinbutton = () => screen.getByRole('spinbutton'); describe('InputNumber', () => { it('exposes its bounds to assistive tech', () => { render(); expect(spinbutton()).toHaveAttribute('aria-valuenow', '3'); expect(spinbutton()).toHaveAttribute('aria-valuemin', '0'); expect(spinbutton()).toHaveAttribute('aria-valuemax', '10'); }); it('omits the bound attributes when unbounded', () => { render(); expect(spinbutton()).not.toHaveAttribute('aria-valuemin'); expect(spinbutton()).not.toHaveAttribute('aria-valuemax'); }); describe('keyboard', () => { it('steps with the arrow keys', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.click(spinbutton()); await user.keyboard('{ArrowUp}'); expect(onChange).toHaveBeenLastCalledWith(4); await user.keyboard('{ArrowDown}{ArrowDown}'); expect(onChange).toHaveBeenLastCalledWith(2); }); it('steps by the given step size', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.click(spinbutton()); await user.keyboard('{ArrowUp}'); expect(onChange).toHaveBeenLastCalledWith(25); }); it('keeps decimal steps exact instead of drifting', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.click(spinbutton()); await user.keyboard('{ArrowUp}'); /* 0.1 + 0.2 is 0.30000000000000004 in binary floating point. */ expect(onChange).toHaveBeenLastCalledWith(0.3); }); it('keeps the value’s own decimals when the step has none', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.click(spinbutton()); await user.keyboard('{ArrowUp}'); /* Rounding to the step's zero decimals would quietly turn 2.5 into 3. */ expect(onChange).toHaveBeenLastCalledWith(2.5); }); it('does not step past the bounds', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render( ); await user.click(spinbutton()); await user.keyboard('{ArrowUp}{ArrowUp}{ArrowUp}'); expect(onChange).toHaveBeenLastCalledWith(10); expect(spinbutton()).toHaveValue('10'); }); it('can be turned off', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render( ); await user.click(spinbutton()); await user.keyboard('{ArrowUp}'); expect(onChange).not.toHaveBeenCalled(); }); }); describe('stepper controls', () => { it('steps on click', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.click(screen.getByRole('button', { name: 'Increase' })); expect(onChange).toHaveBeenLastCalledWith(4); await user.click(screen.getByRole('button', { name: 'Decrease' })); expect(onChange).toHaveBeenLastCalledWith(3); }); it('disables the control that would cross a bound', () => { render(); expect(screen.getByRole('button', { name: 'Increase' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Decrease' })).toBeEnabled(); }); it('stays out of the tab order, since the spinbutton owns the keyboard', () => { render(); expect(screen.getByRole('button', { name: 'Increase' })).toHaveAttribute('tabindex', '-1'); }); it('is omitted when controls is false', () => { render(); expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); }); describe('typing', () => { it('reports each parseable keystroke', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.type(spinbutton(), '42'); expect(onChange).toHaveBeenNthCalledWith(1, 4); expect(onChange).toHaveBeenLastCalledWith(42); }); it('does not clamp mid-keystroke', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); /* Typing "50" passes through "5", which is below min. Clamping there would rewrite the field to "10" and make the value untypeable. */ await user.type(spinbutton(), '50'); expect(spinbutton()).toHaveValue('50'); expect(onChange).toHaveBeenLastCalledWith(50); }); it('clamps on blur', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.type(spinbutton(), '150'); await user.tab(); expect(onChange).toHaveBeenLastCalledWith(99); expect(spinbutton()).toHaveValue('99'); }); it('reports null once cleared', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.clear(spinbutton()); expect(onChange).toHaveBeenLastCalledWith(null); expect(spinbutton()).not.toHaveAttribute('aria-valuenow'); }); it('discards an unparseable entry on blur rather than committing NaN', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.clear(spinbutton()); await user.type(spinbutton(), 'abc'); await user.tab(); expect(onChange).not.toHaveBeenCalledWith(NaN); expect(spinbutton()).toHaveValue(''); }); it('applies precision on blur, rounding as the decimal reads', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.type(spinbutton(), '1.005'); await user.tab(); /* `(1.005).toFixed(2)` is "1.00" — 1.005 is stored as 1.00499…. Rounding has to go through the decimal string to match what was typed. */ expect(spinbutton()).toHaveValue('1.01'); expect(onChange).toHaveBeenLastCalledWith(1.01); }); it('keeps typed decimals when no precision is set', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.type(spinbutton(), '1.005'); await user.tab(); expect(onChange).toHaveBeenLastCalledWith(1.005); }); }); describe('formatter and parser', () => { const currency = { formatter: (value: number | null) => (value === null ? '' : `$ ${value}`), parser: (text: string) => Number(text.replace(/\$\s?/, '')), }; it('shows the formatted value but reports a number', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); expect(spinbutton()).toHaveValue('$ 1250'); await user.click(screen.getByRole('button', { name: 'Increase' })); expect(onChange).toHaveBeenLastCalledWith(1251); expect(spinbutton()).toHaveValue('$ 1251'); }); it('announces the formatted text, not the bare number', () => { render(); expect(spinbutton()).toHaveAttribute('aria-valuetext', '$ 1250'); expect(spinbutton()).toHaveAttribute('aria-valuenow', '1250'); }); it('parses what the user types back through the parser', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.type(spinbutton(), '$ 40'); await user.tab(); expect(onChange).toHaveBeenLastCalledWith(40); }); }); describe('controlled', () => { it('holds its value until the parent updates it', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.click(screen.getByRole('button', { name: 'Increase' })); expect(onChange).toHaveBeenCalledWith(6); expect(spinbutton()).toHaveValue('5'); }); it('re-renders when the parent value changes', () => { const { rerender } = render(); expect(spinbutton()).toHaveValue('5'); rerender(); expect(spinbutton()).toHaveValue('12'); }); }); it('ignores interaction when disabled', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); expect(spinbutton()).toBeDisabled(); await user.click(screen.getByRole('button', { name: 'Increase' })); expect(onChange).not.toHaveBeenCalled(); }); it('does not step when read-only', async () => { const user = userEvent.setup(); const onChange = vi.fn(); render(); await user.click(spinbutton()); await user.keyboard('{ArrowUp}'); expect(onChange).not.toHaveBeenCalled(); }); });