/** * Test Suite for ComboControl Component * * IMPORTANT: These tests require a proper browser environment to run. * * Svelte 5's $state() and $effect() runes do not work in jsdom (server-side simulation). * The current vitest.config.component.ts uses 'environment: jsdom', which doesn't support Svelte 5 reactivity. * * To run these tests, you need to: * 1. Update vitest to use browser-based testing with @vitest/browser-playwright * 2. OR use Playwright E2E tests in e2e/ComboControl.e2e.test.ts * * To run E2E tests (recommended): * ```bash * yarn test:e2e ComboControl * ``` * * This suite tests the actual Svelte component rendering, interactions, and behavior. * Tests for the createTypographyControl helper function are in createTypographyControl.test.ts * * Test Coverage: * 1. Component Rendering: Button labels, icons, and initial state * 2. Button States: Disabled states based on isAtMin/isAtMax * 3. Button Clicks: Increase/decrease button functionality * 4. Popover Behavior: Opening/closing popover with slider and input * 5. Slider Interaction: Dragging slider to update values * 6. Input Field: Typing values directly * 7. Accessibility: ARIA labels and keyboard navigation * 8. Reactivity: Value updates propagating through the component * 9. Edge Cases: Boundary conditions and special values * * Note: This file is intentionally left as-is with comprehensive @testing-library/svelte tests * as a reference for when the browser environment is properly set up. */ import { createTypographyControl } from '$shared/lib'; import { fireEvent, render, screen, waitFor, } from '@testing-library/svelte'; import { describe, expect, it, } from 'vitest'; import ComboControl from './ComboControl.svelte'; describe('ComboControl Component', () => { /** * Helper function to create a TypographyControl for testing */ function createTestControl(initialValue: number, options?: { min?: number; max?: number; step?: number; }) { return createTypographyControl({ value: initialValue, min: options?.min ?? 0, max: options?.max ?? 100, step: options?.step ?? 1, }); } describe('Rendering', () => { it('renders all three buttons (decrease, control, increase)', () => { const control = createTestControl(50); render(ComboControl, { control, }); const buttons = screen.getAllByRole('button'); expect(buttons).toHaveLength(3); }); it('displays current value on control button', () => { const control = createTestControl(42); render(ComboControl, { control, }); expect(screen.getByText('42')).toBeInTheDocument(); }); it('displays decimal values on control button', () => { const control = createTestControl(12.5, { min: 0, max: 100, step: 0.5 }); render(ComboControl, { control, }); expect(screen.getByText('12.5')).toBeInTheDocument(); }); it('applies custom ARIA labels to buttons', () => { const control = createTestControl(50); render(ComboControl, { control, decreaseLabel: 'Decrease font size', controlLabel: 'Font size control', increaseLabel: 'Increase font size', }); expect(screen.getByLabelText('Decrease font size')).toBeInTheDocument(); expect(screen.getByLabelText('Font size control')).toBeInTheDocument(); expect(screen.getByLabelText('Increase font size')).toBeInTheDocument(); }); it('renders decrease button with minus icon', () => { const control = createTestControl(50); const { container } = render(ComboControl, { control, }); const decreaseBtn = screen.getAllByRole('button')[0]; expect(decreaseBtn).toBeInTheDocument(); // Check for lucide icon SVG const svg = container.querySelector('button svg'); expect(svg).toBeInTheDocument(); }); it('renders increase button with plus icon', () => { const control = createTestControl(50); const { container } = render(ComboControl, { control, }); const increaseBtn = screen.getAllByRole('button')[2]; expect(increaseBtn).toBeInTheDocument(); // Check for lucide icon SVG const svgs = container.querySelectorAll('button svg'); expect(svgs.length).toBeGreaterThan(0); }); it('handles zero value correctly', () => { const control = createTestControl(0, { min: 0, max: 100 }); render(ComboControl, { control, }); expect(screen.getByText('0')).toBeInTheDocument(); }); it('handles negative values correctly', () => { const control = createTestControl(-5, { min: -10, max: 10 }); render(ComboControl, { control, }); expect(screen.getByText('-5')).toBeInTheDocument(); }); }); describe('Button States', () => { it('disables decrease button when at min value', () => { const control = createTestControl(0, { min: 0, max: 100 }); const { container } = render(ComboControl, { control, }); const buttons = container.querySelectorAll('button'); const decreaseBtn = buttons[0]; expect(decreaseBtn).toBeDisabled(); }); it('disables increase button when at max value', () => { const control = createTestControl(100, { min: 0, max: 100 }); const { container } = render(ComboControl, { control, }); const buttons = container.querySelectorAll('button'); const increaseBtn = buttons[2]; expect(increaseBtn).toBeDisabled(); }); it('both buttons enabled when within bounds', () => { const control = createTestControl(50, { min: 0, max: 100 }); const { container } = render(ComboControl, { control, }); const buttons = container.querySelectorAll('button'); expect(buttons[0]).not.toBeDisabled(); // decrease expect(buttons[1]).not.toBeDisabled(); // control expect(buttons[2]).not.toBeDisabled(); // increase }); it('control button always enabled regardless of value', () => { const control = createTestControl(0, { min: 0, max: 0 }); const { container } = render(ComboControl, { control, }); const buttons = container.querySelectorAll('button'); const controlBtn = buttons[1]; expect(controlBtn).not.toBeDisabled(); }); }); describe('Button Clicks', () => { it('decrease button reduces value by step', async () => { const control = createTestControl(50, { min: 0, max: 100, step: 5 }); render(ComboControl, { control, }); const decreaseBtn = screen.getAllByRole('button')[0]; await fireEvent.click(decreaseBtn); expect(control.value).toBe(45); await waitFor(() => { expect(screen.getByText('45')).toBeInTheDocument(); }); }); it('increase button increases value by step', async () => { const control = createTestControl(50, { min: 0, max: 100, step: 5 }); render(ComboControl, { control, }); const increaseBtn = screen.getAllByRole('button')[2]; await fireEvent.click(increaseBtn); expect(control.value).toBe(55); await waitFor(() => { expect(screen.getByText('55')).toBeInTheDocument(); }); }); it('value updates on control button after multiple clicks', async () => { const control = createTestControl(50, { min: 0, max: 100 }); render(ComboControl, { control, }); const buttons = screen.getAllByRole('button'); const decreaseBtn = buttons[0]; const increaseBtn = buttons[2]; await fireEvent.click(increaseBtn); await fireEvent.click(increaseBtn); await fireEvent.click(increaseBtn); expect(control.value).toBe(53); await waitFor(() => { expect(screen.getByText('53')).toBeInTheDocument(); }); await fireEvent.click(decreaseBtn); expect(control.value).toBe(52); await waitFor(() => { expect(screen.getByText('52')).toBeInTheDocument(); }); }); it('decrease button does not go below min', async () => { const control = createTestControl(1, { min: 0, max: 100, step: 5 }); render(ComboControl, { control, }); const decreaseBtn = screen.getAllByRole('button')[0]; await fireEvent.click(decreaseBtn); expect(control.value).toBe(0); await waitFor(() => { expect(screen.getByText('0')).toBeInTheDocument(); }); }); it('increase button does not go above max', async () => { const control = createTestControl(99, { min: 0, max: 100, step: 5 }); render(ComboControl, { control, }); const increaseBtn = screen.getAllByRole('button')[2]; await fireEvent.click(increaseBtn); expect(control.value).toBe(100); await waitFor(() => { expect(screen.getByText('100')).toBeInTheDocument(); }); }); it('respects step precision on button clicks', async () => { const control = createTestControl(5.5, { min: 0, max: 10, step: 0.25 }); render(ComboControl, { control, }); const increaseBtn = screen.getAllByRole('button')[2]; await fireEvent.click(increaseBtn); expect(control.value).toBeCloseTo(5.75); await waitFor(() => { expect(screen.getByText('5.75')).toBeInTheDocument(); }); }); }); describe('Popover Behavior', () => { it('popover content not visible initially', () => { const control = createTestControl(50); render(ComboControl, { control, }); // Popover content should not be visible initially const popover = screen.queryByTestId('combo-control-popover'); expect(popover).not.toBeInTheDocument(); const sliderInput = screen.queryByRole('slider'); expect(sliderInput).not.toBeInTheDocument(); const numberInput = screen.queryByTestId('combo-control-input'); expect(numberInput).not.toBeInTheDocument(); }); it('clicking control button toggles popover', async () => { const control = createTestControl(50); render(ComboControl, { control, }); const controlBtn = screen.getByTestId('combo-control-value'); // Click to open popover await fireEvent.click(controlBtn); // Wait for popover to render (it's portaled to body) await waitFor(() => { const popover = screen.getByTestId('combo-control-popover'); expect(popover).toBeInTheDocument(); }); await waitFor(() => { const slider = screen.queryByRole('slider'); expect(slider).toBeInTheDocument(); }); await waitFor(() => { const numberInput = screen.queryByTestId('combo-control-input'); expect(numberInput).toBeInTheDocument(); }); }); it('popover contains slider and input', async () => { const control = createTestControl(50, { min: 10, max: 90, step: 5 }); render(ComboControl, { control, }); const controlBtn = screen.getByTestId('combo-control-value'); await fireEvent.click(controlBtn); // Verify both slider and input are present const slider = await screen.findByRole('slider'); expect(slider).toBeInTheDocument(); const input = await screen.findByTestId('combo-control-input'); expect(input).toBeInTheDocument(); // Both should show current value const inputElement = input as HTMLInputElement; expect(inputElement.value).toBe('50'); }); it('popover contains input field with current value', async () => { const control = createTestControl(42); render(ComboControl, { control, }); const controlBtn = screen.getByTestId('combo-control-value'); await fireEvent.click(controlBtn); await waitFor(async () => { const input = await screen.findByTestId('combo-control-input') as HTMLInputElement; expect(input).toBeInTheDocument(); expect(input.value).toBe('42'); }); }); it('input field has min/max attributes', async () => { const control = createTestControl(50, { min: 0, max: 100 }); render(ComboControl, { control, }); const controlBtn = screen.getByTestId('combo-control-value'); await fireEvent.click(controlBtn); await waitFor(async () => { const input = await screen.findByTestId('combo-control-input') as HTMLInputElement; expect(input).toHaveAttribute('min', '0'); expect(input).toHaveAttribute('max', '100'); }); }); }); describe('Slider Rendering', () => { it('slider is present in popover', async () => { const control = createTestControl(50); render(ComboControl, { control, }); const controlBtn = screen.getByTestId('combo-control-value'); await fireEvent.click(controlBtn); // Verify slider is present const slider = await screen.findByRole('slider'); expect(slider).toBeInTheDocument(); }); it('slider value syncs with control value', async () => { const control = createTestControl(50); render(ComboControl, { control, }); const controlBtn = screen.getByTestId('combo-control-value'); await fireEvent.click(controlBtn); // Slider should be present and reflect initial value const slider = await screen.findByRole('slider'); expect(slider).toBeInTheDocument(); // Change value via input (which we know works) const input = await screen.findByTestId('combo-control-input') as HTMLInputElement; await fireEvent.change(input, { target: { value: '75' } }); await fireEvent.blur(input); // Slider should still be present (not re-rendered) const sliderAfter = await screen.findByRole('slider'); expect(sliderAfter).toBeInTheDocument(); }); }); describe('Input Field Interaction', () => { it('typing valid number updates control value', async () => { const control = createTestControl(50, { min: 0, max: 100 }); render(ComboControl, { control, }); const controlBtn = screen.getByTestId('combo-control-value'); await fireEvent.click(controlBtn); const input = await screen.findByTestId('combo-control-input') as HTMLInputElement; expect(input).toBeInTheDocument(); // Type new value await fireEvent.change(input, { target: { value: '75' } }); await fireEvent.blur(input); // onchange fires on blur // Wait for control value to update await waitFor(() => { expect(control.value).toBe(75); }); // Check that control button text updates await waitFor(() => { expect(screen.getByText('75')).toBeInTheDocument(); }); }); it('input respects step precision', async () => { const control = createTestControl(5, { min: 0, max: 10, step: 0.25 }); render(ComboControl, { control, }); const controlBtn = screen.getByTestId('combo-control-value'); await fireEvent.click(controlBtn); const input = await screen.findByTestId('combo-control-input') as HTMLInputElement; expect(input).toBeInTheDocument(); // Type value with more precision than step allows (0.25 has 2 decimal places) await fireEvent.change(input, { target: { value: '5.23' } }); await fireEvent.blur(input); // Should be rounded to step precision (2 decimal places) await waitFor(() => { expect(control.value).toBeCloseTo(5.23, 1); }); }); it('input clamps to min', async () => { const control = createTestControl(50, { min: 10, max: 100 }); render(ComboControl, { control, }); const controlBtn = screen.getByTestId('combo-control-value'); await fireEvent.click(controlBtn); const input = await screen.findByTestId('combo-control-input') as HTMLInputElement; expect(input).toBeInTheDocument(); // Type below min await fireEvent.change(input, { target: { value: '5' } }); await fireEvent.blur(input); // Should be clamped to min await waitFor(() => { expect(control.value).toBe(10); }); }); it('input clamps to max', async () => { const control = createTestControl(50, { min: 0, max: 100 }); render(ComboControl, { control, }); const controlBtn = screen.getByTestId('combo-control-value'); await fireEvent.click(controlBtn); const input = await screen.findByTestId('combo-control-input') as HTMLInputElement; expect(input).toBeInTheDocument(); // Type above max await fireEvent.change(input, { target: { value: '150' } }); await fireEvent.blur(input); // Should be clamped to max await waitFor(() => { expect(control.value).toBe(100); }); }); it('rejects invalid input (non-numeric)', async () => { const control = createTestControl(50, { min: 0, max: 100 }); render(ComboControl, { control, }); const controlBtn = screen.getByTestId('combo-control-value'); await fireEvent.click(controlBtn); const originalValue = control.value; const input = await screen.findByTestId('combo-control-input') as HTMLInputElement; expect(input).toBeInTheDocument(); // Type invalid value await fireEvent.change(input, { target: { value: 'abc' } }); await fireEvent.blur(input); // Value should not change for invalid input await waitFor(() => { expect(control.value).toBe(originalValue); }); }); it('handles empty input gracefully', async () => { const control = createTestControl(50, { min: 0, max: 100 }); render(ComboControl, { control, }); const controlBtn = screen.getByTestId('combo-control-value'); await fireEvent.click(controlBtn); const originalValue = control.value; const input = await screen.findByTestId('combo-control-input') as HTMLInputElement; expect(input).toBeInTheDocument(); // Clear input await fireEvent.change(input, { target: { value: '' } }); await fireEvent.blur(input); // Value should not change for empty input await waitFor(() => { expect(control.value).toBe(originalValue); }); }); }); describe('Reactivity', () => { it('external control value change updates control button text', async () => { const control = createTestControl(50); render(ComboControl, { control, }); expect(screen.getByText('50')).toBeInTheDocument(); // Change value externally control.value = 75; // Wait for UI to update await waitFor(() => { expect(screen.getByText('75')).toBeInTheDocument(); }); }); it('button states update when external value changes', async () => { const control = createTestControl(50, { min: 0, max: 100 }); const { container } = render(ComboControl, { control, }); const buttons = container.querySelectorAll('button'); // Both should be enabled expect(buttons[0]).not.toBeDisabled(); expect(buttons[2]).not.toBeDisabled(); // Set to max control.value = 100; // Wait for button state to update await waitFor(() => { expect(buttons[2]).toBeDisabled(); }); }); it('input and slider sync when external value changes', async () => { const control = createTestControl(50, { min: 0, max: 100 }); render(ComboControl, { control, }); const controlBtn = screen.getByTestId('combo-control-value'); await fireEvent.click(controlBtn); // Both should be present const _slider = await screen.findByRole('slider'); const input = await screen.findByTestId('combo-control-input') as HTMLInputElement; // Input should show initial value expect(input.value).toBe('50'); // Change value externally control.value = 75; // Wait for input to update await waitFor(async () => { const updatedInput = await screen.findByTestId( 'combo-control-input', ) as HTMLInputElement; expect(updatedInput.value).toBe('75'); }); // Slider should still be present const updatedSlider = await screen.findByRole('slider'); expect(updatedSlider).toBeInTheDocument(); }); it('decrease button becomes enabled when value increases externally', async () => { const control = createTestControl(0, { min: 0, max: 100 }); const { container } = render(ComboControl, { control, }); const decreaseBtn = container.querySelectorAll('button')[0]; // Initially disabled expect(decreaseBtn).toBeDisabled(); // Increase value externally control.value = 10; // Wait for button to become enabled await waitFor(() => { expect(decreaseBtn).not.toBeDisabled(); }); }); it('increase button becomes enabled when value decreases externally', async () => { const control = createTestControl(100, { min: 0, max: 100 }); const { container } = render(ComboControl, { control, }); const increaseBtn = container.querySelectorAll('button')[2]; // Initially disabled expect(increaseBtn).toBeDisabled(); // Decrease value externally control.value = 90; // Wait for button to become enabled await waitFor(() => { expect(increaseBtn).not.toBeDisabled(); }); }); }); describe('Edge Cases', () => { it('handles equal min and max', () => { const control = createTestControl(5, { min: 5, max: 5 }); render(ComboControl, { control, }); // Should render without errors expect(screen.getByText('5')).toBeInTheDocument(); // Both decrease and increase should be disabled const { container } = render(ComboControl, { control, }); const buttons = container.querySelectorAll('button'); expect(buttons[0]).toBeDisabled(); expect(buttons[2]).toBeDisabled(); }); it('handles very small step values', () => { const control = createTestControl(5, { min: 0, max: 10, step: 0.001 }); render(ComboControl, { control, }); expect(screen.getByText('5')).toBeInTheDocument(); }); it('handles negative range with positive and negative values', async () => { const control = createTestControl(-5, { min: -10, max: 10, step: 1 }); render(ComboControl, { control, }); expect(screen.getByText('-5')).toBeInTheDocument(); const increaseBtn = screen.getAllByRole('button')[2]; await fireEvent.click(increaseBtn); expect(control.value).toBe(-4); }); it('handles zero as min value', async () => { const control = createTestControl(0, { min: 0, max: 10 }); const { container } = render(ComboControl, { control, }); expect(screen.getByText('0')).toBeInTheDocument(); const decreaseBtn = container.querySelectorAll('button')[0]; expect(decreaseBtn).toBeDisabled(); }); it('handles large step value', async () => { const control = createTestControl(5, { min: 0, max: 100, step: 50 }); render(ComboControl, { control, }); const increaseBtn = screen.getAllByRole('button')[2]; await fireEvent.click(increaseBtn); // Should jump by 50 expect(control.value).toBe(55); await fireEvent.click(increaseBtn); expect(control.value).toBe(100); // Clamped to max }); }); describe('Accessibility', () => { it('all buttons have aria-label when provided', () => { const control = createTestControl(50); render(ComboControl, { control, decreaseLabel: 'Decrease value', controlLabel: 'Current value', increaseLabel: 'Increase value', }); expect(screen.getByLabelText('Decrease value')).toBeInTheDocument(); expect(screen.getByLabelText('Current value')).toBeInTheDocument(); expect(screen.getByLabelText('Increase value')).toBeInTheDocument(); }); it('buttons are keyboard accessible', async () => { const control = createTestControl(50); render(ComboControl, { control, }); const buttons = screen.getAllByRole('button'); expect(buttons).toHaveLength(3); // All buttons should be focusable buttons.forEach(btn => { expect(btn).not.toHaveAttribute('disabled'); }); }); it('disabled buttons are properly marked', () => { const control = createTestControl(0, { min: 0, max: 100 }); const { container } = render(ComboControl, { control, }); const decreaseBtn = container.querySelectorAll('button')[0]; expect(decreaseBtn).toBeDisabled(); }); }); describe('Integration Scenarios', () => { it('typical font size control workflow', async () => { const control = createTestControl(16, { min: 12, max: 72, step: 1 }); render(ComboControl, { control, controlLabel: 'Font size', decreaseLabel: 'Decrease font size', increaseLabel: 'Increase font size', }); // Initial state expect(screen.getByText('16')).toBeInTheDocument(); // Increase via button const increaseBtn = screen.getByTestId('increase-button'); await fireEvent.click(increaseBtn); expect(control.value).toBe(17); // Decrease via button const decreaseBtn = screen.getByTestId('decrease-button'); await fireEvent.click(decreaseBtn); expect(control.value).toBe(16); // Open popover and use input const controlBtn = screen.getByTestId('combo-control-value'); await fireEvent.click(controlBtn); const input = await screen.findByTestId('combo-control-input') as HTMLInputElement; await fireEvent.change(input, { target: { value: '24' } }); await fireEvent.blur(input); expect(control.value).toBe(24); }); it('letter spacing control with decimal precision', async () => { const control = createTestControl(0, { min: -0.1, max: 0.5, step: 0.01 }); const { container: _container } = render(ComboControl, { control, }); expect(screen.getByText('0')).toBeInTheDocument(); // Increase to positive value const increaseBtn = screen.getAllByRole('button')[2]; await fireEvent.click(increaseBtn); await fireEvent.click(increaseBtn); expect(control.value).toBeCloseTo(0.02); }); it('line height control with 0.1 step', async () => { const control = createTestControl(1.5, { min: 0.8, max: 2.0, step: 0.1 }); render(ComboControl, { control, }); expect(screen.getByText('1.5')).toBeInTheDocument(); // Decrease to 1.3 const decreaseBtn = screen.getAllByRole('button')[0]; await fireEvent.click(decreaseBtn); await fireEvent.click(decreaseBtn); expect(control.value).toBeCloseTo(1.3); }); }); });