Files
frontend-svelte/src/shared/ui/ComboControl/ComboControl.svelte.test.ts
Ilia Mashkov 7678ab271d
Some checks failed
Build / build (pull_request) Failing after 49s
Lint / Lint Code (pull_request) Failing after 38s
Test / Svelte Checks (pull_request) Failing after 44s
fix: lint warnings
2026-01-14 15:14:58 +03:00

877 lines
30 KiB
TypeScript

/**
* 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);
});
});
});