Загрузка данных
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((_callback: () => 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 HeaderProps['undoRedo'],
showMenuButton: true,
showFullscreenButton: true,
fullscreen: fullscreen 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);
});
});
import { act, fireEvent, render, screen } 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 type { SeasonalityState } from '@src/types';
import type { ReactNode } from 'react';
interface RangeFieldTestProps {
value: [number, number];
min: number;
max: number;
marks: number[];
disabled: boolean;
onRangeChange: (value: [number, number]) => void;
}
jest.mock('@src/components/Dropdown', () => ({
Dropdown: ({ children, selectedValue }: { children: ReactNode; selectedValue: ReactNode }) => (
<div>
<div data-testid="selected-scale">{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, disabled, onRangeChange }: RangeFieldTestProps) => (
<div
data-testid="year-range"
data-marks={marks.join(',')}
>
<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 setScale = jest.fn();
const setRange = jest.fn();
return { stateObs, setScale, setRange };
}
describe('SeasonalityControls', () => {
it('не должен отображаться до получения состояния и при idle', () => {
const stateObs = new Subject<SeasonalityState>();
const { container } = render(
<SeasonalityControls
stateObs={stateObs}
setScale={jest.fn()}
setRange={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');
});
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');
});
});
import { BehaviorSubject, Subscription } from 'rxjs';
import { Chart } from '@core/Chart';
import { Intervals } from '@src/types/intervals';
interface ChartInternals {
eventManager: {
symbolId: () => BehaviorSubject<string>;
getInterval: () => BehaviorSubject<Intervals | null>;
getTimeframe: jest.Mock;
};
compareManager: {
itemsObs: () => BehaviorSubject<{ symbolId: string }[]>;
};
dataSource: {
setSymbols: jest.Mock;
loadTill: jest.Mock;
loadAllHistory: jest.Mock;
loadMoreHistory: jest.Mock;
getOldestTime: jest.Mock;
getLastCandle: jest.Mock;
};
lwcChart: {
timeScale: () => {
getVisibleRange: jest.Mock;
setVisibleRange: jest.Mock;
getVisibleLogicalRange: jest.Mock;
};
};
mouseEvents: {
subscribe: jest.Mock;
};
paneManager: {
setVisibleLogicalRange: jest.Mock;
};
subscriptions: Subscription;
activeSymbolIds: string[];
isSleeping: boolean;
historyBatchRunning: boolean;
setupDataSourceSubs: () => void;
setupHistoricalDataLoading: () => void;
scheduleHistoryBatch: () => void;
}
interface CreateChartOptions {
symbolId?: string;
interval?: Intervals | null;
compareSymbolIds?: string[];
}
interface ChartTestContext {
chart: ChartInternals;
symbolId$: BehaviorSubject<string>;
interval$: BehaviorSubject<Intervals | null>;
getTimeframe: jest.Mock;
compareItems$: BehaviorSubject<{ symbolId: string }[]>;
setSymbols: jest.Mock;
loadTill: jest.Mock;
loadAllHistory: jest.Mock;
loadMoreHistory: jest.Mock;
getOldestTime: jest.Mock;
getLastCandle: jest.Mock;
getVisibleRange: jest.Mock;
getVisibleLogicalRange: jest.Mock;
setVisibleRange: jest.Mock;
mouseEventsSubscribe: jest.Mock;
setVisibleLogicalRange: jest.Mock;
}
interface Deferred {
promise: Promise<void>;
resolve: () => void;
}
function createDeferred(): Deferred {
let resolve!: () => void;
const promise = new Promise<void>((resolver) => {
resolve = resolver;
});
return { promise, resolve };
}
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
describe('Chart', () => {
let animationFrameCallbacks: FrameRequestCallback[];
let subscriptions: Subscription[];
beforeEach(() => {
animationFrameCallbacks = [];
subscriptions = [];
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
animationFrameCallbacks.push(callback);
return animationFrameCallbacks.length;
});
});
afterEach(() => {
subscriptions.forEach((subscription) => subscription.unsubscribe());
jest.restoreAllMocks();
});
function runNextAnimationFrame(): void {
const callback = animationFrameCallbacks.shift();
callback?.(0);
}
function createChartInternals({
symbolId = '',
interval = null,
compareSymbolIds = ['SBER', 'SBER'],
}: CreateChartOptions = {}): ChartTestContext {
const symbolId$ = new BehaviorSubject(symbolId);
const interval$ = new BehaviorSubject<Intervals | null>(interval);
const compareItems$ = new BehaviorSubject(
compareSymbolIds.map((value) => ({
symbolId: value,
})),
);
const setSymbols = jest.fn();
const loadTill = jest.fn(() => Promise.resolve());
const loadAllHistory = jest.fn(() => Promise.resolve());
const loadMoreHistory = jest.fn(() => Promise.resolve());
const getOldestTime = jest.fn(() => 100);
const getLastCandle = jest.fn(() => ({ time: 200 }));
const getVisibleRange = jest.fn(() => null);
const getVisibleLogicalRange = jest.fn(() => ({ from: 0, to: 1000 }));
const setVisibleRange = jest.fn();
const getTimeframe = jest.fn(() => '1m');
const mouseEventsSubscribe = jest.fn();
const setVisibleLogicalRange = jest.fn();
const chart = Object.assign(Object.create(Chart.prototype), {
eventManager: {
symbolId: () => symbolId$,
getInterval: () => interval$,
getTimeframe,
},
compareManager: {
itemsObs: () => compareItems$,
},
dataSource: {
setSymbols,
loadTill,
loadAllHistory,
loadMoreHistory,
getOldestTime,
getLastCandle,
},
lwcChart: {
timeScale: () => ({
getVisibleRange,
getVisibleLogicalRange,
setVisibleRange,
}),
},
mouseEvents: {
subscribe: mouseEventsSubscribe,
},
paneManager: {
setVisibleLogicalRange,
},
subscriptions: new Subscription(),
activeSymbolIds: [],
isSleeping: false,
historyBatchRunning: false,
}) as ChartInternals;
subscriptions.push(chart.subscriptions);
return {
chart,
symbolId$,
interval$,
getTimeframe,
compareItems$,
setSymbols,
loadTill,
loadAllHistory,
loadMoreHistory,
getOldestTime,
getLastCandle,
getVisibleRange,
getVisibleLogicalRange,
setVisibleRange,
mouseEventsSubscribe,
setVisibleLogicalRange,
};
}
it('должен исключать пустой основной symbolId и удалять дубликаты', () => {
const { chart, setSymbols } = createChartInternals();
chart.setupDataSourceSubs();
expect(setSymbols).toHaveBeenCalledTimes(1);
expect(setSymbols).toHaveBeenCalledWith(['SBER']);
});
it('должен зарегистрировать символ до загрузки данных начального интервала', () => {
const callOrder: string[] = [];
const { chart, setSymbols, loadTill } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
interval: Intervals['1Y'],
});
setSymbols.mockImplementation(() => {
callOrder.push('setSymbols');
});
loadTill.mockImplementation(() => {
callOrder.push('loadTill');
return Promise.resolve();
});
chart.setupDataSourceSubs();
expect(callOrder).toEqual(['setSymbols', 'loadTill']);
});
it('должен применить начальный интервал после загрузки данных', async () => {
const { chart, loadTill, setVisibleRange } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
interval: Intervals['1Y'],
});
chart.setupDataSourceSubs();
expect(loadTill).toHaveBeenCalledTimes(1);
expect(loadTill).toHaveBeenCalledWith('SBER', expect.any(Number));
await flushPromises();
expect(setVisibleRange).toHaveBeenCalledTimes(1);
expect(setVisibleRange).toHaveBeenCalledWith({
from: expect.any(Number),
to: expect.any(Number),
});
});
it('не должен применять интервал при отсутствии данных', async () => {
const { chart, getOldestTime, setVisibleRange } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
interval: Intervals['1Y'],
});
getOldestTime.mockReturnValue(null);
chart.setupDataSourceSubs();
await flushPromises();
expect(setVisibleRange).not.toHaveBeenCalled();
});
it('не должен применять результат устаревшего интервала', async () => {
const firstLoad = createDeferred();
const secondLoad = createDeferred();
const { chart, interval$, loadTill, loadAllHistory, setVisibleRange } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
interval: Intervals['1Y'],
});
loadTill.mockReturnValue(firstLoad.promise);
loadAllHistory.mockReturnValue(secondLoad.promise);
chart.setupDataSourceSubs();
expect(loadTill).toHaveBeenCalledTimes(1);
interval$.next(Intervals.All);
expect(loadAllHistory).toHaveBeenCalledTimes(1);
secondLoad.resolve();
await flushPromises();
expect(setVisibleRange).toHaveBeenCalledTimes(1);
expect(setVisibleRange).toHaveBeenCalledWith({
from: 100,
to: 200,
});
firstLoad.resolve();
await flushPromises();
expect(setVisibleRange).toHaveBeenCalledTimes(1);
});
it('не должен повторно регистрировать символы при смене только интервала', () => {
const { chart, interval$, setSymbols } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
interval: Intervals['1Y'],
});
chart.setupDataSourceSubs();
expect(setSymbols).toHaveBeenCalledTimes(1);
interval$.next(Intervals.All);
expect(setSymbols).toHaveBeenCalledTimes(1);
expect(setSymbols).toHaveBeenCalledWith(['SBER']);
});
it('должен зарегистрировать новый основной символ и применить к нему активный интервал', async () => {
const { chart, symbolId$, setSymbols, loadTill } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
interval: Intervals['1Y'],
});
chart.setupDataSourceSubs();
await flushPromises();
setSymbols.mockClear();
loadTill.mockClear();
symbolId$.next('GAZP');
expect(setSymbols).toHaveBeenCalledTimes(1);
expect(setSymbols).toHaveBeenCalledWith(['GAZP']);
expect(loadTill).toHaveBeenCalledTimes(1);
expect(loadTill).toHaveBeenCalledWith('GAZP', expect.any(Number));
});
it('должен загрузить всю историю для интервала All и применить общий диапазон', async () => {
const { chart, loadAllHistory, getOldestTime, getLastCandle, setVisibleRange } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: ['GAZP'],
interval: Intervals.All,
});
getOldestTime.mockImplementation((symbolId: string) => (
symbolId === 'SBER' ? 100 : 50
));
getLastCandle.mockImplementation((symbolId: string) => ({
time: symbolId === 'SBER' ? 200 : 300,
}));
chart.setupDataSourceSubs();
expect(loadAllHistory).toHaveBeenCalledTimes(2);
expect(loadAllHistory).toHaveBeenCalledWith('SBER');
expect(loadAllHistory).toHaveBeenCalledWith('GAZP');
await flushPromises();
expect(setVisibleRange).toHaveBeenCalledTimes(1);
expect(setVisibleRange).toHaveBeenCalledWith({
from: 50,
to: 300,
});
});
it('должен прогреть добавленный символ при отсутствии активного интервала', async () => {
const { chart, getVisibleRange, loadTill } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
interval: null,
});
getVisibleRange.mockReturnValue({
from: 100,
to: 200,
});
chart.setupDataSourceSubs();
expect(loadTill).toHaveBeenCalledTimes(1);
expect(loadTill).toHaveBeenCalledWith('SBER', 100);
await flushPromises();
});
it('не должен применять интервал после отписки', async () => {
const loading = createDeferred();
const { chart, loadTill, setVisibleRange } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
interval: Intervals['1Y'],
});
loadTill.mockReturnValue(loading.promise);
chart.setupDataSourceSubs();
expect(loadTill).toHaveBeenCalledTimes(1);
chart.subscriptions.unsubscribe();
loading.resolve();
await flushPromises();
expect(setVisibleRange).not.toHaveBeenCalled();
});
it('не должен повторно догружать историю, если старейшая свеча не изменилась', async () => {
const { chart, loadMoreHistory, getOldestTime, getVisibleLogicalRange } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
});
chart.activeSymbolIds = ['SBER'];
getOldestTime.mockReturnValue(100);
chart.scheduleHistoryBatch();
runNextAnimationFrame();
await flushPromises();
expect(loadMoreHistory).toHaveBeenCalledTimes(1);
expect(getVisibleLogicalRange).not.toHaveBeenCalled();
expect(animationFrameCallbacks).toHaveLength(0);
});
it('должен продолжить догрузку истории, если старейшая свеча сдвинулась назад', async () => {
const { chart, loadMoreHistory, getOldestTime, getVisibleLogicalRange } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
});
let oldestTime = 100;
chart.activeSymbolIds = ['SBER'];
getOldestTime.mockImplementation(() => oldestTime);
loadMoreHistory.mockImplementation(() => {
oldestTime = 90;
return Promise.resolve();
});
chart.scheduleHistoryBatch();
runNextAnimationFrame();
await flushPromises();
expect(loadMoreHistory).toHaveBeenCalledTimes(1);
expect(getVisibleLogicalRange).toHaveBeenCalledTimes(1);
expect(animationFrameCallbacks).toHaveLength(1);
});
it('должен считать появление первой старейшей свечи прогрессом', async () => {
const { chart, loadMoreHistory, getOldestTime } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
});
let oldestTime: number | null = null;
chart.activeSymbolIds = ['SBER'];
getOldestTime.mockImplementation(() => oldestTime);
loadMoreHistory.mockImplementation(() => {
oldestTime = 100;
return Promise.resolve();
});
chart.scheduleHistoryBatch();
runNextAnimationFrame();
await flushPromises();
expect(animationFrameCallbacks).toHaveLength(1);
});
it('не должен останавливать новую цепочку загрузки при смене символа во время запроса', async () => {
const loading = createDeferred();
const { chart, loadMoreHistory, getOldestTime } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
});
chart.activeSymbolIds = ['SBER'];
getOldestTime.mockImplementation((symbolId: string) => (
symbolId === 'SBER' ? 100 : 200
));
loadMoreHistory.mockReturnValue(loading.promise);
chart.scheduleHistoryBatch();
runNextAnimationFrame();
chart.activeSymbolIds = ['GAZP'];
loading.resolve();
await flushPromises();
expect(animationFrameCallbacks).toHaveLength(1);
});
it('не должен останавливать новую цепочку загрузки при смене таймфрейма во время запроса', async () => {
const loading = createDeferred();
const { chart, getTimeframe, loadMoreHistory, getOldestTime } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
});
let timeframe = '1m';
chart.activeSymbolIds = ['SBER'];
getTimeframe.mockImplementation(() => timeframe);
getOldestTime.mockReturnValue(100);
loadMoreHistory.mockReturnValue(loading.promise);
chart.scheduleHistoryBatch();
runNextAnimationFrame();
timeframe = '5m';
loading.resolve();
await flushPromises();
expect(animationFrameCallbacks).toHaveLength(1);
});
it('должен запускать фоновую догрузку для интервала All', async () => {
const { chart, loadMoreHistory, mouseEventsSubscribe, setVisibleLogicalRange } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
interval: Intervals.All,
});
chart.setupDataSourceSubs();
chart.setupHistoricalDataLoading();
const visibleLogicalRangeChangeHandler = mouseEventsSubscribe.mock.calls[0][1] as (
logicalRange: { from: number; to: number } | null,
) => void;
visibleLogicalRangeChangeHandler({
from: 0,
to: 1000,
});
expect(setVisibleLogicalRange).toHaveBeenCalledWith({
from: 0,
to: 1000,
});
expect(animationFrameCallbacks).toHaveLength(1);
runNextAnimationFrame();
await flushPromises();
expect(loadMoreHistory).toHaveBeenCalledTimes(1);
expect(animationFrameCallbacks).toHaveLength(0);
});
it('не должен применять интервал к спящему графику', async () => {
const { chart, loadTill, setVisibleRange } = createChartInternals({
symbolId: 'SBER',
compareSymbolIds: [],
interval: Intervals['1Y'],
});
chart.isSleeping = true;
chart.setupDataSourceSubs();
await flushPromises();
expect(loadTill).toHaveBeenCalledTimes(1);
expect(setVisibleRange).not.toHaveBeenCalled();
});
it('не должен планировать догрузку для неактивного графика', () => {
const { chart, loadMoreHistory } = createChartInternals();
chart.activeSymbolIds = ['SBER'];
chart.isSleeping = true;
chart.scheduleHistoryBatch();
expect(animationFrameCallbacks).toHaveLength(0);
expect(loadMoreHistory).not.toHaveBeenCalled();
});
it('не должен начинать запрос, если график переведён в сон до выполнения кадра', () => {
const { chart, loadMoreHistory } = createChartInternals();
chart.activeSymbolIds = ['SBER'];
chart.scheduleHistoryBatch();
chart.isSleeping = true;
runNextAnimationFrame();
expect(loadMoreHistory).not.toHaveBeenCalled();
expect(chart.historyBatchRunning).toBe(false);
});
it('не должен повторно планировать кадр при уже запущенной загрузке', () => {
const { chart } = createChartInternals();
chart.activeSymbolIds = ['SBER'];
chart.scheduleHistoryBatch();
chart.scheduleHistoryBatch();
expect(animationFrameCallbacks).toHaveLength(1);
});
it('не должен продолжать цепочку загрузки после перехода в сон', async () => {
const loading = createDeferred();
const { chart, loadMoreHistory } = createChartInternals();
chart.activeSymbolIds = ['SBER'];
loadMoreHistory.mockReturnValue(loading.promise);
chart.scheduleHistoryBatch();
runNextAnimationFrame();
chart.isSleeping = true;
loading.resolve();
await flushPromises();
expect(chart.historyBatchRunning).toBe(false);
expect(animationFrameCallbacks).toHaveLength(0);
});
it('не должен планировать запрос после отписки', () => {
const { chart, loadMoreHistory } = createChartInternals();
chart.activeSymbolIds = ['SBER'];
chart.subscriptions.unsubscribe();
chart.scheduleHistoryBatch();
expect(animationFrameCallbacks).toHaveLength(0);
expect(loadMoreHistory).not.toHaveBeenCalled();
});
});
interface ChartViewportInternals {
setSleeping: (isSleeping: boolean) => void;
preserveSleepingTimeScale: () => void;
isSleeping: boolean;
isPointerDown: boolean;
savedViewport: {
logicalRange: { from: number; to: number } | null;
timeRange: { from: number; to: number } | null;
oldestTimes: (number | null)[];
shiftVisibleRangeOnNewBar: boolean;
allowShiftVisibleRangeOnWhitespaceReplacement: boolean;
} | null;
}
function createViewportContext() {
const applyOptions = jest.fn();
const setVisibleRange = jest.fn();
const setVisibleLogicalRange = jest.fn();
const getOldestTime = jest.fn(() => 100);
const getVisibleRange: jest.Mock = jest.fn(() => ({
from: 120,
to: 200,
}));
const getVisibleLogicalRange: jest.Mock = jest.fn(() => ({
from: 10,
to: 30,
}));
const options = jest.fn(() => ({
shiftVisibleRangeOnNewBar: true,
allowShiftVisibleRangeOnWhitespaceReplacement: false,
}));
const chart = Object.assign(Object.create(Chart.prototype), {
isSleeping: false,
isPointerDown: true,
savedViewport: null,
activeSymbolIds: ['SBER'],
dataSource: { getOldestTime },
lwcChart: {
timeScale: () => ({
options,
applyOptions,
getVisibleRange,
getVisibleLogicalRange,
setVisibleRange,
setVisibleLogicalRange,
}),
},
}) as ChartViewportInternals;
return {
chart,
options,
applyOptions,
getOldestTime,
getVisibleRange,
getVisibleLogicalRange,
setVisibleRange,
setVisibleLogicalRange,
};
}
describe('Chart: сохранение видимой области', () => {
it('должен сохранять видимую область и отключать автосдвиг спящего графика', () => {
const { chart, applyOptions } = createViewportContext();
chart.setSleeping(true);
expect(chart.isSleeping).toBe(true);
expect(chart.isPointerDown).toBe(false);
expect(chart.savedViewport).toEqual({
logicalRange: { from: 10, to: 30 },
timeRange: { from: 120, to: 200 },
oldestTimes: [100],
shiftVisibleRangeOnNewBar: true,
allowShiftVisibleRangeOnWhitespaceReplacement: false,
});
expect(applyOptions).toHaveBeenCalledWith({
shiftVisibleRangeOnNewBar: false,
allowShiftVisibleRangeOnWhitespaceReplacement: false,
});
});
it('не должен повторно сохранять область при неизменном состоянии', () => {
const { chart, options, applyOptions } = createViewportContext();
chart.setSleeping(true);
chart.setSleeping(true);
expect(options).toHaveBeenCalledTimes(1);
expect(applyOptions).toHaveBeenCalledTimes(1);
});
it('должен восстанавливать логический диапазон при неизменной истории', () => {
const { chart, applyOptions, setVisibleRange, setVisibleLogicalRange } = createViewportContext();
chart.setSleeping(true);
chart.setSleeping(false);
expect(chart.savedViewport).toBeNull();
expect(applyOptions).toHaveBeenLastCalledWith({
shiftVisibleRangeOnNewBar: true,
allowShiftVisibleRangeOnWhitespaceReplacement: false,
});
expect(setVisibleLogicalRange).toHaveBeenCalledWith({
from: 10,
to: 30,
});
expect(setVisibleRange).not.toHaveBeenCalled();
});
it('должен восстанавливать временной диапазон после догрузки старых свечей', () => {
const { chart, getOldestTime, setVisibleRange, setVisibleLogicalRange } = createViewportContext();
chart.setSleeping(true);
getOldestTime.mockReturnValue(50);
chart.setSleeping(false);
expect(setVisibleRange).toHaveBeenCalledWith({
from: 120,
to: 200,
});
expect(setVisibleLogicalRange).not.toHaveBeenCalled();
});
it('должен использовать логический диапазон при отсутствии сохранённого временного', () => {
const { chart, getOldestTime, getVisibleRange, setVisibleLogicalRange } = createViewportContext();
getVisibleRange.mockReturnValue(null);
chart.setSleeping(true);
getOldestTime.mockReturnValue(50);
chart.setSleeping(false);
expect(setVisibleLogicalRange).toHaveBeenCalledWith({
from: 10,
to: 30,
});
});
it('не должен применять диапазоны, если они не были сохранены', () => {
const {
chart,
getVisibleRange,
getVisibleLogicalRange,
setVisibleRange,
setVisibleLogicalRange,
} = createViewportContext();
getVisibleRange.mockReturnValue(null);
getVisibleLogicalRange.mockReturnValue(null);
chart.setSleeping(true);
chart.setSleeping(false);
expect(setVisibleRange).not.toHaveBeenCalled();
expect(setVisibleLogicalRange).not.toHaveBeenCalled();
});
it('не должен применять настройки сна к работающему графику', () => {
const { chart, applyOptions } = createViewportContext();
chart.preserveSleepingTimeScale();
expect(applyOptions).not.toHaveBeenCalled();
});
it('должен поддерживать выход из сна без сохранённого состояния', () => {
const { chart, applyOptions } = createViewportContext();
chart.isSleeping = true;
chart.setSleeping(false);
expect(chart.isSleeping).toBe(false);
expect(chart.savedViewport).toBeNull();
expect(applyOptions).not.toHaveBeenCalled();
});
});
import { BehaviorSubject } from 'rxjs';
import { Keys } from '@core/Hotkeys';
import { type ChartCollectionPreset, type IMoexChart, MoexChart } from '@core/MoexChart';
import { ReactRenderer } from '@core/ReactRenderer';
import { ChartViewType } from '@src/types';
import type { SeasonalityRange } from '@src/types';
import type { ReactElement } from 'react';
import type { Observable } from 'rxjs';
interface HeaderTestProps {
copyImage?: () => Promise<void>;
setChartView?: (view: ChartViewType) => void;
chartViewObs: Observable<ChartViewType>;
canCopyImageObs: Observable<boolean>;
}
interface HeaderTestOptions {
showSnapshotButton?: boolean;
showSeasonalityButton?: boolean;
seasonalityActive?: boolean;
}
function createTestContext() {
const drawingsCollectionManager = {
selectedDrawing: jest.fn(),
updateSelectedDrawingSettings: jest.fn(),
toggleSelectedDrawingLock: jest.fn(),
openSelectedDrawingSettings: jest.fn(),
deleteSelectedDrawing: jest.fn(),
};
const mainChart = {
copyImage: jest.fn().mockResolvedValue(undefined),
setSleeping: jest.fn(),
getDrawingsCollectionManager: () => drawingsCollectionManager,
};
const seasonalityManager = {
clear: jest.fn(),
show: jest.fn().mockResolvedValue(undefined),
copyImage: jest.fn().mockResolvedValue(undefined),
};
const eventManager = {
exportChartSettings: jest.fn(() => ({
symbolInfo: {
symbolId: 'SBER',
},
})),
importChartSettings: jest.fn(),
clearSymbol: jest.fn(),
setSymbol: jest.fn(),
getTimeframeObs: jest.fn(),
setTimeframe: jest.fn(),
getSelectedSeries: jest.fn(),
setSeriesSelected: jest.fn(),
};
const headerRenderer = {
renderComponent: jest.fn(),
};
const setChartView = jest.fn();
const backToChart = jest.fn();
const chartView = new BehaviorSubject(ChartViewType.Chart);
const canCopyImageObs = new BehaviorSubject(true);
const rootContainer = document.createElement('div');
const chartContainer = document.createElement('div');
const chartAreaContainer = document.createElement('div');
const footerContainer = document.createElement('div');
const seasonalityContainer = document.createElement('div');
rootContainer.style.gridTemplateRows = '36px minmax(0, 1fr) 32px';
seasonalityContainer.style.visibility = 'hidden';
seasonalityContainer.tabIndex = -1;
rootContainer.append(chartContainer, footerContainer, seasonalityContainer);
chartContainer.append(chartAreaContainer);
document.body.append(rootContainer);
const seasonalityLayout = {
rootContainer,
chartContainer,
chartAreaContainer,
footerContainer,
seasonalityContainer,
chartGridTemplateRows: rootContainer.style.gridTemplateRows,
};
const chart = Object.assign(Object.create(MoexChart.prototype), {
chart: mainChart,
chartView,
canCopyImageObs,
seasonalityLayout,
backToChart,
seasonalityManager,
eventManager,
headerRenderer,
drawingToolbarRenderer: {
renderComponent: jest.fn(),
},
toolbarRenderer: undefined,
controlBarRenderer: undefined,
footerRenderer: undefined,
modalRenderer: {},
fullscreen: {},
setChartView,
}) as MoexChart;
return {
chart,
mainChart,
seasonalityManager,
eventManager,
headerRenderer,
setChartView,
chartView,
canCopyImageObs,
seasonalityLayout,
backToChart,
};
}
function renderHeader({
showSnapshotButton,
showSeasonalityButton,
seasonalityActive = false,
}: HeaderTestOptions = {}) {
const context = createTestContext();
context.chartView.next(
seasonalityActive ? ChartViewType.Seasonality : ChartViewType.Chart,
);
const config = {
chartCollectionPreset: {
supportedTimeframes: [],
supportedChartSeriesTypes: [],
showSettingsButton: false,
showMenuButton: false,
showFullscreenButton: false,
undoRedoEnabled: false,
showCompareButton: false,
showSnapshotButton,
showSeasonalityButton,
theme: 'tr',
},
} as unknown as IMoexChart;
const internals = context.chart as unknown as {
renderAttachments: (config: IMoexChart, toggleToolbar: () => boolean) => void;
};
internals.renderAttachments(
config,
jest.fn(() => false),
);
const header = context.headerRenderer.renderComponent.mock.calls[0][0] as ReactElement<HeaderTestProps>;
return { ...context, header };
}
afterEach(() => {
document.body.replaceChildren();
jest.restoreAllMocks();
});
describe('MoexChart', () => {
it('должен выйти из сезонности перед очисткой основного инструмента', () => {
const { chart, eventManager, backToChart } = createTestContext();
chart.clearSymbol();
expect(backToChart).toHaveBeenCalledTimes(1);
expect(eventManager.clearSymbol).toHaveBeenCalledTimes(1);
expect(backToChart.mock.invocationCallOrder[0]).toBeLessThan(
eventManager.clearSymbol.mock.invocationCallOrder[0],
);
});
it('должен выйти из сезонности перед установкой основного инструмента', () => {
const { chart, eventManager, backToChart } = createTestContext();
const symbol = {
symbolId: 'SBER',
symbol: 'SBER',
symbolName: 'Sberbank',
};
chart.setSymbol(symbol);
expect(backToChart).toHaveBeenCalledTimes(1);
expect(eventManager.setSymbol).toHaveBeenCalledTimes(1);
expect(eventManager.setSymbol).toHaveBeenCalledWith(symbol);
expect(backToChart.mock.invocationCallOrder[0]).toBeLessThan(
eventManager.setSymbol.mock.invocationCallOrder[0],
);
});
it('должен копировать основной график, если сезонность неактивна', async () => {
const { header, mainChart, seasonalityManager } = renderHeader({
showSnapshotButton: true,
});
expect(header.props.copyImage).toEqual(expect.any(Function));
await header.props.copyImage?.();
expect(mainChart.copyImage).toHaveBeenCalledTimes(1);
expect(seasonalityManager.copyImage).not.toHaveBeenCalled();
});
it('должен копировать сезонность, если она активна', async () => {
const { header, mainChart, seasonalityManager } = renderHeader({
showSnapshotButton: true,
seasonalityActive: true,
});
expect(header.props.copyImage).toEqual(expect.any(Function));
await header.props.copyImage?.();
expect(seasonalityManager.copyImage).toHaveBeenCalledTimes(1);
expect(mainChart.copyImage).not.toHaveBeenCalled();
});
it.each([undefined, false])(
'не должен передавать copyImage при showSnapshotButton=%s',
(showSnapshotButton) => {
const { header, mainChart, seasonalityManager } = renderHeader({
showSnapshotButton,
});
expect(header.props.copyImage).toBeUndefined();
expect(mainChart.copyImage).not.toHaveBeenCalled();
expect(seasonalityManager.copyImage).not.toHaveBeenCalled();
},
);
it('должен передавать переключение вида при включённой сезонности', () => {
const { header, setChartView } = renderHeader({
showSeasonalityButton: true,
});
expect(header.props.setChartView).toBe(setChartView);
});
it.each([undefined, false])(
'не должен передавать переключение вида при showSeasonalityButton=%s',
(showSeasonalityButton) => {
const { header } = renderHeader({ showSeasonalityButton });
expect(header.props.setChartView).toBeUndefined();
},
);
it('должен передавать потоки текущего вида и доступности снимка в Header', () => {
const { header, chartView, canCopyImageObs } = renderHeader();
expect(header.props.chartViewObs).toBe(chartView);
expect(header.props.canCopyImageObs).toBe(canCopyImageObs);
});
it('должен выбирать источник снимка по текущему режиму в момент нажатия', async () => {
const { header, chartView, mainChart, seasonalityManager } = renderHeader({
showSnapshotButton: true,
});
chartView.next(ChartViewType.Seasonality);
await header.props.copyImage?.();
chartView.next(ChartViewType.Chart);
await header.props.copyImage?.();
expect(seasonalityManager.copyImage).toHaveBeenCalledTimes(1);
expect(mainChart.copyImage).toHaveBeenCalledTimes(1);
});
it('должен выйти из сезонности перед применением настроек', () => {
const { chart, eventManager, backToChart } = createTestContext();
const settings = {} as Parameters<MoexChart['setSettings']>[0];
chart.setSettings(settings);
expect(backToChart).toHaveBeenCalledTimes(1);
expect(eventManager.importChartSettings).toHaveBeenCalledWith(settings);
expect(backToChart.mock.invocationCallOrder[0]).toBeLessThan(
eventManager.importChartSettings.mock.invocationCallOrder[0],
);
});
it('должен скрывать основной график и футер перед загрузкой сезонности', async () => {
const {
chart,
chartView,
seasonalityManager,
mainChart,
seasonalityLayout,
} = createTestContext();
const range: SeasonalityRange = {
fromYear: 2021,
toYear: 2025,
};
const focus = jest.spyOn(seasonalityLayout.seasonalityContainer, 'focus');
await chart.showSeasonality(range);
expect(mainChart.setSleeping).toHaveBeenCalledWith(true);
expect(chartView.value).toBe(ChartViewType.Seasonality);
expect(seasonalityLayout.rootContainer.style.gridTemplateRows).toBe(
'auto minmax(0, 1fr)',
);
expect(seasonalityLayout.chartContainer.style.visibility).toBe('hidden');
expect(seasonalityLayout.chartContainer.inert).toBe(true);
expect(seasonalityLayout.footerContainer.style.display).toBe('none');
expect(seasonalityLayout.seasonalityContainer.style.visibility).toBe('visible');
expect(seasonalityLayout.seasonalityContainer.style.pointerEvents).toBe('auto');
expect(seasonalityLayout.seasonalityContainer.inert).toBe(false);
expect(focus).toHaveBeenCalledTimes(1);
expect(seasonalityManager.show).toHaveBeenCalledWith('SBER', range);
expect(mainChart.setSleeping.mock.invocationCallOrder[0]).toBeLessThan(
seasonalityManager.show.mock.invocationCallOrder[0],
);
});
it('не должен повторно переводить график в сон при обновлении диапазона сезонности', async () => {
const { chart, mainChart, seasonalityManager, seasonalityLayout } = createTestContext();
const focus = jest.spyOn(seasonalityLayout.seasonalityContainer, 'focus');
const range: SeasonalityRange = {
fromYear: 2023,
toYear: 2025,
};
await chart.showSeasonality();
await chart.showSeasonality(range);
expect(mainChart.setSleeping).toHaveBeenCalledTimes(1);
expect(focus).toHaveBeenCalledTimes(1);
expect(seasonalityManager.show).toHaveBeenLastCalledWith('SBER', range);
});
});
jest.mock('@core/ReactRenderer', () => ({
ReactRenderer: jest.fn(() => ({
renderComponent: jest.fn(),
destroy: jest.fn(),
})),
}));
interface RendererContainers {
headerContainer: HTMLElement;
toolBarContainer: HTMLElement;
drawingToolbarContainer: HTMLElement;
controlBarContainer: HTMLElement;
footerContainer: HTMLElement;
}
interface ChartSetupInternals {
registerUndoRedoHotkeys: (enabled?: boolean) => void;
createRenderers: (preset: ChartCollectionPreset, containers: RendererContainers) => void;
headerRenderer?: object;
toolbarRenderer?: object;
drawingToolbarRenderer?: object;
controlBarRenderer?: object;
footerRenderer?: object;
}
function createSetupContext() {
const register = jest.fn();
const undo = jest.fn();
const redo = jest.fn();
const getUndoRedo = jest.fn(() => ({ undo, redo }));
const containers: RendererContainers = {
headerContainer: document.createElement('div'),
toolBarContainer: document.createElement('div'),
drawingToolbarContainer: document.createElement('div'),
controlBarContainer: document.createElement('div'),
footerContainer: document.createElement('div'),
};
const chart = Object.assign(Object.create(MoexChart.prototype), {
hotkeys: { register },
eventManager: { getUndoRedo },
headerRenderer: undefined,
toolbarRenderer: undefined,
drawingToolbarRenderer: undefined,
controlBarRenderer: undefined,
footerRenderer: undefined,
}) as ChartSetupInternals;
return { chart, containers, register, getUndoRedo, undo, redo };
}
describe('MoexChart: настройка интерфейса', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it.each([undefined, false])(
'не должен регистрировать историю действий при enabled=%s',
(enabled) => {
const { chart, register, getUndoRedo } = createSetupContext();
chart.registerUndoRedoHotkeys(enabled);
expect(getUndoRedo).not.toHaveBeenCalled();
expect(register).not.toHaveBeenCalled();
},
);
it('должен регистрировать undo и оба варианта redo', () => {
const { chart, register, undo, redo } = createSetupContext();
chart.registerUndoRedoHotkeys(true);
expect(register).toHaveBeenCalledTimes(3);
expect(register).toHaveBeenNthCalledWith(1, {
keys: [Keys.mod, Keys.z],
callback: undo,
});
expect(register).toHaveBeenNthCalledWith(2, {
keys: [Keys.mod, Keys.y],
callback: redo,
});
expect(register).toHaveBeenNthCalledWith(3, {
keys: [Keys.mod, Keys.shift, Keys.z],
callback: redo,
});
});
it('должен создавать только обязательные рендереры при выключенных панелях', () => {
const { chart, containers } = createSetupContext();
chart.createRenderers({} as ChartCollectionPreset, containers);
expect(ReactRenderer).toHaveBeenCalledTimes(3);
expect(ReactRenderer).toHaveBeenNthCalledWith(1, containers.headerContainer);
expect(ReactRenderer).toHaveBeenNthCalledWith(2, containers.toolBarContainer);
expect(ReactRenderer).toHaveBeenNthCalledWith(3, containers.drawingToolbarContainer);
expect(chart.headerRenderer).toBeDefined();
expect(chart.toolbarRenderer).toBeDefined();
expect(chart.drawingToolbarRenderer).toBeDefined();
expect(chart.controlBarRenderer).toBeUndefined();
expect(chart.footerRenderer).toBeUndefined();
});
it('должен создавать обе дополнительные панели при включённых флагах', () => {
const { chart, containers } = createSetupContext();
chart.createRenderers({
showControlBar: true,
showBottomPanel: true,
} as ChartCollectionPreset, containers);
expect(ReactRenderer).toHaveBeenCalledTimes(5);
expect(ReactRenderer).toHaveBeenCalledWith(containers.controlBarContainer);
expect(ReactRenderer).toHaveBeenCalledWith(containers.footerContainer);
expect(chart.controlBarRenderer).toBeDefined();
expect(chart.footerRenderer).toBeDefined();
});
it.each([
{ showControlBar: true, showBottomPanel: false },
{ showControlBar: false, showBottomPanel: true },
])('должен независимо учитывать флаги панелей %j', (preset) => {
const { chart, containers } = createSetupContext();
chart.createRenderers(preset as ChartCollectionPreset, containers);
expect(ReactRenderer).toHaveBeenCalledTimes(4);
expect(Boolean(chart.controlBarRenderer)).toBe(preset.showControlBar);
expect(Boolean(chart.footerRenderer)).toBe(preset.showBottomPanel);
});
});
import { createChart, createSeriesMarkers, PriceScaleMode } from 'lightweight-charts';
import { SeasonalityManager } from '@core/SeasonalityManager';
import { createInitialSeasonalityState } from '@src/constants';
import { getThemeStore } from '@src/theme/store';
import { Timeframes } from '@src/types/timeframes';
import { canvasToBlob, drawTextLayer } from '@src/utils';
import type { Candle } from '@src/types';
import type {
ChartOptions,
DeepPartial,
LineData,
LineSeriesPartialOptions,
WhitespaceData,
} from 'lightweight-charts';
interface SeriesTestContext {
options: LineSeriesPartialOptions;
applyOptions: jest.Mock;
setData: jest.Mock;
priceFormatter: () => {
format: (value: number) => string;
};
}
interface ChartTestContext {
options: DeepPartial<ChartOptions>;
series: SeriesTestContext[];
addSeries: jest.Mock;
applyOptions: jest.Mock;
removeSeries: jest.Mock;
remove: jest.Mock;
timeScale: () => {
setVisibleRange: jest.Mock;
};
subscribeCrosshairMove: jest.Mock;
unsubscribeCrosshairMove: jest.Mock;
takeScreenshot: jest.Mock;
chartElement: () => HTMLElement;
}
interface MarkersTestContext {
setMarkers: jest.Mock;
detach: jest.Mock;
}
const mocks = {
charts: [] as ChartTestContext[],
markers: [] as MarkersTestContext[],
};
function createSeriesContext(options: LineSeriesPartialOptions): SeriesTestContext {
return {
options,
applyOptions: jest.fn(),
setData: jest.fn(),
priceFormatter: () => ({
format: (value: number) => value.toFixed(2),
}),
};
}
function createChartContext(
container: HTMLElement,
options: DeepPartial<ChartOptions>,
): ChartTestContext {
const timeScale = {
setVisibleRange: jest.fn(),
};
const series: SeriesTestContext[] = [];
return {
options,
series,
addSeries: jest.fn((_type: unknown, seriesOptions: LineSeriesPartialOptions) => {
const item = createSeriesContext(seriesOptions);
series.push(item);
return item;
}),
applyOptions: jest.fn(),
removeSeries: jest.fn(),
remove: jest.fn(),
timeScale: () => timeScale,
subscribeCrosshairMove: jest.fn(),
unsubscribeCrosshairMove: jest.fn(),
takeScreenshot: jest.fn(() => document.createElement('canvas')),
chartElement: () => container,
};
}
jest.mock('lightweight-charts', () => ({
...jest.requireActual('lightweight-charts'),
createChart: jest.fn(),
createSeriesMarkers: jest.fn(),
}));
jest.mock('@src/theme/store', () => ({
getThemeStore: jest.fn(),
}));
jest.mock('@src/translations', () => ({
t: (key: string) => key,
getLocale: () => 'en',
}));
jest.mock('@src/utils', () => ({
...jest.requireActual('@src/utils/seasonality'),
formatCompactNumber: (value: number) => String(value),
canvasToBlob: jest.fn(),
drawTextLayer: jest.fn(),
}));
function getLastCall(mock: jest.Mock) {
return mock.mock.calls[mock.mock.calls.length - 1];
}
function createCandle(date: string, close = 100, milliseconds = false): Candle {
return {
time: Date.parse(`${date}T12:00:00Z`) / (milliseconds ? 1 : 1000),
open: close,
high: close,
low: close,
close,
volume: 1,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
let container: HTMLDivElement;
let manager: SeasonalityManager;
let getData: jest.Mock;
let state: ReturnType<typeof createInitialSeasonalityState>;
let frames: Map<number, FrameRequestCallback>;
let nextFrame: number;
let clipboardDescriptor: PropertyDescriptor | undefined;
let clipboardItemDescriptor: PropertyDescriptor | undefined;
const colors = {
chartBackground: '#ffffff',
chartTextPrimary: '#111111',
chartPaneSeparator: '#222222',
chartGridLine: '#333333',
chartCrosshairLine: '#444444',
chartCrosshairLabel: '#555555',
};
function createManager() {
manager = new SeasonalityManager({
container,
getData,
precision: 3,
});
manager.getState().subscribe((value) => {
state = value;
});
}
function flushAnimationFrames() {
const callbacks = [...frames.values()];
frames.clear();
callbacks.forEach((callback) => callback(0));
}
async function loadSampleHistory() {
getData
.mockResolvedValueOnce([
createCandle('2020-01-01'),
createCandle('2023-01-01'),
createCandle('2025-01-01'),
createCandle('2025-01-02', 125),
])
.mockResolvedValueOnce(null);
await manager.show('AAA', {
fromYear: 2020,
toYear: 2025,
});
}
beforeEach(() => {
clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
clipboardItemDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'ClipboardItem');
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-06-01T12:00:00Z'));
jest.spyOn(console, 'error').mockImplementation(() => {});
mocks.charts.length = 0;
mocks.markers.length = 0;
(createChart as jest.Mock).mockImplementation(
(element: HTMLElement, options: DeepPartial<ChartOptions>) => {
const chart = createChartContext(element, options);
mocks.charts.push(chart);
return chart;
},
);
(createSeriesMarkers as jest.Mock).mockImplementation(() => {
const markers = {
setMarkers: jest.fn(),
detach: jest.fn(),
};
mocks.markers.push(markers);
return markers;
});
frames = new Map();
nextFrame = 0;
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
frames.set(++nextFrame, callback);
return nextFrame;
});
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => {
frames.delete(id);
});
jest.mocked(getThemeStore).mockReturnValue({
colors,
} as ReturnType<typeof getThemeStore>);
getData = jest.fn();
container = document.createElement('div');
document.body.append(container);
state = createInitialSeasonalityState();
createManager();
});
afterEach(() => {
manager.destroy();
container.remove();
if (clipboardDescriptor) {
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
if (clipboardItemDescriptor) {
Object.defineProperty(globalThis, 'ClipboardItem', clipboardItemDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'ClipboardItem');
}
jest.restoreAllMocks();
jest.clearAllMocks();
jest.useRealTimers();
});
describe('SeasonalityManager: загрузка истории', () => {
it('должен загружать дневные страницы, нормализовать время и удалять граничные дубликаты', async () => {
const boundary = createCandle('2025-01-02', 125);
getData
.mockResolvedValueOnce([
boundary,
createCandle('2025-01-03', 150, true),
])
.mockResolvedValueOnce([
createCandle('2025-01-01', 100),
boundary,
])
.mockResolvedValueOnce(null);
await manager.show('AAA');
expect(state.status).toBe('ready');
expect(
getData.mock.calls.map((args) => args.slice(0, 2)),
).toEqual(Array(3).fill([Timeframes['1d'], 'AAA']));
expect(getData.mock.calls[1][2].time).toBe(boundary.time);
manager.setScale(PriceScaleMode.Normal);
const data = getLastCall(mocks.charts[0].series[0].setData)[0];
expect(
data.filter((point: LineData | WhitespaceData) => 'value' in point),
).toEqual([
{ time: '2000-01-01', value: 100 },
{ time: '2000-01-02', value: 125 },
{ time: '2000-01-03', value: 150 },
]);
});
it('должен завершать загрузку при повторении только граничной свечи', async () => {
const first = createCandle('2025-01-01');
getData
.mockResolvedValueOnce([first])
.mockResolvedValueOnce([first]);
await manager.show('AAA');
expect(state.status).toBe('ready');
expect(getData).toHaveBeenCalledTimes(2);
});
it('должен показывать ошибку при отсутствии прогресса пагинации', async () => {
getData
.mockResolvedValueOnce([createCandle('2025-01-01')])
.mockResolvedValueOnce([createCandle('2025-01-02')]);
await manager.show('AAA');
expect(state.status).toBe('error');
expect(state.error).toContain('made no progress');
});
it.each([{ page: null }, { page: [] }])(
'должен показывать пустое состояние для страницы $page',
async ({ page }) => {
getData.mockResolvedValueOnce(page);
await manager.show('AAA');
expect(state.status).toBe('empty');
expect(mocks.charts).toHaveLength(0);
expect(container.textContent).toContain('No seasonality data');
},
);
it('не должен запрашивать историю без выбранного инструмента', async () => {
await manager.show('');
expect(state.status).toBe('error');
expect(getData).not.toHaveBeenCalled();
});
it.each(['time', 'close'] as const)(
'должен отклонять некорректное поле %s',
async (field) => {
getData.mockResolvedValueOnce([
{
...createCandle('2025-01-01'),
[field]: NaN,
},
]);
await manager.show('AAA');
expect(state.status).toBe('error');
expect(state.error).toContain('Invalid daily candle');
},
);
it('должен повторно загружать историю после ошибки', async () => {
getData.mockRejectedValueOnce(new Error('network'));
await manager.show('AAA');
expect(state.status).toBe('error');
await loadSampleHistory();
expect(state.status).toBe('ready');
expect(state.error).toBeNull();
});
it('не должен применять ответ после очистки сезонности', async () => {
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise);
const loading = manager.show('AAA');
expect(state.status).toBe('loading');
manager.clear();
request.resolve([createCandle('2025-01-01')]);
await loading;
expect(state.status).toBe('idle');
expect(mocks.charts).toHaveLength(0);
});
it('не должен применять ошибку предыдущего инструмента', async () => {
const old = createDeferred<Candle[]>();
getData.mockReturnValueOnce(old.promise);
const loading = manager.show('OLD');
getData
.mockResolvedValueOnce([createCandle('2025-01-01')])
.mockResolvedValueOnce(null);
await manager.show('NEW');
old.reject(new Error('late failure'));
await loading;
expect(state.symbolId).toBe('NEW');
expect(state.status).toBe('ready');
expect(console.error).not.toHaveBeenCalled();
});
it('не должен применять данные предыдущего инструмента', async () => {
const old = createDeferred<Candle[]>();
getData.mockReturnValueOnce(old.promise);
const loading = manager.show('OLD');
getData
.mockResolvedValueOnce([createCandle('2025-01-01')])
.mockResolvedValueOnce(null);
await manager.show('NEW');
old.resolve([createCandle('2020-01-01')]);
await loading;
expect(state.symbolId).toBe('NEW');
expect(state.minYear).toBe(2025);
expect(mocks.charts).toHaveLength(1);
});
it('не должен повторно запрашивать загружаемый инструмент', async () => {
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise);
const loading = manager.show('AAA');
await manager.show('AAA');
expect(getData).toHaveBeenCalledTimes(1);
request.resolve([]);
await loading;
});
it('должен использовать загруженную историю при изменении диапазона', async () => {
await loadSampleHistory();
const count = getData.mock.calls.length;
await manager.show('AAA', {
fromYear: 2025,
toYear: 2025,
});
flushAnimationFrames();
expect(getData).toHaveBeenCalledTimes(count);
expect(state.fromYear).toBe(2025);
expect(mocks.charts[0].removeSeries).toHaveBeenCalledTimes(2);
});
it('должен выбирать последние пять календарных лет по умолчанию', async () => {
getData
.mockResolvedValueOnce([
createCandle('2020-01-01'),
createCandle('2025-01-01'),
])
.mockResolvedValueOnce(null);
await manager.show('AAA');
expect(state.fromYear).toBe(2021);
expect(state.toYear).toBe(2025);
});
});
describe('SeasonalityManager: отображение и очистка', () => {
it('должен объединять изменения диапазона в один кадр и применять последнее значение', async () => {
await loadSampleHistory();
manager.setRange({
fromYear: 2020,
toYear: 2023,
});
manager.setRange({
fromYear: 2023,
toYear: 2023,
});
expect(frames.size).toBe(1);
flushAnimationFrames();
expect(state.status).toBe('ready');
expect(container.textContent).toContain('2023');
expect(container.textContent).not.toContain('2025');
expect(mocks.markers[0].detach).toHaveBeenCalledTimes(1);
});
it('не должен перерисовывать прежний диапазон и должен поддерживать диапазон без данных', async () => {
await loadSampleHistory();
manager.setRange({
fromYear: 2020,
toYear: 2025,
});
expect(frames.size).toBe(0);
manager.setRange({
fromYear: 2021,
toYear: 2022,
});
flushAnimationFrames();
expect(state.status).toBe('empty');
expect(container.textContent).toContain('No seasonality data');
});
it('должен переключать формат значений без пересоздания графика', async () => {
await loadSampleHistory();
manager.setScale(PriceScaleMode.Normal);
manager.setScale(PriceScaleMode.Percentage);
const series = mocks.charts[0].series[mocks.charts[0].series.length - 1];
const { priceFormat } = getLastCall(series.applyOptions)[0];
expect(priceFormat.formatter(12.5)).toBe('12.50%');
expect(priceFormat.minMove).toBe(0.01);
manager.setScale(PriceScaleMode.Normal);
expect(getLastCall(series.applyOptions)[0].priceFormat.minMove).toBe(0.001);
expect(mocks.charts).toHaveLength(1);
expect(mocks.charts[0].timeScale().setVisibleRange).toHaveBeenCalledWith({
from: '2000-01-01',
to: '2000-12-31',
});
expect(getLastCall(mocks.markers[0].setMarkers)[0][0].time).toBe('2000-01-02');
});
it('должен показывать предупреждение о базе только для процентной шкалы', async () => {
getData
.mockResolvedValueOnce([createCandle('2025-01-01', 0)])
.mockResolvedValueOnce(null);
await manager.show('AAA');
manager.setScale(PriceScaleMode.Percentage);
expect(container.textContent).toContain('Percentage requires a positive baseline');
expect(getLastCall(mocks.markers[0].setMarkers)[0]).toEqual([]);
manager.setScale(PriceScaleMode.Normal);
expect((container.lastElementChild as HTMLElement).style.display).toBe('none');
});
it('должен применять актуальные цвета темы при создании и обновлении графика', async () => {
await loadSampleHistory();
expect(mocks.charts[0].options.crosshair).toMatchObject({
vertLine: {
color: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
},
horzLine: {
color: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
},
});
jest.mocked(getThemeStore).mockReturnValue({
colors: {
...colors,
chartTextPrimary: '#999999',
chartCrosshairLine: '#aabbcc',
chartCrosshairLabel: '#ddeeff',
},
} as ReturnType<typeof getThemeStore>);
manager.updateTheme();
expect(getLastCall(mocks.charts[0].applyOptions)[0]).toMatchObject({
layout: {
textColor: '#999999',
},
crosshair: {
vertLine: {
color: '#aabbcc',
labelBackgroundColor: '#ddeeff',
},
horzLine: {
color: '#aabbcc',
labelBackgroundColor: '#ddeeff',
},
},
});
});
it('должен форматировать все варианты времени как день и месяц', async () => {
await loadSampleHistory();
const formatter = mocks.charts[0].options.localization?.timeFormatter;
if (typeof formatter !== 'function') {
throw new Error('Time formatter is not configured');
}
expect(formatter({
year: 2000,
month: 2,
day: 9,
})).toBe('09.02');
expect(formatter('2000-02-09')).toBe('09.02');
expect(formatter(Date.UTC(2000, 1, 9) / 1000)).toBe('09.02');
});
it('должен обновлять легенду по перекрестию и обозначать отсутствующие значения', async () => {
await loadSampleHistory();
const chart = mocks.charts[0];
const listener = chart.subscribeCrosshairMove.mock.calls[0][0];
listener({
time: '2000-01-01',
seriesData: new Map([
[chart.series[0], { value: 42 }],
]),
});
expect(container.textContent).toContain('42.00');
expect(container.textContent).toContain('—');
listener({
time: undefined,
seriesData: new Map(),
});
expect(container.textContent).not.toContain('—');
});
it('должен копировать готовый график вместе с легендой', async () => {
const blob = new Blob(['image'], {
type: 'image/png',
});
const write = jest.fn().mockResolvedValue(undefined);
jest.mocked(canvasToBlob).mockResolvedValueOnce(blob);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { write },
});
Object.defineProperty(globalThis, 'ClipboardItem', {
configurable: true,
value: class {
constructor(public data: Record<string, Blob>) {}
},
});
await manager.copyImage();
expect(write).not.toHaveBeenCalled();
await loadSampleHistory();
await manager.copyImage();
expect(mocks.charts[0].takeScreenshot).toHaveBeenCalledWith(true, false);
expect(drawTextLayer).toHaveBeenCalledTimes(1);
expect(canvasToBlob).toHaveBeenCalledTimes(1);
expect(write).toHaveBeenCalledTimes(1);
const items = write.mock.calls[0][0];
expect(items[0].data['image/png']).toBe(blob);
});
it('должен отменять кадры и освобождать ресурсы при очистке сезонности', async () => {
await loadSampleHistory();
manager.setRange({
fromYear: 2023,
toYear: 2025,
});
const chart = mocks.charts[0];
manager.clear();
manager.clear();
expect(frames.size).toBe(0);
expect(state.status).toBe('idle');
flushAnimationFrames();
expect(mocks.charts).toHaveLength(1);
expect(chart.remove).toHaveBeenCalledTimes(1);
expect(chart.unsubscribeCrosshairMove).toHaveBeenCalledWith(
chart.subscribeCrosshairMove.mock.calls[0][0],
);
expect(mocks.markers[0].detach).toHaveBeenCalledTimes(1);
});
it('должен завершать состояние и игнорировать ответ после уничтожения', async () => {
const complete = jest.fn();
manager.getState().subscribe({ complete });
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise);
const loading = manager.show('AAA');
manager.destroy();
manager.destroy();
request.resolve([createCandle('2025-01-01')]);
await loading;
await manager.show('BBB');
manager.setRange({
fromYear: 2020,
toYear: 2025,
});
manager.setScale(PriceScaleMode.Normal);
expect(complete).toHaveBeenCalledTimes(1);
expect(container.children).toHaveLength(0);
expect(mocks.charts).toHaveLength(0);
expect(getData).toHaveBeenCalledTimes(1);
});
it('не должен изменять диапазон без загруженной истории', () => {
const initial = state;
manager.setRange({
fromYear: 2020,
toYear: 2025,
});
manager.setScale(PriceScaleMode.Normal);
expect(state).toBe(initial);
expect(frames.size).toBe(0);
});
it('должен применять шкалу, выбранную во время загрузки', async () => {
const request = createDeferred<Candle[]>();
getData
.mockReturnValueOnce(request.promise)
.mockResolvedValueOnce(null);
const loading = manager.show('AAA');
manager.setScale(PriceScaleMode.Percentage);
request.resolve([
createCandle('2025-01-01'),
createCandle('2025-01-02', 125),
]);
await loading;
const data = getLastCall(mocks.charts[0].series[0].setData)[0];
expect(state.scale).toBe(PriceScaleMode.Percentage);
expect(
data.filter((point: LineData | WhitespaceData) => 'value' in point),
).toEqual([
{ time: '2000-01-01', value: 0 },
{ time: '2000-01-02', value: 25 },
]);
});
it('должен восстанавливать отображение после выбора пустого диапазона', async () => {
await loadSampleHistory();
const count = getData.mock.calls.length;
manager.setRange({
fromYear: 2021,
toYear: 2022,
});
flushAnimationFrames();
expect(state.status).toBe('empty');
manager.setRange({
fromYear: 2023,
toYear: 2025,
});
flushAnimationFrames();
expect(state.status).toBe('ready');
expect(getData).toHaveBeenCalledTimes(count);
expect(mocks.charts).toHaveLength(1);
expect((container.lastElementChild as HTMLElement).style.display).toBe('none');
});
it('должен сбрасывать статус и повторно загружать инструмент после очистки', async () => {
await loadSampleHistory();
manager.setScale(PriceScaleMode.Percentage);
manager.clear();
expect(state.status).toBe('idle');
expect(state.error).toBeNull();
expect(state.scale).toBe(PriceScaleMode.Percentage);
const count = getData.mock.calls.length;
await loadSampleHistory();
expect(getData).toHaveBeenCalledTimes(count + 2);
expect(state.status).toBe('ready');
expect(mocks.charts).toHaveLength(2);
});
});
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((_callback: () => 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 ChartHeaderProps['undoRedo'],
showMenuButton: true,
showFullscreenButton: true,
fullscreen: fullscreen 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 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 { fireEvent, render, screen } from '@testing-library/react';
import { SeasonalityHeader } from '@src/components/Header/SeasonalityHeader';
jest.mock('exchange-elements/v2', () => ({
Divider: () => <hr />,
}));
describe('SeasonalityHeader', () => {
it('должен отображать переданные контролы и сохранять их обработчики', () => {
const setChartView = jest.fn();
const copyImage = jest.fn();
render(
<SeasonalityHeader
viewControl={
<button
type="button"
onClick={setChartView}
>
Chart view
</button>
}
snapshotControl={
<button
type="button"
onClick={copyImage}
>
Snapshot
</button>
}
/>,
);
fireEvent.click(screen.getByText('Chart view'));
fireEvent.click(screen.getByText('Snapshot'));
expect(setChartView).toHaveBeenCalledTimes(1);
expect(copyImage).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('separator')).not.toBeNull();
});
it('должен оставлять переключение вида при изменении доступности снимка', () => {
const viewControl = <button type="button">Chart view</button>;
const snapshotControl = <button type="button">Snapshot</button>;
const { rerender } = render(
<SeasonalityHeader
viewControl={viewControl}
snapshotControl={snapshotControl}
/>,
);
rerender(
<SeasonalityHeader
viewControl={viewControl}
snapshotControl={null}
/>,
);
expect(screen.queryByText('Chart view')).not.toBeNull();
expect(screen.queryByText('Snapshot')).toBeNull();
expect(screen.queryByRole('separator')).toBeNull();
rerender(
<SeasonalityHeader
viewControl={viewControl}
snapshotControl={snapshotControl}
/>,
);
expect(screen.queryByText('Chart view')).not.toBeNull();
expect(screen.queryByText('Snapshot')).not.toBeNull();
expect(screen.queryByRole('separator')).not.toBeNull();
});
it('должен отображать снимок без переключателя вида', () => {
const copyImage = jest.fn();
render(
<SeasonalityHeader
viewControl={null}
snapshotControl={
<button
type="button"
onClick={copyImage}
>
Snapshot
</button>
}
/>,
);
fireEvent.click(screen.getByText('Snapshot'));
expect(copyImage).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Chart view')).toBeNull();
});
});