Загрузка данных
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { PriceScaleMode } from 'lightweight-charts';
import { BehaviorSubject, Subject } from 'rxjs';
import { SeasonalityControls } from '@src/components/SeasonalityControls';
import { createInitialSeasonalityState } from '@src/constants';
import { SeasonalityViewType } from '@src/types';
import type { SeasonalityState } from '@src/types';
import type { ReactNode } from 'react';
interface RangeFieldTestProps {
value: [number, number];
min: number;
max: number;
marks: number[];
marksPosition?: 'top' | 'bottom';
disabled: boolean;
onRangeChange: (value: [number, number]) => void;
}
jest.mock('@src/components/Button', () => ({
Button: ({
onClick,
label,
disabled,
pressed,
}: {
onClick?: () => void;
label?: ReactNode;
disabled?: boolean;
pressed?: boolean;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
data-pressed={pressed}
>
{label}
</button>
),
}));
jest.mock('@src/components/Icon', () => ({
AverageLineIcon: () => <span data-testid="average-line-icon" />,
CameraIcon: () => <span data-testid="camera-icon" />,
LineIcon: () => <span data-testid="line-icon" />,
TableIcon: () => <span data-testid="table-icon" />,
}));
jest.mock('@src/components/SeasonalityControls/index.module.scss', () => ({
__esModule: true,
default: {
controls: 'controls',
controls_header: 'controls_header',
controls_views: 'controls_views',
controls_actions: 'controls_actions',
controls_button: 'controls_button',
controls_scale: 'controls_scale',
controls_range: 'controls_range',
controls_dropdown: 'controls_dropdown',
},
}));
jest.mock('@src/components/Tooltip', () => ({
Tooltip: ({ children, label }: { children: ReactNode; label: string }) => (
<div data-testid={`tooltip-${label}`}>{children}</div>
),
}));
jest.mock('@src/components/Menu', () => ({
SnapshotMenu: ({ onCopyImage }: { onCopyImage: () => Promise<void> }) => (
<button
type="button"
onClick={onCopyImage}
>
Copy image
</button>
),
}));
jest.mock('@src/components/Dropdown', () => ({
Dropdown: ({
children,
selectedValue,
className,
renderTrigger,
}: {
children: ReactNode;
selectedValue: ReactNode;
className?: string;
renderTrigger?: (props: { isOpen: boolean; onToggle: () => void; onClose: () => void }) => ReactNode;
}) => {
const { useState } = jest.requireActual<typeof import('react')>('react');
const [isOpen, setIsOpen] = useState(false);
return (
<div className={className}>
<div data-testid={typeof selectedValue === 'string' ? 'selected-scale' : 'selected-snapshot'}>
{renderTrigger
? renderTrigger({
isOpen,
onToggle: () => setIsOpen((value) => !value),
onClose: () => setIsOpen(false),
})
: selectedValue}
</div>
{children}
</div>
);
},
}));
jest.mock('@src/components/Menu/SeasonalityScaleMenu', () => ({
SeasonalityScaleMenu: ({ onClick }: { onClick: (scale: number) => void }) => (
<>
<button
type="button"
onClick={() => onClick(0)}
>
Set price
</button>
<button
type="button"
onClick={() => onClick(2)}
>
Set percent
</button>
</>
),
}));
jest.mock('@src/components/FormFields', () => ({
RangeField: ({ value, min, max, marks, marksPosition, disabled, onRangeChange }: RangeFieldTestProps) => (
<div
data-testid="year-range"
data-marks={marks.join(',')}
data-marks-position={marksPosition}
>
<input
aria-label="From year"
type="number"
value={value[0]}
min={min}
max={max}
disabled={disabled}
onChange={(event) => onRangeChange([Number(event.target.value), value[1]])}
/>
<input
aria-label="To year"
type="number"
value={value[1]}
min={min}
max={max}
disabled={disabled}
onChange={(event) => onRangeChange([value[0], Number(event.target.value)])}
/>
</div>
),
}));
jest.mock('@src/translations', () => ({
t: (key: string) => key,
}));
function createTestContext(overrides: Partial<SeasonalityState> = {}) {
const stateObs = new BehaviorSubject<SeasonalityState>({
...createInitialSeasonalityState(),
status: 'ready',
minYear: 2020,
maxYear: 2025,
fromYear: 2021,
toYear: 2025,
...overrides,
});
const setDisplayMode = jest.fn();
const setScale = jest.fn();
const setRange = jest.fn();
const setAverageVisible = jest.fn();
return { stateObs, setDisplayMode, setScale, setRange, setAverageVisible };
}
describe('SeasonalityControls', () => {
it('не должен отображаться до получения состояния и при idle', () => {
const stateObs = new Subject<SeasonalityState>();
const { container } = render(
<SeasonalityControls
stateObs={stateObs}
setDisplayMode={jest.fn()}
setScale={jest.fn()}
setRange={jest.fn()}
setAverageVisible={jest.fn()}
/>,
);
expect(container.children).toHaveLength(0);
act(() => stateObs.next(createInitialSeasonalityState()));
expect(container.children).toHaveLength(0);
});
it('должен отображать выбранный диапазон и границы доступных лет', () => {
const props = createTestContext();
render(<SeasonalityControls {...props} />);
const from = screen.getByLabelText('From year') as HTMLInputElement;
const to = screen.getByLabelText('To year') as HTMLInputElement;
expect(from.value).toBe('2021');
expect(to.value).toBe('2025');
expect(from.min).toBe('2020');
expect(from.max).toBe('2025');
expect(screen.getByTestId('selected-scale').textContent).toBe('Price');
expect(screen.getByTestId('year-range').getAttribute('data-marks-position')).toBe('top');
});
it('должен переключать вид и обновлять выбранную кнопку из состояния', () => {
const props = createTestContext();
render(<SeasonalityControls {...props} />);
const chartButton = within(screen.getByTestId('tooltip-Seasonality chart')).getByRole('button');
const tableButton = screen.getByTestId('table-icon').closest('button') as HTMLButtonElement;
expect(chartButton.getAttribute('data-pressed')).toBe('true');
expect(tableButton.getAttribute('data-pressed')).toBe('false');
fireEvent.click(tableButton);
expect(props.setDisplayMode).toHaveBeenNthCalledWith(1, SeasonalityViewType.Table);
expect(chartButton.getAttribute('data-pressed')).toBe('true');
act(() =>
props.stateObs.next({
...props.stateObs.value,
displayMode: SeasonalityViewType.Table,
}),
);
expect(chartButton.getAttribute('data-pressed')).toBe('false');
expect(tableButton.getAttribute('data-pressed')).toBe('true');
fireEvent.click(chartButton);
expect(props.setDisplayMode).toHaveBeenNthCalledWith(2, SeasonalityViewType.Chart);
act(() =>
props.stateObs.next({
...props.stateObs.value,
displayMode: SeasonalityViewType.Chart,
}),
);
expect(chartButton.getAttribute('data-pressed')).toBe('true');
expect(tableButton.getAttribute('data-pressed')).toBe('false');
});
it.each(['loading', 'error', 'empty'] as const)('должен позволять переключить вид при статусе %s', (status) => {
const props = createTestContext({ status });
render(<SeasonalityControls {...props} />);
fireEvent.click(screen.getByTestId('table-icon'));
expect(props.setDisplayMode).toHaveBeenCalledWith(SeasonalityViewType.Table);
});
it('должен передавать изменение обеих границ диапазона', () => {
const props = createTestContext();
render(<SeasonalityControls {...props} />);
fireEvent.change(screen.getByLabelText('From year'), {
target: { value: '2022' },
});
fireEvent.change(screen.getByLabelText('To year'), {
target: { value: '2024' },
});
expect(props.setRange).toHaveBeenNthCalledWith(1, {
fromYear: 2022,
toYear: 2025,
});
expect(props.setRange).toHaveBeenNthCalledWith(2, {
fromYear: 2021,
toYear: 2024,
});
});
it('должен переключать шкалу и обновлять подпись из состояния', () => {
const props = createTestContext();
render(<SeasonalityControls {...props} />);
fireEvent.click(screen.getByText('Set percent'));
fireEvent.click(screen.getByText('Set price'));
expect(props.setScale).toHaveBeenNthCalledWith(1, PriceScaleMode.Percentage);
expect(props.setScale).toHaveBeenNthCalledWith(2, PriceScaleMode.Normal);
act(() =>
props.stateObs.next({
...props.stateObs.value,
scale: PriceScaleMode.Percentage,
}),
);
expect(screen.getByTestId('selected-scale').textContent).toBe('Percent');
});
it.each(['loading', 'error'] as const)('должен блокировать диапазон при статусе %s', (status) => {
const props = createTestContext({ status });
render(<SeasonalityControls {...props} />);
expect((screen.getByLabelText('From year') as HTMLInputElement).disabled).toBe(true);
expect((screen.getByLabelText('To year') as HTMLInputElement).disabled).toBe(true);
act(() =>
props.stateObs.next({
...props.stateObs.value,
status: 'ready',
}),
);
expect((screen.getByLabelText('From year') as HTMLInputElement).disabled).toBe(false);
});
it('должен позволять изменить пустой диапазон и скрываться после очистки', () => {
const props = createTestContext({ status: 'empty' });
const { container } = render(<SeasonalityControls {...props} />);
fireEvent.change(screen.getByLabelText('From year'), {
target: { value: '2020' },
});
expect(props.setRange).toHaveBeenCalledWith({
fromYear: 2020,
toYear: 2025,
});
act(() =>
props.stateObs.next({
...props.stateObs.value,
status: 'idle',
}),
);
expect(container.children).toHaveLength(0);
});
it('должен корректно отображать единственный доступный год', () => {
const props = createTestContext({
minYear: 2025,
maxYear: 2025,
fromYear: 2025,
toYear: 2025,
});
render(<SeasonalityControls {...props} />);
expect(screen.getByTestId('year-range').getAttribute('data-marks')).toBe('2025');
expect((screen.getByLabelText('From year') as HTMLInputElement).value).toBe('2025');
expect((screen.getByLabelText('To year') as HTMLInputElement).value).toBe('2025');
});
});
describe('SeasonalityControls: среднее и подсказки', () => {
it('должен переключать среднее и обновлять pressed только из состояния', () => {
const props = createTestContext();
render(<SeasonalityControls {...props} />);
const button = screen.getByRole('button', { name: 'Average' });
expect(button.getAttribute('data-pressed')).toBe('false');
expect(within(button).queryByTestId('average-line-icon')).not.toBeNull();
fireEvent.click(button);
expect(props.setAverageVisible).toHaveBeenLastCalledWith(true);
expect(button.getAttribute('data-pressed')).toBe('false');
act(() => props.stateObs.next({ ...props.stateObs.value, showAverage: true }));
expect(button.getAttribute('data-pressed')).toBe('true');
fireEvent.click(button);
expect(props.setAverageVisible).toHaveBeenLastCalledWith(false);
});
it.each(['loading', 'error', 'empty'] as const)(
'должен блокировать переключение среднего при статусе %s',
(status) => {
const props = createTestContext({ status });
render(<SeasonalityControls {...props} />);
const button = screen.getByRole('button', { name: 'Average' }) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.click(button);
expect(props.setAverageVisible).not.toHaveBeenCalled();
},
);
it('должен сохранять pressed среднего при переключении на таблицу', () => {
const props = createTestContext({ showAverage: true });
render(<SeasonalityControls {...props} />);
act(() =>
props.stateObs.next({
...props.stateObs.value,
displayMode: SeasonalityViewType.Table,
}),
);
expect(screen.getByRole('button', { name: 'Average' }).getAttribute('data-pressed')).toBe('true');
expect(props.setAverageVisible).not.toHaveBeenCalled();
});
it('должен передавать подписи подсказок для кнопок управления', () => {
const props = createTestContext();
render(
<SeasonalityControls
{...props}
copyImage={jest.fn().mockResolvedValue(undefined)}
/>,
);
['Seasonality chart', 'Seasonality table', 'Average for selected years', 'Scale', 'Snapshot'].forEach((label) => {
expect(within(screen.getByTestId(`tooltip-${label}`)).getByRole('button')).not.toBeNull();
});
});
it('должен передавать копирование снимка из правой панели', () => {
const props = createTestContext();
const copyImage = jest.fn().mockResolvedValue(undefined);
const { container } = render(
<SeasonalityControls
{...props}
copyImage={copyImage}
/>,
);
const camera = screen.getByTestId('camera-icon');
expect(container.querySelector('.controls_actions')?.contains(camera)).toBe(true);
expect(container.querySelector('.controls_views')?.contains(camera)).toBe(false);
fireEvent.click(screen.getByRole('button', { name: 'Copy image' }));
expect(copyImage).toHaveBeenCalledTimes(1);
});
it.each([
['ready', SeasonalityViewType.Table],
['loading', SeasonalityViewType.Chart],
['error', SeasonalityViewType.Chart],
['empty', SeasonalityViewType.Chart],
] as const)('не должен отображать снимок при статусе %s и виде %s', (status, displayMode) => {
const props = createTestContext({ status, displayMode });
render(
<SeasonalityControls
{...props}
copyImage={jest.fn().mockResolvedValue(undefined)}
/>,
);
expect(screen.queryByTestId('camera-icon')).toBeNull();
expect(screen.queryByRole('button', { name: 'Copy image' })).toBeNull();
});
it('не должен отображать снимок без обработчика копирования', () => {
render(<SeasonalityControls {...createTestContext()} />);
expect(screen.queryByTestId('camera-icon')).toBeNull();
});
it('должен ограничивать количество отметок и сохранять крайние годы', () => {
render(<SeasonalityControls {...createTestContext({ minYear: 2000, maxYear: 2025 })} />);
const marks = screen.getByTestId('year-range').getAttribute('data-marks')!.split(',').map(Number);
expect(marks).toHaveLength(5);
expect(marks[0]).toBe(2000);
expect(marks[marks.length - 1]).toBe(2025);
expect(new Set(marks).size).toBe(marks.length);
});
});
describe('SeasonalityControls: кнопки открытия меню', () => {
it.each([
['selected-scale', 'Scale'],
['selected-snapshot', 'Snapshot'],
])('должен привязывать подсказку %s к кнопке открытия и сохранять её обработчик', (testId, label) => {
const props = createTestContext();
render(
<SeasonalityControls
{...props}
copyImage={jest.fn().mockResolvedValue(undefined)}
/>,
);
const trigger = screen.getByTestId(testId);
const tooltip = screen.getByTestId(`tooltip-${label}`);
const button = within(tooltip).getByRole('button');
expect(trigger.contains(tooltip)).toBe(true);
expect(button.getAttribute('data-pressed')).toBe('false');
fireEvent.click(button);
expect(button.getAttribute('data-pressed')).toBe('true');
fireEvent.click(button);
expect(button.getAttribute('data-pressed')).toBe('false');
});
});