Загрузка данных
import { fireEvent, render, screen } from '@testing-library/react';
import { BehaviorSubject } from 'rxjs';
import { Header } from '@src/components/Header';
import type { ReactNode } from 'react';
jest.mock('exchange-elements/v2', () => ({
Button: ({ onClick, label, disabled }: { onClick?: () => void; label?: ReactNode; disabled?: boolean }) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
>
{label}
</button>
),
Divider: () => <hr />,
}));
jest.mock('@src/components/IndicatorsSelect', () => ({
IndicatorsSelect: () => <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/Menu', () => ({
TimeframesMenu: () => <div />,
SeriesMenu: () => <div />,
SnapshotMenu: ({ onCopyImage }: { onCopyImage: () => Promise<void> }) => (
<button
type="button"
onClick={() => onCopyImage()}
>
Copy image
</button>
),
}));
jest.mock('@src/translations', () => ({
t: (key: string) => key,
}));
describe('Header', () => {
it('должен отображать меню снимка и вызывать copyImage', () => {
const copyImage = jest.fn().mockResolvedValue(undefined);
const fullscreen = {
isFullscreen: false,
onChange: jest.fn(() => jest.fn()),
toggle: jest.fn(),
};
render(
<Header
timeframes={[]}
selectedTimeframeObs={new BehaviorSubject(null) as never}
setTimeframe={jest.fn()}
seriesTypes={[]}
setSelectedSeries={jest.fn()}
selectedSeriesObs={new BehaviorSubject('Candlestick') as never}
showSettingsModal={undefined}
addIndicatorToChart={jest.fn()}
toggleToolbarVisible={jest.fn(() => false)}
showCompareButton={false}
showSymbolSearchButton={false}
undoRedo={undefined}
showMenuButton={false}
showFullscreenButton={false}
fullscreen={fullscreen as never}
copyImage={copyImage}
isMXT={false}
/>,
);
expect(document.querySelector('[data-tooltip="Snapshot"]')).not.toBeNull();
fireEvent.click(screen.getByText('Copy image'));
expect(copyImage).toHaveBeenCalledTimes(1);
});
});
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[];
active: 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: [],
active: true,
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.active = false;
chart.setupDataSourceSubs();
await flushPromises();
expect(loadTill).toHaveBeenCalledTimes(1);
expect(setVisibleRange).not.toHaveBeenCalled();
});
it('не должен планировать догрузку для неактивного графика', () => {
const { chart, loadMoreHistory } = createChartInternals();
chart.activeSymbolIds = ['SBER'];
chart.active = false;
chart.scheduleHistoryBatch();
expect(animationFrameCallbacks).toHaveLength(0);
expect(loadMoreHistory).not.toHaveBeenCalled();
});
it('не должен начинать запрос, если график выключен до выполнения кадра', () => {
const { chart, loadMoreHistory } = createChartInternals();
chart.activeSymbolIds = ['SBER'];
chart.scheduleHistoryBatch();
chart.active = false;
runNextAnimationFrame();
expect(loadMoreHistory).not.toHaveBeenCalled();
expect(chart.historyBatchRunning).toBe(false);
});
});
interface ChartViewportInternals {
setActive: (active: boolean) => void;
preserveInactiveTimeScale: () => void;
active: 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), {
active: true,
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.setActive(false);
expect(chart.active).toBe(false);
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.setActive(false);
chart.setActive(false);
expect(options).toHaveBeenCalledTimes(1);
expect(applyOptions).toHaveBeenCalledTimes(1);
});
it('должен восстанавливать логический диапазон при неизменной истории', () => {
const { chart, applyOptions, setVisibleRange, setVisibleLogicalRange } = createViewportContext();
chart.setActive(false);
chart.setActive(true);
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.setActive(false);
getOldestTime.mockReturnValue(50);
chart.setActive(true);
expect(setVisibleRange).toHaveBeenCalledWith({ from: 120, to: 200 });
expect(setVisibleLogicalRange).not.toHaveBeenCalled();
});
it('должен использовать логический диапазон при отсутствии сохранённого временного', () => {
const { chart, getOldestTime, getVisibleRange, setVisibleLogicalRange } = createViewportContext();
getVisibleRange.mockReturnValue(null);
chart.setActive(false);
getOldestTime.mockReturnValue(50);
chart.setActive(true);
expect(setVisibleLogicalRange).toHaveBeenCalledWith({ from: 10, to: 30 });
});
it('не должен применять диапазоны, если они не были сохранены', () => {
const { chart, getVisibleRange, getVisibleLogicalRange, setVisibleRange, setVisibleLogicalRange } =
createViewportContext();
getVisibleRange.mockReturnValue(null);
getVisibleLogicalRange.mockReturnValue(null);
chart.setActive(false);
chart.setActive(true);
expect(setVisibleRange).not.toHaveBeenCalled();
expect(setVisibleLogicalRange).not.toHaveBeenCalled();
});
it('не должен применять настройки неактивности к активному графику', () => {
const { chart, applyOptions } = createViewportContext();
chart.preserveInactiveTimeScale();
expect(applyOptions).not.toHaveBeenCalled();
});
it('должен поддерживать активацию без сохранённого состояния', () => {
const { chart, applyOptions } = createViewportContext();
chart.active = false;
chart.setActive(true);
expect(chart.active).toBe(true);
expect(chart.savedViewport).toBeNull();
expect(applyOptions).not.toHaveBeenCalled();
});
});
import { EMPTY } from 'rxjs';
import { Keys } from '@core/Hotkeys';
import { type ChartCollectionPreset, type IMoexChart, MoexChart } from '@core/MoexChart';
import { ReactRenderer } from '@core/ReactRenderer';
import type { ReactElement } from 'react';
interface HeaderTestProps {
copyImage?: () => Promise<void>;
setChartView?: (view: unknown) => void;
seasonalityStateObs: typeof EMPTY;
}
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),
getDrawingsCollectionManager: () => drawingsCollectionManager,
};
const seasonalityManager = {
back: jest.fn(),
getState: jest.fn(() => EMPTY),
isActive: jest.fn(() => false),
copyImage: jest.fn().mockResolvedValue(undefined),
};
const eventManager = {
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 chart = Object.assign(Object.create(MoexChart.prototype), {
chart: mainChart,
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,
};
}
function renderHeader({
showSnapshotButton,
showSeasonalityButton,
seasonalityActive = false,
}: HeaderTestOptions = {}) {
const context = createTestContext();
context.seasonalityManager.isActive.mockReturnValue(seasonalityActive);
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 };
}
describe('MoexChart', () => {
it('должен выйти из сезонности перед очисткой основного инструмента', () => {
const { chart, eventManager, seasonalityManager } = createTestContext();
chart.clearSymbol();
expect(seasonalityManager.back).toHaveBeenCalledTimes(1);
expect(eventManager.clearSymbol).toHaveBeenCalledTimes(1);
expect(seasonalityManager.back.mock.invocationCallOrder[0]).toBeLessThan(
eventManager.clearSymbol.mock.invocationCallOrder[0],
);
});
it('должен выйти из сезонности перед установкой основного инструмента', () => {
const { chart, eventManager, seasonalityManager } = createTestContext();
const symbol = {
symbolId: 'SBER',
symbol: 'SBER',
symbolName: 'Sberbank',
};
chart.setSymbol(symbol);
expect(seasonalityManager.back).toHaveBeenCalledTimes(1);
expect(eventManager.setSymbol).toHaveBeenCalledTimes(1);
expect(eventManager.setSymbol).toHaveBeenCalledWith(symbol);
expect(seasonalityManager.back.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, seasonalityManager } = renderHeader();
expect(seasonalityManager.getState).toHaveBeenCalledTimes(1);
expect(header.props.seasonalityStateObs).toBe(EMPTY);
});
});
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 { ChartViewType } from '@src/types';
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 onOpen: jest.Mock;
let onBack: 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,
onOpen,
onBack,
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();
onOpen = jest.fn();
onBack = 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 },
]);
expect(onOpen).toHaveBeenCalledTimes(1);
});
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.back();
request.resolve([createCandle('2025-01-01')]);
await loading;
expect(state.view).toBe(ChartViewType.Chart);
expect(state.status).toBe('idle');
expect(mocks.charts).toHaveLength(0);
expect(onBack).toHaveBeenCalledTimes(1);
});
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();
expect(mocks.charts[0].options.crosshair?.vertLine).toMatchObject({
color: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
});
});
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.back();
manager.back();
expect(frames.size).toBe(0);
expect(chart.remove).toHaveBeenCalledTimes(1);
expect(chart.unsubscribeCrosshairMove).toHaveBeenCalledWith(chart.subscribeCrosshairMove.mock.calls[0][0]);
expect(mocks.markers[0].detach).toHaveBeenCalledTimes(1);
expect(onBack).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);
});
});