Загрузка данных
import { act, fireEvent, render, screen } from '@testing-library/react';
import { BehaviorSubject } from 'rxjs';
import { ChartHeader } from '@src/components/Header/ChartHeader';
import { Timeframes } from '@src/types/timeframes';
import type { ChartHeaderProps } from '@src/components/Header/types';
import type { UndoRedoState } from '@src/core/UndoRedo';
import type { ChartSeriesType } from '@src/types';
import type { ReactNode } from 'react';
jest.mock('exchange-elements/v2', () => ({
Divider: () => <hr />,
}));
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/IndicatorsSelect', () => ({
IndicatorsSelect: () => <div>Indicators</div>,
}));
jest.mock('@src/components/Tooltip', () => ({
Tooltip: ({ children, label }: { children: ReactNode; label: string }) => (
<div data-tooltip={label}>{children}</div>
),
}));
jest.mock('@src/components/Dropdown', () => ({
Dropdown: ({ children, selectedValue }: { children: ReactNode; selectedValue?: ReactNode }) => (
<div>
<div>{selectedValue}</div>
{children}
</div>
),
}));
jest.mock('@src/components/Icon', () => ({
BurgerMenuIcon: () => <span>Drawing toolbar</span>,
FullscreenIcon: () => <span>Fullscreen</span>,
PlusCircleIcon: () => <span>Compare</span>,
RedoIcon: () => <span>Redo</span>,
SearchIcon: () => <span>Search</span>,
UndoIcon: () => <span>Undo</span>,
}));
jest.mock('@src/components/SeriesTypeIcon', () => ({
SeriesTypeIcon: ({ type }: { type: string }) => <span>{type}</span>,
}));
jest.mock('@src/components/Menu', () => ({
TimeframesMenu: ({ onClick }: { onClick: (value: string) => void }) => (
<button
type="button"
onClick={() => onClick('1d')}
>
Set timeframe
</button>
),
SeriesMenu: ({ onClick }: { onClick: (value: string) => void }) => (
<button
type="button"
onClick={() => onClick('Line')}
>
Set series
</button>
),
}));
jest.mock('@src/translations', () => ({
t: (key: string) => key,
}));
function createTestContext(overrides: Partial<ChartHeaderProps> = {}) {
const selectedTimeframeObs = new BehaviorSubject(Timeframes['1d']);
const selectedSeriesObs = new BehaviorSubject<ChartSeriesType>('Candlestick');
const undoRedoState = new BehaviorSubject<UndoRedoState>({
canUndo: false,
canRedo: false,
});
const undo = jest.fn();
const redo = jest.fn();
const unsubscribe = jest.fn();
const fullscreen = {
isFullscreen: false,
onChange: jest.fn<() => void, [() => void]>(() => unsubscribe),
toggle: jest.fn(),
};
const props: ChartHeaderProps = {
timeframes: [Timeframes['1d'], Timeframes['1h']],
selectedTimeframeObs,
setTimeframe: jest.fn(),
seriesTypes: ['Candlestick', 'Line'],
setSelectedSeries: jest.fn(),
selectedSeriesObs,
showSettingsModal: jest.fn(),
addIndicatorToChart: jest.fn(),
showCompareButton: true,
showSymbolSearchButton: true,
undoRedo: {
getState: () => undoRedoState,
undo,
redo,
} as unknown as ChartHeaderProps['undoRedo'],
showMenuButton: true,
showFullscreenButton: true,
fullscreen: fullscreen as unknown as ChartHeaderProps['fullscreen'],
openCompareModal: jest.fn(),
openSymbolSearchModal: jest.fn(),
isMXT: false,
viewControl: <button type="button">View control</button>,
snapshotControl: <button type="button">Snapshot control</button>,
isToolbarOpen: true,
onToggleToolbar: jest.fn(),
...overrides,
};
return {
props,
selectedTimeframeObs,
selectedSeriesObs,
undoRedoState,
undo,
redo,
fullscreen,
unsubscribe,
};
}
describe('ChartHeader', () => {
it('должен отображать переданные общие контролы и вызывать действия графика', () => {
const { props } = createTestContext();
render(<ChartHeader {...props} />);
expect(screen.queryByText('View control')).not.toBeNull();
expect(screen.queryByText('Snapshot control')).not.toBeNull();
expect(screen.queryByText('Indicators')).not.toBeNull();
fireEvent.click(screen.getByText('Drawing toolbar'));
fireEvent.click(screen.getByText('Search'));
fireEvent.click(screen.getByText('Compare'));
fireEvent.click(screen.getByText('Settings'));
expect(props.onToggleToolbar).toHaveBeenCalledTimes(1);
expect(props.openSymbolSearchModal).toHaveBeenCalledTimes(1);
expect(props.openCompareModal).toHaveBeenCalledTimes(1);
expect(props.showSettingsModal).toHaveBeenCalledTimes(1);
});
it('должен передавать выбранные таймфрейм и тип графика в обработчики', () => {
const { props } = createTestContext();
render(<ChartHeader {...props} />);
fireEvent.click(screen.getByText('Set timeframe'));
fireEvent.click(screen.getByText('Set series'));
expect(props.setTimeframe).toHaveBeenCalledWith(Timeframes['1d']);
expect(props.setSelectedSeries).toHaveBeenCalledWith('Line');
});
it('должен обновлять выбранные значения из потоков', () => {
const { props, selectedTimeframeObs, selectedSeriesObs } = createTestContext();
render(<ChartHeader {...props} />);
act(() => {
selectedTimeframeObs.next(Timeframes['1h']);
selectedSeriesObs.next('Line');
});
expect(screen.queryByText(Timeframes['1h'])).not.toBeNull();
expect(screen.queryByText('Line')).not.toBeNull();
expect(screen.queryByText('Candlestick')).toBeNull();
});
it('должен обновлять состояние кнопки рисования из props', () => {
const { props } = createTestContext();
const { rerender } = render(<ChartHeader {...props} />);
const button = screen.getByRole('button', { name: 'Drawing toolbar' });
expect(button.getAttribute('data-pressed')).toBe('true');
expect(document.querySelector('[data-tooltip="Hide drawing toolbar"]')).not.toBeNull();
rerender(
<ChartHeader
{...props}
isToolbarOpen={false}
/>,
);
expect(button.getAttribute('data-pressed')).toBe('false');
expect(document.querySelector('[data-tooltip="Show drawing toolbar"]')).not.toBeNull();
});
it('должен обновлять доступность undo и redo', () => {
const { props, undoRedoState, undo, redo } = createTestContext();
render(<ChartHeader {...props} />);
const undoButton = screen.getByRole('button', { name: 'Undo' }) as HTMLButtonElement;
const redoButton = screen.getByRole('button', { name: 'Redo' }) as HTMLButtonElement;
expect(undoButton.disabled).toBe(true);
expect(redoButton.disabled).toBe(true);
fireEvent.click(undoButton);
fireEvent.click(redoButton);
expect(undo).not.toHaveBeenCalled();
expect(redo).not.toHaveBeenCalled();
act(() => undoRedoState.next({
canUndo: true,
canRedo: false,
}));
fireEvent.click(undoButton);
expect(undoButton.disabled).toBe(false);
expect(redoButton.disabled).toBe(true);
expect(undo).toHaveBeenCalledTimes(1);
act(() => undoRedoState.next({
canUndo: false,
canRedo: true,
}));
fireEvent.click(redoButton);
expect(undoButton.disabled).toBe(true);
expect(redoButton.disabled).toBe(false);
expect(redo).toHaveBeenCalledTimes(1);
});
it('должен скрывать отключённые необязательные действия', () => {
const { props } = createTestContext({
showMenuButton: false,
showCompareButton: false,
showSymbolSearchButton: false,
showSettingsModal: undefined,
showFullscreenButton: false,
undoRedo: undefined,
snapshotControl: null,
});
render(<ChartHeader {...props} />);
[
'Drawing toolbar',
'Search',
'Compare',
'Settings',
'Fullscreen',
'Undo',
'Redo',
'Snapshot control',
].forEach((label) => {
expect(screen.queryByText(label)).toBeNull();
});
expect(screen.queryByText('Set timeframe')).not.toBeNull();
expect(screen.queryByText('Set series')).not.toBeNull();
expect(screen.queryByText('View control')).not.toBeNull();
});
it('должен отображать снимок при отключённом fullscreen', () => {
const { props } = createTestContext({
showFullscreenButton: false,
});
render(<ChartHeader {...props} />);
expect(screen.queryByText('Snapshot control')).not.toBeNull();
expect(screen.queryByText('Fullscreen')).toBeNull();
});
it('должен учитывать начальное состояние fullscreen', () => {
const { props, fullscreen } = createTestContext();
fullscreen.isFullscreen = true;
render(<ChartHeader {...props} />);
expect(
screen.getByRole('button', { name: 'Fullscreen' }).getAttribute('data-pressed'),
).toBe('true');
expect(document.querySelector('[data-tooltip="Exit fullscreen"]')).not.toBeNull();
});
it('должен переключать fullscreen и обновлять его состояние', () => {
const { props, fullscreen } = createTestContext();
render(<ChartHeader {...props} />);
fireEvent.click(screen.getByText('Fullscreen'));
expect(fullscreen.toggle).toHaveBeenCalledTimes(1);
act(() => {
fullscreen.isFullscreen = true;
fullscreen.onChange.mock.calls[0][0]();
});
expect(
screen.getByRole('button', { name: 'Fullscreen' }).getAttribute('data-pressed'),
).toBe('true');
expect(document.querySelector('[data-tooltip="Exit fullscreen"]')).not.toBeNull();
});
it('должен менять подписку при замене fullscreen и очищать её при размонтировании', () => {
const { props, fullscreen, unsubscribe } = createTestContext();
const {
fullscreen: nextFullscreen,
unsubscribe: nextUnsubscribe,
} = createTestContext();
const { rerender, unmount } = render(<ChartHeader {...props} />);
expect(fullscreen.onChange).toHaveBeenCalledTimes(1);
rerender(
<ChartHeader
{...props}
fullscreen={nextFullscreen as unknown as ChartHeaderProps['fullscreen']}
/>,
);
expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(nextFullscreen.onChange).toHaveBeenCalledTimes(1);
expect(nextUnsubscribe).not.toHaveBeenCalled();
unmount();
expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(nextUnsubscribe).toHaveBeenCalledTimes(1);
});
});
import { act, fireEvent, render, screen } from '@testing-library/react';
import { BehaviorSubject } from 'rxjs';
import { Header } from '@src/components/Header';
import { ChartViewType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import type { HeaderProps } from '@src/components/Header/types';
import type { UndoRedoState } from '@src/core/UndoRedo';
import type { ChartSeriesType } from '@src/types';
import type { ReactNode } from 'react';
jest.mock('exchange-elements/v2', () => ({
Divider: () => <hr />,
}));
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/IndicatorsSelect', () => ({
IndicatorsSelect: () => <div>Indicators</div>,
}));
jest.mock('@src/components/Tooltip', () => ({
Tooltip: ({ children, label }: { children: ReactNode; label: string }) => (
<div data-tooltip={label}>{children}</div>
),
}));
jest.mock('@src/components/Dropdown', () => ({
Dropdown: ({ children, selectedValue }: { children: ReactNode; selectedValue?: ReactNode }) => (
<div>
<div>{selectedValue}</div>
{children}
</div>
),
}));
jest.mock('@src/components/Icon', () => ({
BurgerMenuIcon: () => <span>Drawing toolbar</span>,
CameraIcon: () => <span>Camera</span>,
FullscreenIcon: () => <span>Fullscreen</span>,
PlusCircleIcon: () => <span>Compare</span>,
RedoIcon: () => <span>Redo</span>,
SearchIcon: () => <span>Search</span>,
UndoIcon: () => <span>Undo</span>,
}));
jest.mock('@src/components/SeriesTypeIcon', () => ({
SeriesTypeIcon: ({ type }: { type: string }) => <span>{type}</span>,
}));
jest.mock('@src/components/ChartViewTypeIcon', () => ({
ChartViewTypeIcon: ({ type }: { type: string }) => <span>{type}</span>,
}));
jest.mock('@src/components/Menu', () => ({
TimeframesMenu: ({ onClick }: { onClick: (value: string) => void }) => (
<button
type="button"
onClick={() => onClick('1d')}
>
Set timeframe
</button>
),
SeriesMenu: ({ onClick }: { onClick: (value: string) => void }) => (
<button
type="button"
onClick={() => onClick('Line')}
>
Set series
</button>
),
SnapshotMenu: ({ onCopyImage }: { onCopyImage: () => Promise<void> }) => (
<button
type="button"
onClick={() => onCopyImage()}
>
Copy image
</button>
),
}));
jest.mock('@src/components/Menu/ChartViewMenu', () => ({
ChartViewMenu: ({ onClick }: { onClick: (view: string) => void }) => (
<>
<button
type="button"
onClick={() => onClick('chart')}
>
Chart view
</button>
<button
type="button"
onClick={() => onClick('seasonality')}
>
Seasonality view
</button>
</>
),
}));
jest.mock('@src/translations', () => ({
t: (key: string) => key,
}));
function createTestContext(overrides: Partial<HeaderProps> = {}) {
const chartViewObs = new BehaviorSubject(ChartViewType.Chart);
const canCopyImageObs = new BehaviorSubject(true);
const selectedTimeframeObs = new BehaviorSubject(Timeframes['1d']);
const selectedSeriesObs = new BehaviorSubject<ChartSeriesType>('Candlestick');
const undoRedoState = new BehaviorSubject<UndoRedoState>({
canUndo: false,
canRedo: false,
});
const undo = jest.fn();
const redo = jest.fn();
const unsubscribe = jest.fn();
const fullscreen = {
isFullscreen: false,
onChange: jest.fn<() => void, [() => void]>(() => unsubscribe),
toggle: jest.fn(),
};
const props: HeaderProps = {
chartViewObs,
canCopyImageObs,
timeframes: [Timeframes['1d']],
selectedTimeframeObs,
setTimeframe: jest.fn(),
seriesTypes: ['Candlestick', 'Line'],
setSelectedSeries: jest.fn(),
selectedSeriesObs,
showSettingsModal: jest.fn(),
addIndicatorToChart: jest.fn(),
toggleToolbarVisible: jest.fn(() => false),
showCompareButton: true,
showSymbolSearchButton: true,
undoRedo: {
getState: () => undoRedoState,
undo,
redo,
} as unknown as HeaderProps['undoRedo'],
showMenuButton: true,
showFullscreenButton: true,
fullscreen: fullscreen as unknown as HeaderProps['fullscreen'],
openCompareModal: jest.fn(),
openSymbolSearchModal: jest.fn(),
copyImage: jest.fn().mockResolvedValue(undefined),
setChartView: jest.fn(),
isMXT: false,
...overrides,
};
return {
props,
chartViewObs,
canCopyImageObs,
selectedTimeframeObs,
selectedSeriesObs,
undoRedoState,
undo,
redo,
fullscreen,
unsubscribe,
};
}
describe('Header', () => {
it.each([ChartViewType.Chart, ChartViewType.Seasonality])(
'должен копировать снимок в режиме %s',
(view) => {
const { props, chartViewObs } = createTestContext();
chartViewObs.next(view);
render(<Header {...props} />);
fireEvent.click(screen.getByText('Copy image'));
expect(document.querySelector('[data-tooltip="Snapshot"]')).not.toBeNull();
expect(props.copyImage).toHaveBeenCalledTimes(1);
},
);
it('должен оставлять в сезонности только переключение вида и снимок', () => {
const { props, chartViewObs } = createTestContext();
render(<Header {...props} />);
expect(screen.queryByText('Set timeframe')).not.toBeNull();
act(() => chartViewObs.next(ChartViewType.Seasonality));
[
'Drawing toolbar',
'Search',
'Compare',
'Settings',
'Indicators',
'Set timeframe',
'Set series',
'Undo',
'Redo',
'Fullscreen',
].forEach((label) => {
expect(screen.queryByText(label)).toBeNull();
});
expect(screen.queryByText('Chart view')).not.toBeNull();
expect(screen.queryByText('Copy image')).not.toBeNull();
act(() => chartViewObs.next(ChartViewType.Chart));
expect(screen.queryByText('Set timeframe')).not.toBeNull();
});
it('должен обновлять доступность снимка из потока', () => {
const { props, chartViewObs, canCopyImageObs } = createTestContext();
chartViewObs.next(ChartViewType.Seasonality);
canCopyImageObs.next(false);
render(<Header {...props} />);
expect(screen.queryByText('Copy image')).toBeNull();
expect(screen.queryByText('Chart view')).not.toBeNull();
act(() => canCopyImageObs.next(true));
expect(screen.queryByText('Copy image')).not.toBeNull();
act(() => canCopyImageObs.next(false));
expect(screen.queryByText('Copy image')).toBeNull();
});
it('не должен показывать снимок без callback', () => {
const { props } = createTestContext({
copyImage: undefined,
});
render(<Header {...props} />);
expect(screen.queryByText('Copy image')).toBeNull();
});
it('должен передавать выбранный режим в setChartView', () => {
const { props } = createTestContext();
render(<Header {...props} />);
fireEvent.click(screen.getByText('Seasonality view'));
fireEvent.click(screen.getByText('Chart view'));
expect(props.setChartView).toHaveBeenNthCalledWith(1, ChartViewType.Seasonality);
expect(props.setChartView).toHaveBeenNthCalledWith(2, ChartViewType.Chart);
});
it('не должен показывать переключение без callback', () => {
const { props } = createTestContext({
setChartView: undefined,
});
render(<Header {...props} />);
expect(screen.queryByText('Chart view')).toBeNull();
expect(screen.queryByText('Seasonality view')).toBeNull();
});
it('должен сохранять состояние панели рисования после переключения режимов', () => {
const { props, chartViewObs } = createTestContext();
render(<Header {...props} />);
expect(
screen.getByRole('button', { name: 'Drawing toolbar' }).getAttribute('data-pressed'),
).toBe('true');
fireEvent.click(screen.getByText('Drawing toolbar'));
expect(props.toggleToolbarVisible).toHaveBeenCalledTimes(1);
expect(
screen.getByRole('button', { name: 'Drawing toolbar' }).getAttribute('data-pressed'),
).toBe('false');
act(() => chartViewObs.next(ChartViewType.Seasonality));
act(() => chartViewObs.next(ChartViewType.Chart));
expect(
screen.getByRole('button', { name: 'Drawing toolbar' }).getAttribute('data-pressed'),
).toBe('false');
expect(props.toggleToolbarVisible).toHaveBeenCalledTimes(1);
});
it('должен вызывать действия основного графика', () => {
const { props } = createTestContext();
render(<Header {...props} />);
fireEvent.click(screen.getByText('Search'));
fireEvent.click(screen.getByText('Compare'));
fireEvent.click(screen.getByText('Settings'));
fireEvent.click(screen.getByText('Set timeframe'));
fireEvent.click(screen.getByText('Set series'));
expect(props.openSymbolSearchModal).toHaveBeenCalledTimes(1);
expect(props.openCompareModal).toHaveBeenCalledTimes(1);
expect(props.showSettingsModal).toHaveBeenCalledTimes(1);
expect(props.setTimeframe).toHaveBeenCalledWith(Timeframes['1d']);
expect(props.setSelectedSeries).toHaveBeenCalledWith('Line');
});
it('должен обновлять выбранные таймфрейм и тип графика', () => {
const { props, selectedTimeframeObs, selectedSeriesObs } = createTestContext();
render(<Header {...props} />);
act(() => {
selectedTimeframeObs.next(Timeframes['1h']);
selectedSeriesObs.next('Line');
});
expect(screen.queryByText(Timeframes['1h'])).not.toBeNull();
expect(screen.queryByText('Line')).not.toBeNull();
expect(screen.queryByText('Candlestick')).toBeNull();
});
it('должен обновлять доступность undo и redo из потока', () => {
const { props, undoRedoState, undo, redo } = createTestContext();
render(<Header {...props} />);
const undoButton = screen.getByRole('button', { name: 'Undo' }) as HTMLButtonElement;
const redoButton = screen.getByRole('button', { name: 'Redo' }) as HTMLButtonElement;
expect(undoButton.disabled).toBe(true);
expect(redoButton.disabled).toBe(true);
act(() => undoRedoState.next({
canUndo: true,
canRedo: false,
}));
fireEvent.click(undoButton);
fireEvent.click(redoButton);
expect(undo).toHaveBeenCalledTimes(1);
expect(redo).not.toHaveBeenCalled();
act(() => undoRedoState.next({
canUndo: false,
canRedo: true,
}));
fireEvent.click(redoButton);
expect(undoButton.disabled).toBe(true);
expect(redo).toHaveBeenCalledTimes(1);
});
it('должен скрывать отключённые необязательные действия', () => {
const { props } = createTestContext({
showMenuButton: false,
showCompareButton: false,
showSymbolSearchButton: false,
showSettingsModal: undefined,
showFullscreenButton: false,
undoRedo: undefined,
copyImage: undefined,
});
render(<Header {...props} />);
[
'Drawing toolbar',
'Search',
'Compare',
'Settings',
'Fullscreen',
'Undo',
'Redo',
'Copy image',
].forEach((label) => {
expect(screen.queryByText(label)).toBeNull();
});
expect(screen.queryByText('Set timeframe')).not.toBeNull();
});
it('должен обновлять fullscreen и отписываться при смене режима', () => {
const { props, chartViewObs, fullscreen, unsubscribe } = createTestContext();
render(<Header {...props} />);
fireEvent.click(screen.getByText('Fullscreen'));
expect(fullscreen.toggle).toHaveBeenCalledTimes(1);
act(() => {
fullscreen.isFullscreen = true;
fullscreen.onChange.mock.calls[0][0]();
});
expect(
screen.getByRole('button', { name: 'Fullscreen' }).getAttribute('data-pressed'),
).toBe('true');
expect(document.querySelector('[data-tooltip="Exit fullscreen"]')).not.toBeNull();
act(() => chartViewObs.next(ChartViewType.Seasonality));
expect(unsubscribe).toHaveBeenCalledTimes(1);
act(() => chartViewObs.next(ChartViewType.Chart));
expect(
screen.getByRole('button', { name: 'Fullscreen' }).getAttribute('data-pressed'),
).toBe('true');
expect(fullscreen.onChange).toHaveBeenCalledTimes(2);
});
it('должен отписываться от fullscreen при размонтировании', () => {
const { props, unsubscribe } = createTestContext();
const { unmount } = render(<Header {...props} />);
unmount();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});