Загрузка данных
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 type { ChartOptions, DeepPartial, LineData, LineSeriesPartialOptions, WhitespaceData } from 'lightweight-charts';
import { createInitialSeasonalityState } from '@src/constants';
import { SeasonalityManager } from '@core/SeasonalityManager';
import { ChartViewType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { getThemeStore } from '@src/theme/store';
import { canvasToBlob, drawTextLayer } from '@src/utils';
import type { Candle } from '@src/types';
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(async () => new Blob(['image'], { type: 'image/png' })),
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].priceFormat;
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;
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 write = jest.fn().mockResolvedValue(undefined);
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']).toBeInstanceOf(Blob);
Reflect.deleteProperty(navigator, 'clipboard');
});
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);
});
});
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { SymbolSource } from '@core/SymbolSource';
import { Timeframes } from '@src/types/timeframes';
import type { Candle } from '@src/types';
dayjs.extend(utc);
jest.mock('@src/utils', () => ({
normalizeSeriesData: (candles: Candle[]) =>
[...new Map(candles.map((candle) => [candle.time, candle])).values()].sort(
(first, second) => first.time - second.time,
),
parseTimeframe: () => ({
candleWidth: 1,
dayjsUnit: 'day',
}),
}));
function createCandle(day: number, close = day): Candle {
return {
time: Date.UTC(2025, 0, day) / 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 source: SymbolSource;
let getData: jest.Mock;
let data: Candle[];
async function initialize(candles: Candle[] = [createCandle(2), createCandle(3)]) {
getData.mockResolvedValueOnce(candles);
await source.reload(Timeframes['1d']);
}
function realCandles() {
return data.filter((item) => item.open !== undefined);
}
beforeEach(() => {
getData = jest.fn().mockResolvedValue(null);
source = new SymbolSource({
symbol: 'AAA',
getData,
getTimeframe: () => Timeframes['1d'],
});
data = [];
source.data$().subscribe((value) => {
data = value;
});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
source.destroy();
jest.restoreAllMocks();
});
describe('SymbolSource.loadMoreHistory', () => {
it('должен объединять историю и сохранять текущую граничную свечу', async () => {
await initialize();
getData.mockResolvedValueOnce([
createCandle(2, 999),
createCandle(1),
]);
await source.loadMoreHistory();
expect(getData).toHaveBeenLastCalledWith(
Timeframes['1d'],
'AAA',
createCandle(2),
);
expect(realCandles()).toEqual([
createCandle(1),
createCandle(2),
createCandle(3),
]);
expect(source.getOldestTime()).toBe(createCandle(1).time);
expect(source.getLastValue()).toEqual(createCandle(3));
expect(source.isLoadingValue()).toBe(false);
expect(data).toHaveLength(5003);
});
it('должен использовать один запрос для параллельных вызовов догрузки', async () => {
await initialize();
const request = createDeferred<Candle[] | null>();
getData.mockReturnValueOnce(request.promise);
const first = source.loadMoreHistory();
const second = source.loadMoreHistory();
await Promise.resolve();
expect(getData).toHaveBeenCalledTimes(2);
expect(source.isLoadingValue()).toBe(true);
request.resolve([createCandle(1)]);
await Promise.all([first, second]);
expect(realCandles()).toEqual([
createCandle(1),
createCandle(2),
createCandle(3),
]);
});
it('не должен догружать историю после получения null', async () => {
await initialize();
getData.mockResolvedValueOnce(null);
await source.loadMoreHistory();
await source.loadMoreHistory();
expect(getData).toHaveBeenCalledTimes(2);
expect(source.getOldestTime()).toBe(createCandle(2).time);
expect(source.isLoadingValue()).toBe(false);
});
it('должен разрешать повторную попытку после пустой страницы', async () => {
await initialize();
getData
.mockResolvedValueOnce([])
.mockResolvedValueOnce([createCandle(1)]);
await source.loadMoreHistory();
expect(source.getOldestTime()).toBe(createCandle(2).time);
await source.loadMoreHistory();
expect(source.getOldestTime()).toBe(createCandle(1).time);
expect(getData).toHaveBeenCalledTimes(3);
});
it('должен игнорировать страницу без более ранних свечей и разрешать повторную попытку', async () => {
await initialize();
getData
.mockResolvedValueOnce([createCandle(2), createCandle(3)])
.mockResolvedValueOnce([createCandle(1)]);
await source.loadMoreHistory();
expect(realCandles()).toEqual([
createCandle(2),
createCandle(3),
]);
await source.loadMoreHistory();
expect(source.getOldestTime()).toBe(createCandle(1).time);
});
it('не должен догружать историю без старейшей свечи', async () => {
await initialize([]);
await source.loadMoreHistory();
expect(getData).toHaveBeenCalledTimes(1);
expect(source.getOldestTime()).toBeNull();
});
it('должен сбрасывать состояние загрузки после ошибки и разрешать повторную попытку', async () => {
await initialize();
const error = new Error('network');
getData
.mockRejectedValueOnce(error)
.mockResolvedValueOnce([createCandle(1)]);
await expect(source.loadMoreHistory()).resolves.toBeUndefined();
expect(source.isLoadingValue()).toBe(false);
expect(console.error).toHaveBeenCalledWith(
'[DataSource] Ошибка при догрузке истории:',
error,
);
await source.loadMoreHistory();
expect(source.getOldestTime()).toBe(createCandle(1).time);
});
it('должен игнорировать старую страницу и сохранять состояние новой загрузки', async () => {
await initialize();
const page = createDeferred<Candle[] | null>();
const reload = createDeferred<Candle[] | null>();
getData
.mockReturnValueOnce(page.promise)
.mockReturnValueOnce(reload.promise);
const loading = source.loadMoreHistory();
await Promise.resolve();
const reloading = source.reload(Timeframes['1d']);
page.resolve([createCandle(1)]);
await loading;
expect(realCandles()).toEqual([]);
expect(source.isLoadingValue()).toBe(true);
reload.resolve([createCandle(10)]);
await reloading;
expect(realCandles()).toEqual([createCandle(10)]);
expect(source.isLoadingValue()).toBe(false);
});
it('не должен завершать новую историю из-за устаревшего ответа null', async () => {
await initialize();
const page = createDeferred<Candle[] | null>();
getData.mockReturnValueOnce(page.promise);
const loading = source.loadMoreHistory();
await Promise.resolve();
await initialize([createCandle(10)]);
page.resolve(null);
await loading;
getData.mockResolvedValueOnce([createCandle(9)]);
await source.loadMoreHistory();
expect(source.getOldestTime()).toBe(createCandle(9).time);
});
it('не должен публиковать ответ после уничтожения источника', async () => {
await initialize();
const listener = jest.fn();
source.data$().subscribe(listener);
const page = createDeferred<Candle[] | null>();
getData.mockReturnValueOnce(page.promise);
const loading = source.loadMoreHistory();
await Promise.resolve();
source.destroy();
page.resolve([createCandle(1)]);
await loading;
expect(listener).toHaveBeenCalledTimes(1);
expect(source.getOldestTime()).toBeNull();
});
it('должен сохранять realtime-свечи, полученные во время догрузки истории', async () => {
await initialize();
const page = createDeferred<Candle[] | null>();
getData.mockReturnValueOnce(page.promise);
const loading = source.loadMoreHistory();
await Promise.resolve();
source.pushRealtime(createCandle(4));
page.resolve([createCandle(1)]);
await loading;
expect(realCandles()).toEqual([
createCandle(1),
createCandle(2),
createCandle(3),
createCandle(4),
]);
expect(source.getLastValue()).toEqual(createCandle(4));
});
it('должен преобразовывать время из миллисекунд в секунды', async () => {
await initialize();
getData.mockResolvedValueOnce([
{
...createCandle(1),
time: createCandle(1).time * 1000,
},
]);
await source.loadMoreHistory();
expect(source.getOldestTime()).toBe(createCandle(1).time);
});
});
describe('SymbolSource: завершение загрузки', () => {
it('должен загружать все страницы и сбрасывать состояние загрузки', async () => {
await initialize();
getData
.mockResolvedValueOnce([createCandle(1)])
.mockResolvedValueOnce(null);
await source.loadAllHistory();
expect(realCandles()).toEqual([
createCandle(1),
createCandle(2),
createCandle(3),
]);
expect(source.isLoadingValue()).toBe(false);
});
it('должен сбрасывать состояние загрузки после ошибки полной истории', async () => {
await initialize();
getData.mockRejectedValueOnce(new Error('network'));
await source.loadAllHistory();
expect(source.isLoadingValue()).toBe(false);
expect(source.getOldestTime()).toBe(createCandle(2).time);
});
it('должен сбрасывать состояние загрузки после ошибки reload', async () => {
getData.mockRejectedValueOnce(new Error('network'));
await source.reload(Timeframes['1d']);
expect(source.isLoadingValue()).toBe(false);
});
});
import { Hotkeys, Keys } from '@core/Hotkeys';
let hotkeys: Hotkeys;
let scope: HTMLDivElement;
function pressKey(
code: string,
init: KeyboardEventInit = {},
target: EventTarget = scope,
) {
const event = new KeyboardEvent('keydown', {
code,
bubbles: true,
cancelable: true,
...init,
});
target.dispatchEvent(event);
return event;
}
beforeEach(() => {
scope = document.createElement('div');
scope.tabIndex = 0;
document.body.append(scope);
scope.focus();
hotkeys = new Hotkeys(scope);
});
afterEach(() => {
hotkeys.destroy();
document.body.replaceChildren();
jest.restoreAllMocks();
});
describe('Hotkeys', () => {
it.each([
{ ctrlKey: true },
{ metaKey: true },
{ ctrlKey: true, metaKey: true },
])('должен обрабатывать mod с модификаторами %j', (modifiers) => {
const callback = jest.fn();
hotkeys.register({
keys: [Keys.mod, Keys.z],
callback,
});
expect(pressKey('KeyZ', modifiers).defaultPrevented).toBe(true);
expect(callback).toHaveBeenCalledTimes(1);
});
it('должен учитывать точный набор модификаторов', () => {
const callback = jest.fn();
hotkeys.register({
keys: [Keys.control, Keys.shift, Keys.alt, Keys.z],
callback,
});
pressKey('KeyZ', {
ctrlKey: true,
shiftKey: true,
});
pressKey('KeyZ', {
ctrlKey: true,
shiftKey: true,
altKey: true,
metaKey: true,
});
expect(callback).not.toHaveBeenCalled();
pressKey('KeyZ', {
ctrlKey: true,
shiftKey: true,
altKey: true,
});
expect(callback).toHaveBeenCalledTimes(1);
});
it('должен выбирать последний разрешённый обработчик и возвращаться к предыдущему после отписки', () => {
const first = jest.fn();
const second = jest.fn();
const disabled = jest.fn();
hotkeys.register({
keys: [Keys.escape],
callback: first,
});
const unregister = hotkeys.register({
keys: [Keys.escape],
callback: second,
});
hotkeys.register({
keys: [Keys.escape],
callback: disabled,
enabled: () => false,
});
pressKey('Escape');
expect(second).toHaveBeenCalledTimes(1);
expect(first).not.toHaveBeenCalled();
unregister();
unregister();
pressKey('Escape');
expect(first).toHaveBeenCalledTimes(1);
expect(disabled).not.toHaveBeenCalled();
});
it('должен разрешать Escape при отключённых основных хоткеях', () => {
hotkeys.destroy();
hotkeys = new Hotkeys(scope, () => false);
const drawing = jest.fn();
const back = jest.fn();
hotkeys.register({
keys: [Keys.t],
callback: drawing,
});
hotkeys.register({
keys: [Keys.escape],
callback: back,
enabled: () => true,
});
expect(pressKey('KeyT').defaultPrevented).toBe(false);
pressKey('Escape');
expect(drawing).not.toHaveBeenCalled();
expect(back).toHaveBeenCalledTimes(1);
});
it('должен проверять доступность обработчика при каждом событии', () => {
let enabled = false;
const callback = jest.fn();
hotkeys.register({
keys: [Keys.escape],
callback,
enabled: () => enabled,
});
pressKey('Escape');
enabled = true;
pressKey('Escape');
expect(callback).toHaveBeenCalledTimes(1);
});
it.each(['input', 'textarea', 'select', 'contenteditable'])(
'не должен обрабатывать хоткей в редактируемом элементе %s',
(kind) => {
const element = document.createElement(
kind === 'contenteditable' ? 'div' : kind,
);
if (kind === 'contenteditable') {
element.setAttribute('contenteditable', 'true');
}
scope.append(element);
const callback = jest.fn();
hotkeys.register({
keys: [Keys.escape],
callback,
});
pressKey('Escape', {}, element);
expect(callback).not.toHaveBeenCalled();
},
);
it.each([
{ repeat: true },
{ isComposing: true },
])('не должен обрабатывать повторное нажатие или композицию %j', (init) => {
const callback = jest.fn();
hotkeys.register({
keys: [Keys.escape],
callback,
});
pressKey('Escape', init);
expect(callback).not.toHaveBeenCalled();
});
it('не должен обрабатывать событие с preventDefault', () => {
const callback = jest.fn();
hotkeys.register({
keys: [Keys.escape],
callback,
});
const event = new KeyboardEvent('keydown', {
code: 'Escape',
bubbles: true,
cancelable: true,
});
event.preventDefault();
scope.dispatchEvent(event);
expect(callback).not.toHaveBeenCalled();
});
it('не должен обрабатывать хоткей при фокусе вне графика', () => {
const callback = jest.fn();
hotkeys.register({
keys: [Keys.escape],
callback,
});
const button = document.createElement('button');
document.body.append(button);
button.focus();
pressKey('Escape', {}, button);
expect(callback).not.toHaveBeenCalled();
});
it('должен удалять все варианты комбинации mod при отписке', () => {
const callback = jest.fn();
const unregister = hotkeys.register({
keys: [Keys.mod, Keys.z],
callback,
});
unregister();
pressKey('KeyZ', { ctrlKey: true });
pressKey('KeyZ', { metaKey: true });
pressKey('KeyZ', { ctrlKey: true, metaKey: true });
expect(callback).not.toHaveBeenCalled();
});
it('должен удалять обработчик событий при уничтожении', () => {
const callback = jest.fn();
hotkeys.register({
keys: [Keys.escape],
callback,
});
hotkeys.destroy();
pressKey('Escape');
expect(callback).not.toHaveBeenCalled();
});
it.each([
{ keys: [] },
{ keys: [Keys.t, Keys.z] },
{ keys: [Keys.mod, Keys.control] },
{ keys: [Keys.mod, Keys.meta] },
])('должен отклонять некорректную комбинацию $keys', ({ keys }) => {
expect(() =>
hotkeys.register({
keys,
callback: jest.fn(),
}),
).toThrow('[Hotkeys]');
});
});
import type { ComponentProps } from 'react';
import { cleanup, fireEvent, render } from '@testing-library/react';
import { RangeField } from '@src/components/FormFields/RangeField';
afterEach(() => {
cleanup();
jest.restoreAllMocks();
});
type IntervalRangeProps = Extract<
ComponentProps<typeof RangeField>,
{ mode: 'range' }
>;
function renderRangeField(props: Partial<IntervalRangeProps> = {}) {
const onRangeChange = jest.fn();
const result = render(
<RangeField
mode="range"
value={[20, 40]}
min={0}
max={100}
step={5}
onRangeChange={onRangeChange}
{...props}
/>,
);
const handles = result.getAllByRole('slider') as HTMLInputElement[];
const selection = result.container.querySelector<HTMLDivElement>(
'div[style*="left:"]',
)!;
const track = selection.parentElement!;
jest.spyOn(track, 'getBoundingClientRect').mockReturnValue({
width: 200,
} as DOMRect);
Object.defineProperty(selection, 'setPointerCapture', {
configurable: true,
value: jest.fn(),
});
return {
...result,
handles,
selection,
onRangeChange,
};
}
function pointer(
element: HTMLElement,
type: string,
clientX: number,
button = 0,
) {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientX,
button,
});
Object.defineProperty(event, 'pointerId', {
value: 1,
});
fireEvent(element, event);
}
describe('RangeField', () => {
it('должен сохранять поведение одиночного слайдера и связь с подписью', () => {
const onChange = jest.fn();
const result = render(
<RangeField
label="Opacity"
value={50}
onChange={onChange}
/>,
);
const input = result.getByLabelText('Opacity') as HTMLInputElement;
expect(result.container.textContent).toContain('50%');
fireEvent.change(input, {
target: { value: '60' },
});
expect(onChange).toHaveBeenCalledTimes(1);
});
it('не должен допускать пересечения границ диапазона', () => {
const { handles, onRangeChange } = renderRangeField();
fireEvent.change(handles[0], {
target: { value: '70' },
});
expect(onRangeChange).toHaveBeenLastCalledWith([40, 40]);
fireEvent.change(handles[1], {
target: { value: '10' },
});
expect(onRangeChange).toHaveBeenLastCalledWith([20, 20]);
});
it('должен вычислять положение диапазона относительно минимальной и максимальной границ', () => {
const { selection } = renderRangeField({
value: [2020, 2025],
min: 2010,
max: 2030,
suffix: '',
});
expect(selection.style.left).toBe('50%');
expect(selection.style.width).toBe('25%');
});
it('должен сортировать отметки и исключать повторяющиеся значения', () => {
const { container } = renderRangeField({
marks: [100, 50, 25, 50, -1, 200, 0],
});
const marks = [...container.querySelectorAll('span[style*="left:"]')].map(
(node) => node.textContent,
);
expect(marks).toEqual(['0%', '25%', '50%', '100%']);
});
it('должен переносить диапазон с учётом шага и сохранять его ширину', () => {
const { selection, onRangeChange } = renderRangeField();
pointer(selection, 'pointerdown', 100);
pointer(selection, 'pointermove', 121);
expect(onRangeChange).toHaveBeenLastCalledWith([30, 50]);
expect(selection.setPointerCapture).toHaveBeenCalledWith(1);
});
it.each([
[1000, [80, 100]],
[-1000, [0, 20]],
] as const)(
'должен ограничивать перенос границами шкалы при x=%s',
(clientX, expected) => {
const { selection, onRangeChange } = renderRangeField();
pointer(selection, 'pointerdown', 100);
pointer(selection, 'pointermove', clientX);
expect(onRangeChange).toHaveBeenLastCalledWith(expected);
},
);
it.each([
'pointerup',
'pointercancel',
'lostpointercapture',
])('должен завершать перенос после события %s', (endEvent) => {
const { selection, onRangeChange } = renderRangeField();
pointer(selection, 'pointerdown', 100);
pointer(selection, endEvent, 100);
pointer(selection, 'pointermove', 150);
expect(onRangeChange).not.toHaveBeenCalled();
});
it('не должен начинать перенос без нажатия основной кнопки', () => {
const { selection, onRangeChange } = renderRangeField();
pointer(selection, 'pointermove', 150);
pointer(selection, 'pointerdown', 100, 2);
pointer(selection, 'pointermove', 150);
expect(onRangeChange).not.toHaveBeenCalled();
});
it('не должен переносить диапазон при нулевой ширине шкалы', () => {
const { selection, onRangeChange } = renderRangeField();
jest.mocked(
selection.parentElement!.getBoundingClientRect,
).mockReturnValue({
width: 0,
} as DOMRect);
pointer(selection, 'pointerdown', 100);
pointer(selection, 'pointermove', 150);
expect(onRangeChange).not.toHaveBeenCalled();
});
it('должен отключать обе границы и перенос диапазона', () => {
const { handles, selection, onRangeChange } = renderRangeField({
disabled: true,
});
expect(handles.every((input) => input.disabled)).toBe(true);
pointer(selection, 'pointerdown', 100);
pointer(selection, 'pointermove', 150);
expect(onRangeChange).not.toHaveBeenCalled();
});
it('должен отключать диапазон нулевой длины и сохранять корректные позиции', () => {
const { handles, selection, container } = renderRangeField({
min: 2025,
max: 2025,
value: [2025, 2025],
suffix: '',
});
expect(handles.every((input) => input.disabled)).toBe(true);
expect(selection.style.left).toBe('0%');
expect(selection.style.width).toBe('0%');
expect(
[...container.querySelectorAll('span[style*="left:"]')].map(
(node) => node.textContent,
),
).toEqual(['2025']);
});
});
import { PriceScaleMode } from 'lightweight-charts';
import {
buildSeasonality,
normalizeRange,
toSeriesData,
} from '@src/utils/seasonality';
describe('normalizeRange', () => {
it.each([
[
{ fromYear: 2010, toYear: 2040 },
{ fromYear: 2020, toYear: 2025 },
],
[
{ fromYear: 2022.6, toYear: 2024.4 },
{ fromYear: 2023, toYear: 2024 },
],
[
{ fromYear: 2024, toYear: 2021 },
{ fromYear: 2024, toYear: 2024 },
],
[
{ fromYear: 2010, toYear: 2011 },
{ fromYear: 2020, toYear: 2020 },
],
])('должен ограничивать и упорядочивать диапазон %j', (input, expected) => {
expect(normalizeRange(input, 2020, 2025)).toEqual(expected);
});
it.each([NaN, Infinity, -Infinity])(
'должен отклонять некорректную границу %s',
(value) => {
expect(() =>
normalizeRange(
{ fromYear: value, toYear: 2024 },
2020,
2025,
),
).toThrow('Invalid year range');
expect(() =>
normalizeRange(
{ fromYear: 2020, toYear: value },
2020,
2025,
),
).toThrow('Invalid year range');
},
);
});
describe('buildSeasonality', () => {
it('должен возвращать пустой результат для пустой истории', () => {
expect(buildSeasonality([])).toEqual([]);
});
it('должен сортировать данные без изменения исходного массива и выбирать базу отдельно для каждого года', () => {
const rows = [
{ date: '2024-01-03', close: 125 },
{ date: '2023-12-31', close: 50 },
{ date: '2024-01-02', close: 100 },
];
const snapshot = rows.map((row) => ({ ...row }));
const years = buildSeasonality(rows);
expect(rows).toEqual(snapshot);
expect(
years.map(({ year, baselinePrice }) => [year, baselinePrice]),
).toEqual([
[2023, 50],
[2024, 100],
]);
expect(years[1].baselineDate).toBe('2024-01-02');
expect(
years[1].points.map(({ percentage }) => percentage),
).toEqual([0, 25]);
expect(years[1].points[0].chartDate).toBe('2000-01-02');
});
it('должен рассчитывать отрицательную доходность', () => {
const [year] = buildSeasonality([
{ date: '2024-01-01', close: 100 },
{ date: '2024-01-02', close: 75 },
]);
expect(year.points[1].percentage).toBe(-25);
});
it.each([0, -10])(
'должен сохранять цены без процентов при неположительной базе %s',
(close) => {
const [year] = buildSeasonality([
{ date: '2024-01-01', close },
{ date: '2024-01-02', close: 10 },
]);
expect(
year.points.map((point) => point.percentage),
).toEqual([null, null]);
expect(year.points[1].price).toBe(10);
},
);
it('должен отклонять повторяющиеся даты независимо от порядка данных', () => {
expect(() =>
buildSeasonality([
{ date: '2024-01-01', close: 100 },
{ date: '2023-01-01', close: 90 },
{ date: '2024-01-01', close: 101 },
]),
).toThrow('Multiple daily candles');
});
it('должен отклонять переполнение при расчёте доходности', () => {
expect(() =>
buildSeasonality([
{ date: '2024-01-01', close: Number.MIN_VALUE },
{ date: '2024-01-02', close: Number.MAX_VALUE },
]),
).toThrow('Invalid percentage');
});
});
describe('toSeriesData', () => {
it('должен совмещать годы по високосному календарю и сохранять пропуски торговых дней', () => {
const [year] = buildSeasonality([
{ date: '2023-02-28', close: 100 },
{ date: '2023-03-01', close: 110 },
]);
const data = toSeriesData(year, PriceScaleMode.Normal);
expect(data).toHaveLength(366);
expect(data[0]).toEqual({ time: '2000-01-01' });
expect(data[data.length - 1]).toEqual({ time: '2000-12-31' });
expect(
data.find((point) => point.time === '2000-02-28'),
).toEqual({
time: '2000-02-28',
value: 100,
});
expect(
data.find((point) => point.time === '2000-02-29'),
).toEqual({
time: '2000-02-29',
});
expect(
data.find((point) => point.time === '2000-03-01'),
).toEqual({
time: '2000-03-01',
value: 110,
});
});
it('должен сохранять 29 февраля и рассчитывать процентные значения', () => {
const [year] = buildSeasonality([
{ date: '2024-02-29', close: 10 },
{ date: '2024-03-01', close: 15 },
]);
const data = toSeriesData(year, PriceScaleMode.Percentage);
expect(
data.find((point) => point.time === '2000-02-29'),
).toEqual({
time: '2000-02-29',
value: 0,
});
expect(
data.find((point) => point.time === '2000-03-01'),
).toEqual({
time: '2000-03-01',
value: 50,
});
});
it('должен оставлять пропуски вместо процентов при отсутствии положительной базы', () => {
const [year] = buildSeasonality([
{ date: '2024-01-01', close: 0 },
]);
expect(
toSeriesData(year, PriceScaleMode.Percentage)[0],
).toEqual({
time: '2000-01-01',
});
expect(
toSeriesData(year, PriceScaleMode.Normal)[0],
).toEqual({
time: '2000-01-01',
value: 0,
});
});
});
import { cleanup, render } from '@testing-library/react';
import { ChartViewTypeIcon } from '@src/components/ChartViewTypeIcon';
import { ChartViewType } from '@src/types';
jest.mock('@src/components/Icon', () => ({
ChartIcon: () => <svg data-testid="chart-icon" />,
SeasonalityIcon: () => <svg data-testid="seasonality-icon" />,
}));
afterEach(cleanup);
describe('ChartViewTypeIcon', () => {
it('должен показывать иконку основного графика', () => {
const { getByTestId, queryByTestId } = render(
<ChartViewTypeIcon type={ChartViewType.Chart} />,
);
expect(getByTestId('chart-icon')).toBeTruthy();
expect(queryByTestId('seasonality-icon')).toBeNull();
});
it('должен показывать иконку сезонности', () => {
const { getByTestId, queryByTestId } = render(
<ChartViewTypeIcon type={ChartViewType.Seasonality} />,
);
expect(getByTestId('seasonality-icon')).toBeTruthy();
expect(queryByTestId('chart-icon')).toBeNull();
});
it('должен показывать иконку основного графика по умолчанию', () => {
const { getByTestId, queryByTestId } = render(
<ChartViewTypeIcon />,
);
expect(getByTestId('chart-icon')).toBeTruthy();
expect(queryByTestId('seasonality-icon')).toBeNull();
});
});