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


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;
  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;
    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;
  initialInterval?: string;
  savedData?: string;
}

interface MockPropertiesState {
  moexChartState?: MockMoexChartState;
}

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

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

interface TestComponentProps {
  symbol?: string;
  symbolName?: 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',
        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 = ({
    symbol = 'MOEX:SBER',
    symbolName = 'Сбербанк',
    indicativeData,
  }: TestComponentProps): React.ReactElement => {
    hookResult = useMoexChart({
      symbol,
      symbolName,
      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, symbol name and timeframe', () => {
    // Arrange & Act
    render(
      <TestComponent
        symbol="MOEX:SBER"
        symbolName="Сбербанк"
      />,
    );

    // 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]
        ?.symbolName,
    ).toBe('Сбербанк');

    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"
        symbolName="Газпром"
      />,
    );

    // Assert
    expect(
      lastMoexChartConfig?.snapshot.charts[0]?.symbol,
    ).toBe('MOEX:GAZP');

    expect(
      lastMoexChartConfig?.snapshot.charts[0]
        ?.symbolName,
    ).toBe('Газпром');

    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
        symbol="MOEX:SBER"
        symbolName="Сбербанк"
      />,
    );

    // 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 and display name in snapshot', () => {
    // Arrange
    const snapshotWithIndicators: MockChartSnapshot = {
      charts: [
        {
          symbol: 'MOEX:SBER',
          symbolName: 'Сбербанк',
          timeframe: Timeframes['1m'],
          chartSeriesType: 'Candlestick',
          panes: [
            {
              indicators: [
                {
                  id: 'compare-gazp',
                  indicatorType: undefined,
                  dataSource: {
                    subscription: {},
                  },
                  config: {
                    symbol: 'MOEX: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
        symbol="MOEX:SBER"
        symbolName="Сбербанк"
      />,
    );

    // 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',
        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, symbol name and timeframe', () => {
    // Arrange
    mockMoexChartState = {
      timeframe: Timeframes['5m'],
      savedData: JSON.stringify(mockSnapshot),
    };

    render(
      <TestComponent
        symbol="MOEX:SBER"
        symbolName="Сбербанк"
      />,
    );

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

    // Assert
    expect(mockSetSnapshot).toHaveBeenCalledWith({
      ...mockSnapshot,
      charts: [
        {
          ...mockSnapshot.charts[0],
          symbol: 'MOEX: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 instrument changes', () => {
    // Arrange
    const { rerender } = render(
      <TestComponent
        symbol="MOEX:SBER"
        symbolName="Сбербанк"
      />,
    );

    // Act
    rerender(
      <TestComponent
        symbol="MOEX:GAZP"
        symbolName="Газпром"
      />,
    );

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

  it('should update chart when only external symbol name changes', () => {
    // Arrange
    const { rerender } = render(
      <TestComponent
        symbol="MOEX:SBER"
        symbolName="Сбербанк"
      />,
    );

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

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

  it('should use symbol as display name when symbol name is empty', () => {
    // Arrange
    const { rerender } = render(
      <TestComponent
        symbol="MOEX:SBER"
        symbolName="Сбербанк"
      />,
    );

    // Act
    rerender(
      <TestComponent
        symbol="MOEX:SBER"
        symbolName=""
      />,
    );

    // Assert
    expect(mockSetSymbol).toHaveBeenCalledTimes(1);
    expect(mockSetSymbol).toHaveBeenCalledWith(
      'MOEX:SBER',
      'MOEX:SBER',
    );
  });

  it('should not update chart when symbol and symbol name are unchanged', () => {
    // Arrange
    const { rerender } = render(
      <TestComponent
        symbol="MOEX:SBER"
        symbolName="Сбербанк"
      />,
    );

    // Act
    rerender(
      <TestComponent
        symbol="MOEX:SBER"
        symbolName="Сбербанк"
      />,
    );

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

  it('should not update chart when external symbol is empty', () => {
    // Arrange
    const { rerender } = render(
      <TestComponent
        symbol="MOEX:SBER"
        symbolName="Сбербанк"
      />,
    );

    // Act
    rerender(
      <TestComponent
        symbol=""
        symbolName="Пустой инструмент"
      />,
    );

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

  it('should not recreate chart when external instrument changes', () => {
    // Arrange
    const { rerender } = render(
      <TestComponent
        symbol="MOEX:SBER"
        symbolName="Сбербанк"
      />,
    );

    // Act
    rerender(
      <TestComponent
        symbol="MOEX:GAZP"
        symbolName="Газпром"
      />,
    );

    // 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
        symbol="MOEX:SBER"
        symbolName="Сбербанк"
      />,
    );

    rerender(
      <TestComponent
        symbol="MOEX:GAZP"
        symbolName="Газпром"
      />,
    );

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

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

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

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

    // Assert
    expect(mockGetDataSource).toHaveBeenCalledWith(
      indicativeData,
      expect.any(Function),
    );

    expect(mockSetSymbol).toHaveBeenCalledWith(
      '2xOFZ: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);
  });
});