import { createTypographyControl } from '$shared/lib'; import { fireEvent, render, screen, waitFor, within, } from '@testing-library/svelte'; import ComboControl from './ComboControl.svelte'; function makeControl(value: number, opts: { min?: number; max?: number; step?: number } = {}) { return createTypographyControl({ value, min: opts.min ?? 0, max: opts.max ?? 100, step: opts.step ?? 1, }); } /** * The trigger is the button wired to the popover (has popovertarget). The native * Popover always renders its content (the vertical slider, which also displays the * value) in the DOM, so value assertions must be scoped to the trigger to avoid * matching the slider's own value label. */ function getTrigger(): HTMLElement { return document.querySelector('button[popovertarget]') as HTMLElement; } describe('ComboControl', () => { describe('Rendering', () => { it('renders decrease and increase buttons', () => { render(ComboControl, { control: makeControl(50) }); expect(screen.getByLabelText('Decrease')).toBeInTheDocument(); expect(screen.getByLabelText('Increase')).toBeInTheDocument(); }); it('renders the current integer value', () => { render(ComboControl, { control: makeControl(42) }); expect(within(getTrigger()).getByText('42')).toBeInTheDocument(); }); it('formats decimal value to 1 decimal place when step >= 0.1', () => { render(ComboControl, { control: makeControl(1.5, { step: 0.1 }) }); expect(within(getTrigger()).getByText('1.5')).toBeInTheDocument(); }); it('formats decimal value to 2 decimal places when step < 0.1', () => { render(ComboControl, { control: makeControl(1.55, { step: 0.01 }) }); expect(within(getTrigger()).getByText('1.55')).toBeInTheDocument(); }); it('renders label when label prop is provided', () => { render(ComboControl, { control: makeControl(16), label: 'Size' }); expect(screen.getByText('Size')).toBeInTheDocument(); }); it('applies custom aria-labels to buttons', () => { render(ComboControl, { control: makeControl(50), decreaseLabel: 'Decrease font size', increaseLabel: 'Increase font size', }); expect(screen.getByLabelText('Decrease font size')).toBeInTheDocument(); expect(screen.getByLabelText('Increase font size')).toBeInTheDocument(); }); }); describe('Disabled state', () => { it('disables decrease button when at min', () => { render(ComboControl, { control: makeControl(0, { min: 0 }) }); expect(screen.getByLabelText('Decrease')).toBeDisabled(); }); it('disables increase button when at max', () => { render(ComboControl, { control: makeControl(100, { max: 100 }) }); expect(screen.getByLabelText('Increase')).toBeDisabled(); }); it('enables both buttons when value is within bounds', () => { render(ComboControl, { control: makeControl(50, { min: 0, max: 100 }) }); expect(screen.getByLabelText('Decrease')).not.toBeDisabled(); expect(screen.getByLabelText('Increase')).not.toBeDisabled(); }); }); describe('Click interactions', () => { it('decreases value on decrease button click', async () => { const control = makeControl(50, { step: 5 }); render(ComboControl, { control }); await fireEvent.click(screen.getByLabelText('Decrease')); expect(control.value).toBe(45); }); it('increases value on increase button click', async () => { const control = makeControl(50, { step: 5 }); render(ComboControl, { control }); await fireEvent.click(screen.getByLabelText('Increase')); expect(control.value).toBe(55); }); it('clamps decrease at min', async () => { const control = makeControl(2, { min: 0, step: 5 }); render(ComboControl, { control }); await fireEvent.click(screen.getByLabelText('Decrease')); expect(control.value).toBe(0); }); it('clamps increase at max', async () => { const control = makeControl(98, { max: 100, step: 5 }); render(ComboControl, { control }); await fireEvent.click(screen.getByLabelText('Increase')); expect(control.value).toBe(100); }); it('updates displayed value after click', async () => { const control = makeControl(50); render(ComboControl, { control }); await fireEvent.click(screen.getByLabelText('Increase')); await waitFor(() => expect(within(getTrigger()).getByText('51')).toBeInTheDocument()); }); }); describe('Popover', () => { /** * The native Popover always renders its content; opening is driven by the * browser's declarative popovertarget invoker, which jsdom does not simulate * on click (mirrors Popover.svelte.test.ts). So assert the wired-but-closed * state, then drive the open through the API the browser would call. */ it('exposes a popover trigger with the vertical slider as its content', async () => { render(ComboControl, { control: makeControl(50), controlLabel: 'Size control' }); const trigger = getTrigger(); expect(trigger).toHaveAttribute('aria-expanded', 'false'); const content = document.getElementById(trigger.getAttribute('popovertarget')!) as HTMLElement; expect(content).toHaveAttribute('data-state', 'closed'); // The vertical slider lives inside the popover content. While closed the // content is visibility:hidden, so query including hidden elements. expect(within(content).getByRole('slider', { hidden: true })).toBeInTheDocument(); content.showPopover(); await waitFor(() => expect(content).toHaveAttribute('data-state', 'open')); expect(trigger).toHaveAttribute('aria-expanded', 'true'); }); }); describe('Reduced mode', () => { it('renders horizontal slider directly without popover trigger', () => { render(ComboControl, { control: makeControl(50), reduced: true }); expect(screen.getByRole('slider')).toBeInTheDocument(); expect(screen.queryByLabelText('Decrease')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Increase')).not.toBeInTheDocument(); }); it('shows formatted value in reduced mode', () => { const { container } = render(ComboControl, { control: makeControl(75), reduced: true }); expect(container.textContent).toContain('75'); }); }); });