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();
});
});