diff --git a/frontend/src/components/ui/__tests__/Button.test.tsx b/frontend/src/components/ui/__tests__/Button.test.tsx new file mode 100644 index 00000000..128e019e --- /dev/null +++ b/frontend/src/components/ui/__tests__/Button.test.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { Button } from '../Button'; + +describe('Button', () => { + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + it('renders its children', () => { + render(); + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + it('defaults to type="button"', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('type', 'button'); + }); + + it('respects an explicit type="submit"', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('type', 'submit'); + }); + + it('applies the primary variant class by default', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('ui-btn--primary'); + }); + + it.each(['primary', 'secondary', 'danger', 'ghost'] as const)( + 'applies the %s variant class', + (variant) => { + render(); + expect(screen.getByRole('button')).toHaveClass(`ui-btn--${variant}`); + } + ); + + it('merges a custom className', () => { + render(); + const btn = screen.getByRole('button'); + expect(btn).toHaveClass('ui-btn'); + expect(btn).toHaveClass('my-class'); + }); + + // ------------------------------------------------------------------------- + // Disabled / loading states + // ------------------------------------------------------------------------- + + it('is not disabled by default', () => { + render(); + expect(screen.getByRole('button')).not.toBeDisabled(); + }); + + it('is disabled when disabled prop is set', () => { + render(); + const btn = screen.getByRole('button'); + expect(btn).toBeDisabled(); + expect(btn).toHaveAttribute('aria-disabled', 'true'); + }); + + it('is disabled when isLoading=true', () => { + render(); + const btn = screen.getByRole('button'); + expect(btn).toBeDisabled(); + expect(btn).toHaveAttribute('aria-disabled', 'true'); + }); + + it('sets aria-busy="true" when isLoading=true', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true'); + }); + + it('does not set aria-busy when not loading', () => { + render(); + expect(screen.getByRole('button')).not.toHaveAttribute('aria-busy'); + }); + + it('renders a spinner element when isLoading=true', () => { + const { container } = render(); + expect(container.querySelector('.ui-btn__spinner')).toBeInTheDocument(); + }); + + it('does not render a spinner when not loading', () => { + const { container } = render(); + expect(container.querySelector('.ui-btn__spinner')).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Icons + // ------------------------------------------------------------------------- + + it('renders leftIcon when not loading', () => { + render(); + expect(screen.getByTestId('left')).toBeInTheDocument(); + }); + + it('renders rightIcon when not loading', () => { + render(); + expect(screen.getByTestId('right')).toBeInTheDocument(); + }); + + it('hides leftIcon when isLoading=true', () => { + render(); + expect(screen.queryByTestId('left')).not.toBeInTheDocument(); + }); + + it('hides rightIcon when isLoading=true', () => { + render(); + expect(screen.queryByTestId('right')).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Interaction + // ------------------------------------------------------------------------- + + it('calls onClick when clicked', async () => { + const onClick = jest.fn(); + render(); + await userEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('does not fire onClick when disabled', async () => { + const onClick = jest.fn(); + render(); + await userEvent.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('does not fire onClick when isLoading', async () => { + const onClick = jest.fn(); + render(); + await userEvent.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + // Ref forwarding + // ------------------------------------------------------------------------- + + it('forwards ref to the underlying ); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); +}); diff --git a/frontend/src/components/ui/__tests__/Card.test.tsx b/frontend/src/components/ui/__tests__/Card.test.tsx new file mode 100644 index 00000000..2249d596 --- /dev/null +++ b/frontend/src/components/ui/__tests__/Card.test.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Card, CardHeader, CardTitle, CardBody, CardFooter } from '../Card'; + +describe('Card', () => { + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + it('renders children', () => { + render(Card content); + expect(screen.getByText('Card content')).toBeInTheDocument(); + }); + + it('applies the base ui-card class', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('ui-card'); + }); + + it('merges a custom className', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('ui-card'); + expect(container.firstChild).toHaveClass('my-card'); + }); + + it('forwards extra HTML attributes', () => { + render(); + const el = screen.getByTestId('card-root'); + expect(el).toHaveAttribute('role', 'region'); + expect(el).toHaveAttribute('aria-label', 'Market'); + }); + + // ------------------------------------------------------------------------- + // Variants + // ------------------------------------------------------------------------- + + it('does not apply interactive class by default', () => { + const { container } = render(); + expect(container.firstChild).not.toHaveClass('ui-card--interactive'); + }); + + it('applies interactive class when interactive=true', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('ui-card--interactive'); + }); + + it('does not apply no-padding class by default', () => { + const { container } = render(); + expect(container.firstChild).not.toHaveClass('ui-card--no-padding'); + }); + + it('applies no-padding class when noPadding=true', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('ui-card--no-padding'); + }); + + it('can combine interactive and noPadding', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('ui-card--interactive'); + expect(container.firstChild).toHaveClass('ui-card--no-padding'); + }); + + // ------------------------------------------------------------------------- + // Ref forwarding + // ------------------------------------------------------------------------- + + it('forwards ref to the underlying
', () => { + const ref = React.createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); + +describe('CardHeader', () => { + it('renders children and applies the header class', () => { + const { container } = render(Header); + expect(screen.getByText('Header')).toBeInTheDocument(); + expect(container.firstChild).toHaveClass('ui-card__header'); + }); + + it('merges a custom className', () => { + const { container } = render(H); + expect(container.firstChild).toHaveClass('ui-card__header'); + expect(container.firstChild).toHaveClass('extra'); + }); +}); + +describe('CardTitle', () => { + it('renders as

by default', () => { + render(My title); + expect(screen.getByRole('heading', { level: 3, name: 'My title' })).toBeInTheDocument(); + }); + + it.each(['h2', 'h3', 'h4'] as const)('renders as <%s> when as="%s"', (tag) => { + render(Title); + const level = parseInt(tag[1]); + expect(screen.getByRole('heading', { level, name: 'Title' })).toBeInTheDocument(); + }); + + it('applies the title class', () => { + const { container } = render(T); + expect(container.firstChild).toHaveClass('ui-card__title'); + }); +}); + +describe('CardBody', () => { + it('renders children and applies the body class', () => { + const { container } = render(Body content); + expect(screen.getByText('Body content')).toBeInTheDocument(); + expect(container.firstChild).toHaveClass('ui-card__body'); + }); +}); + +describe('CardFooter', () => { + it('renders children and applies the footer class', () => { + const { container } = render(Footer content); + expect(screen.getByText('Footer content')).toBeInTheDocument(); + expect(container.firstChild).toHaveClass('ui-card__footer'); + }); +}); + +describe('Card composition', () => { + it('renders a full Card with all sub-components', () => { + render( + + + Market Title + + Some body text. + Actions here + + ); + expect(screen.getByRole('heading', { name: 'Market Title' })).toBeInTheDocument(); + expect(screen.getByText('Some body text.')).toBeInTheDocument(); + expect(screen.getByText('Actions here')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/__tests__/Modal.test.tsx b/frontend/src/components/ui/__tests__/Modal.test.tsx new file mode 100644 index 00000000..5a97f6f5 --- /dev/null +++ b/frontend/src/components/ui/__tests__/Modal.test.tsx @@ -0,0 +1,316 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { Modal } from '../Modal'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface Options { + open?: boolean; + onClose?: jest.Mock; + disableBackdropDismiss?: boolean; + disableEscapeKey?: boolean; + description?: string; + footer?: React.ReactNode; +} + +function renderModal({ + open = true, + onClose = jest.fn(), + disableBackdropDismiss = false, + disableEscapeKey = false, + description, + footer, +}: Options = {}) { + return render( + + + + + ); +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +describe('Modal rendering', () => { + it('renders nothing when open=false', () => { + renderModal({ open: false }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders the dialog when open=true', () => { + renderModal(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('renders the title', () => { + renderModal(); + expect(screen.getByText('Confirm')).toBeInTheDocument(); + }); + + it('renders the description when provided', () => { + renderModal({ description: 'This cannot be undone.' }); + expect(screen.getByText('This cannot be undone.')).toBeInTheDocument(); + }); + + it('does not render a description paragraph when omitted', () => { + renderModal(); + expect(screen.queryByText('This cannot be undone.')).not.toBeInTheDocument(); + }); + + it('renders children inside the modal body', () => { + renderModal(); + expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('renders an optional footer slot', () => { + renderModal({ footer: Footer }); + expect(screen.getByTestId('footer-content')).toBeInTheDocument(); + }); + + it('does not render a footer element when footer is omitted', () => { + const { container } = renderModal(); + expect(container.querySelector('.ui-modal__footer')).not.toBeInTheDocument(); + }); + + it('renders a close button by default', () => { + renderModal(); + expect(screen.getByRole('button', { name: /close dialog/i })).toBeInTheDocument(); + }); + + it('hides the close button when disableBackdropDismiss=true', () => { + renderModal({ disableBackdropDismiss: true }); + expect(screen.queryByRole('button', { name: /close dialog/i })).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // ARIA attributes + // ------------------------------------------------------------------------- + + it('sets role="dialog" and aria-modal="true"', () => { + renderModal(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + }); + + it('sets aria-labelledby pointing to the title element', () => { + renderModal(); + const dialog = screen.getByRole('dialog'); + const labelledById = dialog.getAttribute('aria-labelledby'); + expect(labelledById).toBeTruthy(); + expect(document.getElementById(labelledById!)).toHaveTextContent('Confirm'); + }); + + it('sets aria-describedby when a description is provided', () => { + renderModal({ description: 'Details here.' }); + const dialog = screen.getByRole('dialog'); + const describedById = dialog.getAttribute('aria-describedby'); + expect(describedById).toBeTruthy(); + expect(document.getElementById(describedById!)).toHaveTextContent('Details here.'); + }); + + it('does not set aria-describedby when description is omitted', () => { + renderModal(); + expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-describedby'); + }); +}); + +// --------------------------------------------------------------------------- +// Open / close behaviour +// --------------------------------------------------------------------------- + +describe('Modal open/close behaviour', () => { + it('calls onClose when the close button is clicked', async () => { + const onClose = jest.fn(); + renderModal({ onClose }); + await userEvent.click(screen.getByRole('button', { name: /close dialog/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when the backdrop is clicked', () => { + const onClose = jest.fn(); + renderModal({ onClose }); + const backdrop = document.querySelector('.ui-modal-backdrop') as HTMLElement; + fireEvent.click(backdrop, { target: backdrop }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when clicking inside the dialog', () => { + const onClose = jest.fn(); + renderModal({ onClose }); + fireEvent.click(screen.getByRole('dialog')); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('does not call onClose on backdrop click when disableBackdropDismiss=true', () => { + const onClose = jest.fn(); + renderModal({ onClose, disableBackdropDismiss: true }); + const backdrop = document.querySelector('.ui-modal-backdrop') as HTMLElement; + fireEvent.click(backdrop, { target: backdrop }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('calls onClose when Escape is pressed', () => { + const onClose = jest.fn(); + renderModal({ onClose }); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose on Escape when disableEscapeKey=true', () => { + const onClose = jest.fn(); + renderModal({ onClose, disableEscapeKey: true }); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('unmounts the dialog when open transitions to false', () => { + const { rerender } = renderModal({ open: true }); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + rerender( + + + + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Body scroll lock +// --------------------------------------------------------------------------- + +describe('Modal body scroll lock', () => { + afterEach(() => { + document.body.style.overflow = ''; + }); + + it('sets body overflow to hidden when open', () => { + renderModal({ open: true }); + expect(document.body.style.overflow).toBe('hidden'); + }); + + it('restores body overflow when closed via rerender', () => { + const { rerender } = renderModal({ open: true }); + rerender( + + + + ); + expect(document.body.style.overflow).toBe(''); + }); + + it('restores body overflow when unmounted', () => { + const { unmount } = renderModal({ open: true }); + unmount(); + expect(document.body.style.overflow).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// Focus management +// --------------------------------------------------------------------------- + +describe('Modal focus management', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('focuses the first focusable element after opening', () => { + renderModal({ open: true }); + act(() => { jest.runAllTimers(); }); + const focused = document.activeElement as HTMLElement; + expect(focused.tagName).toBe('BUTTON'); + expect(screen.getByRole('dialog').contains(focused)).toBe(true); + }); + + it('restores focus to the previously focused element on close', () => { + const trigger = document.createElement('button'); + trigger.textContent = 'Open'; + document.body.appendChild(trigger); + trigger.focus(); + + const { rerender } = renderModal({ open: true }); + act(() => { jest.runAllTimers(); }); + + rerender( + + + + ); + + expect(document.activeElement).toBe(trigger); + document.body.removeChild(trigger); + }); + + it('traps Tab focus: wraps forward from last to first focusable', () => { + renderModal({ open: true }); + act(() => { jest.runAllTimers(); }); + + const dialog = screen.getByRole('dialog'); + const focusable = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + const last = focusable[focusable.length - 1]; + last.focus(); + fireEvent.keyDown(document, { key: 'Tab', shiftKey: false }); + expect(document.activeElement).toBe(focusable[0]); + }); + + it('traps Shift+Tab focus: wraps backward from first to last focusable', () => { + renderModal({ open: true }); + act(() => { jest.runAllTimers(); }); + + const dialog = screen.getByRole('dialog'); + const focusable = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + first.focus(); + fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); + expect(document.activeElement).toBe(last); + }); +}); + +// --------------------------------------------------------------------------- +// Event listener cleanup +// --------------------------------------------------------------------------- + +describe('Modal event listener cleanup', () => { + it('removes keydown listener when the modal closes', () => { + const spy = jest.spyOn(document, 'removeEventListener'); + const { rerender } = renderModal({ open: true }); + rerender( + + + + ); + expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function)); + spy.mockRestore(); + }); + + it('removes keydown listener when the component unmounts', () => { + const spy = jest.spyOn(document, 'removeEventListener'); + const { unmount } = renderModal({ open: true }); + unmount(); + expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function)); + spy.mockRestore(); + }); +}); diff --git a/frontend/src/components/ui/__tests__/Select.test.tsx b/frontend/src/components/ui/__tests__/Select.test.tsx new file mode 100644 index 00000000..66c34d4d --- /dev/null +++ b/frontend/src/components/ui/__tests__/Select.test.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { Select } from '../Select'; + +describe('Select', () => { + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + it('renders a + + + ); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('renders options as children', () => { + render( + + ); + expect(screen.getByRole('option', { name: 'Yes' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'No' })).toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Label + // ------------------------------------------------------------------------- + + it('renders a label when label prop is provided', () => { + render( + + ); + expect(screen.getByText('Status')).toBeInTheDocument(); + }); + + it('associates label with the select via htmlFor/id', () => { + render( + + ); + expect(screen.getByLabelText('Status')).toBeInTheDocument(); + }); + + it('uses a caller-supplied id when provided', () => { + render( + + ); + expect(screen.getByRole('combobox')).toHaveAttribute('id', 'my-select'); + }); + + it('renders a required asterisk when required=true', () => { + render( + + ); + expect(screen.getByText('*')).toBeInTheDocument(); + }); + + it('does not render a required asterisk when required is omitted', () => { + render( + + ); + expect(screen.queryByText('*')).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Hint + // ------------------------------------------------------------------------- + + it('renders hint text when hint prop is provided', () => { + render( + + ); + expect(screen.getByText('Choose the current status')).toBeInTheDocument(); + }); + + it('does not render hint text when hint is omitted', () => { + render( + + ); + expect(screen.queryByText('Choose the current status')).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Error / aria + // ------------------------------------------------------------------------- + + it('renders an error message in role=alert when error prop is provided', () => { + render( + + ); + expect(screen.getByRole('alert')).toHaveTextContent('Selection required'); + }); + + it('does not render role=alert when error is omitted', () => { + render( + + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('sets aria-invalid="true" when error is provided', () => { + render( + + ); + expect(screen.getByRole('combobox')).toHaveAttribute('aria-invalid', 'true'); + }); + + it('sets aria-invalid="false" when there is no error', () => { + render( + + ); + expect(screen.getByRole('combobox')).toHaveAttribute('aria-invalid', 'false'); + }); + + it('sets aria-describedby pointing to the error element', () => { + render( + + ); + const select = screen.getByRole('combobox'); + const describedById = select.getAttribute('aria-describedby'); + expect(describedById).toBeTruthy(); + expect(document.getElementById(describedById!)).toHaveTextContent('Bad value'); + }); + + it('sets aria-describedby to include the hint id when hint is present', () => { + render( + + ); + const describedBy = screen.getByRole('combobox').getAttribute('aria-describedby') ?? ''; + const ids = describedBy.split(' '); + expect(ids.length).toBe(2); + expect(document.getElementById(ids[0])).toHaveTextContent('Pick one'); + expect(document.getElementById(ids[1])).toHaveTextContent('Required'); + }); + + it('does not set aria-describedby when neither hint nor error is present', () => { + render( + + ); + expect(screen.getByRole('combobox')).not.toHaveAttribute('aria-describedby'); + }); + + // ------------------------------------------------------------------------- + // Interaction + // ------------------------------------------------------------------------- + + it('reflects a user selection', async () => { + render( + + ); + const select = screen.getByRole('combobox') as HTMLSelectElement; + await userEvent.selectOptions(select, 'b'); + expect(select.value).toBe('b'); + }); + + // ------------------------------------------------------------------------- + // Ref forwarding + // ------------------------------------------------------------------------- + + it('forwards ref to the underlying + + + ); + expect(ref.current).toBeInstanceOf(HTMLSelectElement); + }); +}); diff --git a/frontend/src/components/ui/__tests__/TextInput.test.tsx b/frontend/src/components/ui/__tests__/TextInput.test.tsx new file mode 100644 index 00000000..e890d43e --- /dev/null +++ b/frontend/src/components/ui/__tests__/TextInput.test.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { TextInput } from '../TextInput'; + +describe('TextInput', () => { + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + it('renders an element', () => { + render(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('renders without a label when label is omitted', () => { + render(); + expect(screen.queryByRole('label')).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Label + // ------------------------------------------------------------------------- + + it('renders a label when label prop is provided', () => { + render(); + expect(screen.getByText('Email address')).toBeInTheDocument(); + }); + + it('associates the label with the input via htmlFor', () => { + render(); + expect(screen.getByLabelText('Username')).toBeInTheDocument(); + }); + + it('uses the caller-supplied id when provided', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('id', 'my-input'); + expect(screen.getByLabelText('Name')).toHaveAttribute('id', 'my-input'); + }); + + it('renders a required asterisk when required=true', () => { + render(); + expect(screen.getByText('*')).toBeInTheDocument(); + }); + + it('does not render a required asterisk when required is omitted', () => { + render(); + expect(screen.queryByText('*')).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Hint + // ------------------------------------------------------------------------- + + it('renders hint text when hint prop is provided', () => { + render(); + expect(screen.getByText('Enter your email')).toBeInTheDocument(); + }); + + it('does not render hint text when hint is omitted', () => { + render(); + expect(screen.queryByText('Enter your email')).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Error / aria + // ------------------------------------------------------------------------- + + it('renders an error message in role=alert when error prop is provided', () => { + render(); + expect(screen.getByRole('alert')).toHaveTextContent('This field is required'); + }); + + it('does not render role=alert when error is omitted', () => { + render(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('sets aria-invalid="true" when error is provided', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true'); + }); + + it('sets aria-invalid="false" when there is no error', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'false'); + }); + + it('sets aria-describedby pointing to the error element', () => { + render(); + const input = screen.getByRole('textbox'); + const describedById = input.getAttribute('aria-describedby'); + expect(describedById).toBeTruthy(); + expect(document.getElementById(describedById!)).toHaveTextContent('Invalid email'); + }); + + it('sets aria-describedby to include the hint id when hint is present', () => { + render(); + const describedBy = screen.getByRole('textbox').getAttribute('aria-describedby') ?? ''; + const ids = describedBy.split(' '); + expect(ids.length).toBe(2); + expect(document.getElementById(ids[0])).toHaveTextContent('Format: user@example.com'); + expect(document.getElementById(ids[1])).toHaveTextContent('Invalid email'); + }); + + it('does not set aria-describedby when neither hint nor error is present', () => { + render(); + expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby'); + }); + + it('applies ui-input--error class on the input when error is set', () => { + render(); + expect(screen.getByRole('textbox')).toHaveClass('ui-input--error'); + }); + + it('does not apply ui-input--error class when there is no error', () => { + render(); + expect(screen.getByRole('textbox')).not.toHaveClass('ui-input--error'); + }); + + // ------------------------------------------------------------------------- + // Interaction + // ------------------------------------------------------------------------- + + it('accepts user input', async () => { + render(); + const input = screen.getByRole('textbox'); + await userEvent.type(input, 'Alice'); + expect(input).toHaveValue('Alice'); + }); + + it('forwards extra HTML attributes to the input', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('placeholder', 'Search…'); + expect(input).toHaveAttribute('maxlength', '50'); + }); + + // ------------------------------------------------------------------------- + // Ref forwarding + // ------------------------------------------------------------------------- + + it('forwards ref to the underlying ', () => { + const ref = React.createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + }); +}); diff --git a/frontend/src/components/ui/__tests__/Textarea.test.tsx b/frontend/src/components/ui/__tests__/Textarea.test.tsx new file mode 100644 index 00000000..8835848a --- /dev/null +++ b/frontend/src/components/ui/__tests__/Textarea.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { Textarea } from '../Textarea'; + +describe('Textarea', () => { + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + it('renders a