Загрузка данных
import isNil from 'lodash/isNil';
import { Intervals, MoexChart, Timeframes } from 'moex-chart';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useChangeProperties, useSelectProperties } from '@modules/widgetProperties';
import { WidgetProperties } from '@widgets/Chart/properties/types';
import { ChartIndicativeData } from '@widgets/Chart/types';
import { MOEX_CHART_CONFIG } from '../constants';
import { DataSourceProvider } from '../dataSourceProvide';
import type { __CompareManager__, IMoexChart, SymbolInfoInput } from 'moex-chart';
interface TUseMoexChartProps {
symbolInfo: SymbolInfoInput;
indicativeData?: ChartIndicativeData;
}
export const useMoexChart = ({ symbolInfo, indicativeData }: TUseMoexChartProps) => {
const moexChartState = useSelectProperties(
(widgetProperties: Partial<WidgetProperties>) => widgetProperties.moexChartState,
);
const { updateProperties } = useChangeProperties<WidgetProperties>();
const [isCompareOpen, setIsCompareOpen] = useState(false);
const [isSymbolSearchOpen, setIsSymbolSearchOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<MoexChart | null>(null);
const compareManagerRef = useRef<__CompareManager__ | null>(null);
const currentSymbolInfoRef = useRef<SymbolInfoInput>(symbolInfo);
const timeframeRef = useRef<Timeframes | undefined>(moexChartState?.timeframe);
const savedDataRef = useRef<string | undefined>(moexChartState?.savedData);
const initialIntervalRef = useRef<Intervals | undefined>(moexChartState?.initialInterval);
const updateTimeframeRef = useRef<((timeframe: Timeframes) => void) | null>(null);
useEffect(() => {
if (
currentSymbolInfoRef.current.symbolId === symbolInfo.symbolId &&
currentSymbolInfoRef.current.symbol === symbolInfo.symbol &&
currentSymbolInfoRef.current.symbolName === symbolInfo.symbolName
) {
return;
}
currentSymbolInfoRef.current = {
symbolId: symbolInfo.symbolId,
symbol: symbolInfo.symbol,
symbolName: symbolInfo.symbolName,
};
chartRef.current?.setSymbol(currentSymbolInfoRef.current);
}, [symbolInfo.symbolId, symbolInfo.symbol, symbolInfo.symbolName]);
useEffect(() => {
timeframeRef.current = moexChartState?.timeframe;
savedDataRef.current = moexChartState?.savedData;
}, [moexChartState?.savedData, moexChartState?.timeframe]);
updateTimeframeRef.current = (timeframe: Timeframes) => {
if (timeframeRef.current === timeframe) {
return;
}
timeframeRef.current = timeframe;
updateProperties((state) => {
state.moexChartState = {
...state.moexChartState,
timeframe,
};
});
};
const saveSnapshot = useCallback((): void => {
const snapshot = chartRef.current?.getSnapshot();
if (!snapshot) {
return;
}
const savedData = JSON.stringify({
...snapshot,
charts: snapshot.charts.map((chart) => ({
...chart,
panes: chart.panes.map((pane) => ({
...pane,
indicators: pane.indicators.map((indicator) => {
const compareSeries = indicator.config?.series[0];
const compareSymbolInfo = indicator.config?.symbolInfo;
return {
...indicator,
dataSource: undefined, // dataSource пока не среиализуем
config:
indicator.indicatorType === undefined && compareSymbolInfo && indicator.config?.label && compareSeries
? {
symbolInfo: compareSymbolInfo,
label: indicator.config.label,
newPane: indicator.config.newPane,
series: [
{
name: compareSeries.name,
seriesOptions: {
priceScaleId: compareSeries.seriesOptions?.priceScaleId,
},
},
],
}
: undefined,
};
}),
})),
})),
});
if (savedDataRef.current === savedData) {
return;
}
savedDataRef.current = savedData;
updateProperties((state) => {
state.moexChartState = {
...state.moexChartState,
timeframe: timeframeRef.current || Timeframes['1m'],
savedData,
};
});
}, [updateProperties]);
const getSnapshotWithCurrentSymbol = (
snapshot: typeof MOEX_CHART_CONFIG.snapshot | IMoexChart['snapshot'],
timeframe: Timeframes,
): IMoexChart['snapshot'] => ({
...snapshot,
charts: snapshot.charts.map((chartSnapshot) => ({
...chartSnapshot,
...currentSymbolInfoRef.current,
timeframe,
})),
});
const applySnapshot = (): void => {
if (!savedDataRef.current || !chartRef.current) {
return;
}
const savedSnapshot = JSON.parse(savedDataRef.current) as IMoexChart['snapshot'];
const timeframe = timeframeRef.current || Timeframes['1m'];
chartRef.current.setSnapshot(getSnapshotWithCurrentSymbol(savedSnapshot, timeframe));
compareManagerRef.current = chartRef.current.getCompareManager();
};
useEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const timeframe = timeframeRef.current || Timeframes['1m'];
const savedSnapshot = savedDataRef.current
? (JSON.parse(savedDataRef.current) as IMoexChart['snapshot'])
: MOEX_CHART_CONFIG.snapshot;
const dataProvider = new DataSourceProvider();
const chart = new MoexChart({
...MOEX_CHART_CONFIG,
container,
snapshot: getSnapshotWithCurrentSymbol(savedSnapshot, timeframe),
chartCollectionPreset: {
...MOEX_CHART_CONFIG.chartCollectionPreset,
openCompareModal: () => setIsCompareOpen(true),
openSymbolSearchModal: () => setIsSymbolSearchOpen(true),
getDataSource: dataProvider.getDataSource(indicativeData, (nextTimeframe) => {
updateTimeframeRef.current?.(nextTimeframe);
}),
startRealtime: (getSymbols, getTimeframe, update) =>
dataProvider.startRealtime({
getSymbols,
getTimeframe,
update,
indicativeData,
}),
},
});
chartRef.current = chart;
compareManagerRef.current = chart.getCompareManager();
if (initialIntervalRef.current) {
chart.setSettings({ interval: initialIntervalRef.current });
updateProperties((state) => {
state.moexChartState = {
...state.moexChartState,
initialInterval: undefined,
};
});
initialIntervalRef.current = undefined;
}
const intervalId = setInterval(() => {
saveSnapshot();
}, 1000);
// явная инициализация индикативных данных в график
if (!isNil(indicativeData)) {
chartRef.current?.setSymbol({ symbolId: indicativeData.key, symbolName: indicativeData.instrumentName });
}
return () => {
clearInterval(intervalId);
saveSnapshot();
chartRef.current = null;
compareManagerRef.current = null;
chart.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- исправим позже
}, [indicativeData?.key]);
return {
containerRef,
isCompareOpen,
isSymbolSearchOpen,
compareManagerRef,
setIsCompareOpen,
setIsSymbolSearchOpen,
saveSnapshot,
applySnapshot,
hasSavedSnapshot: Boolean(moexChartState?.savedData),
};
};
import type { ChartIndicativeData } from '../types';
import type { Contract } from '@modules/contracts/types';
import type { WidgetProperties as BaseWidgetProperties } from '@modules/widgetProperties/types';
import type { Intervals, Timeframes } from 'moex-chart';
export interface WidgetProperties extends BaseWidgetProperties {
chartState: {
savedInstrument: Contract['issKey'] | null;
savedSymbol?: Contract['symbol'];
savedSymbolName?: Contract['displayName'];
interval: string;
savedData?: string;
};
indicativeData?: ChartIndicativeData;
moexChartState?: {
initialInterval?: Intervals;
timeframe?: Timeframes;
savedData?: string;
};
}
import { ContextMenuItem } from '@uikit/ContextMenu/types';
import { useContextMenuOverlay } from '@uikit/ContextMenuOverlay';
import { createGraphicWidget } from '@widgets/Chart/creator';
import { downloadHistory } from '@widgets/ntb/Indexes/utils/downloadHistory';
import { isNtbIndex } from '@widgets/ntb/Indexes/utils/isNtbIndex';
import type { IndexesTableDataItem } from '../types';
type UseContextMenuProps = {
onShowHistory: (secId: string, sourceId?: string) => void;
};
export const useContextMenu = ({ onShowHistory }: UseContextMenuProps) => {
const {
onContextMenu,
menuProps,
payload: instrument,
} = useContextMenuOverlay<IndexesTableDataItem>({
shouldOpen: (record) => !!record?.issKey || isNtbIndex(record?.sourceId),
});
const onOpenChart = () => {
if (instrument) {
createGraphicWidget({
chartState: {
savedInstrument: instrument.issKey,
},
moexChartState: {
tf: '1d',
initialInterval: '1Y',
},
});
}
};
const handleShowHistory = () => {
if (instrument) {
onShowHistory(instrument.securityId, instrument.sourceId);
}
};
const handleDownloadHistory = () => {
if (instrument) {
downloadHistory(instrument.securityId, instrument.sourceId);
}
};
const items: ContextMenuItem[] = [
...(instrument?.issKey ? [{ key: 'openChart', label: 'Отобразить в виджете График', onClick: onOpenChart }] : []),
{ key: 'showHistory', label: 'Отобразить исторические данные', onClick: handleShowHistory },
{ key: 'downloadHistory', label: 'Экспорт истории в Excel', onClick: handleDownloadHistory },
];
return { onContextMenu, menuProps, items };
};
import { useCallback } from 'react';
import { createGraphicWidget } from '@widgets/Chart/creator';
import { useCalcLogistic } from '../hooks/useCalcLogistic';
import type { LogisticContextMenuProps } from './LogisticContextMenu';
export const useContextMenu = ({
row,
setIsActiveHistoryData,
disableRequestToChat,
setOpenContextMenu,
groupName,
}: Pick<
LogisticContextMenuProps,
'row' | 'setIsActiveHistoryData' | 'disableRequestToChat' | 'setOpenContextMenu' | 'groupName'
>) => {
const { calcLogistic, isAvailable: isCalcLogisticAvailable } = useCalcLogistic(row?.partner.unicode, groupName);
const issKey = row?.issKey ?? null;
const handleOpenRequestToChatModal = useCallback(() => {
calcLogistic();
setOpenContextMenu(false);
}, [calcLogistic, setOpenContextMenu]);
const handleShowHistoryData = useCallback(() => {
setOpenContextMenu(false);
setIsActiveHistoryData(true);
}, [setIsActiveHistoryData, setOpenContextMenu]);
const handleOpenChart = useCallback(() => {
if (issKey) {
createGraphicWidget({
chartState: { savedInstrument: issKey },
moexChartState: { tf: '1d', initialInterval: '1Y' },
});
}
setOpenContextMenu(false);
}, [issKey, setOpenContextMenu]);
const showRequestToChatOption = isCalcLogisticAvailable && !disableRequestToChat;
const showChartOption = Boolean(issKey);
return {
handleOpenRequestToChatModal,
handleShowHistoryData,
handleOpenChart,
showRequestToChatOption,
showChartOption,
};
};
import { act, render } from '@testing-library/react';
import { MoexChart, Timeframes } from 'moex-chart';
import React from 'react';
import { useChangeProperties, useSelectProperties } from '@modules/widgetProperties';
import { useMoexChart } from '../components/MoexChart/hooks';
import type { ChartIndicativeData } from '@widgets/Chart/types';
import type { SymbolInfoInput } from 'moex-chart';
jest.mock('@modules/widgetProperties');
jest.mock('../components/MoexChart/dataSourceProvide', () => ({
DataSourceProvider: jest.fn(),
}));
jest.mock('moex-chart', () => ({
MoexChart: jest.fn(),
Timeframes: {
'10s': '10s',
'1m': '1m',
'5m': '5m',
},
DateFormat: {
DD_MM_YYYY_HH_mm_ss: 'DD_MM_YYYY_HH_mm_ss',
},
IndicatorsIds: {
Volume: 'Volume',
},
Locale: {
rus: 'ru-RU',
eng: 'en-US',
},
}));
jest.mock('@api/index', () => ({
updateMenuLocked: jest.fn(),
widgetsController: {
delete: jest.fn(),
},
widgetPropertiesController: {
update: jest.fn(),
},
workspaceController: {
update: jest.fn(),
},
}));
jest.mock('@api/controllers/workspace', () => ({
workspaceController: {
update: jest.fn(),
},
}));
const mockedDataSourceProvide = jest.requireMock('../components/MoexChart/dataSourceProvide') as {
DataSourceProvider: jest.Mock;
};
type TimeframeValue = (typeof Timeframes)[keyof typeof Timeframes];
interface MockCompareSeries {
name: string;
seriesOptions?: {
priceScaleId?: string;
color?: string;
};
}
interface MockIndicatorConfig {
symbolInfo?: SymbolInfoInput;
label?: string;
newPane?: boolean;
series: MockCompareSeries[];
}
interface MockIndicatorSnapshot {
id?: string;
indicatorType?: string;
dataSource?: unknown;
config?: MockIndicatorConfig;
}
interface MockPaneSnapshot {
indicators: MockIndicatorSnapshot[];
}
interface MockChartSnapshot {
charts: {
symbolId: string;
symbol: string;
symbolName: string;
timeframe: TimeframeValue;
chartSeriesType: string;
panes: MockPaneSnapshot[];
}[];
}
interface MockMoexChartConfig {
container: HTMLElement;
snapshot: MockChartSnapshot;
chartCollectionPreset: {
openCompareModal: () => void;
openSymbolSearchModal: () => void;
getDataSource: jest.Mock;
startRealtime: (
getSymbols: () => string[],
getTimeframe: () => TimeframeValue,
update: jest.Mock,
) => (() => void) | undefined;
};
}
interface MockMoexChartState {
timeframe?: TimeframeValue;
savedData?: string;
}
interface MockPropertiesState {
moexChartState?: MockMoexChartState;
}
type UpdatePropertiesCallback = (state: MockPropertiesState) => void;
type TimeframeChangeCallback = (timeframe: TimeframeValue) => void;
interface TestComponentProps {
symbolInfo?: SymbolInfoInput;
indicativeData?: ChartIndicativeData;
}
const defaultSymbolInfo: SymbolInfoInput = {
symbolId: 'MOEX:SBER',
symbol: 'SBER',
symbolName: 'Сбербанк',
};
describe('useMoexChart', () => {
const mockUseSelectProperties = useSelectProperties as jest.Mock;
const mockUseChangeProperties = useChangeProperties as jest.Mock;
const mockMoexChart = MoexChart as unknown as jest.Mock;
const mockDataSourceProvider = mockedDataSourceProvide.DataSourceProvider;
const mockUpdateProperties = jest.fn();
const mockDestroy = jest.fn();
const mockGetSnapshot = jest.fn();
const mockSetSnapshot = jest.fn();
const mockSetSymbol = jest.fn();
const mockGetCompareManager = jest.fn();
const mockSetSettings = jest.fn();
const mockDataSource = jest.fn();
const mockStartRealtime = jest.fn();
const mockGetDataSource = jest.fn();
const mockRealtimeUnsubscribe = jest.fn();
const mockCompareManager = {
id: 'compare-manager',
};
const mockSnapshot: MockChartSnapshot = {
charts: [
{
symbolId: 'OLD:SYMBOL',
symbol: 'OLD',
symbolName: 'Old instrument',
timeframe: Timeframes['5m'],
chartSeriesType: 'Candlestick',
panes: [
{
indicators: [],
},
],
},
],
};
let hookResult: ReturnType<typeof useMoexChart> | null = null;
let lastMoexChartConfig: MockMoexChartConfig | null = null;
let mockMoexChartState: MockMoexChartState | undefined;
const TestComponent = ({
symbolInfo = defaultSymbolInfo,
indicativeData,
}: TestComponentProps): React.ReactElement => {
hookResult = useMoexChart({
symbolInfo,
indicativeData,
});
return <div ref={hookResult.containerRef} />;
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
hookResult = null;
lastMoexChartConfig = null;
mockMoexChartState = {
timeframe: Timeframes['1m'],
savedData: undefined,
};
mockUseSelectProperties.mockImplementation((selector: (state: MockPropertiesState) => unknown) =>
selector({
moexChartState: mockMoexChartState,
}),
);
mockUseChangeProperties.mockReturnValue({
updateProperties: mockUpdateProperties,
});
mockGetDataSource.mockImplementation(
(_indicativeData?: ChartIndicativeData, callback?: (timeframe: TimeframeValue) => void) =>
(timeframe: TimeframeValue) => {
callback?.(timeframe);
return mockDataSource();
},
);
mockStartRealtime.mockReturnValue(mockRealtimeUnsubscribe);
mockDataSourceProvider.mockImplementation(() => ({
getDataSource: mockGetDataSource,
startRealtime: mockStartRealtime,
}));
mockGetSnapshot.mockReturnValue(mockSnapshot);
mockGetCompareManager.mockReturnValue(mockCompareManager);
mockMoexChart.mockImplementation((config: MockMoexChartConfig) => {
lastMoexChartConfig = config;
return {
destroy: mockDestroy,
getSnapshot: mockGetSnapshot,
setSnapshot: mockSetSnapshot,
setSymbol: mockSetSymbol,
setSettings: mockSetSettings,
getCompareManager: mockGetCompareManager,
setSettings: mockSetSettings,
};
});
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
it('should create moex chart with current symbol info and timeframe', () => {
// Arrange & Act
render(<TestComponent />);
// Assert
expect(mockMoexChart).toHaveBeenCalledTimes(1);
expect(mockDataSourceProvider).toHaveBeenCalledTimes(1);
expect(lastMoexChartConfig?.container).toBeInstanceOf(HTMLDivElement);
expect(lastMoexChartConfig?.snapshot.charts[0]).toEqual(
expect.objectContaining({
symbolId: 'MOEX:SBER',
symbol: 'SBER',
symbolName: 'Сбербанк',
timeframe: Timeframes['1m'],
}),
);
expect(hookResult?.compareManagerRef.current).toBe(mockCompareManager);
expect(hookResult?.hasSavedSnapshot).toBe(false);
});
it('should create chart with saved snapshot and current symbol info', () => {
// Arrange
mockMoexChartState = {
timeframe: Timeframes['5m'],
savedData: JSON.stringify(mockSnapshot),
};
// Act
render(
<TestComponent
symbolInfo={{
symbolId: 'MOEX:GAZP',
symbol: 'GAZP',
symbolName: 'Газпром',
}}
/>,
);
// Assert
expect(lastMoexChartConfig?.snapshot.charts[0]).toEqual(
expect.objectContaining({
symbolId: 'MOEX:GAZP',
symbol: 'GAZP',
symbolName: 'Газпром',
timeframe: Timeframes['5m'],
}),
);
expect(hookResult?.hasSavedSnapshot).toBe(true);
});
it('should save chart snapshot to widget properties', () => {
// Arrange
render(<TestComponent />);
// Act
act(() => {
hookResult?.saveSnapshot();
});
// Assert
expect(mockGetSnapshot).toHaveBeenCalledTimes(1);
expect(mockUpdateProperties).toHaveBeenCalledTimes(1);
const updateCallback = mockUpdateProperties.mock.calls[0]?.[0] as UpdatePropertiesCallback;
const mockState: MockPropertiesState = {
moexChartState: {
timeframe: Timeframes['1m'],
},
};
updateCallback(mockState);
expect(mockState.moexChartState).toEqual({
timeframe: Timeframes['1m'],
savedData: JSON.stringify(mockSnapshot),
});
});
it('should serialize compare symbol info in snapshot', () => {
// Arrange
const snapshotWithIndicators: MockChartSnapshot = {
charts: [
{
symbolId: 'MOEX:SBER',
symbol: 'SBER',
symbolName: 'Сбербанк',
timeframe: Timeframes['1m'],
chartSeriesType: 'Candlestick',
panes: [
{
indicators: [
{
id: 'compare-gazp',
indicatorType: undefined,
dataSource: {
subscription: {},
},
config: {
symbolInfo: {
symbolId: 'MOEX:GAZP',
symbol: 'GAZP',
symbolName: 'Газпром',
},
label: 'Газпром',
newPane: false,
series: [
{
name: 'Line',
seriesOptions: {
priceScaleId: 'left',
color: '#FFFFFF',
},
},
],
},
},
{
id: 'rsi',
indicatorType: 'RSI',
dataSource: {
subscription: {},
},
config: {
label: 'RSI',
newPane: true,
series: [
{
name: 'Line',
seriesOptions: {
priceScaleId: 'right',
},
},
],
},
},
],
},
],
},
],
};
mockGetSnapshot.mockReturnValue(snapshotWithIndicators);
render(<TestComponent />);
// Act
act(() => {
hookResult?.saveSnapshot();
});
const updateCallback = mockUpdateProperties.mock.calls[0]?.[0] as UpdatePropertiesCallback;
const mockState: MockPropertiesState = {
moexChartState: {
timeframe: Timeframes['1m'],
},
};
updateCallback(mockState);
const savedSnapshot = JSON.parse(mockState.moexChartState?.savedData ?? '{}') as MockChartSnapshot;
const [compareIndicator, regularIndicator] = savedSnapshot.charts[0]?.panes[0]?.indicators ?? [];
// Assert
expect(compareIndicator).toEqual({
id: 'compare-gazp',
config: {
symbolInfo: {
symbolId: 'MOEX:GAZP',
symbol: 'GAZP',
symbolName: 'Газпром',
},
label: 'Газпром',
newPane: false,
series: [
{
name: 'Line',
seriesOptions: {
priceScaleId: 'left',
},
},
],
},
});
expect(regularIndicator).toEqual({
id: 'rsi',
indicatorType: 'RSI',
});
});
it('should not save snapshot when chart does not return snapshot', () => {
// Arrange
mockGetSnapshot.mockReturnValue(undefined);
render(<TestComponent />);
// Act
act(() => {
hookResult?.saveSnapshot();
});
// Assert
expect(mockGetSnapshot).toHaveBeenCalledTimes(1);
expect(mockUpdateProperties).not.toHaveBeenCalled();
});
it('should apply saved snapshot with current symbol info and timeframe', () => {
// Arrange
mockMoexChartState = {
timeframe: Timeframes['5m'],
savedData: JSON.stringify(mockSnapshot),
};
render(<TestComponent />);
// Act
act(() => {
hookResult?.applySnapshot();
});
// Assert
expect(mockSetSnapshot).toHaveBeenCalledWith({
...mockSnapshot,
charts: [
{
...mockSnapshot.charts[0],
symbolId: 'MOEX:SBER',
symbol: 'SBER',
symbolName: 'Сбербанк',
timeframe: Timeframes['5m'],
},
],
});
expect(hookResult?.compareManagerRef.current).toBe(mockCompareManager);
});
it('should update compare modal state', () => {
// Arrange
render(<TestComponent />);
// Act
act(() => {
hookResult?.setIsCompareOpen(true);
});
// Assert
expect(hookResult?.isCompareOpen).toBe(true);
});
it('should open compare modal from chart preset callback', () => {
// Arrange
render(<TestComponent />);
// Act
act(() => {
lastMoexChartConfig?.chartCollectionPreset.openCompareModal();
});
// Assert
expect(hookResult?.isCompareOpen).toBe(true);
});
it('should update symbol search modal state', () => {
// Arrange
render(<TestComponent />);
// Act
act(() => {
hookResult?.setIsSymbolSearchOpen(true);
});
// Assert
expect(hookResult?.isSymbolSearchOpen).toBe(true);
});
it('should open symbol search modal from chart preset callback', () => {
// Arrange
render(<TestComponent />);
// Act
act(() => {
lastMoexChartConfig?.chartCollectionPreset.openSymbolSearchModal();
});
// Assert
expect(hookResult?.isSymbolSearchOpen).toBe(true);
});
it('should update chart when external symbol info changes', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(
<TestComponent
symbolInfo={{
symbolId: 'MOEX:GAZP',
symbol: 'GAZP',
symbolName: 'Газпром',
}}
/>,
);
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
expect(mockSetSymbol).toHaveBeenCalledWith({
symbolId: 'MOEX:GAZP',
symbol: 'GAZP',
symbolName: 'Газпром',
});
});
it('should update chart when only symbol name changes', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(
<TestComponent
symbolInfo={{
symbolId: 'MOEX:SBER',
symbol: 'SBER',
symbolName: 'Сбербанк ПАО',
}}
/>,
);
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
expect(mockSetSymbol).toHaveBeenCalledWith({
symbolId: 'MOEX:SBER',
symbol: 'SBER',
symbolName: 'Сбербанк ПАО',
});
});
it('should update chart when only symbol changes', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(
<TestComponent
symbolInfo={{
symbolId: 'MOEX:SBER',
symbol: 'SBERP',
symbolName: 'Сбербанк',
}}
/>,
);
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
expect(mockSetSymbol).toHaveBeenCalledWith({
symbolId: 'MOEX:SBER',
symbol: 'SBERP',
symbolName: 'Сбербанк',
});
});
it('should delegate missing symbol name fallback to moex chart', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(
<TestComponent
symbolInfo={{
symbolId: 'MOEX:SBER',
symbol: 'SBER',
}}
/>,
);
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
expect(mockSetSymbol).toHaveBeenCalledWith({
symbolId: 'MOEX:SBER',
symbol: 'SBER',
symbolName: undefined,
});
});
it('should delegate missing symbol fallback to moex chart', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(
<TestComponent
symbolInfo={{
symbolId: 'MOEX:SBER',
symbolName: 'Сбербанк',
}}
/>,
);
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
expect(mockSetSymbol).toHaveBeenCalledWith({
symbolId: 'MOEX:SBER',
symbol: undefined,
symbolName: 'Сбербанк',
});
});
it('should not update chart when symbol info is unchanged', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(<TestComponent />);
// Assert
expect(mockSetSymbol).not.toHaveBeenCalled();
});
it('should not recreate chart when external symbol changes', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(
<TestComponent
symbolInfo={{
symbolId: 'MOEX:GAZP',
symbol: 'GAZP',
symbolName: 'Газпром',
}}
/>,
);
// Assert
expect(mockMoexChart).toHaveBeenCalledTimes(1);
});
it('should apply saved snapshot with externally selected symbol', () => {
// Arrange
mockMoexChartState = {
timeframe: Timeframes['5m'],
savedData: JSON.stringify(mockSnapshot),
};
const { rerender } = render(<TestComponent />);
rerender(
<TestComponent
symbolInfo={{
symbolId: 'MOEX:GAZP',
symbol: 'GAZP',
symbolName: 'Газпром',
}}
/>,
);
// Act
act(() => {
hookResult?.applySnapshot();
});
// Assert
expect(mockSetSnapshot).toHaveBeenCalledWith({
...mockSnapshot,
charts: [
{
...mockSnapshot.charts[0],
symbolId: 'MOEX:GAZP',
symbol: 'GAZP',
symbolName: 'Газпром',
timeframe: Timeframes['5m'],
},
],
});
});
it('should initialize indicative instrument with symbol info', () => {
// Arrange
const indicativeData: ChartIndicativeData = {
id: 1,
title: 'Indicative instrument',
secId: 'INAV',
instrumentName: 'Индикатив',
settlement: 'Расчётный',
firmName: 'Тестовая фирма',
key: '2xOFZ:INAV',
};
// Act
render(
<TestComponent
symbolInfo={{
symbolId: '2xOFZ:INAV',
symbol: 'INAV',
symbolName: 'Индикатив Расчётный',
}}
indicativeData={indicativeData}
/>,
);
// Assert
expect(mockGetDataSource).toHaveBeenCalledWith(indicativeData, expect.any(Function));
expect(lastMoexChartConfig?.snapshot.charts[0]).toEqual(
expect.objectContaining({
symbolId: '2xOFZ:INAV',
symbol: 'INAV',
symbolName: 'Индикатив Расчётный',
}),
);
});
it('should pass realtime params to data source provider', () => {
// Arrange
render(<TestComponent />);
const getSymbols = jest.fn(() => ['MOEX:SBER']);
const getTimeframe = jest.fn(() => Timeframes['1m']);
const update = jest.fn();
// Act
const unsubscribe = lastMoexChartConfig?.chartCollectionPreset.startRealtime(getSymbols, getTimeframe, update);
// Assert
expect(mockStartRealtime).toHaveBeenCalledWith({
getSymbols,
getTimeframe,
update,
});
expect(unsubscribe).toBe(mockRealtimeUnsubscribe);
});
it('should update timeframe from data source callback', () => {
// Arrange
render(<TestComponent />);
const timeframeChangeCallback = mockGetDataSource.mock.calls[0]?.[1] as TimeframeChangeCallback;
// Act
act(() => {
timeframeChangeCallback(Timeframes['5m']);
});
// Assert
expect(mockUpdateProperties).toHaveBeenCalledTimes(1);
const updateCallback = mockUpdateProperties.mock.calls[0]?.[0] as UpdatePropertiesCallback;
const mockState: MockPropertiesState = {
moexChartState: {
timeframe: Timeframes['1m'],
},
};
updateCallback(mockState);
expect(mockState.moexChartState?.timeframe).toBe(Timeframes['5m']);
});
it('should not update timeframe when it is the same as current timeframe', () => {
// Arrange
render(<TestComponent />);
const timeframeChangeCallback = mockGetDataSource.mock.calls[0]?.[1] as TimeframeChangeCallback;
// Act
act(() => {
timeframeChangeCallback(Timeframes['1m']);
});
// Assert
expect(mockUpdateProperties).not.toHaveBeenCalled();
});
it('should destroy chart on unmount', () => {
// Arrange
const { unmount } = render(<TestComponent />);
// Act
unmount();
// Assert
expect(mockDestroy).toHaveBeenCalledTimes(1);
});
it('should apply initial interval on chart creation', () => {
// Arrange
mockMoexChartState = {
timeframe: Timeframes['1m'],
savedData: undefined,
initialInterval: '1Y',
};
// Act
render(<TestComponent symbol="MOEX:SBER" />);
// Assert
expect(mockSetSettings).toHaveBeenCalledWith({ interval: '1Y' });
});
it('should apply initial interval even when saved data exists', () => {
// Arrange
mockMoexChartState = {
timeframe: Timeframes['5m'],
savedData: JSON.stringify(mockSnapshot),
initialInterval: '1Y',
};
// Act
render(<TestComponent symbol="MOEX:SBER" />);
// Assert
expect(mockSetSettings).toHaveBeenCalledWith({ interval: '1Y' });
});
it('should clear initial interval in widget properties after applying', () => {
// Arrange
mockMoexChartState = {
timeframe: Timeframes['1m'],
savedData: undefined,
initialInterval: '1Y',
};
// Act
render(<TestComponent symbol="MOEX:SBER" />);
// Assert
expect(mockUpdateProperties).toHaveBeenCalledTimes(1);
const updateCallback = mockUpdateProperties.mock.calls[0]?.[0] as UpdatePropertiesCallback;
const mockState: MockPropertiesState = {
moexChartState: {
timeframe: Timeframes['1m'],
initialInterval: '1Y',
},
};
updateCallback(mockState);
expect(mockState.moexChartState?.initialInterval).toBeUndefined();
});
it('should not apply interval when initial interval is not set', () => {
// Arrange
render(<TestComponent symbol="MOEX:SBER" />);
// Assert
expect(mockSetSettings).not.toHaveBeenCalled();
});
});