Загрузка данных
import { Contract } from '@modules/contracts';
import type { WidgetProperties } from './properties/types';
import type { WidgetProperties as BaseWidgetProperties } from '@modules/widgetProperties/types';
import type { WidgetContentBasicProps } from 'types/Widgets';
export interface ChartIndicativeData extends BaseWidgetProperties {
secId: string;
instrumentName: string;
settlement: string;
firmName: string;
key: string;
}
export interface SelectedInstrument {
issKey: Contract['issKey'];
displayName: Contract['displayName'];
symbol: Contract['symbol'];
}
export type ChartContainerProps = WidgetContentBasicProps<WidgetProperties>;
import React, { FC, lazy, useContext, useState } from 'react';
import { DNDWrapper } from '@components/DNDWrapper';
import { InstrumentSearch } from '@components/InstrumentSearch';
import WidgetContentWrapper from '@components/WidgetContentWrapper';
import WidgetHeader from '@components/WidgetHeader';
import { useDropInstrument } from '@hooks/dnd';
import { WidgetEnvContext } from '@terminal/desktop/workspaces/default/components/Widget/context';
import { useWidgetHeaderName } from '@utils/useWidgetName';
import useChartComponentFacade from './hooks/useChartComponentFacade';
import type { ChartContainerProps } from './types';
const MoexChart = lazy(() => import('./components/MoexChart/MoexChart'));
export const Chart: FC<ChartContainerProps> = function (props): JSX.Element {
const indicativeData = props.widgetContentProps?.indicativeData;
const {
dropDownOpen,
setDropdownOpen,
currentInstrument,
currentInstrumentName,
currentInstrumentTicker,
setIsWidgetHeaderContextMenuOpen,
isWidgetHeaderContextMenuOpen,
onDropInstruments,
addInstrumentFromModal,
isOver,
} = useChartComponentFacade(props);
/* на дроп обновляем название бумаги в сторе
и делаем апдейт на бэк, чтобы сохранить изменения
при обновлении страницы */
const { dropRef } = useDropInstrument((dragData) => {
onDropInstruments(dragData.properties.issKey, true);
});
const [isOpenEmptyAction, setIsOpenEmptyAction] = useState<boolean>(false);
const openInstrumentModal = () => {
setIsOpenEmptyAction(true);
setIsWidgetHeaderContextMenuOpen(false);
};
// TODO: Зависит от сеток
const { isDraggedOver } = useContext(WidgetEnvContext);
const contractsInstrumentName = useWidgetHeaderName(currentInstrument);
const instrumentName = indicativeData
? `${indicativeData.instrumentName} ${indicativeData.settlement} - ${indicativeData.firmName}`
: currentInstrumentName || contractsInstrumentName || currentInstrument;
const instrumentTicker = indicativeData ? indicativeData.secId : currentInstrumentTicker || currentInstrument;
return (
<DNDWrapper
ref={dropRef}
isOver={isOver}
canDrop
>
<WidgetHeader
{...props}
dropdownOpen={dropDownOpen}
setDropdownOpen={setDropdownOpen}
itemsSearchIcon={[true]}
handlerSaveAsExcel={null}
addToWidgetNamePrefix={instrumentName}
setIsOpenContextMenuFromWidget={setIsWidgetHeaderContextMenuOpen}
isOpenContextMenuFromWidget={isWidgetHeaderContextMenuOpen}
openInstrumentsModal={openInstrumentModal}
/>
<WidgetContentWrapper {...props}>
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
pointerEvents: isDraggedOver ? 'none' : 'inherit',
}}
>
<MoexChart
symbolInfo={{
symbolId: currentInstrument,
symbolName
}}
symbol={currentInstrument}
instrumentName={instrumentName}
instrumentTicker={instrumentTicker}
indicativeData={indicativeData}
widgetId={props.widgetId}
addInstrumentFromModal={addInstrumentFromModal}
/>
{isOpenEmptyAction && (
<InstrumentSearch
setOpen={setIsOpenEmptyAction}
isOpen={isOpenEmptyAction}
variant="single"
widgetId={props.widgetId}
// withNRD={false}
addInstruments={addInstrumentFromModal}
/>
)}
</div>
</WidgetContentWrapper>
</DNDWrapper>
);
};
import { Contract } from '@modules/contracts/types';
import { ChartIndicativeData } from '../types';
import type { WidgetProperties as BaseWidgetProperties } from '@modules/widgetProperties/types';
import type { Timeframes } from 'moex-chart';
export interface WidgetProperties extends BaseWidgetProperties {
chartState: {
savedInstrument: Contract['issKey'] | null;
savedInstrumentName?: string;
savedInstrumentTicker?: string;
interval: string;
savedData?: string;
};
indicativeData?: ChartIndicativeData;
moexChartState?: {
timeframe: Timeframes;
savedData?: string;
};
}
import { useEffect, useMemo } from 'react';
import { useAppSelect } from '@hooks/useAppSelector';
import { Contract, useContracts } from '@modules/contracts';
import { filterByUniqIssKey } from '@utils/filterByUniqIssKey';
import { DEFAULT_SYMBOL } from '@widgets/Chart/const';
import { Widget } from 'types/Widgets';
interface UseChartPublicContextArg {
setCurrInstrument: (instrumentId: string, instrumentName?: string, instrumentTicker?: string) => void;
widgetId: Widget['id'];
getMasterInstrumentFromPublicContext: () => string | number | null | undefined;
}
interface UseChartPublicContextReturn {
issKey: Contract['issKey'] | undefined;
}
export function useChartPublicContext({
widgetId,
setCurrInstrument,
getMasterInstrumentFromPublicContext,
}: UseChartPublicContextArg): UseChartPublicContextReturn {
const publicContext = useAppSelect((state) => state.publicContext.publicContext);
const widget = useAppSelect((state) => state.widgets.widgets.find(({ id }) => id === widgetId));
const { contracts } = useContracts();
const instruments = useMemo(() => [...(contracts && filterByUniqIssKey(contracts, ['issKey']))], [contracts]);
const issKey = useMemo(() => {
const fieldValue = getMasterInstrumentFromPublicContext();
const instrKey = instruments.find((item) => item.issKey === fieldValue)?.issKey;
return instrKey;
// eslint-disable-next-line react-hooks/exhaustive-deps -- Посмотреть этот момент.
}, [publicContext, instruments]);
useEffect(() => {
const fieldValue = getMasterInstrumentFromPublicContext();
if (!fieldValue && widget?.master) {
const defaultInstrument = instruments.find((item) => item.issKey === DEFAULT_SYMBOL);
setCurrInstrument(DEFAULT_SYMBOL, defaultInstrument?.displayName, defaultInstrument?.symbol);
return;
}
const instrument = instruments.find((item) => item.issKey === fieldValue);
if (instrument?.issKey) {
setCurrInstrument(instrument.issKey, instrument.displayName, instrument.symbol);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Посмотреть этот момент.
}, [publicContext, instruments, widget?.externalProperties, setCurrInstrument]);
return {
issKey: issKey ?? undefined,
};
}
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { communicator } from '@core/comm';
import { useAppSelect } from '@hooks/useAppSelector';
import { CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT, HIGHLIGHT_WIDGET_EVENT } from '@modules/widgets/shared';
import { addContentPropsToWidget, unbindWidgets } from '@store/slices/widgets';
import { getState } from '@store/store';
import { useWidgetsBind } from '@utils/hooks/useWidgetsBind';
import { DEFAULT_SYMBOL } from '../const';
import { useChartPublicContext } from './useChartPublicContext';
import type { WidgetProperties } from '../properties/types';
import type { ChartContainerProps, SelectedInstrument } from '../types';
import type { Dispatch, SetStateAction } from 'react';
import type { Widget } from 'types/Widgets';
interface UseChartComponentFacadeReturn {
dropDownOpen: boolean;
setDropdownOpen: Dispatch<SetStateAction<boolean>>;
currentInstrument: string;
currentInstrumentName: string;
currentInstrumentTicker: string;
isWidgetHeaderContextMenuOpen: boolean;
setIsWidgetHeaderContextMenuOpen: Dispatch<SetStateAction<boolean>>;
onDropInstruments: (instrument: SelectedInstrument, withUpdate?: boolean) => void;
addInstrumentFromModal: (instruments: SelectedInstrument[]) => void;
isOver: boolean;
}
type SaveContentPropsOptions = Partial<WidgetProperties['chartState']> & {
withUpdate?: boolean;
cleanIndicativeData?: boolean;
};
export default function useChartComponentFacade(props: ChartContainerProps): UseChartComponentFacadeReturn {
const { widgetId } = props;
const dispatch = useDispatch();
const [isOver, setIsOver] = useState(false);
const [dropDownOpen, setDropdownOpen] = useState(false);
const [isWidgetHeaderContextMenuOpen, setIsWidgetHeaderContextMenuOpen] = useState(false);
const widgetProperties = useAppSelect(
(state) => state.widgets.widgets.find(({ id }) => id === widgetId)?.widgetContentProps,
) as WidgetProperties | undefined;
const initialInstrument = widgetProperties?.chartState?.savedInstrument ?? DEFAULT_SYMBOL;
const initialInstrumentName = widgetProperties?.chartState?.savedInstrumentName ?? initialInstrument;
const initialInstrumentTicker = widgetProperties?.chartState?.savedInstrumentTicker ?? initialInstrument;
// TODO временно реф из-за непредсказуемого изменения если используется useState,
// нужен рефакторинг и вернуть обратно useState
const currentInstrumentRef = useRef(initialInstrument);
const currentInstrumentNameRef = useRef(initialInstrumentName);
const currentInstrumentTickerRef = useRef(initialInstrumentTicker);
const [currentInstrument, setCurrentInstrument] = useState(initialInstrument);
const [currentInstrumentName, setCurrentInstrumentName] = useState(initialInstrumentName);
const [currentInstrumentTicker, setCurrentInstrumentTicker] = useState(initialInstrumentTicker);
const { triggerRelatedWidgetsToUpdate, getMasterInstrumentFromPublicContext } = useWidgetsBind({
widgetId,
});
const triggerRelatedWidgetsToUpdateRef = useRef(triggerRelatedWidgetsToUpdate);
useEffect(() => {
triggerRelatedWidgetsToUpdateRef.current = triggerRelatedWidgetsToUpdate;
}, [triggerRelatedWidgetsToUpdate]);
const saveContentProps = useCallback(
(val: SaveContentPropsOptions): void => {
const { withUpdate, cleanIndicativeData, ...chartStateVal } = val;
const oldProps = (getState().widgets.widgets.find(({ id }) => id === widgetId) as Widget | undefined)
?.widgetContentProps;
if (!oldProps) {
return;
}
dispatch(
addContentPropsToWidget({
id: widgetId,
withoutSend: !withUpdate,
widgetContentProps: {
chartState: {
...oldProps.chartState,
...chartStateVal,
},
// при смене инструмента очищаем индикативные данные, переданные из виджета Индикативные котировки
indicativeData: cleanIndicativeData ? undefined : oldProps.indicativeData,
},
}),
);
},
[dispatch, widgetId],
);
const onInstrumentChange = useCallback(
(
instrumentId: string,
instrumentName?: string,
instrumentTicker?: string,
withUpdate?: boolean,
unbind = true,
): void => {
if (!instrumentId) {
return;
}
setIsWidgetHeaderContextMenuOpen(false);
const symbolChanged = currentInstrumentRef.current !== instrumentId;
const normalizedName = instrumentName?.trim();
const normalizedTicker = instrumentTicker?.trim();
const nextInstrumentName = normalizedName || (symbolChanged ? instrumentId : currentInstrumentNameRef.current);
const nextInstrumentTicker =
normalizedTicker || (symbolChanged ? instrumentId : currentInstrumentTickerRef.current);
const nameChanged = currentInstrumentNameRef.current !== nextInstrumentName;
const tickerChanged = currentInstrumentTickerRef.current !== nextInstrumentTicker;
if (!symbolChanged && !nameChanged && !tickerChanged) {
return;
}
if (symbolChanged) {
currentInstrumentRef.current = instrumentId;
setCurrentInstrument(instrumentId);
if (unbind) {
dispatch(unbindWidgets({ widgetId }));
}
}
if (nameChanged) {
currentInstrumentNameRef.current = nextInstrumentName;
setCurrentInstrumentName(nextInstrumentName);
}
if (tickerChanged) {
currentInstrumentTickerRef.current = nextInstrumentTicker;
setCurrentInstrumentTicker(nextInstrumentTicker);
}
// при смене инструмента очищаем индикативные данные виджета график
// т.к. логика для графика индикатива построена на наличии в widgetContentProps данных indicativeData
saveContentProps({
savedInstrument: instrumentId,
savedInstrumentName: nextInstrumentName,
savedInstrumentTicker: nextInstrumentTicker,
withUpdate,
cleanIndicativeData: symbolChanged,
});
if (symbolChanged) {
triggerRelatedWidgetsToUpdateRef.current(instrumentId);
}
},
[dispatch, saveContentProps, widgetId],
);
const addInstrumentFromModal = useCallback(
(instruments: SelectedInstrument[]): void => {
const instrument = instruments[0];
if (!instrument?.issKey) {
return;
}
onInstrumentChange(instrument.issKey, instrument.displayName, instrument.symbol);
},
[onInstrumentChange],
);
const onInstrumentChangeFromBind = useCallback(
(instrumentId: string, instrumentName?: string, instrumentTicker?: string): void => {
onInstrumentChange(instrumentId, instrumentName, instrumentTicker, true, false);
},
[onInstrumentChange],
);
const onDropInstruments = useCallback(
(instrument: SelectedInstrument, withUpdate?: boolean): void => {
if (!instrument.issKey) {
return;
}
onInstrumentChange(instrument.issKey, instrument.displayName, instrument.symbol, withUpdate);
},
[onInstrumentChange],
);
useChartPublicContext({
setCurrInstrument: onInstrumentChangeFromBind,
widgetId,
getMasterInstrumentFromPublicContext,
});
useEffect(() => {
triggerRelatedWidgetsToUpdateRef.current(currentInstrumentRef.current);
}, []);
useEffect(() => {
const unsubscribe = communicator.listen(
{
messageType: CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT,
},
(message) => {
const issKey = (message as Record<number, string>)[widgetId];
if (issKey) {
onInstrumentChange(issKey);
}
},
);
const unsubscribeHighlighter = communicator.listen(
{
messageType: HIGHLIGHT_WIDGET_EVENT,
},
(message) => {
const typedMessage = message as Record<number, boolean>;
if (Object.keys(typedMessage).includes(String(widgetId))) {
setIsOver(typedMessage[widgetId]);
}
},
);
return () => {
unsubscribe();
unsubscribeHighlighter();
};
}, [onInstrumentChange, widgetId]);
return {
dropDownOpen,
setDropdownOpen,
currentInstrument,
currentInstrumentName,
currentInstrumentTicker,
isWidgetHeaderContextMenuOpen,
setIsWidgetHeaderContextMenuOpen,
onDropInstruments,
addInstrumentFromModal,
isOver,
};
}
import { SymbolInfoInput } from 'moex-chart';
import React from 'react';
import { SymbolSearchModal } from '@widgets/Chart/components/MoexChart/components/SymbolSearchModal';
import { ChartIndicativeData, SelectedInstrument } from '../../types';
import { CompareModal } from './components/CompareModal';
import { useMoexChart } from './hooks';
import 'moex-chart/dist/styles.css';
interface TRProps {
symbolInfo: SymbolInfoInput;
indicativeData?: ChartIndicativeData | undefined;
widgetId: number;
addInstrumentFromModal: (instruments: SelectedInstrument[]) => void;
}
export default React.memo(
({ symbol, instrumentName, instrumentTicker, indicativeData, widgetId, addInstrumentFromModal }: TRProps) => {
const {
containerRef,
isCompareOpen,
isSymbolSearchOpen,
compareManagerRef,
setIsCompareOpen,
setIsSymbolSearchOpen,
} = useMoexChart({
indicativeData,
symbol,
instrumentName,
instrumentTicker,
});
const handleSymbolChange = (instrument: SelectedInstrument): void => {
addInstrumentFromModal([instrument]);
};
return (
<div
style={{
flex: '1 1 0',
minHeight: 0,
minWidth: 0,
}}
>
<div ref={containerRef} />
{isCompareOpen && (
<CompareModal
onClose={() => setIsCompareOpen(false)}
compareManager={compareManagerRef}
widgetId={widgetId}
isOpen={isCompareOpen}
setOpen={setIsCompareOpen}
/>
)}
{isSymbolSearchOpen && (
<SymbolSearchModal
widgetId={widgetId}
isOpen={isSymbolSearchOpen}
setOpen={setIsSymbolSearchOpen}
onSymbolChange={handleSymbolChange}
/>
)}
</div>
);
},
);
import { DateFormat, IndicatorsIds, Locale, Timeframes } from 'moex-chart';
import type { ChartCollectionPreset, IMoexChart, MoexChartSnapshot } from 'moex-chart';
type ChartCollectionPresetConfig = Omit<ChartCollectionPreset, 'getDataSource' | 'startRealtime'>;
type ChartSnapshotItemConfig = Omit<
MoexChartSnapshot['charts'][number],
'symbol' | 'instrumentName' | 'instrumentTicker'
>;
type MoexChartSnapshotConfig = Omit<MoexChartSnapshot, 'charts'> & {
charts: ChartSnapshotItemConfig[];
};
type MoexChartConfig = Omit<IMoexChart, 'container' | 'chartCollectionPreset' | 'snapshot'> & {
snapshot: MoexChartSnapshotConfig;
chartCollectionPreset: ChartCollectionPresetConfig;
};
const MOEX_CHART_CONFIG: MoexChartConfig = {
snapshot: {
charts: [
{
chartSeriesType: 'Candlestick',
timeframe: Timeframes['10s'],
timeFormat: '24h',
dateFormat: DateFormat.DD_MM_YYYY_HH_mm_ss,
panes: [
{
isMain: true,
id: 0,
indicators: [
{
indicatorType: IndicatorsIds.Volume,
},
],
drawings: [],
},
],
},
],
},
chartCollectionPreset: {
undoRedoEnabled: true,
showMenuButton: true,
showBottomPanel: true,
showControlBar: true,
showFullscreenButton: true,
showSettingsButton: true,
showCompareButton: true,
tooltipConfig: {
showTooltip: false,
time: { visible: true, label: 'Время' },
close: { visible: true, label: 'Закр.' },
change: { visible: true, label: 'Изм.' },
volume: { visible: true, label: 'Объем' },
open: { visible: true, label: 'Откр.' },
high: { visible: true, label: 'Макс.' },
low: { visible: true, label: 'Мин.' },
},
supportedTimeframes: [
Timeframes['1m'],
Timeframes['5m'],
Timeframes['10m'],
Timeframes['15m'],
Timeframes['30m'],
Timeframes['45m'],
Timeframes['1h'],
Timeframes['4h'],
Timeframes['1d'],
Timeframes['1w'],
Timeframes['1М'],
],
supportedChartSeriesTypes: ['Candlestick', 'Line', 'Bar'],
theme: 'tr',
ohlc: {
show: true,
precision: 4,
},
mode: 'dark',
locale: Locale.rus,
},
lwcInheritedChartOptions: {
timeVisible: true,
secondsVisible: false,
},
};
export { MOEX_CHART_CONFIG };
import { MoexChart, Timeframes } from 'moex-chart';
import { 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 } from 'moex-chart';
interface TUseMoexChartProps {
symbol: string;
instrumentName?: string;
instrumentTicker?: string;
indicativeData?: ChartIndicativeData;
}
export const useMoexChart = ({ symbol, instrumentName, instrumentTicker, indicativeData }: TUseMoexChartProps) => {
const moexChartState = useSelectProperties((wProps: Partial<WidgetProperties>) => wProps.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<null | __CompareManager__>(null);
const currentSymbolRef = useRef(symbol);
const currentInstrumentNameRef = useRef(instrumentName || undefined);
const currentInstrumentTickerRef = useRef(instrumentTicker || undefined);
const timeframeRef = useRef<Timeframes | undefined>(moexChartState?.timeframe);
const savedDataRef = useRef<string | undefined>(moexChartState?.savedData);
const updateTimeframeRef = useRef<((tf: Timeframes) => void) | null>(null);
useEffect(() => {
if (!symbol) {
return;
}
const nextInstrumentName = instrumentName?.trim() || undefined;
const nextInstrumentTicker = instrumentTicker?.trim() || undefined;
const symbolChanged = currentSymbolRef.current !== symbol;
const instrumentNameChanged = currentInstrumentNameRef.current !== nextInstrumentName;
const instrumentTickerChanged = currentInstrumentTickerRef.current !== nextInstrumentTicker;
if (!symbolChanged && !instrumentNameChanged && !instrumentTickerChanged) {
return;
}
currentSymbolRef.current = symbol;
currentInstrumentNameRef.current = nextInstrumentName;
currentInstrumentTickerRef.current = nextInstrumentTicker;
chartRef.current?.setSymbol(symbol, nextInstrumentName, nextInstrumentTicker);
}, [symbol, instrumentName, instrumentTicker]);
useEffect(() => {
timeframeRef.current = moexChartState?.timeframe;
savedDataRef.current = moexChartState?.savedData;
}, [moexChartState]);
updateTimeframeRef.current = (timeframe: Timeframes) => {
if (timeframeRef.current === timeframe) {
return;
}
timeframeRef.current = timeframe;
updateProperties((state) => {
state.moexChartState = {
...state.moexChartState,
timeframe,
};
});
};
const saveSnapshot = () => {
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];
return {
...indicator,
dataSource: undefined, // dataSource пока не среиализуем
config:
indicator.indicatorType === undefined && indicator.config?.label && compareSeries
? {
symbol: indicator.config.symbol,
instrumentName: indicator.config.instrumentName,
instrumentTicker: indicator.config.instrumentTicker,
label: indicator.config.label,
newPane: indicator.config.newPane,
series: [
{
name: compareSeries.name,
seriesOptions: {
priceScaleId: compareSeries.seriesOptions?.priceScaleId,
},
},
],
}
: undefined,
};
}),
})),
})),
});
savedDataRef.current = savedData;
updateProperties((state) => {
state.moexChartState = {
...state.moexChartState,
timeframe: timeframeRef.current || Timeframes['1m'],
savedData,
};
});
};
const applySnapshot = () => {
if (!savedDataRef.current || !chartRef.current) {
return;
}
const savedSnapshot = JSON.parse(savedDataRef.current) as IMoexChart['snapshot'];
const timeframe = timeframeRef.current || Timeframes['1m'];
chartRef.current.setSnapshot({
...savedSnapshot,
charts: savedSnapshot.charts.map((chartSnapshot) => ({
...chartSnapshot,
symbol: currentSymbolRef.current,
instrumentName: currentInstrumentNameRef.current,
instrumentTicker: currentInstrumentTickerRef.current,
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: {
...savedSnapshot,
charts: savedSnapshot.charts.map((chartSnapshot) => ({
...chartSnapshot,
symbol: currentSymbolRef.current,
instrumentName: currentInstrumentNameRef.current,
instrumentTicker: currentInstrumentTickerRef.current,
timeframe,
})),
},
chartCollectionPreset: {
...MOEX_CHART_CONFIG.chartCollectionPreset,
openCompareModal: () => setIsCompareOpen(true),
openSymbolSearchModal: () => setIsSymbolSearchOpen(true),
getDataSource: dataProvider.getDataSource(indicativeData, (tf) => {
updateTimeframeRef.current?.(tf);
}),
startRealtime: (getSymbols, getTimeframe, update) =>
dataProvider.startRealtime({
getSymbols,
getTimeframe,
update,
}),
},
});
chartRef.current = chart;
compareManagerRef.current = chart.getCompareManager();
const intervalId = setInterval(() => {
saveSnapshot();
}, 1000);
return () => {
clearInterval(intervalId);
saveSnapshot();
chartRef.current = null;
compareManagerRef.current = null;
chart.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- исправим позже
}, [indicativeData]);
return {
containerRef,
isCompareOpen,
isSymbolSearchOpen,
compareManagerRef,
setIsCompareOpen,
setIsSymbolSearchOpen,
saveSnapshot,
applySnapshot,
hasSavedSnapshot: Boolean(moexChartState?.savedData),
};
};
import React from 'react';
import { InstrumentSearch } from '@components/InstrumentSearch';
import type { Contract } from '@modules/contracts';
interface SymbolSearchModalProps {
widgetId: number;
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
onSymbolChange: (instrument: Contract) => void;
}
export const SymbolSearchModal = ({ widgetId, isOpen, setOpen, onSymbolChange }: SymbolSearchModalProps) => {
const handleAddInstruments = (instruments: Contract[]) => {
const selectedInstrument = instruments[0];
if (!selectedInstrument?.issKey) {
return;
}
onSymbolChange(selectedInstrument);
setOpen(false);
};
return (
<InstrumentSearch
widgetId={widgetId}
variant="single"
isOpen={isOpen}
setOpen={setOpen}
addInstruments={handleAddInstruments}
/>
);
};
import { __CompareManager__, CompareMode } from 'moex-chart';
import React, { MutableRefObject, useEffect, useState } from 'react';
import { InstrumentSearch } from '@components/InstrumentSearch';
import { Contract } from '@modules/contracts';
export const CompareModal = ({
compareManager,
widgetId,
isOpen,
setOpen,
}: {
onClose: () => void;
widgetId: number;
compareManager: MutableRefObject<__CompareManager__ | null>;
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
}) => {
const [isNewScaleDisabled, setIsNewScaleDisabled] = useState(false);
useEffect(() => {
const manager = compareManager.current;
if (!isOpen || !manager) {
setIsNewScaleDisabled(false);
return;
}
setIsNewScaleDisabled(manager.isNewScaleDisabled());
const subscription = manager.isNewScaleDisabledObservable().subscribe(setIsNewScaleDisabled);
return () => subscription.unsubscribe();
}, [compareManager, isOpen]);
const setCompareMode = (instrument: Contract, mode: CompareMode): void => {
const symbol = instrument.issKey;
if (!symbol) {
return;
}
compareManager.current?.setSymbolMode(
'Line',
{
symbol,
instrumentName: instrument.displayName || symbol,
instrumentTicker: instrument.symbol || symbol,
},
mode,
);
};
const handlePercent = (instrument: Contract): void => {
setCompareMode(instrument, CompareMode.Percentage);
};
const handleNewScale = (instrument: Contract): void => {
setCompareMode(instrument, CompareMode.NewScale);
};
const handleNewPanel = (instrument: Contract): void => {
setCompareMode(instrument, CompareMode.NewPane);
};
return (
<InstrumentSearch
setOpen={setOpen}
isOpen={isOpen}
variant="single"
widgetId={widgetId}
// withNRD={false}
// Временный костыль, пока не завезем свой поиск интструментов
addInstruments={() => {
// nothing
}}
isNewScaleDisabled={isNewScaleDisabled}
customActionsFooterHandlers={{
handlePercent,
handleNewScale,
handleNewPanel,
}}
/>
);
};
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';
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 {
symbol?: string;
instrumentName?: string;
instrumentTicker?: string;
label?: string;
newPane?: boolean;
series: MockCompareSeries[];
}
interface MockIndicatorSnapshot {
id?: string;
indicatorType?: string;
dataSource?: unknown;
config?: MockIndicatorConfig;
}
interface MockPaneSnapshot {
indicators: MockIndicatorSnapshot[];
}
interface MockChartSnapshot {
charts: {
symbol: string;
instrumentName?: string;
instrumentTicker?: 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;
initialInterval?: string;
savedData?: string;
}
interface MockPropertiesState {
moexChartState?: MockMoexChartState;
}
type UpdatePropertiesCallback = (state: MockPropertiesState) => void;
type TimeframeChangeCallback = (timeframe: TimeframeValue) => void;
interface TestComponentProps {
symbol?: string;
instrumentName?: string;
instrumentTicker?: string;
indicativeData?: ChartIndicativeData;
}
describe('useMoexChart', () => {
const mockUseSelectProperties = useSelectProperties as jest.Mock;
const mockUseChangeProperties = useChangeProperties as jest.Mock;
const mockMoexChart = MoexChart 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: [
{
symbol: 'OLD:SYMBOL',
instrumentName: 'Old instrument',
instrumentTicker: 'OLD',
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 = ({
symbol = 'MOEX:SBER',
instrumentName = 'Сбербанк',
instrumentTicker = 'SBER',
indicativeData,
}: TestComponentProps): React.ReactElement => {
hookResult = useMoexChart({
symbol,
instrumentName,
instrumentTicker,
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,
};
});
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
it('should create moex chart with current symbol, name, ticker and timeframe', () => {
// Arrange & Act
render(
<TestComponent
symbol="MOEX:SBER"
instrumentName="Сбербанк"
instrumentTicker="SBER"
/>,
);
// Assert
expect(mockMoexChart).toHaveBeenCalledTimes(1);
expect(mockDataSourceProvider).toHaveBeenCalledTimes(1);
expect(lastMoexChartConfig?.container).toBeInstanceOf(HTMLDivElement);
expect(lastMoexChartConfig?.snapshot.charts[0]?.symbol).toBe('MOEX:SBER');
expect(lastMoexChartConfig?.snapshot.charts[0]?.instrumentName).toBe('Сбербанк');
expect(lastMoexChartConfig?.snapshot.charts[0]?.instrumentTicker).toBe('SBER');
expect(lastMoexChartConfig?.snapshot.charts[0]?.timeframe).toBe(Timeframes['1m']);
expect(hookResult?.compareManagerRef.current).toBe(mockCompareManager);
expect(hookResult?.hasSavedSnapshot).toBe(false);
});
it('should create chart with saved snapshot and current instrument data', () => {
// Arrange
mockMoexChartState = {
timeframe: Timeframes['5m'],
savedData: JSON.stringify(mockSnapshot),
};
// Act
render(
<TestComponent
symbol="MOEX:GAZP"
instrumentName="Газпром"
instrumentTicker="GAZP"
/>,
);
// Assert
expect(lastMoexChartConfig?.snapshot.charts[0]?.symbol).toBe('MOEX:GAZP');
expect(lastMoexChartConfig?.snapshot.charts[0]?.instrumentName).toBe('Газпром');
expect(lastMoexChartConfig?.snapshot.charts[0]?.instrumentTicker).toBe('GAZP');
expect(lastMoexChartConfig?.snapshot.charts[0]?.timeframe).toBe(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, display name and ticker in snapshot', () => {
// Arrange
const snapshotWithIndicators: MockChartSnapshot = {
charts: [
{
symbol: 'MOEX:SBER',
instrumentName: 'Сбербанк',
instrumentTicker: 'SBER',
timeframe: Timeframes['1m'],
chartSeriesType: 'Candlestick',
panes: [
{
indicators: [
{
id: 'compare-gazp',
indicatorType: undefined,
dataSource: {
subscription: {},
},
config: {
symbol: 'MOEX:GAZP',
instrumentName: 'Газпром',
instrumentTicker: 'GAZP',
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: {
symbol: 'MOEX:GAZP',
instrumentName: 'Газпром',
instrumentTicker: 'GAZP',
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 instrument data and timeframe', () => {
// Arrange
mockMoexChartState = {
timeframe: Timeframes['5m'],
savedData: JSON.stringify(mockSnapshot),
};
render(
<TestComponent
symbol="MOEX:SBER"
instrumentName="Сбербанк"
instrumentTicker="SBER"
/>,
);
// Act
act(() => {
hookResult?.applySnapshot();
});
// Assert
expect(mockSetSnapshot).toHaveBeenCalledWith({
...mockSnapshot,
charts: [
{
...mockSnapshot.charts[0],
symbol: 'MOEX:SBER',
instrumentName: 'Сбербанк',
instrumentTicker: 'SBER',
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 instrument changes', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(
<TestComponent
symbol="MOEX:GAZP"
instrumentName="Газпром"
instrumentTicker="GAZP"
/>,
);
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
expect(mockSetSymbol).toHaveBeenCalledWith('MOEX:GAZP', 'Газпром', 'GAZP');
});
it('should update chart when only external instrument name changes', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(
<TestComponent
symbol="MOEX:SBER"
instrumentName="Сбербанк ПАО"
instrumentTicker="SBER"
/>,
);
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
expect(mockSetSymbol).toHaveBeenCalledWith('MOEX:SBER', 'Сбербанк ПАО', 'SBER');
});
it('should update chart when only external instrument ticker changes', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(
<TestComponent
symbol="MOEX:SBER"
instrumentName="Сбербанк"
instrumentTicker="SBERP"
/>,
);
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
expect(mockSetSymbol).toHaveBeenCalledWith('MOEX:SBER', 'Сбербанк', 'SBERP');
});
it('should delegate empty instrument name fallback to moex-chart', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(
<TestComponent
symbol="MOEX:SBER"
instrumentName=""
instrumentTicker="SBER"
/>,
);
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
expect(mockSetSymbol).toHaveBeenCalledWith('MOEX:SBER', undefined, 'SBER');
});
it('should delegate empty instrument ticker fallback to moex-chart', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(
<TestComponent
symbol="MOEX:SBER"
instrumentName="Сбербанк"
instrumentTicker=""
/>,
);
// Assert
expect(mockSetSymbol).toHaveBeenCalledTimes(1);
expect(mockSetSymbol).toHaveBeenCalledWith('MOEX:SBER', 'Сбербанк', undefined);
});
it('should not update chart when symbol, name and ticker are unchanged', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(<TestComponent />);
// Assert
expect(mockSetSymbol).not.toHaveBeenCalled();
});
it('should not update chart when external symbol is empty', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(
<TestComponent
symbol=""
instrumentName="Пустой инструмент"
instrumentTicker="EMPTY"
/>,
);
// Assert
expect(mockSetSymbol).not.toHaveBeenCalled();
});
it('should not recreate chart when external instrument changes', () => {
// Arrange
const { rerender } = render(<TestComponent />);
// Act
rerender(
<TestComponent
symbol="MOEX:GAZP"
instrumentName="Газпром"
instrumentTicker="GAZP"
/>,
);
// Assert
expect(mockMoexChart).toHaveBeenCalledTimes(1);
});
it('should apply saved snapshot with externally selected instrument', () => {
// Arrange
mockMoexChartState = {
timeframe: Timeframes['5m'],
savedData: JSON.stringify(mockSnapshot),
};
const { rerender } = render(<TestComponent />);
rerender(
<TestComponent
symbol="MOEX:GAZP"
instrumentName="Газпром"
instrumentTicker="GAZP"
/>,
);
// Act
act(() => {
hookResult?.applySnapshot();
});
// Assert
expect(mockSetSnapshot).toHaveBeenCalledWith({
...mockSnapshot,
charts: [
{
...mockSnapshot.charts[0],
symbol: 'MOEX:GAZP',
instrumentName: 'Газпром',
instrumentTicker: 'GAZP',
timeframe: Timeframes['5m'],
},
],
});
});
it('should initialize indicative instrument with id, name and ticker', () => {
// Arrange
const indicativeData: ChartIndicativeData = {
id: 1,
title: 'Indicative instrument',
secId: 'INAV',
instrumentName: 'Индикатив',
settlement: 'Расчётный',
firmName: 'Тестовая фирма',
key: '2xOFZ:INAV',
};
// Act
render(
<TestComponent
symbol="2xOFZ:INAV"
instrumentName="Индикатив Расчётный"
instrumentTicker="INAV"
indicativeData={indicativeData}
/>,
);
// Assert
expect(mockGetDataSource).toHaveBeenCalledWith(indicativeData, expect.any(Function));
expect(lastMoexChartConfig?.snapshot.charts[0]).toEqual(
expect.objectContaining({
symbol: '2xOFZ:INAV',
instrumentName: 'Индикатив Расчётный',
instrumentTicker: 'INAV',
}),
);
});
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);
});
});
import { renderHook } from '@testing-library/react';
import { useAppSelect } from '@hooks/useAppSelector';
import { useContracts } from '@modules/contracts';
import { filterByUniqIssKey } from '@utils/filterByUniqIssKey';
import { DEFAULT_SYMBOL } from '@widgets/Chart/const';
import { useChartPublicContext } from '../hooks/useChartPublicContext';
import type { Contract } from '@modules/contracts';
// Mock the dependencies
jest.mock('@hooks/useAppSelector');
jest.mock('@modules/contracts');
jest.mock('@utils/filterByUniqIssKey');
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(),
},
}));
describe('useChartPublicContext', () => {
const mockUseAppSelect = useAppSelect as jest.Mock;
const mockUseContracts = useContracts as jest.Mock;
const mockFilterByUniqIssKey = filterByUniqIssKey as jest.Mock;
const mockContracts = [
{
issKey: DEFAULT_SYMBOL,
displayName: 'Инструмент по умолчанию',
symbol: 'DEFAULT',
// ... other contract properties
},
{
issKey: 'MOEX:TEST1',
displayName: 'Test Instrument 1',
symbol: 'TEST1',
// ... other contract properties
},
{
issKey: 'MOEX:TEST2',
displayName: 'Test Instrument 2',
symbol: 'TEST2',
// ... other contract properties
},
{
issKey: 'MOEX:TEST3',
displayName: 'Test Instrument 3',
symbol: 'TEST3',
// ... other contract properties
},
] as Contract[];
const mockWidget = {
id: 1,
name: 'Test Widget',
type: 'graphic',
master: 123,
externalProperties: [
{
key: 'instrument',
value: 'MOEX:TEST1',
},
],
// ... other widget properties
};
const mockPublicContext = {
instrument: 'MOEX:TEST1',
};
beforeEach(() => {
jest.clearAllMocks();
// Mock the useAppSelect to return our test data
mockUseAppSelect.mockImplementation((selector) => {
if (selector.toString().includes('publicContext')) {
return mockPublicContext;
}
if (selector.toString().includes('widgets')) {
return mockWidget;
}
return {};
});
// Mock useContracts to return our test contracts
mockUseContracts.mockReturnValue({
contracts: mockContracts,
});
// Mock filterByUniqIssKey to return the same contracts
mockFilterByUniqIssKey.mockImplementation((contracts: Contract[]) => contracts);
});
it('should return the correct issKey when a matching instrument is found', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST1');
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBe('MOEX:TEST1');
});
it('should pass instrument id and display name when a matching instrument is found', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST2');
// Act
renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(mockSetCurrInstrument).toHaveBeenCalledTimes(1);
expect(mockSetCurrInstrument).toHaveBeenCalledWith('MOEX:TEST2', 'Test Instrument 2', 'TEST2');
});
it('should return undefined when no matching instrument is found', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:NONEXISTENT');
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBeUndefined();
expect(mockSetCurrInstrument).not.toHaveBeenCalled();
});
it('should return undefined when getMasterInstrumentFromPublicContext returns null', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(null);
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBeUndefined();
});
it('should return undefined when getMasterInstrumentFromPublicContext returns undefined', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(undefined);
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBeUndefined();
});
it('should set current instrument to DEFAULT_SYMBOL with display name when no field value and widget has master', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(null);
// Act
renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(mockSetCurrInstrument).toHaveBeenCalledTimes(1);
expect(mockSetCurrInstrument).toHaveBeenCalledWith(DEFAULT_SYMBOL, 'Инструмент по умолчанию', 'DEFAULT');
});
it('should handle case when widget is not found in widgets array', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST1');
// Mock useAppSelect to return null for widget
mockUseAppSelect.mockImplementation((selector) => {
if (selector.toString().includes('publicContext')) {
return mockPublicContext;
}
if (selector.toString().includes('widgets')) {
return null; // Widget not found
}
return {};
});
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 999, // Non-existent widget ID
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBe('MOEX:TEST1');
expect(mockSetCurrInstrument).toHaveBeenCalledWith('MOEX:TEST1', 'Test Instrument 1', 'TEST1');
});
it('should handle case when contracts array is empty', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST1');
// Mock useContracts to return empty contracts
mockUseContracts.mockReturnValue({
contracts: [],
});
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBeUndefined();
expect(mockSetCurrInstrument).not.toHaveBeenCalled();
});
});
import { act, renderHook } from '@testing-library/react';
import { useDispatch } from 'react-redux';
import { communicator } from '@core/comm';
import { useAppSelect } from '@hooks/useAppSelector';
import { CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT, HIGHLIGHT_WIDGET_EVENT } from '@modules/widgets/shared';
import { addContentPropsToWidget, unbindWidgets } from '@store/slices/widgets';
import { getState } from '@store/store';
import { useWidgetsBind } from '@utils/hooks/useWidgetsBind';
import useChartComponentFacade from '../hooks/useChartComponentFacade';
import { useChartPublicContext } from '../hooks/useChartPublicContext';
import type { WidgetProperties } from '../properties/types';
import type { ChartContainerProps } from '../types';
jest.mock('@store/store', () => ({
getState: jest.fn(),
}));
jest.mock('@store/slices/widgets', () => ({
addContentPropsToWidget: jest.fn(),
unbindWidgets: jest.fn(),
}));
jest.mock('react-redux', () => ({
useDispatch: jest.fn(),
}));
jest.mock('@core/comm', () => ({
communicator: {
listen: jest.fn(),
},
}));
jest.mock('@hooks/useAppSelector', () => ({
useAppSelect: jest.fn(),
}));
jest.mock('@utils/hooks/useWidgetsBind', () => ({
useWidgetsBind: jest.fn(),
}));
jest.mock('../hooks/useChartPublicContext', () => ({
useChartPublicContext: jest.fn(),
}));
interface PublicContextMockParams {
setCurrInstrument: (instrumentId: string, instrumentName?: string, instrumentTicker?: string) => void;
}
describe('useChartComponentFacade', () => {
const mockUseDispatch = useDispatch as jest.Mock;
const mockUseAppSelect = useAppSelect as jest.Mock;
const mockAddContentPropsToWidget = addContentPropsToWidget as unknown as jest.Mock;
const mockUnbindWidgets = unbindWidgets as unknown as jest.Mock;
const mockGetState = getState as jest.Mock;
const mockUseWidgetsBind = useWidgetsBind as jest.Mock;
const mockUseChartPublicContext = useChartPublicContext as jest.Mock;
const mockCommunicatorListen = communicator.listen as jest.Mock;
const mockDispatch = jest.fn();
const mockTriggerRelatedWidgetsToUpdate = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn();
const mockUnsubscribeCorpActions = jest.fn();
const mockUnsubscribeHighlighter = jest.fn();
const widgetProperties = {
chartState: {
savedInstrument: 'MOEX:SBER',
savedInstrumentName: 'Сбербанк',
savedInstrumentTicker: 'SBER',
interval: '1m',
},
indicativeData: {
id: 1,
key: 'INDICATIVE',
secId: 'INAV',
instrumentName: 'Индикатив',
settlement: 'Расчётный',
firmName: 'Тестовая фирма',
},
} as WidgetProperties;
const renderFacade = () =>
renderHook(() =>
useChartComponentFacade({
widgetId: 42,
} as ChartContainerProps),
);
beforeEach(() => {
jest.clearAllMocks();
mockUseDispatch.mockReturnValue(mockDispatch);
mockUseAppSelect.mockReturnValue(widgetProperties);
mockGetState.mockReturnValue({
widgets: {
widgets: [
{
id: 42,
widgetContentProps: widgetProperties,
},
],
},
});
mockUseWidgetsBind.mockReturnValue({
triggerRelatedWidgetsToUpdate: mockTriggerRelatedWidgetsToUpdate,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
});
mockUseChartPublicContext.mockReturnValue({
issKey: undefined,
});
mockAddContentPropsToWidget.mockImplementation((payload) => ({
type: 'widgets/addContentPropsToWidget',
payload,
}));
mockUnbindWidgets.mockImplementation((payload) => ({
type: 'widgets/unbindWidgets',
payload,
}));
mockCommunicatorListen.mockImplementation(({ messageType }, listener) => {
if (messageType === CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT) {
return mockUnsubscribeCorpActions;
}
if (messageType === HIGHLIGHT_WIDGET_EVENT) {
return mockUnsubscribeHighlighter;
}
return jest.fn();
});
});
it('should initialize symbol, display name and ticker from widget properties', () => {
// Arrange & Act
const { result } = renderFacade();
// Assert
expect(result.current.currentInstrument).toBe('MOEX:SBER');
expect(result.current.currentInstrumentName).toBe('Сбербанк');
expect(result.current.currentInstrumentTicker).toBe('SBER');
});
it('should update and save instrument selected from modal', () => {
// Arrange
const { result } = renderFacade();
jest.clearAllMocks();
// Act
act(() => {
result.current.addInstrumentFromModal([
{
issKey: 'MOEX:GAZP',
displayName: 'Газпром',
symbol: 'GAZP',
},
]);
});
// Assert
expect(result.current.currentInstrument).toBe('MOEX:GAZP');
expect(result.current.currentInstrumentName).toBe('Газпром');
expect(result.current.currentInstrumentTicker).toBe('GAZP');
expect(mockUnbindWidgets).toHaveBeenCalledWith({
widgetId: 42,
});
expect(mockAddContentPropsToWidget).toHaveBeenCalledWith({
id: 42,
withoutSend: true,
widgetContentProps: {
chartState: {
...widgetProperties.chartState,
savedInstrument: 'MOEX:GAZP',
savedInstrumentName: 'Газпром',
savedInstrumentTicker: 'GAZP',
},
indicativeData: undefined,
},
});
expect(mockTriggerRelatedWidgetsToUpdate).toHaveBeenCalledWith('MOEX:GAZP');
});
it('should update name and ticker without unbinding when symbol is unchanged', () => {
// Arrange
const { result } = renderFacade();
jest.clearAllMocks();
// Act
act(() => {
result.current.addInstrumentFromModal([
{
issKey: 'MOEX:SBER',
displayName: 'Сбербанк ПАО',
symbol: 'SBERP',
},
]);
});
// Assert
expect(result.current.currentInstrument).toBe('MOEX:SBER');
expect(result.current.currentInstrumentName).toBe('Сбербанк ПАО');
expect(result.current.currentInstrumentTicker).toBe('SBERP');
expect(mockUnbindWidgets).not.toHaveBeenCalled();
expect(mockTriggerRelatedWidgetsToUpdate).not.toHaveBeenCalled();
expect(mockAddContentPropsToWidget).toHaveBeenCalledWith({
id: 42,
withoutSend: true,
widgetContentProps: {
chartState: {
...widgetProperties.chartState,
savedInstrument: 'MOEX:SBER',
savedInstrumentName: 'Сбербанк ПАО',
savedInstrumentTicker: 'SBERP',
},
indicativeData: widgetProperties.indicativeData,
},
});
});
it('should send dropped instrument update immediately', () => {
// Arrange
const { result } = renderFacade();
jest.clearAllMocks();
// Act
act(() => {
result.current.onDropInstruments(
{
issKey: 'MOEX:LKOH',
displayName: 'Лукойл',
symbol: 'LKOH',
},
true,
);
});
// Assert
expect(mockAddContentPropsToWidget).toHaveBeenCalledWith(
expect.objectContaining({
id: 42,
withoutSend: false,
widgetContentProps: expect.objectContaining({
chartState: expect.objectContaining({
savedInstrument: 'MOEX:LKOH',
savedInstrumentName: 'Лукойл',
savedInstrumentTicker: 'LKOH',
}),
}),
}),
);
});
it('should update instrument from public context without unbinding widget', () => {
// Arrange
renderFacade();
const publicContextParams = mockUseChartPublicContext.mock.calls[0]?.[0] as PublicContextMockParams;
jest.clearAllMocks();
// Act
act(() => {
publicContextParams.setCurrInstrument('MOEX:ROSN', 'Роснефть', 'ROSN');
});
// Assert
expect(mockUnbindWidgets).not.toHaveBeenCalled();
expect(mockAddContentPropsToWidget).toHaveBeenCalledWith(
expect.objectContaining({
withoutSend: false,
widgetContentProps: expect.objectContaining({
chartState: expect.objectContaining({
savedInstrument: 'MOEX:ROSN',
savedInstrumentName: 'Роснефть',
savedInstrumentTicker: 'ROSN',
}),
}),
}),
);
});
it('should use ticker extracted from instrument id when metadata is unavailable', () => {
// Arrange
renderFacade();
const corpActionsListener = mockCommunicatorListen.mock.calls.find(
([options]) => options.messageType === CORPACTIONS_OPEN_EXESTED_WIDGET_EVENT,
)?.[1] as (message: Record<number, string>) => void;
// Act
act(() => {
corpActionsListener({
42: 'MOEX:UNKNOWN',
});
});
// Assert
expect(mockAddContentPropsToWidget).toHaveBeenCalledWith(
expect.objectContaining({
widgetContentProps: expect.objectContaining({
chartState: expect.objectContaining({
savedInstrument: 'MOEX:UNKNOWN',
savedInstrumentName: 'UNKNOWN',
savedInstrumentTicker: 'UNKNOWN',
}),
}),
}),
);
});
it('should not update instrument when modal selection is empty', () => {
// Arrange
const { result } = renderFacade();
jest.clearAllMocks();
// Act
act(() => {
result.current.addInstrumentFromModal([]);
});
// Assert
expect(mockAddContentPropsToWidget).not.toHaveBeenCalled();
expect(mockUnbindWidgets).not.toHaveBeenCalled();
expect(mockTriggerRelatedWidgetsToUpdate).not.toHaveBeenCalled();
});
it('should update widget highlight state', () => {
// Arrange
const { result } = renderFacade();
const highlighterListener = mockCommunicatorListen.mock.calls.find(
([options]) => options.messageType === HIGHLIGHT_WIDGET_EVENT,
)?.[1] as (message: Record<number, boolean>) => void;
// Act
act(() => {
highlighterListener({
42: true,
});
});
// Assert
expect(result.current.isOver).toBe(true);
});
it('should unsubscribe communicator listeners on unmount', () => {
// Arrange
const { unmount } = renderFacade();
// Act
unmount();
// Assert
expect(mockUnsubscribeCorpActions).toHaveBeenCalledTimes(1);
expect(mockUnsubscribeHighlighter).toHaveBeenCalledTimes(1);
});
});
import { renderHook } from '@testing-library/react';
import { useAppSelect } from '@hooks/useAppSelector';
import { useContracts } from '@modules/contracts';
import { filterByUniqIssKey } from '@utils/filterByUniqIssKey';
import { DEFAULT_SYMBOL } from '@widgets/Chart/const';
import { useChartPublicContext } from '../hooks/useChartPublicContext';
import type { Contract } from '@modules/contracts';
// Mock the dependencies
jest.mock('@hooks/useAppSelector');
jest.mock('@modules/contracts');
jest.mock('@utils/filterByUniqIssKey');
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(),
},
}));
describe('useChartPublicContext', () => {
const mockUseAppSelect = useAppSelect as jest.Mock;
const mockUseContracts = useContracts as jest.Mock;
const mockFilterByUniqIssKey = filterByUniqIssKey as jest.Mock;
const mockContracts = [
{
issKey: DEFAULT_SYMBOL,
displayName: 'Инструмент по умолчанию',
symbol: 'DEFAULT',
// ... other contract properties
},
{
issKey: 'MOEX:TEST1',
displayName: 'Test Instrument 1',
symbol: 'TEST1',
// ... other contract properties
},
{
issKey: 'MOEX:TEST2',
displayName: 'Test Instrument 2',
symbol: 'TEST2',
// ... other contract properties
},
{
issKey: 'MOEX:TEST3',
displayName: 'Test Instrument 3',
symbol: 'TEST3',
// ... other contract properties
},
] as Contract[];
const mockWidget = {
id: 1,
name: 'Test Widget',
type: 'graphic',
master: 123,
externalProperties: [
{
key: 'instrument',
value: 'MOEX:TEST1',
},
],
// ... other widget properties
};
const mockPublicContext = {
instrument: 'MOEX:TEST1',
};
beforeEach(() => {
jest.clearAllMocks();
// Mock the useAppSelect to return our test data
mockUseAppSelect.mockImplementation((selector) => {
if (selector.toString().includes('publicContext')) {
return mockPublicContext;
}
if (selector.toString().includes('widgets')) {
return mockWidget;
}
return {};
});
// Mock useContracts to return our test contracts
mockUseContracts.mockReturnValue({
contracts: mockContracts,
});
// Mock filterByUniqIssKey to return the same contracts
mockFilterByUniqIssKey.mockImplementation((contracts: Contract[]) => contracts);
});
it('should return the correct issKey when a matching instrument is found', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST1');
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBe('MOEX:TEST1');
});
it('should pass instrument id, display name and ticker when a matching instrument is found', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST2');
// Act
renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(mockSetCurrInstrument).toHaveBeenCalledTimes(1);
expect(mockSetCurrInstrument).toHaveBeenCalledWith('MOEX:TEST2', 'Test Instrument 2', 'TEST2');
});
it('should return undefined when no matching instrument is found', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:NONEXISTENT');
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBeUndefined();
expect(mockSetCurrInstrument).not.toHaveBeenCalled();
});
it('should return undefined when getMasterInstrumentFromPublicContext returns null', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(null);
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBeUndefined();
});
it('should return undefined when getMasterInstrumentFromPublicContext returns undefined', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(undefined);
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBeUndefined();
});
it('should set default instrument with display name and ticker when context value is empty', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue(null);
// Act
renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(mockSetCurrInstrument).toHaveBeenCalledTimes(1);
expect(mockSetCurrInstrument).toHaveBeenCalledWith(DEFAULT_SYMBOL, 'Инструмент по умолчанию', 'DEFAULT');
});
it('should resolve instrument when widget is not found', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST1');
mockUseAppSelect.mockImplementation((selector) => {
if (selector.toString().includes('publicContext')) {
return mockPublicContext;
}
if (selector.toString().includes('widgets')) {
return null;
}
return {};
});
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 999,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBe('MOEX:TEST1');
expect(mockSetCurrInstrument).toHaveBeenCalledWith('MOEX:TEST1', 'Test Instrument 1', 'TEST1');
});
it('should not set instrument when contracts array is empty', () => {
// Arrange
const mockSetCurrInstrument = jest.fn();
const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST1');
mockUseContracts.mockReturnValue({
contracts: [],
});
// Act
const { result } = renderHook(() =>
useChartPublicContext({
widgetId: 1,
setCurrInstrument: mockSetCurrInstrument,
getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
}),
);
// Assert
expect(result.current.issKey).toBeUndefined();
expect(mockSetCurrInstrument).not.toHaveBeenCalled();
});
});
import { act, render } from '@testing-library/react';
import React from 'react';
import { CompareModal } from '../components/MoexChart/components/CompareModal';
import { SymbolSearchModal } from '../components/MoexChart/components/SymbolSearchModal';
import { useMoexChart } from '../components/MoexChart/hooks';
import MoexChartComponent from '../components/MoexChart/MoexChart';
import type { Contract } from '@modules/contracts';
import type { __CompareManager__ } from 'moex-chart';
import type { MutableRefObject } from 'react';
jest.mock('../components/MoexChart/hooks', () => ({
useMoexChart: jest.fn(),
}));
jest.mock('../components/MoexChart/components/CompareModal', () => ({
CompareModal: jest.fn(() => null),
}));
jest.mock('../components/MoexChart/components/SymbolSearchModal', () => ({
SymbolSearchModal: jest.fn(() => null),
}));
interface CompareModalMockProps {
widgetId: number;
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
onClose: () => void;
compareManager: MutableRefObject<__CompareManager__ | null>;
}
interface SymbolSearchModalMockProps {
widgetId: number;
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
onSymbolChange: (instrument: Contract) => void;
}
describe('MoexChart', () => {
const mockUseMoexChart = useMoexChart as jest.Mock;
const mockCompareModal = CompareModal as jest.Mock;
const mockSymbolSearchModal = SymbolSearchModal as jest.Mock;
const mockSetIsCompareOpen = jest.fn();
const mockSetIsSymbolSearchOpen = jest.fn();
const mockAddInstrumentFromModal = jest.fn();
let containerRef: MutableRefObject<HTMLDivElement | null>;
let compareManagerRef: MutableRefObject<__CompareManager__ | null>;
const renderComponent = () =>
render(
<MoexChartComponent
symbol="MXSE:TQBR:SBER"
instrumentName="Сбербанк"
instrumentTicker="SBER"
widgetId={42}
addInstrumentFromModal={mockAddInstrumentFromModal}
/>,
);
beforeEach(() => {
jest.clearAllMocks();
containerRef = {
current: null,
};
compareManagerRef = {
current: null,
};
mockUseMoexChart.mockReturnValue({
containerRef,
isCompareOpen: false,
isSymbolSearchOpen: false,
compareManagerRef,
setIsCompareOpen: mockSetIsCompareOpen,
setIsSymbolSearchOpen: mockSetIsSymbolSearchOpen,
saveSnapshot: jest.fn(),
applySnapshot: jest.fn(),
hasSavedSnapshot: false,
});
});
it('should initialize chart hook with symbol, name and ticker', () => {
// Arrange & Act
renderComponent();
// Assert
expect(mockUseMoexChart).toHaveBeenCalledTimes(1);
expect(mockUseMoexChart).toHaveBeenCalledWith({
symbol: 'MXSE:TQBR:SBER',
instrumentName: 'Сбербанк',
instrumentTicker: 'SBER',
indicativeData: undefined,
});
});
it('should attach chart container ref', () => {
// Arrange & Act
renderComponent();
// Assert
expect(containerRef.current).toBeInstanceOf(HTMLDivElement);
});
it('should not render modals when they are closed', () => {
// Arrange & Act
renderComponent();
// Assert
expect(mockCompareModal).not.toHaveBeenCalled();
expect(mockSymbolSearchModal).not.toHaveBeenCalled();
});
it('should render compare modal with chart manager', () => {
// Arrange
mockUseMoexChart.mockReturnValue({
containerRef,
isCompareOpen: true,
isSymbolSearchOpen: false,
compareManagerRef,
setIsCompareOpen: mockSetIsCompareOpen,
setIsSymbolSearchOpen: mockSetIsSymbolSearchOpen,
saveSnapshot: jest.fn(),
applySnapshot: jest.fn(),
hasSavedSnapshot: false,
});
// Act
renderComponent();
const compareModalProps = mockCompareModal.mock.calls[0]?.[0] as CompareModalMockProps;
// Assert
expect(compareModalProps.widgetId).toBe(42);
expect(compareModalProps.isOpen).toBe(true);
expect(compareModalProps.setOpen).toBe(mockSetIsCompareOpen);
expect(compareModalProps.compareManager).toBe(compareManagerRef);
});
it('should close compare modal through onClose callback', () => {
// Arrange
mockUseMoexChart.mockReturnValue({
containerRef,
isCompareOpen: true,
isSymbolSearchOpen: false,
compareManagerRef,
setIsCompareOpen: mockSetIsCompareOpen,
setIsSymbolSearchOpen: mockSetIsSymbolSearchOpen,
saveSnapshot: jest.fn(),
applySnapshot: jest.fn(),
hasSavedSnapshot: false,
});
renderComponent();
const compareModalProps = mockCompareModal.mock.calls[0]?.[0] as CompareModalMockProps;
// Act
act(() => {
compareModalProps.onClose();
});
// Assert
expect(mockSetIsCompareOpen).toHaveBeenCalledWith(false);
});
it('should render symbol search modal', () => {
// Arrange
mockUseMoexChart.mockReturnValue({
containerRef,
isCompareOpen: false,
isSymbolSearchOpen: true,
compareManagerRef,
setIsCompareOpen: mockSetIsCompareOpen,
setIsSymbolSearchOpen: mockSetIsSymbolSearchOpen,
saveSnapshot: jest.fn(),
applySnapshot: jest.fn(),
hasSavedSnapshot: false,
});
// Act
renderComponent();
const symbolSearchModalProps = mockSymbolSearchModal.mock.calls[0]?.[0] as SymbolSearchModalMockProps;
// Assert
expect(symbolSearchModalProps.widgetId).toBe(42);
expect(symbolSearchModalProps.isOpen).toBe(true);
expect(symbolSearchModalProps.setOpen).toBe(mockSetIsSymbolSearchOpen);
});
it('should pass selected instrument from symbol search modal', () => {
// Arrange
mockUseMoexChart.mockReturnValue({
containerRef,
isCompareOpen: false,
isSymbolSearchOpen: true,
compareManagerRef,
setIsCompareOpen: mockSetIsCompareOpen,
setIsSymbolSearchOpen: mockSetIsSymbolSearchOpen,
saveSnapshot: jest.fn(),
applySnapshot: jest.fn(),
hasSavedSnapshot: false,
});
renderComponent();
const symbolSearchModalProps = mockSymbolSearchModal.mock.calls[0]?.[0] as SymbolSearchModalMockProps;
const instrument = {
issKey: 'MXSE:TQBR:GAZP',
displayName: 'Газпром',
symbol: 'GAZP',
} as Contract;
// Act
act(() => {
symbolSearchModalProps.onSymbolChange(instrument);
});
// Assert
expect(mockAddInstrumentFromModal).toHaveBeenCalledTimes(1);
expect(mockAddInstrumentFromModal).toHaveBeenCalledWith([instrument]);
});
});
import type { Timeframes as TimeframesType } from 'moex-chart';
type DataSourceProvideModule = typeof import('@widgets/Chart/components/MoexChart/dataSourceProvide');
const mockRequestBars = jest.fn();
const mockRequestRealtimeBars = jest.fn();
const mockMoexChartTimeConverter = jest.fn();
const mockParseTimeframe = jest.fn();
const mockGetStartTime = jest.fn();
const mockMoexChartToIssTimeframe = jest.fn();
const mockGetISSOddTimeframePart = jest.fn();
// Mock the dependencies
jest.mock('moex-chart', () => ({
Timeframes: {
'10s': '10s',
'1m': '1m',
'5m': '5m',
},
parseTimeframe: mockParseTimeframe,
getStartTime: mockGetStartTime,
}));
jest.mock('@utils/chartToReqTimeConverter', () => ({
moexChartTimeConverter: mockMoexChartTimeConverter,
moexChartToIssTimeframe: mockMoexChartToIssTimeframe,
getISSOddTimeframePart: mockGetISSOddTimeframePart,
}));
jest.mock('../requestBars', () => ({
requestBars: mockRequestBars,
requestRealtimeBars: mockRequestRealtimeBars,
}));
jest.mock('@widgets/Chart/requestBars', () => ({
requestBars: mockRequestBars,
requestRealtimeBars: mockRequestRealtimeBars,
}));
const { DataSourceProvider } = jest.requireActual(
'@widgets/Chart/components/MoexChart/dataSourceProvide',
) as DataSourceProvideModule;
const Timeframes = {
'10s': '10s' as TimeframesType,
'1m': '1m' as TimeframesType,
'5m': '5m' as TimeframesType,
};
const flushPromises = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
};
describe('DataSourceProvider', () => {
const mockBar = {
time: 1640995200,
open: 100,
close: 110,
high: 120,
low: 90,
volume: 1000,
};
const baseTime = Date.parse('2026-05-19T10:00:00Z');
const createBar = (minute: number, overrides: Partial<typeof mockBar> = {}) => ({
time: baseTime + minute * 60_000,
open: 100 + minute,
close: 101 + minute,
high: 102 + minute,
low: 99 - minute,
volume: minute + 1,
...overrides,
});
const configureFiveMinuteConvolution = (): void => {
mockMoexChartToIssTimeframe.mockReturnValue(Timeframes['1m']);
mockParseTimeframe.mockImplementation((timeframe: TimeframesType) =>
timeframe === Timeframes['5m']
? {
candleWidth: 5,
dayjsUnit: 'minute',
}
: {
candleWidth: 1,
dayjsUnit: 'minute',
},
);
mockGetStartTime.mockImplementation((timeframe: TimeframesType, time: number) => {
const intervalMs = timeframe === Timeframes['5m'] ? 5 * 60_000 : 60_000;
return (Math.floor(time / intervalMs) * intervalMs) / 1000;
});
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-19T10:00:00Z'));
mockMoexChartTimeConverter.mockReturnValue('1');
mockMoexChartToIssTimeframe.mockImplementation((timeframe: TimeframesType) => timeframe);
mockGetISSOddTimeframePart.mockReturnValue({
value: 0,
unit: 'minute',
});
mockParseTimeframe.mockReturnValue({
candleWidth: 1,
dayjsUnit: 'minute',
});
mockGetStartTime.mockImplementation((_timeframe: TimeframesType, time: number) => Math.floor(time / 1000));
});
afterEach(() => {
jest.useRealTimers();
});
it('should request chart history data with converted timeframe', async () => {
// Arrange
const mockTimeframeCallback = jest.fn();
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource(undefined, mockTimeframeCallback);
// Act
const result = await dataSource(Timeframes['1m'], 'MOEX:SBER');
// Assert
expect(mockTimeframeCallback).toHaveBeenCalledWith(Timeframes['1m']);
expect(mockMoexChartTimeConverter).toHaveBeenCalledWith(Timeframes['1m']);
expect(mockRequestBars).toHaveBeenCalledWith({
currencyPair: 'MOEX.SBER',
interval: '1',
periodParams: {
firstDataRequest: true,
to: Math.round(Date.now() / 1000),
from: Date.now(),
countBack: 2000,
},
ticker: 'MOEX:SBER',
indicativeData: undefined,
});
expect(result).toEqual([mockBar]);
});
it('should request chart history data with indicative data', async () => {
// Arrange
const indicativeData = {
id: 1,
title: 'Test instrument',
secId: 'SBER',
instrumentName: 'SBER',
settlement: 'TQBR',
firmName: 'Test firm',
key: 'SBER_TBQR',
};
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource(indicativeData);
// Act
await dataSource(Timeframes['1m'], 'MOEX:SBER');
// Assert
expect(mockRequestBars).toHaveBeenCalledWith(
expect.objectContaining({
indicativeData,
}),
);
});
it('should use until time when it is provided', async () => {
// Arrange
mockMoexChartTimeConverter.mockReturnValue('5');
mockRequestBars.mockResolvedValue([mockBar]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
const until = { time: 111 } as NonNullable<Parameters<typeof dataSource>[2]>;
// Act
await dataSource(Timeframes['5m'], 'MOEX:GAZP', until);
// Assert
expect(mockRequestBars).toHaveBeenCalledWith(
expect.objectContaining({
periodParams: expect.objectContaining({
to: 111,
}),
}),
);
});
it('should return null when history data is empty', async () => {
// Arrange
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestBars.mockResolvedValue([]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
// Act
const result = await dataSource(Timeframes['1m'], 'MOEX:SBER');
// Assert
expect(result).toBeNull();
});
it('should request realtime data and update normalized symbol', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
// mockMoexChartTimeConverter.mockReturnValue('1');
const localMockedBar = {
time: 1640995200,
open: 100,
close: 110,
high: 120,
low: 90,
volume: Math.floor(Math.random() * 1000),
};
mockRequestRealtimeBars.mockImplementation(() => localMockedBar);
provider.startRealtime({
getSymbols: () => [' moex:sber '],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockRequestRealtimeBars).toHaveBeenCalledWith({
currencyPair: 'moex.sber',
interval: '1',
ticker: 'moex:sber',
indicativeData: undefined,
});
expect(mockUpdate).toHaveBeenCalledWith('moex:sber', localMockedBar);
});
it('should request realtime data with indicative data', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const indicativeData = {
id: 1,
title: 'Test instrument',
secId: 'SBER',
instrumentName: 'SBER',
settlement: 'TQBR',
firmName: 'Test firm',
key: 'SBER_TBQR',
};
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestRealtimeBars.mockResolvedValue(mockBar);
provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
indicativeData,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockRequestRealtimeBars).toHaveBeenCalledWith(
expect.objectContaining({
indicativeData,
}),
);
});
it('should not call update when realtime data is empty', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestRealtimeBars.mockResolvedValue(undefined);
provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockUpdate).not.toHaveBeenCalled();
});
it('should not request realtime data when symbols list is empty', async () => {
// Arrange
const provider = new DataSourceProvider();
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestRealtimeBars.mockResolvedValue(mockBar);
provider.startRealtime({
getSymbols: () => [],
getTimeframe: () => Timeframes['1m'],
update: jest.fn(),
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
});
it('should clear realtime timer on unsubscribe', async () => {
// Arrange
const provider = new DataSourceProvider();
// mockMoexChartTimeConverter.mockReturnValue('1');
mockRequestRealtimeBars.mockResolvedValue(mockBar);
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: jest.fn(),
periodMs: 1000,
});
// Act
unsubscribe();
jest.advanceTimersByTime(1000);
await flushPromises();
// Assert
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
});
it('should convolve history data when ISS timeframe differs', async () => {
// Arrange
configureFiveMinuteConvolution();
const historyData = Array.from({ length: 11 }, (_, minute) => createBar(minute));
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
// Act
const result = await dataSource(Timeframes['5m'], 'MOEX:SBER');
// Assert
expect(result).toEqual([
{
time: baseTime + 5 * 60_000,
open: 105,
close: 110,
high: 111,
low: 90,
volume: 40,
},
]);
});
it('should convolve incomplete history candle group', async () => {
// Arrange
configureFiveMinuteConvolution();
const historyData = Array.from({ length: 8 }, (_, minute) => createBar(minute));
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
// Act
const result = await dataSource(Timeframes['5m'], 'MOEX:SBER');
// Assert
expect(result).toEqual([
{
time: baseTime + 5 * 60_000,
open: 105,
close: 108,
high: 109,
low: 92,
volume: 21,
},
]);
});
it('should stop candle group when candle is outside current interval', async () => {
// Arrange
configureFiveMinuteConvolution();
const historyData = [
createBar(0),
createBar(1),
createBar(2),
createBar(3),
createBar(4),
createBar(5),
createBar(11),
createBar(12),
];
mockRequestBars.mockResolvedValue(historyData);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
// Act
const result = await dataSource(Timeframes['5m'], 'MOEX:SBER');
// Assert
expect(result).toEqual([
{
time: baseTime + 5 * 60_000,
open: 105,
close: 112,
high: 113,
low: 88,
volume: 18,
},
]);
});
it('should return empty data when convolution start index is not found', async () => {
// Arrange
configureFiveMinuteConvolution();
mockGetStartTime.mockReturnValue(1);
mockRequestBars.mockResolvedValue([createBar(0), createBar(1)]);
const provider = new DataSourceProvider();
const dataSource = provider.getDataSource();
// Act
const result = await dataSource(Timeframes['5m'], 'MOEX:SBER');
// Assert
expect(result).toEqual([]);
});
it('should append, replace and reset realtime candles during convolution', async () => {
// Arrange
configureFiveMinuteConvolution();
mockRequestBars.mockResolvedValue(Array.from({ length: 11 }, (_, minute) => createBar(minute)));
const provider = new DataSourceProvider();
await provider.getDataSource()(Timeframes['5m'], 'MOEX:SBER');
const firstCandle = {
time: baseTime + 5 * 60_000,
open: 100,
high: 103,
low: 99,
close: 102,
volume: 10,
};
const secondCandle = {
time: baseTime + 6 * 60_000,
open: 102,
high: 106,
low: 98,
close: 105,
volume: 20,
};
const updatedSecondCandle = {
...secondCandle,
high: 107,
low: 97,
close: 106,
volume: 25,
};
const nextTimeframeCandle = {
time: baseTime + 10 * 60_000,
open: 106,
high: 108,
low: 105,
close: 107,
volume: 30,
};
mockRequestRealtimeBars
.mockResolvedValueOnce(firstCandle)
.mockResolvedValueOnce(secondCandle)
.mockResolvedValueOnce(updatedSecondCandle)
.mockResolvedValueOnce(nextTimeframeCandle);
const mockUpdate = jest.fn();
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['5m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
jest.advanceTimersByTime(1000);
await flushPromises();
jest.advanceTimersByTime(1000);
await flushPromises();
jest.advanceTimersByTime(1000);
await flushPromises();
unsubscribe();
// Assert
expect(mockUpdate).toHaveBeenNthCalledWith(1, 'MOEX:SBER', firstCandle);
expect(mockUpdate).toHaveBeenNthCalledWith(2, 'MOEX:SBER', {
time: firstCandle.time,
open: firstCandle.open,
high: 106,
low: 98,
close: 105,
volume: 30,
});
expect(mockUpdate).toHaveBeenNthCalledWith(3, 'MOEX:SBER', {
time: firstCandle.time,
open: firstCandle.open,
high: 107,
low: 97,
close: 106,
volume: 35,
});
expect(mockUpdate).toHaveBeenNthCalledWith(4, 'MOEX:SBER', nextTimeframeCandle);
});
it('should update distinct realtime candles without convolution', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const firstCandle = {
...mockBar,
close: 110,
};
const secondCandle = {
...mockBar,
close: 111,
};
mockRequestRealtimeBars.mockResolvedValueOnce(firstCandle).mockResolvedValueOnce(secondCandle);
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
jest.advanceTimersByTime(1000);
await flushPromises();
unsubscribe();
// Assert
expect(mockUpdate).toHaveBeenNthCalledWith(1, 'MOEX:SBER', firstCandle);
expect(mockUpdate).toHaveBeenNthCalledWith(2, 'MOEX:SBER', secondCandle);
});
it('should not update chart for duplicated realtime candle', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
const realtimeCandle = {
time: 1640995200,
open: 100,
close: 110,
high: 120,
low: 90,
volume: 1000,
};
mockRequestRealtimeBars.mockResolvedValue(realtimeCandle);
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
jest.advanceTimersByTime(1000);
await flushPromises();
unsubscribe();
// Assert
expect(mockRequestRealtimeBars).toHaveBeenCalledTimes(2);
expect(mockUpdate).toHaveBeenCalledTimes(1);
expect(mockUpdate).toHaveBeenCalledWith('MOEX:SBER', realtimeCandle);
});
it('should not request realtime data for empty normalized symbol', async () => {
// Arrange
const provider = new DataSourceProvider();
const unsubscribe = provider.startRealtime({
getSymbols: () => [' '],
getTimeframe: () => Timeframes['1m'],
update: jest.fn(),
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
unsubscribe();
// Assert
expect(mockRequestRealtimeBars).not.toHaveBeenCalled();
});
it('should replace existing realtime timer when startRealtime is called again', async () => {
// Arrange
const provider = new DataSourceProvider();
const mockUpdate = jest.fn();
mockRequestRealtimeBars.mockResolvedValue(mockBar);
provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
const unsubscribe = provider.startRealtime({
getSymbols: () => ['MOEX:SBER'],
getTimeframe: () => Timeframes['1m'],
update: mockUpdate,
periodMs: 1000,
});
// Act
jest.advanceTimersByTime(1000);
await flushPromises();
unsubscribe();
// Assert
expect(mockRequestRealtimeBars).toHaveBeenCalledTimes(1);
expect(mockUpdate).toHaveBeenCalledTimes(1);
});
});
import { act, render } from '@testing-library/react';
import { CompareMode } from 'moex-chart';
import React from 'react';
import { InstrumentSearch } from '@components/InstrumentSearch';
import { CompareModal } from '../components/MoexChart/components/CompareModal';
import type { Contract } from '@modules/contracts';
import type { __CompareManager__ } from 'moex-chart';
import type { MutableRefObject } from 'react';
jest.mock('moex-chart', () => ({
__esModule: true,
CompareMode: {
Percentage: 'PCT',
NewScale: 'SCALE',
NewPane: 'PANE',
},
}));
jest.mock('@components/InstrumentSearch', () => ({
InstrumentSearch: jest.fn(() => null),
}));
interface CompareActions {
handlePercent: (instrument: Contract) => void;
handleNewScale: (instrument: Contract) => void;
handleNewPanel: (instrument: Contract) => void;
}
interface InstrumentSearchMockProps {
widgetId: number;
variant: string;
isOpen: boolean;
setOpen: (isOpen: boolean) => void;
isNewScaleDisabled: boolean;
customActionsFooterHandlers: CompareActions;
}
describe('CompareModal', () => {
const mockInstrumentSearch = InstrumentSearch as jest.Mock;
const mockSetOpen = jest.fn();
const mockSetSymbolMode = jest.fn();
const mockIsNewScaleDisabled = jest.fn();
const mockIsNewScaleDisabledObservable = jest.fn();
const mockSubscribe = jest.fn();
const mockUnsubscribe = jest.fn();
let newScaleDisabledListener: ((disabled: boolean) => void) | null;
let compareManager: __CompareManager__;
let compareManagerRef: MutableRefObject<__CompareManager__ | null>;
const getInstrumentSearchProps = (): InstrumentSearchMockProps => {
const lastCall = mockInstrumentSearch.mock.calls[mockInstrumentSearch.mock.calls.length - 1];
return lastCall?.[0] as InstrumentSearchMockProps;
};
const renderComponent = (isOpen = true) =>
render(
<CompareModal
onClose={jest.fn()}
widgetId={42}
compareManager={compareManagerRef}
isOpen={isOpen}
setOpen={mockSetOpen}
/>,
);
beforeEach(() => {
jest.clearAllMocks();
newScaleDisabledListener = null;
mockIsNewScaleDisabled.mockReturnValue(false);
mockSetSymbolMode.mockResolvedValue(undefined);
mockSubscribe.mockImplementation((listener: (disabled: boolean) => void) => {
newScaleDisabledListener = listener;
return {
unsubscribe: mockUnsubscribe,
};
});
mockIsNewScaleDisabledObservable.mockReturnValue({
subscribe: mockSubscribe,
});
compareManager = {
setSymbolMode: mockSetSymbolMode,
isNewScaleDisabled: mockIsNewScaleDisabled,
isNewScaleDisabledObservable: mockIsNewScaleDisabledObservable,
} as unknown as __CompareManager__;
compareManagerRef = {
current: compareManager,
};
});
it('should pass modal properties and current scale state to instrument search', () => {
// Arrange
mockIsNewScaleDisabled.mockReturnValue(true);
// Act
renderComponent();
const instrumentSearchProps = getInstrumentSearchProps();
// Assert
expect(instrumentSearchProps.widgetId).toBe(42);
expect(instrumentSearchProps.variant).toBe('single');
expect(instrumentSearchProps.isOpen).toBe(true);
expect(instrumentSearchProps.setOpen).toBe(mockSetOpen);
expect(instrumentSearchProps.isNewScaleDisabled).toBe(true);
expect(instrumentSearchProps.customActionsFooterHandlers).toEqual({
handlePercent: expect.any(Function),
handleNewScale: expect.any(Function),
handleNewPanel: expect.any(Function),
});
});
it('should subscribe to new scale disabled state', () => {
// Arrange & Act
renderComponent();
// Assert
expect(mockIsNewScaleDisabled).toHaveBeenCalledTimes(1);
expect(mockIsNewScaleDisabledObservable).toHaveBeenCalledTimes(1);
expect(mockSubscribe).toHaveBeenCalledTimes(1);
});
it('should update new scale disabled state from manager observable', () => {
// Arrange
renderComponent();
// Act
act(() => {
newScaleDisabledListener?.(true);
});
// Assert
expect(getInstrumentSearchProps().isNewScaleDisabled).toBe(true);
});
it('should unsubscribe from manager observable on unmount', () => {
// Arrange
const { unmount } = renderComponent();
// Act
unmount();
// Assert
expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
});
it('should not subscribe when modal is closed', () => {
// Arrange & Act
renderComponent(false);
// Assert
expect(mockIsNewScaleDisabled).not.toHaveBeenCalled();
expect(mockIsNewScaleDisabledObservable).not.toHaveBeenCalled();
expect(getInstrumentSearchProps().isNewScaleDisabled).toBe(false);
});
it('should not subscribe when compare manager is unavailable', () => {
// Arrange
compareManagerRef.current = null;
// Act
renderComponent();
// Assert
expect(mockIsNewScaleDisabled).not.toHaveBeenCalled();
expect(mockIsNewScaleDisabledObservable).not.toHaveBeenCalled();
expect(getInstrumentSearchProps().isNewScaleDisabled).toBe(false);
});
it.each([
['handlePercent', CompareMode.Percentage],
['handleNewScale', CompareMode.NewScale],
['handleNewPanel', CompareMode.NewPane],
] as const)('should add compare instrument using %s action', (handlerName, mode) => {
// Arrange
renderComponent();
const instrument = {
issKey: 'MXSE:TQBR:SBER',
displayName: 'Сбербанк',
symbol: 'SBER',
} as Contract;
const handlers = getInstrumentSearchProps().customActionsFooterHandlers;
// Act
handlers[handlerName](instrument);
// Assert
expect(mockSetSymbolMode).toHaveBeenCalledTimes(1);
expect(mockSetSymbolMode).toHaveBeenCalledWith(
'Line',
{
symbol: 'MXSE:TQBR:SBER',
instrumentName: 'Сбербанк',
instrumentTicker: 'SBER',
},
mode,
);
});
it('should delegate missing instrument metadata fallback to moex-chart', () => {
// Arrange
renderComponent();
const instrument = {
issKey: 'MXSE:TQBR:SBER',
displayName: '',
symbol: '',
} as Contract;
// Act
getInstrumentSearchProps().customActionsFooterHandlers.handlePercent(instrument);
// Assert
expect(mockSetSymbolMode).toHaveBeenCalledWith(
'Line',
{
symbol: 'MXSE:TQBR:SBER',
instrumentName: undefined,
instrumentTicker: undefined,
},
CompareMode.Percentage,
);
});
it('should not add compare instrument without issKey', () => {
// Arrange
renderComponent();
const instrument = {
issKey: '',
displayName: 'Сбербанк',
symbol: 'SBER',
} as Contract;
// Act
getInstrumentSearchProps().customActionsFooterHandlers.handlePercent(instrument);
// Assert
expect(mockSetSymbolMode).not.toHaveBeenCalled();
});
});