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


import isNil from 'lodash/isNil';

import { Intervals, MoexChart, Timeframes } from 'moex-chart';

import { useCallback, useEffect, useRef, useState } from 'react';

import { useChangeProperties, useSelectProperties } from '@modules/widgetProperties';
import { WidgetProperties } from '@widgets/Chart/properties/types';
import { ChartIndicativeData } from '@widgets/Chart/types';

import { MOEX_CHART_CONFIG } from '../constants';
import { DataSourceProvider } from '../dataSourceProvide';

import type { __CompareManager__, IMoexChart, SymbolInfoInput } from 'moex-chart';

interface TUseMoexChartProps {
  symbolInfo: SymbolInfoInput;
  indicativeData?: ChartIndicativeData;
}

export const useMoexChart = ({ symbolInfo, indicativeData }: TUseMoexChartProps) => {
  const moexChartState = useSelectProperties(
    (widgetProperties: Partial<WidgetProperties>) => widgetProperties.moexChartState,
  );

  const { updateProperties } = useChangeProperties<WidgetProperties>();

  const [isCompareOpen, setIsCompareOpen] = useState(false);
  const [isSymbolSearchOpen, setIsSymbolSearchOpen] = useState(false);

  const containerRef = useRef<HTMLDivElement | null>(null);
  const chartRef = useRef<MoexChart | null>(null);
  const compareManagerRef = useRef<__CompareManager__ | null>(null);
  const currentSymbolInfoRef = useRef<SymbolInfoInput>(symbolInfo);

  const timeframeRef = useRef<Timeframes | undefined>(moexChartState?.timeframe);
  const savedDataRef = useRef<string | undefined>(moexChartState?.savedData);
  const initialIntervalRef = useRef<Intervals | undefined>(moexChartState?.initialInterval);
  const updateTimeframeRef = useRef<((timeframe: Timeframes) => void) | null>(null);

  useEffect(() => {
    if (
      currentSymbolInfoRef.current.symbolId === symbolInfo.symbolId &&
      currentSymbolInfoRef.current.symbol === symbolInfo.symbol &&
      currentSymbolInfoRef.current.symbolName === symbolInfo.symbolName
    ) {
      return;
    }

    currentSymbolInfoRef.current = {
      symbolId: symbolInfo.symbolId,
      symbol: symbolInfo.symbol,
      symbolName: symbolInfo.symbolName,
    };

    chartRef.current?.setSymbol(currentSymbolInfoRef.current);
  }, [symbolInfo.symbolId, symbolInfo.symbol, symbolInfo.symbolName]);

  useEffect(() => {
    timeframeRef.current = moexChartState?.timeframe;
    savedDataRef.current = moexChartState?.savedData;
  }, [moexChartState?.savedData, moexChartState?.timeframe]);

  updateTimeframeRef.current = (timeframe: Timeframes) => {
    if (timeframeRef.current === timeframe) {
      return;
    }

    timeframeRef.current = timeframe;

    updateProperties((state) => {
      state.moexChartState = {
        ...state.moexChartState,
        timeframe,
      };
    });
  };

  const saveSnapshot = useCallback((): void => {
    const snapshot = chartRef.current?.getSnapshot();

    if (!snapshot) {
      return;
    }

    const savedData = JSON.stringify({
      ...snapshot,
      charts: snapshot.charts.map((chart) => ({
        ...chart,
        panes: chart.panes.map((pane) => ({
          ...pane,
          indicators: pane.indicators.map((indicator) => {
            const compareSeries = indicator.config?.series[0];
            const compareSymbolInfo = indicator.config?.symbolInfo;

            return {
              ...indicator,
              dataSource: undefined, // dataSource пока не среиализуем
              config:
                indicator.indicatorType === undefined && compareSymbolInfo && indicator.config?.label && compareSeries
                  ? {
                      symbolInfo: compareSymbolInfo,
                      label: indicator.config.label,
                      newPane: indicator.config.newPane,
                      series: [
                        {
                          name: compareSeries.name,
                          seriesOptions: {
                            priceScaleId: compareSeries.seriesOptions?.priceScaleId,
                          },
                        },
                      ],
                    }
                  : undefined,
            };
          }),
        })),
      })),
    });

    if (savedDataRef.current === savedData) {
      return;
    }

    savedDataRef.current = savedData;

    updateProperties((state) => {
      state.moexChartState = {
        ...state.moexChartState,
        timeframe: timeframeRef.current || Timeframes['1m'],
        savedData,
      };
    });
  }, [updateProperties]);

  const getSnapshotWithCurrentSymbol = (
    snapshot: typeof MOEX_CHART_CONFIG.snapshot | IMoexChart['snapshot'],
    timeframe: Timeframes,
  ): IMoexChart['snapshot'] => ({
    ...snapshot,
    charts: snapshot.charts.map((chartSnapshot) => ({
      ...chartSnapshot,
      ...currentSymbolInfoRef.current,
      timeframe,
    })),
  });

  const applySnapshot = (): void => {
    if (!savedDataRef.current || !chartRef.current) {
      return;
    }

    const savedSnapshot = JSON.parse(savedDataRef.current) as IMoexChart['snapshot'];
    const timeframe = timeframeRef.current || Timeframes['1m'];

    chartRef.current.setSnapshot(getSnapshotWithCurrentSymbol(savedSnapshot, timeframe));

    compareManagerRef.current = chartRef.current.getCompareManager();
  };

  useEffect(() => {
    const container = containerRef.current;

    if (!container) {
      return undefined;
    }

    const timeframe = timeframeRef.current || Timeframes['1m'];
    const savedSnapshot = savedDataRef.current
      ? (JSON.parse(savedDataRef.current) as IMoexChart['snapshot'])
      : MOEX_CHART_CONFIG.snapshot;
    const dataProvider = new DataSourceProvider();

    const chart = new MoexChart({
      ...MOEX_CHART_CONFIG,
      container,
      snapshot: getSnapshotWithCurrentSymbol(savedSnapshot, timeframe),
      chartCollectionPreset: {
        ...MOEX_CHART_CONFIG.chartCollectionPreset,
        openCompareModal: () => setIsCompareOpen(true),
        openSymbolSearchModal: () => setIsSymbolSearchOpen(true),
        getDataSource: dataProvider.getDataSource(indicativeData, (nextTimeframe) => {
          updateTimeframeRef.current?.(nextTimeframe);
        }),
        startRealtime: (getSymbols, getTimeframe, update) =>
          dataProvider.startRealtime({
            getSymbols,
            getTimeframe,
            update,
            indicativeData,
          }),
      },
    });

    chartRef.current = chart;
    compareManagerRef.current = chart.getCompareManager();

    if (initialIntervalRef.current) {
      chart.setSettings({ interval: initialIntervalRef.current });

      updateProperties((state) => {
        state.moexChartState = {
          ...state.moexChartState,
          initialInterval: undefined,
        };
      });
      initialIntervalRef.current = undefined;
    }

    const intervalId = setInterval(() => {
      saveSnapshot();
    }, 1000);

    // явная инициализация индикативных данных в график
    if (!isNil(indicativeData)) {
      chartRef.current?.setSymbol({ symbolId: indicativeData.key, symbolName: indicativeData.instrumentName });
    }

    return () => {
      clearInterval(intervalId);
      saveSnapshot();

      chartRef.current = null;
      compareManagerRef.current = null;
      chart.destroy();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- исправим позже
  }, [indicativeData?.key]);

  return {
    containerRef,
    isCompareOpen,
    isSymbolSearchOpen,
    compareManagerRef,
    setIsCompareOpen,
    setIsSymbolSearchOpen,
    saveSnapshot,
    applySnapshot,
    hasSavedSnapshot: Boolean(moexChartState?.savedData),
  };
};



import type { ChartIndicativeData } from '../types';

import type { Contract } from '@modules/contracts/types';

import type { WidgetProperties as BaseWidgetProperties } from '@modules/widgetProperties/types';
import type { Intervals, Timeframes } from 'moex-chart';

export interface WidgetProperties extends BaseWidgetProperties {
  chartState: {
    savedInstrument: Contract['issKey'] | null;
    savedSymbol?: Contract['symbol'];
    savedSymbolName?: Contract['displayName'];
    interval: string;
    savedData?: string;
  };
  indicativeData?: ChartIndicativeData;
  moexChartState?: {
    initialInterval?: Intervals;
    timeframe?: Timeframes;
    savedData?: string;
  };
}



import { ContextMenuItem } from '@uikit/ContextMenu/types';
import { useContextMenuOverlay } from '@uikit/ContextMenuOverlay';
import { createGraphicWidget } from '@widgets/Chart/creator';
import { downloadHistory } from '@widgets/ntb/Indexes/utils/downloadHistory';
import { isNtbIndex } from '@widgets/ntb/Indexes/utils/isNtbIndex';

import type { IndexesTableDataItem } from '../types';

type UseContextMenuProps = {
  onShowHistory: (secId: string, sourceId?: string) => void;
};

export const useContextMenu = ({ onShowHistory }: UseContextMenuProps) => {
  const {
    onContextMenu,
    menuProps,
    payload: instrument,
  } = useContextMenuOverlay<IndexesTableDataItem>({
    shouldOpen: (record) => !!record?.issKey || isNtbIndex(record?.sourceId),
  });

  const onOpenChart = () => {
    if (instrument) {
      createGraphicWidget({
        chartState: {
          savedInstrument: instrument.issKey,
        },
        moexChartState: {
          tf: '1d',
          initialInterval: '1Y',
        },
      });
    }
  };

  const handleShowHistory = () => {
    if (instrument) {
      onShowHistory(instrument.securityId, instrument.sourceId);
    }
  };

  const handleDownloadHistory = () => {
    if (instrument) {
      downloadHistory(instrument.securityId, instrument.sourceId);
    }
  };

  const items: ContextMenuItem[] = [
    ...(instrument?.issKey ? [{ key: 'openChart', label: 'Отобразить в виджете График', onClick: onOpenChart }] : []),
    { key: 'showHistory', label: 'Отобразить исторические данные', onClick: handleShowHistory },
    { key: 'downloadHistory', label: 'Экспорт истории в Excel', onClick: handleDownloadHistory },
  ];

  return { onContextMenu, menuProps, items };
};



import { useCallback } from 'react';

import { createGraphicWidget } from '@widgets/Chart/creator';

import { useCalcLogistic } from '../hooks/useCalcLogistic';

import type { LogisticContextMenuProps } from './LogisticContextMenu';

export const useContextMenu = ({
  row,
  setIsActiveHistoryData,
  disableRequestToChat,
  setOpenContextMenu,
  groupName,
}: Pick<
  LogisticContextMenuProps,
  'row' | 'setIsActiveHistoryData' | 'disableRequestToChat' | 'setOpenContextMenu' | 'groupName'
>) => {
  const { calcLogistic, isAvailable: isCalcLogisticAvailable } = useCalcLogistic(row?.partner.unicode, groupName);

  const issKey = row?.issKey ?? null;

  const handleOpenRequestToChatModal = useCallback(() => {
    calcLogistic();
    setOpenContextMenu(false);
  }, [calcLogistic, setOpenContextMenu]);

  const handleShowHistoryData = useCallback(() => {
    setOpenContextMenu(false);
    setIsActiveHistoryData(true);
  }, [setIsActiveHistoryData, setOpenContextMenu]);

  const handleOpenChart = useCallback(() => {
    if (issKey) {
      createGraphicWidget({
        chartState: { savedInstrument: issKey },
        moexChartState: { tf: '1d', initialInterval: '1Y' },
      });
    }
    setOpenContextMenu(false);
  }, [issKey, setOpenContextMenu]);

  const showRequestToChatOption = isCalcLogisticAvailable && !disableRequestToChat;
  const showChartOption = Boolean(issKey);

  return {
    handleOpenRequestToChatModal,
    handleShowHistoryData,
    handleOpenChart,
    showRequestToChatOption,
    showChartOption,
  };
};



import { act, render } from '@testing-library/react';

import { MoexChart, Timeframes } from 'moex-chart';
import React from 'react';

import { useChangeProperties, useSelectProperties } from '@modules/widgetProperties';

import { useMoexChart } from '../components/MoexChart/hooks';

import type { ChartIndicativeData } from '@widgets/Chart/types';
import type { SymbolInfoInput } from 'moex-chart';

jest.mock('@modules/widgetProperties');

jest.mock('../components/MoexChart/dataSourceProvide', () => ({
  DataSourceProvider: jest.fn(),
}));

jest.mock('moex-chart', () => ({
  MoexChart: jest.fn(),
  Timeframes: {
    '10s': '10s',
    '1m': '1m',
    '5m': '5m',
  },
  DateFormat: {
    DD_MM_YYYY_HH_mm_ss: 'DD_MM_YYYY_HH_mm_ss',
  },
  IndicatorsIds: {
    Volume: 'Volume',
  },
  Locale: {
    rus: 'ru-RU',
    eng: 'en-US',
  },
}));

jest.mock('@api/index', () => ({
  updateMenuLocked: jest.fn(),
  widgetsController: {
    delete: jest.fn(),
  },
  widgetPropertiesController: {
    update: jest.fn(),
  },
  workspaceController: {
    update: jest.fn(),
  },
}));

jest.mock('@api/controllers/workspace', () => ({
  workspaceController: {
    update: jest.fn(),
  },
}));

const mockedDataSourceProvide = jest.requireMock('../components/MoexChart/dataSourceProvide') as {
  DataSourceProvider: jest.Mock;
};

type TimeframeValue = (typeof Timeframes)[keyof typeof Timeframes];

interface MockCompareSeries {
  name: string;
  seriesOptions?: {
    priceScaleId?: string;
    color?: string;
  };
}

interface MockIndicatorConfig {
  symbolInfo?: SymbolInfoInput;
  label?: string;
  newPane?: boolean;
  series: MockCompareSeries[];
}

interface MockIndicatorSnapshot {
  id?: string;
  indicatorType?: string;
  dataSource?: unknown;
  config?: MockIndicatorConfig;
}

interface MockPaneSnapshot {
  indicators: MockIndicatorSnapshot[];
}

interface MockChartSnapshot {
  charts: {
    symbolId: string;
    symbol: string;
    symbolName: string;
    timeframe: TimeframeValue;
    chartSeriesType: string;
    panes: MockPaneSnapshot[];
  }[];
}

interface MockMoexChartConfig {
  container: HTMLElement;
  snapshot: MockChartSnapshot;
  chartCollectionPreset: {
    openCompareModal: () => void;
    openSymbolSearchModal: () => void;
    getDataSource: jest.Mock;
    startRealtime: (
      getSymbols: () => string[],
      getTimeframe: () => TimeframeValue,
      update: jest.Mock,
    ) => (() => void) | undefined;
  };
}

interface MockMoexChartState {
  timeframe?: TimeframeValue;
  savedData?: string;
}

interface MockPropertiesState {
  moexChartState?: MockMoexChartState;
}

type UpdatePropertiesCallback = (state: MockPropertiesState) => void;

type TimeframeChangeCallback = (timeframe: TimeframeValue) => void;

interface TestComponentProps {
  symbolInfo?: SymbolInfoInput;
  indicativeData?: ChartIndicativeData;
}

const defaultSymbolInfo: SymbolInfoInput = {
  symbolId: 'MOEX:SBER',
  symbol: 'SBER',
  symbolName: 'Сбербанк',
};

describe('useMoexChart', () => {
  const mockUseSelectProperties = useSelectProperties as jest.Mock;
  const mockUseChangeProperties = useChangeProperties as jest.Mock;
  const mockMoexChart = MoexChart as unknown as jest.Mock;
  const mockDataSourceProvider = mockedDataSourceProvide.DataSourceProvider;

  const mockUpdateProperties = jest.fn();

  const mockDestroy = jest.fn();
  const mockGetSnapshot = jest.fn();
  const mockSetSnapshot = jest.fn();
  const mockSetSymbol = jest.fn();
  const mockGetCompareManager = jest.fn();
  const mockSetSettings = jest.fn();

  const mockDataSource = jest.fn();
  const mockStartRealtime = jest.fn();
  const mockGetDataSource = jest.fn();
  const mockRealtimeUnsubscribe = jest.fn();

  const mockCompareManager = {
    id: 'compare-manager',
  };

  const mockSnapshot: MockChartSnapshot = {
    charts: [
      {
        symbolId: 'OLD:SYMBOL',
        symbol: 'OLD',
        symbolName: 'Old instrument',
        timeframe: Timeframes['5m'],
        chartSeriesType: 'Candlestick',
        panes: [
          {
            indicators: [],
          },
        ],
      },
    ],
  };

  let hookResult: ReturnType<typeof useMoexChart> | null = null;
  let lastMoexChartConfig: MockMoexChartConfig | null = null;
  let mockMoexChartState: MockMoexChartState | undefined;

  const TestComponent = ({
    symbolInfo = defaultSymbolInfo,
    indicativeData,
  }: TestComponentProps): React.ReactElement => {
    hookResult = useMoexChart({
      symbolInfo,
      indicativeData,
    });

    return <div ref={hookResult.containerRef} />;
  };

  beforeEach(() => {
    jest.clearAllMocks();
    jest.useFakeTimers();

    hookResult = null;
    lastMoexChartConfig = null;

    mockMoexChartState = {
      timeframe: Timeframes['1m'],
      savedData: undefined,
    };

    mockUseSelectProperties.mockImplementation((selector: (state: MockPropertiesState) => unknown) =>
      selector({
        moexChartState: mockMoexChartState,
      }),
    );

    mockUseChangeProperties.mockReturnValue({
      updateProperties: mockUpdateProperties,
    });

    mockGetDataSource.mockImplementation(
      (_indicativeData?: ChartIndicativeData, callback?: (timeframe: TimeframeValue) => void) =>
        (timeframe: TimeframeValue) => {
          callback?.(timeframe);

          return mockDataSource();
        },
    );

    mockStartRealtime.mockReturnValue(mockRealtimeUnsubscribe);

    mockDataSourceProvider.mockImplementation(() => ({
      getDataSource: mockGetDataSource,
      startRealtime: mockStartRealtime,
    }));

    mockGetSnapshot.mockReturnValue(mockSnapshot);
    mockGetCompareManager.mockReturnValue(mockCompareManager);

    mockMoexChart.mockImplementation((config: MockMoexChartConfig) => {
      lastMoexChartConfig = config;

      return {
        destroy: mockDestroy,
        getSnapshot: mockGetSnapshot,
        setSnapshot: mockSetSnapshot,
        setSymbol: mockSetSymbol,
        setSettings: mockSetSettings,
        getCompareManager: mockGetCompareManager,
        setSettings: mockSetSettings,
      };
    });
  });

  afterEach(() => {
    jest.clearAllTimers();
    jest.useRealTimers();
  });

  it('should create moex chart with current symbol info and timeframe', () => {
    // Arrange & Act
    render(<TestComponent />);

    // Assert
    expect(mockMoexChart).toHaveBeenCalledTimes(1);
    expect(mockDataSourceProvider).toHaveBeenCalledTimes(1);
    expect(lastMoexChartConfig?.container).toBeInstanceOf(HTMLDivElement);
    expect(lastMoexChartConfig?.snapshot.charts[0]).toEqual(
      expect.objectContaining({
        symbolId: 'MOEX:SBER',
        symbol: 'SBER',
        symbolName: 'Сбербанк',
        timeframe: Timeframes['1m'],
      }),
    );
    expect(hookResult?.compareManagerRef.current).toBe(mockCompareManager);
    expect(hookResult?.hasSavedSnapshot).toBe(false);
  });

  it('should create chart with saved snapshot and current symbol info', () => {
    // Arrange
    mockMoexChartState = {
      timeframe: Timeframes['5m'],
      savedData: JSON.stringify(mockSnapshot),
    };

    // Act
    render(
      <TestComponent
        symbolInfo={{
          symbolId: 'MOEX:GAZP',
          symbol: 'GAZP',
          symbolName: 'Газпром',
        }}
      />,
    );

    // Assert
    expect(lastMoexChartConfig?.snapshot.charts[0]).toEqual(
      expect.objectContaining({
        symbolId: 'MOEX:GAZP',
        symbol: 'GAZP',
        symbolName: 'Газпром',
        timeframe: Timeframes['5m'],
      }),
    );
    expect(hookResult?.hasSavedSnapshot).toBe(true);
  });

  it('should save chart snapshot to widget properties', () => {
    // Arrange
    render(<TestComponent />);

    // Act
    act(() => {
      hookResult?.saveSnapshot();
    });

    // Assert
    expect(mockGetSnapshot).toHaveBeenCalledTimes(1);
    expect(mockUpdateProperties).toHaveBeenCalledTimes(1);

    const updateCallback = mockUpdateProperties.mock.calls[0]?.[0] as UpdatePropertiesCallback;

    const mockState: MockPropertiesState = {
      moexChartState: {
        timeframe: Timeframes['1m'],
      },
    };

    updateCallback(mockState);

    expect(mockState.moexChartState).toEqual({
      timeframe: Timeframes['1m'],
      savedData: JSON.stringify(mockSnapshot),
    });
  });

  it('should serialize compare symbol info in snapshot', () => {
    // Arrange
    const snapshotWithIndicators: MockChartSnapshot = {
      charts: [
        {
          symbolId: 'MOEX:SBER',
          symbol: 'SBER',
          symbolName: 'Сбербанк',
          timeframe: Timeframes['1m'],
          chartSeriesType: 'Candlestick',
          panes: [
            {
              indicators: [
                {
                  id: 'compare-gazp',
                  indicatorType: undefined,
                  dataSource: {
                    subscription: {},
                  },
                  config: {
                    symbolInfo: {
                      symbolId: 'MOEX:GAZP',
                      symbol: 'GAZP',
                      symbolName: 'Газпром',
                    },
                    label: 'Газпром',
                    newPane: false,
                    series: [
                      {
                        name: 'Line',
                        seriesOptions: {
                          priceScaleId: 'left',
                          color: '#FFFFFF',
                        },
                      },
                    ],
                  },
                },
                {
                  id: 'rsi',
                  indicatorType: 'RSI',
                  dataSource: {
                    subscription: {},
                  },
                  config: {
                    label: 'RSI',
                    newPane: true,
                    series: [
                      {
                        name: 'Line',
                        seriesOptions: {
                          priceScaleId: 'right',
                        },
                      },
                    ],
                  },
                },
              ],
            },
          ],
        },
      ],
    };

    mockGetSnapshot.mockReturnValue(snapshotWithIndicators);

    render(<TestComponent />);

    // Act
    act(() => {
      hookResult?.saveSnapshot();
    });

    const updateCallback = mockUpdateProperties.mock.calls[0]?.[0] as UpdatePropertiesCallback;

    const mockState: MockPropertiesState = {
      moexChartState: {
        timeframe: Timeframes['1m'],
      },
    };

    updateCallback(mockState);

    const savedSnapshot = JSON.parse(mockState.moexChartState?.savedData ?? '{}') as MockChartSnapshot;

    const [compareIndicator, regularIndicator] = savedSnapshot.charts[0]?.panes[0]?.indicators ?? [];

    // Assert
    expect(compareIndicator).toEqual({
      id: 'compare-gazp',
      config: {
        symbolInfo: {
          symbolId: 'MOEX:GAZP',
          symbol: 'GAZP',
          symbolName: 'Газпром',
        },
        label: 'Газпром',
        newPane: false,
        series: [
          {
            name: 'Line',
            seriesOptions: {
              priceScaleId: 'left',
            },
          },
        ],
      },
    });

    expect(regularIndicator).toEqual({
      id: 'rsi',
      indicatorType: 'RSI',
    });
  });

  it('should not save snapshot when chart does not return snapshot', () => {
    // Arrange
    mockGetSnapshot.mockReturnValue(undefined);

    render(<TestComponent />);

    // Act
    act(() => {
      hookResult?.saveSnapshot();
    });

    // Assert
    expect(mockGetSnapshot).toHaveBeenCalledTimes(1);
    expect(mockUpdateProperties).not.toHaveBeenCalled();
  });

  it('should apply saved snapshot with current symbol info and timeframe', () => {
    // Arrange
    mockMoexChartState = {
      timeframe: Timeframes['5m'],
      savedData: JSON.stringify(mockSnapshot),
    };

    render(<TestComponent />);

    // Act
    act(() => {
      hookResult?.applySnapshot();
    });

    // Assert
    expect(mockSetSnapshot).toHaveBeenCalledWith({
      ...mockSnapshot,
      charts: [
        {
          ...mockSnapshot.charts[0],
          symbolId: 'MOEX:SBER',
          symbol: 'SBER',
          symbolName: 'Сбербанк',
          timeframe: Timeframes['5m'],
        },
      ],
    });
    expect(hookResult?.compareManagerRef.current).toBe(mockCompareManager);
  });

  it('should update compare modal state', () => {
    // Arrange
    render(<TestComponent />);

    // Act
    act(() => {
      hookResult?.setIsCompareOpen(true);
    });

    // Assert
    expect(hookResult?.isCompareOpen).toBe(true);
  });

  it('should open compare modal from chart preset callback', () => {
    // Arrange
    render(<TestComponent />);

    // Act
    act(() => {
      lastMoexChartConfig?.chartCollectionPreset.openCompareModal();
    });

    // Assert
    expect(hookResult?.isCompareOpen).toBe(true);
  });

  it('should update symbol search modal state', () => {
    // Arrange
    render(<TestComponent />);

    // Act
    act(() => {
      hookResult?.setIsSymbolSearchOpen(true);
    });

    // Assert
    expect(hookResult?.isSymbolSearchOpen).toBe(true);
  });

  it('should open symbol search modal from chart preset callback', () => {
    // Arrange
    render(<TestComponent />);

    // Act
    act(() => {
      lastMoexChartConfig?.chartCollectionPreset.openSymbolSearchModal();
    });

    // Assert
    expect(hookResult?.isSymbolSearchOpen).toBe(true);
  });

  it('should update chart when external symbol info changes', () => {
    // Arrange
    const { rerender } = render(<TestComponent />);

    // Act
    rerender(
      <TestComponent
        symbolInfo={{
          symbolId: 'MOEX:GAZP',
          symbol: 'GAZP',
          symbolName: 'Газпром',
        }}
      />,
    );

    // Assert
    expect(mockSetSymbol).toHaveBeenCalledTimes(1);
    expect(mockSetSymbol).toHaveBeenCalledWith({
      symbolId: 'MOEX:GAZP',
      symbol: 'GAZP',
      symbolName: 'Газпром',
    });
  });

  it('should update chart when only symbol name changes', () => {
    // Arrange
    const { rerender } = render(<TestComponent />);

    // Act
    rerender(
      <TestComponent
        symbolInfo={{
          symbolId: 'MOEX:SBER',
          symbol: 'SBER',
          symbolName: 'Сбербанк ПАО',
        }}
      />,
    );

    // Assert
    expect(mockSetSymbol).toHaveBeenCalledTimes(1);
    expect(mockSetSymbol).toHaveBeenCalledWith({
      symbolId: 'MOEX:SBER',
      symbol: 'SBER',
      symbolName: 'Сбербанк ПАО',
    });
  });

  it('should update chart when only symbol changes', () => {
    // Arrange
    const { rerender } = render(<TestComponent />);

    // Act
    rerender(
      <TestComponent
        symbolInfo={{
          symbolId: 'MOEX:SBER',
          symbol: 'SBERP',
          symbolName: 'Сбербанк',
        }}
      />,
    );

    // Assert
    expect(mockSetSymbol).toHaveBeenCalledTimes(1);
    expect(mockSetSymbol).toHaveBeenCalledWith({
      symbolId: 'MOEX:SBER',
      symbol: 'SBERP',
      symbolName: 'Сбербанк',
    });
  });

  it('should delegate missing symbol name fallback to moex chart', () => {
    // Arrange
    const { rerender } = render(<TestComponent />);

    // Act
    rerender(
      <TestComponent
        symbolInfo={{
          symbolId: 'MOEX:SBER',
          symbol: 'SBER',
        }}
      />,
    );

    // Assert
    expect(mockSetSymbol).toHaveBeenCalledTimes(1);
    expect(mockSetSymbol).toHaveBeenCalledWith({
      symbolId: 'MOEX:SBER',
      symbol: 'SBER',
      symbolName: undefined,
    });
  });

  it('should delegate missing symbol fallback to moex chart', () => {
    // Arrange
    const { rerender } = render(<TestComponent />);

    // Act
    rerender(
      <TestComponent
        symbolInfo={{
          symbolId: 'MOEX:SBER',
          symbolName: 'Сбербанк',
        }}
      />,
    );

    // Assert
    expect(mockSetSymbol).toHaveBeenCalledTimes(1);
    expect(mockSetSymbol).toHaveBeenCalledWith({
      symbolId: 'MOEX:SBER',
      symbol: undefined,
      symbolName: 'Сбербанк',
    });
  });

  it('should not update chart when symbol info is unchanged', () => {
    // Arrange
    const { rerender } = render(<TestComponent />);

    // Act
    rerender(<TestComponent />);

    // Assert
    expect(mockSetSymbol).not.toHaveBeenCalled();
  });

  it('should not recreate chart when external symbol changes', () => {
    // Arrange
    const { rerender } = render(<TestComponent />);

    // Act
    rerender(
      <TestComponent
        symbolInfo={{
          symbolId: 'MOEX:GAZP',
          symbol: 'GAZP',
          symbolName: 'Газпром',
        }}
      />,
    );

    // Assert
    expect(mockMoexChart).toHaveBeenCalledTimes(1);
  });

  it('should apply saved snapshot with externally selected symbol', () => {
    // Arrange
    mockMoexChartState = {
      timeframe: Timeframes['5m'],
      savedData: JSON.stringify(mockSnapshot),
    };

    const { rerender } = render(<TestComponent />);

    rerender(
      <TestComponent
        symbolInfo={{
          symbolId: 'MOEX:GAZP',
          symbol: 'GAZP',
          symbolName: 'Газпром',
        }}
      />,
    );

    // Act
    act(() => {
      hookResult?.applySnapshot();
    });

    // Assert
    expect(mockSetSnapshot).toHaveBeenCalledWith({
      ...mockSnapshot,
      charts: [
        {
          ...mockSnapshot.charts[0],
          symbolId: 'MOEX:GAZP',
          symbol: 'GAZP',
          symbolName: 'Газпром',
          timeframe: Timeframes['5m'],
        },
      ],
    });
  });

  it('should initialize indicative instrument with symbol info', () => {
    // Arrange
    const indicativeData: ChartIndicativeData = {
      id: 1,
      title: 'Indicative instrument',
      secId: 'INAV',
      instrumentName: 'Индикатив',
      settlement: 'Расчётный',
      firmName: 'Тестовая фирма',
      key: '2xOFZ:INAV',
    };

    // Act
    render(
      <TestComponent
        symbolInfo={{
          symbolId: '2xOFZ:INAV',
          symbol: 'INAV',
          symbolName: 'Индикатив Расчётный',
        }}
        indicativeData={indicativeData}
      />,
    );

    // Assert
    expect(mockGetDataSource).toHaveBeenCalledWith(indicativeData, expect.any(Function));
    expect(lastMoexChartConfig?.snapshot.charts[0]).toEqual(
      expect.objectContaining({
        symbolId: '2xOFZ:INAV',
        symbol: 'INAV',
        symbolName: 'Индикатив Расчётный',
      }),
    );
  });

  it('should pass realtime params to data source provider', () => {
    // Arrange
    render(<TestComponent />);

    const getSymbols = jest.fn(() => ['MOEX:SBER']);
    const getTimeframe = jest.fn(() => Timeframes['1m']);
    const update = jest.fn();

    // Act
    const unsubscribe = lastMoexChartConfig?.chartCollectionPreset.startRealtime(getSymbols, getTimeframe, update);

    // Assert
    expect(mockStartRealtime).toHaveBeenCalledWith({
      getSymbols,
      getTimeframe,
      update,
    });
    expect(unsubscribe).toBe(mockRealtimeUnsubscribe);
  });

  it('should update timeframe from data source callback', () => {
    // Arrange
    render(<TestComponent />);

    const timeframeChangeCallback = mockGetDataSource.mock.calls[0]?.[1] as TimeframeChangeCallback;

    // Act
    act(() => {
      timeframeChangeCallback(Timeframes['5m']);
    });

    // Assert
    expect(mockUpdateProperties).toHaveBeenCalledTimes(1);

    const updateCallback = mockUpdateProperties.mock.calls[0]?.[0] as UpdatePropertiesCallback;

    const mockState: MockPropertiesState = {
      moexChartState: {
        timeframe: Timeframes['1m'],
      },
    };

    updateCallback(mockState);

    expect(mockState.moexChartState?.timeframe).toBe(Timeframes['5m']);
  });

  it('should not update timeframe when it is the same as current timeframe', () => {
    // Arrange
    render(<TestComponent />);

    const timeframeChangeCallback = mockGetDataSource.mock.calls[0]?.[1] as TimeframeChangeCallback;

    // Act
    act(() => {
      timeframeChangeCallback(Timeframes['1m']);
    });

    // Assert
    expect(mockUpdateProperties).not.toHaveBeenCalled();
  });

  it('should destroy chart on unmount', () => {
    // Arrange
    const { unmount } = render(<TestComponent />);

    // Act
    unmount();

    // Assert
    expect(mockDestroy).toHaveBeenCalledTimes(1);
  });
  it('should apply initial interval on chart creation', () => {
    // Arrange
    mockMoexChartState = {
      timeframe: Timeframes['1m'],
      savedData: undefined,
      initialInterval: '1Y',
    };
    // Act
    render(<TestComponent symbol="MOEX:SBER" />);
    // Assert
    expect(mockSetSettings).toHaveBeenCalledWith({ interval: '1Y' });
  });
  it('should apply initial interval even when saved data exists', () => {
    // Arrange
    mockMoexChartState = {
      timeframe: Timeframes['5m'],
      savedData: JSON.stringify(mockSnapshot),
      initialInterval: '1Y',
    };
    // Act
    render(<TestComponent symbol="MOEX:SBER" />);
    // Assert
    expect(mockSetSettings).toHaveBeenCalledWith({ interval: '1Y' });
  });
  it('should clear initial interval in widget properties after applying', () => {
    // Arrange
    mockMoexChartState = {
      timeframe: Timeframes['1m'],
      savedData: undefined,
      initialInterval: '1Y',
    };
    // Act
    render(<TestComponent symbol="MOEX:SBER" />);
    // Assert
    expect(mockUpdateProperties).toHaveBeenCalledTimes(1);
    const updateCallback = mockUpdateProperties.mock.calls[0]?.[0] as UpdatePropertiesCallback;
    const mockState: MockPropertiesState = {
      moexChartState: {
        timeframe: Timeframes['1m'],
        initialInterval: '1Y',
      },
    };
    updateCallback(mockState);
    expect(mockState.moexChartState?.initialInterval).toBeUndefined();
  });
  it('should not apply interval when initial interval is not set', () => {
    // Arrange
    render(<TestComponent symbol="MOEX:SBER" />);
    // Assert
    expect(mockSetSettings).not.toHaveBeenCalled();
  });
});