Загрузка данных


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();
  });
});