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


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,
  };

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



import { indicativeQuotesController } from '@api/controllers/indicativeQuotesController';
import api from '@api/index';
import { candleToBar } from '@utils/candleToBar';

import { requestBars, requestRealtimeBars } from '../requestBars';
import { isIndicativeTicker } from '../utils/isIndicativeTicker';

// Mock the dependencies
jest.mock('../utils/isIndicativeTicker');
jest.mock('@utils/candleToBar');

// Mock the api module
jest.mock('@api/index', () => ({
  __esModule: true,
  default: {
    getBars: jest.fn(),
  },
}));

// Mock the indicativeQuotesController module
jest.mock('@api/controllers/indicativeQuotesController', () => ({
  indicativeQuotesController: {
    getCandles: jest.fn(),
  },
}));

describe('requestBars', () => {
  const mockPeriodParams = {
    countBack: 100,
    from: 1640195200,
    to: 1640995200, // 2022-01-01T00:00:00Z
    firstDataRequest: true,
  };

  const mockCandle = {
    open: 100,
    close: 110,
    high: 120,
    low: 90,
    ticker: 'TEST',
    volume: 1000,
    end: '2022-01-01T10:00:00Z',
    interval: '1',
  };

  const mockBar = {
    open: 100,
    close: 110,
    high: 120,
    low: 90,
    volume: 1000,
    time: 1640995200000,
  };

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

  it('should handle indicative instrument correctly when indicativeData matches ticker', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(false);
    (candleToBar as jest.Mock).mockReturnValue(mockBar);

    const mockIndicativeData = {
      key: 'test-ticker',
      secId: 'string',
      instrumentName: 'string',
      settlement: 'string',
      firmName: 'string',
    };

    const mockResponse = {
      data: {
        indicativeCandles: [mockCandle],
      },
    };

    (
      indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
    ).mockResolvedValue(mockResponse);

    // Act
    const result = await requestBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      periodParams: mockPeriodParams,
      ticker: 'test-ticker',
      indicativeData: mockIndicativeData,
    });

    // Assert
    expect(indicativeQuotesController.getCandles).toHaveBeenCalledWith({
      count: mockPeriodParams.countBack,
      key: 'USD/RUB',
      date: '2022-01-01T00:00:00',
      interval: '1',
    });
    expect(result).toEqual([mockBar]);
  });

  it('should handle indicative instrument correctly when ticker contains indicative board', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(true);
    (candleToBar as jest.Mock).mockReturnValue(mockBar);

    const mockResponse = {
      data: {
        indicativeCandles: [mockCandle],
      },
    };

    (
      indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
    ).mockResolvedValue(mockResponse);

    // Act
    const result = await requestBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      periodParams: mockPeriodParams,
      ticker: 'TEST.indicative_spot',
    });

    // Assert
    expect(indicativeQuotesController.getCandles).toHaveBeenCalledWith({
      count: mockPeriodParams.countBack,
      key: 'USD/RUB',
      date: '2022-01-01T00:00:00',
      interval: '1',
    });
    expect(result).toEqual([mockBar]);
  });

  it('should handle non-indicative instrument correctly', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(false);
    (candleToBar as jest.Mock).mockReturnValue(mockBar);

    const mockResponse = {
      data: [mockCandle],
    };

    (api.getBars as unknown as { mockResolvedValue: (mockResponse: object) => void }).mockResolvedValue(mockResponse);

    // Act
    const result = await requestBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      periodParams: mockPeriodParams,
      ticker: 'TEST',
    });

    // Assert
    expect(api.getBars).toHaveBeenCalledWith({
      currencyPair: 'USD/RUB',
      date: '2022-01-01%2000:00:00',
      interval: '1',
      count: mockPeriodParams.countBack,
      ticker: 'TEST',
    });
    expect(result).toEqual([mockBar]);
  });

  it('should call onHistoryCallback with data when indicative instrument succeeds', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(true);
    (candleToBar as jest.Mock).mockReturnValue(mockBar);

    const mockResponse = {
      data: {
        indicativeCandles: [mockCandle],
      },
    };

    (
      indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
    ).mockResolvedValue(mockResponse);

    const mockHistoryCallback = jest.fn();

    // Act
    await requestBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      periodParams: mockPeriodParams,
      onHistoryCallback: mockHistoryCallback,
      ticker: 'TEST.indicative_spot',
    });

    // Assert
    expect(mockHistoryCallback).toHaveBeenCalledWith([mockBar], { noData: false });
  });

  it('should call onHistoryCallback with empty array and noData flag when indicative instrument fails', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(true);

    (
      indicativeQuotesController.getCandles as unknown as { mockRejectedValue: (mockResponse: object) => void }
    ).mockRejectedValue(new Error('API Error'));

    const mockHistoryCallback = jest.fn();

    // Act
    const result = await requestBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      periodParams: mockPeriodParams,
      onHistoryCallback: mockHistoryCallback,
      ticker: 'TEST.indicative_spot',
    });

    // Assert
    expect(mockHistoryCallback).toHaveBeenCalledWith([], { noData: true });
    expect(result).toEqual([]);
  });

  it('should call onHistoryCallback with empty array and noData flag when non-indicative instrument fails', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(false);

    (api.getBars as unknown as { mockRejectedValue: (mockResponse: object) => void }).mockRejectedValue(
      new Error('API Error'),
    );

    const mockHistoryCallback = jest.fn();

    // Act
    const result = await requestBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      periodParams: mockPeriodParams,
      onHistoryCallback: mockHistoryCallback,
      ticker: 'TEST',
    });

    // Assert
    expect(mockHistoryCallback).toHaveBeenCalledWith([], { noData: true });
    expect(result).toEqual([]);
  });

  it('should handle multiple candles for indicative instrument', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(true);
    (candleToBar as jest.Mock).mockReturnValue(mockBar);

    const mockCandles = [
      { ...mockCandle, end: '2022-01-01T10:00:00Z' },
      { ...mockCandle, end: '2022-01-01T11:00:00Z' },
      { ...mockCandle, end: '2022-01-01T12:00:00Z' },
    ];

    const mockResponse = {
      data: {
        indicativeCandles: mockCandles,
      },
    };

    (
      indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
    ).mockResolvedValue(mockResponse);

    // Act
    const result = await requestBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      periodParams: mockPeriodParams,
      ticker: 'TEST.indicative_spot',
    });

    // Assert
    expect(indicativeQuotesController.getCandles).toHaveBeenCalledWith({
      count: mockPeriodParams.countBack,
      key: 'USD/RUB',
      date: '2022-01-01T00:00:00',
      interval: '1',
    });
    expect(candleToBar).toHaveBeenCalledTimes(3);
    expect(result).toEqual([mockBar, mockBar, mockBar]);
  });

  it('should handle multiple candles for non-indicative instrument', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(false);
    (candleToBar as jest.Mock).mockReturnValue(mockBar);

    const mockCandles = [
      { ...mockCandle, end: '2022-01-01T10:00:00Z' },
      { ...mockCandle, end: '2022-01-01T11:00:00Z' },
      { ...mockCandle, end: '2022-01-01T12:00:00Z' },
    ];

    const mockResponse = {
      data: mockCandles,
    };

    (api.getBars as unknown as { mockResolvedValue: (mockResponse: object) => void }).mockResolvedValue(mockResponse);

    // Act
    const result = await requestBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      periodParams: mockPeriodParams,
      ticker: 'TEST',
    });

    // Assert
    expect(api.getBars).toHaveBeenCalledWith({
      currencyPair: 'USD/RUB',
      date: '2022-01-01%2000:00:00',
      interval: '1',
      count: mockPeriodParams.countBack,
      ticker: 'TEST',
    });
    expect(candleToBar).toHaveBeenCalledTimes(3);
    expect(result).toEqual([mockBar, mockBar, mockBar]);
  });

  it('should handle empty indicativeCandles array', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(true);

    const mockResponse = {
      data: {
        indicativeCandles: [],
      },
    };

    (
      indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
    ).mockResolvedValue(mockResponse);

    const mockHistoryCallback = jest.fn();

    // Act
    const result = await requestBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      periodParams: mockPeriodParams,
      onHistoryCallback: mockHistoryCallback,
      ticker: 'TEST.indicative_spot',
    });

    // Assert
    expect(mockHistoryCallback).toHaveBeenCalledWith([], { noData: true });
    expect(result).toEqual([]);
  });

  it('should handle empty bars array for non-indicative instrument', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(false);

    const mockResponse = {
      data: [],
    };

    (api.getBars as unknown as { mockResolvedValue: (mockResponse: object) => void }).mockResolvedValue(mockResponse);

    const mockHistoryCallback = jest.fn();

    // Act
    const result = await requestBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      periodParams: mockPeriodParams,
      onHistoryCallback: mockHistoryCallback,
      ticker: 'TEST',
    });

    // Assert
    expect(mockHistoryCallback).toHaveBeenCalledWith([], { noData: true });
    expect(result).toEqual([]);
  });
});

describe('requestRealtimeBars', () => {
  const mockCandle = {
    open: 100,
    close: 110,
    high: 120,
    low: 90,
    ticker: 'TEST',
    volume: 1000,
    end: '2022-01-01T10:00:00Z',
    interval: '1',
  };

  const mockBar = {
    open: 100,
    close: 110,
    high: 120,
    low: 90,
    volume: 1000,
    time: 1640995200000,
  };

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

  it('should handle indicative instrument correctly', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(true);
    (candleToBar as jest.Mock).mockReturnValue(mockBar);

    const mockResponse = {
      data: {
        indicativeCandles: [mockCandle],
      },
    };

    (
      indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
    ).mockResolvedValue(mockResponse);

    // Act
    const result = await requestRealtimeBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      ticker: 'TEST.indicative_spot',
    });

    // Assert
    expect(indicativeQuotesController.getCandles).toHaveBeenCalledWith({
      count: 1,
      key: 'USD/RUB',
      date: expect.any(String), // We can't predict the exact date string
      interval: '1',
    });
    expect(candleToBar).toHaveBeenCalledWith(mockCandle);
    expect(result).toEqual(mockBar);
  });

  it('should handle non-indicative instrument correctly', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(false);
    (candleToBar as jest.Mock).mockReturnValue(mockBar);

    const mockResponse = {
      data: [mockCandle],
    };

    (api.getBars as unknown as { mockResolvedValue: (mockResponse: object) => void }).mockResolvedValue(mockResponse);

    // Act
    const result = await requestRealtimeBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      ticker: 'TEST',
    });

    // Assert
    expect(api.getBars).toHaveBeenCalledWith({
      currencyPair: 'USD/RUB',
      date: expect.any(String), // We can't predict the exact date string
      interval: '1',
      count: 1,
      ticker: 'TEST',
    });
    expect(candleToBar).toHaveBeenCalledWith(mockCandle);
    expect(result).toEqual(mockBar);
  });

  it('should handle empty indicativeCandles array in realtime request', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(true);

    const mockResponse = {
      data: {
        indicativeCandles: [],
      },
    };

    (
      indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
    ).mockResolvedValue(mockResponse);

    // Act
    const result = await requestRealtimeBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      ticker: 'TEST.indicative_spot',
    });

    // Assert
    expect(result).toBeUndefined();
  });

  it('should handle empty bars array in realtime request', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(false);

    const mockResponse = {
      data: [],
    };

    (api.getBars as unknown as { mockResolvedValue: (mockResponse: object) => void }).mockResolvedValue(mockResponse);

    // Act
    const result = await requestRealtimeBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      ticker: 'TEST',
    });

    // Assert
    expect(result).toBeUndefined();
  });

  it('should call onRealtimeCallback with bar when indicative instrument succeeds', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(true);
    (candleToBar as jest.Mock).mockReturnValue(mockBar);

    const mockResponse = {
      data: {
        indicativeCandles: [mockCandle],
      },
    };

    (
      indicativeQuotesController.getCandles as unknown as { mockResolvedValue: (mockResponse: object) => void }
    ).mockResolvedValue(mockResponse);

    const mockRealtimeCallback = jest.fn();

    // Act
    await requestRealtimeBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      ticker: 'TEST.indicative_spot',
      onRealtimeCallback: mockRealtimeCallback,
    });

    // Assert
    expect(mockRealtimeCallback).toHaveBeenCalledWith(mockBar);
  });

  it('should call onRealtimeCallback with bar when non-indicative instrument succeeds', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(false);
    (candleToBar as jest.Mock).mockReturnValue(mockBar);

    const mockResponse = {
      data: [mockCandle],
    };

    (api.getBars as unknown as { mockResolvedValue: (mockResponse: object) => void }).mockResolvedValue(mockResponse);

    const mockRealtimeCallback = jest.fn();

    // Act
    await requestRealtimeBars({
      currencyPair: 'USD/RUB',
      interval: '1',
      ticker: 'TEST',
      onRealtimeCallback: mockRealtimeCallback,
    });

    // Assert
    expect(mockRealtimeCallback).toHaveBeenCalledWith(mockBar);
  });

  it('should handle error in indicative instrument request', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(true);

    (
      indicativeQuotesController.getCandles as unknown as { mockRejectedValue: (mockResponse: object) => void }
    ).mockRejectedValue(new Error('API Error'));

    const mockRealtimeCallback = jest.fn();

    // Act & Assert
    await expect(
      requestRealtimeBars({
        currencyPair: 'USD/RUB',
        interval: '1',
        ticker: 'TEST.indicative_spot',
        onRealtimeCallback: mockRealtimeCallback,
      }),
    ).resolves.toBeUndefined();
  });

  it('should handle error in non-indicative instrument request', async () => {
    // Arrange
    (isIndicativeTicker as jest.Mock).mockReturnValue(false);

    (api.getBars as unknown as { mockRejectedValue: (mockResponse: object) => void }).mockRejectedValue(
      new Error('API Error'),
    );

    const mockRealtimeCallback = jest.fn();

    // Act & Assert
    await expect(
      requestRealtimeBars({
        currencyPair: 'USD/RUB',
        interval: '1',
        ticker: 'TEST',
        onRealtimeCallback: mockRealtimeCallback,
      }),
    ).resolves.toBeUndefined();
  });
});


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

import { InstrumentSearch } from '@components/InstrumentSearch';

import { SymbolSearchModal } from '../components/MoexChart/components/SymbolSearchModal';

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

jest.mock('@components/InstrumentSearch', () => ({
  InstrumentSearch: jest.fn(() => null),
}));

interface InstrumentSearchMockProps {
  widgetId: number;
  variant: string;
  isOpen: boolean;
  setOpen: (isOpen: boolean) => void;
  addInstruments: (instruments: Contract[]) => void;
}

describe('SymbolSearchModal', () => {
  const mockInstrumentSearch = InstrumentSearch as jest.Mock;

  const mockSetOpen = jest.fn();
  const mockOnSymbolChange = jest.fn();

  const renderComponent = (): InstrumentSearchMockProps => {
    render(
      <SymbolSearchModal
        widgetId={42}
        isOpen
        setOpen={mockSetOpen}
        onSymbolChange={mockOnSymbolChange}
      />,
    );

    return mockInstrumentSearch.mock.calls[0]?.[0] as InstrumentSearchMockProps;
  };

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

  it('should pass modal properties to instrument search', () => {
    // Arrange & Act
    const instrumentSearchProps = renderComponent();

    // Assert
    expect(instrumentSearchProps.widgetId).toBe(42);
    expect(instrumentSearchProps.variant).toBe('single');
    expect(instrumentSearchProps.isOpen).toBe(true);
    expect(instrumentSearchProps.setOpen).toBe(mockSetOpen);
    expect(instrumentSearchProps.addInstruments).toEqual(expect.any(Function));
  });

  it('should change symbol after instrument selection', () => {
    // Arrange
    const instrumentSearchProps = renderComponent();

    const instrument = {
      issKey: 'MOEX:SBER',
    } as Contract;

    // Act
    instrumentSearchProps.addInstruments([instrument]);

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

  it('should close modal after instrument selection', () => {
    // Arrange
    const instrumentSearchProps = renderComponent();

    const instrument = {
      issKey: 'MOEX:SBER',
    } as Contract;

    // Act
    instrumentSearchProps.addInstruments([instrument]);

    // Assert
    expect(mockSetOpen).toHaveBeenCalledTimes(1);
    expect(mockSetOpen).toHaveBeenCalledWith(false);
  });

  it('should not change symbol when instruments list is empty', () => {
    // Arrange
    const instrumentSearchProps = renderComponent();

    // Act
    instrumentSearchProps.addInstruments([]);

    // Assert
    expect(mockOnSymbolChange).not.toHaveBeenCalled();
    expect(mockSetOpen).not.toHaveBeenCalled();
  });

  it('should not change symbol when selected instrument has no issKey', () => {
    // Arrange
    const instrumentSearchProps = renderComponent();

    // Act
    instrumentSearchProps.addInstruments([{} as Contract]);

    // Assert
    expect(mockOnSymbolChange).not.toHaveBeenCalled();
    expect(mockSetOpen).not.toHaveBeenCalled();
  });

  it('should not change symbol when selected instrument has empty issKey', () => {
    // Arrange
    const instrumentSearchProps = renderComponent();

    const instrument = {
      issKey: '',
    } as Contract;

    // Act
    instrumentSearchProps.addInstruments([instrument]);

    // Assert
    expect(mockOnSymbolChange).not.toHaveBeenCalled();
    expect(mockSetOpen).not.toHaveBeenCalled();
  });
});

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';

// 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: 'MOEX:TEST1',
      instrName: 'Test Instrument 1',
      symbol: 'TEST1',
      // ... other contract properties
    },
    {
      issKey: 'MOEX:TEST2',
      instrName: 'Test Instrument 2',
      symbol: 'TEST2',
      // ... other contract properties
    },
    {
      issKey: 'MOEX:TEST3',
      instrName: 'Test Instrument 3',
      symbol: 'TEST3',
      // ... other contract properties
    },
  ];

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

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

  it('should set current instrument to the found issKey when a matching instrument exists', () => {
    // Arrange
    const mockSetCurrInstrument = jest.fn();
    const mockGetMasterInstrumentFromPublicContext = jest.fn().mockReturnValue('MOEX:TEST2');

    // Act
    renderHook(() =>
      useChartPublicContext({
        widgetId: 1,
        setCurrInstrument: mockSetCurrInstrument,
        getMasterInstrumentFromPublicContext: mockGetMasterInstrumentFromPublicContext,
      }),
    );

    // Assert
    expect(mockSetCurrInstrument).toHaveBeenCalledWith('MOEX:TEST2');
  });

  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 (widget not found)
    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');
  });

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

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

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

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

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

// Mock the dependencies
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',
  },
}));

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 MockPaneSnapshot {
  indicators: unknown[];
}

interface MockChartSnapshot {
  charts: {
    symbol: 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 {
  tf?: TimeframeValue;
  savedData?: string;
}

interface MockPropertiesState {
  moexChartState?: MockMoexChartState;
}

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

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

interface TestComponentProps {
  symbol?: string;
}

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 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',
        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' }: TestComponentProps): React.ReactElement => {
    hookResult = useMoexChart({
      symbol,
      indicativeData: undefined,
    });

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

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

    hookResult = null;
    lastMoexChartConfig = null;

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

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

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

    mockGetDataSource.mockImplementation(
      (_indicativeData?: ChartIndicativeData, callback?: (timeframe: Timeframes) => void) =>
        (timeframe: Timeframes) => {
          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,
        getCompareManager: mockGetCompareManager,
      };
    });
  });

  it('should create moex chart with current symbol and timeframe', () => {
    // Arrange & Act
    render(<TestComponent symbol="MOEX: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]?.timeframe).toBe(Timeframes['1m']);

    expect(hookResult?.compareManagerRef.current).toBe(mockCompareManager);
  });

  it('should create chart with saved snapshot when saved data exists', () => {
    // Arrange
    mockMoexChartState = {
      tf: Timeframes['5m'],
      savedData: JSON.stringify(mockSnapshot),
    };

    // Act
    render(<TestComponent symbol="MOEX:GAZP" />);

    // Assert
    expect(lastMoexChartConfig?.snapshot.charts[0]?.symbol).toBe('MOEX: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 symbol="MOEX:SBER" />);

    // 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: {
        tf: Timeframes['1m'],
      },
    };

    updateCallback(mockState);

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

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

    render(<TestComponent symbol="MOEX:SBER" />);

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

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

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

    render(<TestComponent symbol="MOEX:SBER" />);

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

    // Assert
    expect(mockSetSnapshot).toHaveBeenCalledWith({
      ...mockSnapshot,
      charts: [
        {
          ...mockSnapshot.charts[0],
          symbol: 'MOEX:SBER',
          timeframe: Timeframes['5m'],
        },
      ],
    });

    expect(hookResult?.compareManagerRef.current).toBe(mockCompareManager);
  });

  it('should update compare modal state', () => {
    // Arrange
    render(<TestComponent symbol="MOEX:SBER" />);

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

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

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

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

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

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

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

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

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

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

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

  it('should normalize and change main symbol', () => {
    // Arrange
    render(<TestComponent symbol="MOEX:SBER" />);

    // Act
    act(() => {
      hookResult?.setMainSymbol('  MOEX:GAZP  ');
    });

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

  it('should not change main symbol when value is empty', () => {
    // Arrange
    render(<TestComponent symbol="MOEX:SBER" />);

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

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

  it('should not change main symbol when it is already selected', () => {
    // Arrange
    render(<TestComponent symbol="MOEX:SBER" />);

    // Act
    act(() => {
      hookResult?.setMainSymbol('MOEX:SBER');
    });

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

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

    // Act
    rerender(<TestComponent symbol="MOEX:GAZP" />);

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

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

    // Act
    rerender(<TestComponent symbol="MOEX:GAZP" />);

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

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

    render(<TestComponent symbol="MOEX:SBER" />);

    act(() => {
      hookResult?.setMainSymbol('MOEX:GAZP');
    });

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

    // Assert
    expect(mockSetSnapshot).toHaveBeenCalledWith({
      ...mockSnapshot,
      charts: [
        {
          ...mockSnapshot.charts[0],
          symbol: 'MOEX:GAZP',
          timeframe: Timeframes['5m'],
        },
      ],
    });
  });

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

    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 symbol="MOEX:SBER" />);

    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: {
        tf: Timeframes['1m'],
      },
    };

    updateCallback(mockState);

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

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

    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 symbol="MOEX:SBER" />);

    // Act
    unmount();

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

import { __CompareManager__, CompareMode } from 'moex-chart';
import React, { MutableRefObject, useEffect, useState } from 'react';

import { InstrumentSearch } from '@components/InstrumentSearch';

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 handlePercent = (symbol: string) => {
    compareManager?.current?.setSymbolMode('Line', symbol, CompareMode.Percentage);
  };

  const handleNewScale = (symbol: string) => {
    compareManager?.current?.setSymbolMode('Line', symbol, CompareMode.NewScale);
  };

  const handleNewPanel = (symbol: string) => {
    compareManager?.current?.setSymbolMode('Line', symbol, CompareMode.NewPane);
  };

  return (
    <InstrumentSearch
      setOpen={setOpen}
      isOpen={isOpen}
      variant="single"
      widgetId={widgetId}
      // withNRD={false}
      // Временный костыль, пока не завезем свой поиск интструментов
      addInstruments={() => {
        // nothing
      }}
      isNewScaleDisabled={isNewScaleDisabled}
      customActionsFooterHandlers={{
        handlePercent,
        handleNewScale,
        handleNewPanel,
      }}
    />
  );
};

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: (symbol: string) => void;
}

export const SymbolSearchModal = ({ widgetId, isOpen, setOpen, onSymbolChange }: SymbolSearchModalProps) => {
  const handleAddInstruments = (instruments: Contract[]) => {
    const symbol = instruments[0]?.issKey;

    if (!symbol) {
      return;
    }

    onSymbolChange(symbol);
    setOpen(false);
  };

  return (
    <InstrumentSearch
      widgetId={widgetId}
      variant="single"
      isOpen={isOpen}
      setOpen={setOpen}
      addInstruments={handleAddInstruments}
    />
  );
};

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

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

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

import { WidgetProperties } from '../../../properties/types';

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

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

type TUseMoexChartProps = {
  symbol: string;
  indicativeData: ChartIndicativeData | undefined;
};

export const useMoexChart = ({ symbol, 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 timeframeRef = useRef<Timeframes | undefined>(moexChartState?.tf);
  const savedDataRef = useRef<string | undefined>(moexChartState?.savedData);
  const updateTimeframeRef = useRef<((tf: Timeframes) => void) | null>(null);

  useEffect(() => {
    if (!symbol || currentSymbolRef.current === symbol) {
      return;
    }

    currentSymbolRef.current = symbol;
    chartRef.current?.setSymbol(symbol);
  }, [symbol]);

  const setMainSymbol = (nextSymbol: string) => {
    const normalizedSymbol = nextSymbol.trim();

    if (!normalizedSymbol || currentSymbolRef.current === normalizedSymbol) {
      return;
    }

    currentSymbolRef.current = normalizedSymbol;
    chartRef.current?.setSymbol(normalizedSymbol);
  };

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

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

    timeframeRef.current = tf;

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

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

    if (!snapshot) {
      return;
    }

    const savedData = JSON.stringify({
      ...snapshot,
      charts: snapshot.charts.map((c) => ({
        ...c,
        panes: c.panes.map((p) => ({
          ...p,
          indicators: p.indicators.map((i) => ({
            ...i,
            dataSource: undefined, // dataSource пока не среиализуем
            config: undefined, // config пока не среиализуем
          })),
        })),
      })),
    });
    savedDataRef.current = savedData;

    updateProperties((state) => {
      state.moexChartState = {
        ...state.moexChartState,
        tf: 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,
        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,
          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 () => {
      chartRef.current = null;
      clearInterval(intervalId);
      compareManagerRef.current = null;
      chart.destroy();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- исправим позже
  }, [indicativeData]);

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


import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';

import { getStartTime, parseTimeframe } from 'moex-chart';

import {
  getISSOddTimeframePart,
  moexChartTimeConverter,
  moexChartToIssTimeframe,
} from '@utils/chartToReqTimeConverter';

import { requestBars, requestRealtimeBars } from '../../requestBars';
import { ChartIndicativeData } from '../../types';

import type { ManipulateType } from 'dayjs';
import type { Candle, Timeframes } from 'moex-chart';

dayjs.extend(duration);

function normalizeSymbol(symbolRaw?: string): string {
  return String(symbolRaw ?? '')
    .trim()
    .toUpperCase();
}

const proceedConvolution = ({
  data,
  moexCandle,
  requestedTimeframe,
  dayjsUnit,
  iterationCounter,
}: {
  data: Candle[];
  moexCandle: number;
  requestedTimeframe: Timeframes;
  dayjsUnit: ManipulateType;
  iterationCounter: number;
}): Candle[] => {
  const result: Candle[] = [];

  let index = 0;

  while (index < data.length - 1) {
    const firstCandle = data[index];

    if (!firstCandle) {
      break;
    }

    const startTime = getStartTime(
      requestedTimeframe,
      dayjs(firstCandle.time).add(moexCandle, dayjsUnit).unix() * 1000,
    );

    let aggregatedCandle: Candle | undefined;
    let offset = 0;
    let consumedCount = 0;

    while (offset < iterationCounter) {
      const currentCandle = data[index + offset];

      if (!currentCandle) {
        break;
      }

      if (!aggregatedCandle) {
        aggregatedCandle = currentCandle;
      } else {
        aggregatedCandle = {
          ...aggregatedCandle,
          high: Math.max(currentCandle.high, aggregatedCandle.high),
          low: Math.min(currentCandle.low, aggregatedCandle.low),
          close: currentCandle.close,
          volume: (aggregatedCandle.volume ?? 0) + (currentCandle.volume ?? 0),
        };
      }

      consumedCount += 1;

      if (index + offset + 1 === data.length || currentCandle.time > startTime * 1000) {
        break;
      }

      offset += 1;
    }

    if (!aggregatedCandle || consumedCount === 0) {
      break;
    }

    result.push(aggregatedCandle);
    index += consumedCount;
  }

  return result;
};

const timeframeConvolution = (data: Candle[], requestedTimeframe: Timeframes): Candle[] => {
  const issTimeframe = moexChartToIssTimeframe(requestedTimeframe);

  if (issTimeframe === requestedTimeframe) {
    return data;
  }

  const { candleWidth: moexCandle, dayjsUnit } = parseTimeframe(requestedTimeframe);

  const firstCandle = data[0];

  if (!firstCandle) {
    return [];
  }

  const dataStartTime = getStartTime(
    requestedTimeframe,
    dayjs(firstCandle.time).add(moexCandle, dayjsUnit).unix() * 1000,
  );

  const { value, unit } = getISSOddTimeframePart(issTimeframe);

  let startIndex = 0;

  while (
    startIndex < data.length &&
    startIndex < 60 &&
    dataStartTime !== dayjs(data[startIndex].time).subtract(value, unit).unix()
  ) {
    startIndex += 1;
  }

  if (startIndex >= data.length || startIndex >= 60) {
    return [];
  }

  const { candleWidth: issCandle } = parseTimeframe(issTimeframe);

  return proceedConvolution({
    data: data.slice(startIndex),
    moexCandle,
    requestedTimeframe,
    dayjsUnit,
    iterationCounter: moexCandle / issCandle,
  });
};

// По хорошему - класс должен быть синглтоном, чтобы кормить MoexChart одинаковой датой,
// и не плодить несколько подключений на одни символа
class DataSourceProvider {
  private prevRealtimeDataArr: Candle[] = [];

  private prevRealtimeData: Candle | undefined;

  private realtimeShouldBeConvoluted = false;

  private realtimeTimer: ReturnType<typeof setInterval> | null = null;

  public getDataSource =
    (indicativeData?: ChartIndicativeData, cb?: (timeframe: Timeframes) => void) =>
    async (timeframe: Timeframes, symbol: string, until?: Candle) => {
      cb?.(timeframe);

      const interval = moexChartTimeConverter(timeframe);
      const date = until?.time || Math.round(Date.now() / 1000);

      const data = await requestBars({
        currencyPair: symbol.replaceAll(':', '.'),
        interval,
        periodParams: {
          firstDataRequest: true,
          to: date,
          from: Date.now(),
          countBack: 2000,
        },
        ticker: symbol,
        indicativeData,
      });

      if (data.length === 0) {
        return null;
      }

      const issTimeframe = moexChartToIssTimeframe(timeframe);

      if (issTimeframe === timeframe) {
        this.realtimeShouldBeConvoluted = false;

        return data;
      }

      this.realtimeShouldBeConvoluted = true;

      return timeframeConvolution(data, timeframe);
    };

  public startRealtime({
    getSymbols,
    getTimeframe,
    update,
    periodMs = 5000,
    indicativeData,
  }: {
    getSymbols: () => string[];
    getTimeframe: () => Timeframes;
    update: (symbol: string, candle: Candle) => void;
    periodMs?: number;
    indicativeData?: ChartIndicativeData;
  }): () => void {
    if (this.realtimeTimer) {
      clearInterval(this.realtimeTimer);
    }

    this.realtimeTimer = setInterval(() => {
      const timeframe = getTimeframe();
      const symbols = getSymbols();

      Promise.all(
        symbols.map(async (value) => {
          const symbol = normalizeSymbol(value);

          if (!symbol) {
            return;
          }

          const data = await requestRealtimeBars({
            currencyPair: symbol.replaceAll(':', '.'),
            interval: moexChartTimeConverter(timeframe),
            ticker: symbol,
            indicativeData,
          });

          if (!data) {
            return;
          }

          if (!this.prevRealtimeData) {
            this.prevRealtimeData = data;
            this.prevRealtimeDataArr = [data];
            update(symbol, data);

            return;
          }

          if (JSON.stringify(data) !== JSON.stringify(this.prevRealtimeData)) {
            this.realtimeConvolution(timeframe, data, (candle) => {
              update(symbol, candle);
            });
          }
        }),
      );
    }, periodMs);

    return () => {
      if (this.realtimeTimer) {
        clearInterval(this.realtimeTimer);
      }

      this.realtimeTimer = null;
    };
  }

  private realtimeConvolution(timeframe: Timeframes, data: Candle, update: (candle: Candle) => void): void {
    let candle = data;

    if (this.realtimeShouldBeConvoluted && this.prevRealtimeData) {
      const { candleWidth: moexCandle, dayjsUnit } = parseTimeframe(timeframe);

      const dataStartTimeFromPrevData = getStartTime(
        timeframe,
        dayjs(this.prevRealtimeData.time).add(moexCandle, dayjsUnit).unix() * 1000,
      );

      const dataStartTime = getStartTime(timeframe, dayjs(data.time).add(moexCandle, dayjsUnit).unix() * 1000);

      const isNextTimeframeStep = dataStartTime !== dataStartTimeFromPrevData;

      if (isNextTimeframeStep) {
        this.prevRealtimeDataArr = [data];
      } else {
        const isNewBarInsideTimeframe = this.prevRealtimeData.time !== data.time;

        if (isNewBarInsideTimeframe) {
          this.prevRealtimeDataArr.push(data);
        } else {
          this.prevRealtimeDataArr[this.prevRealtimeDataArr.length - 1] = data;
        }

        const firstCandle = this.prevRealtimeDataArr[0];
        const lastCandle = this.prevRealtimeDataArr[this.prevRealtimeDataArr.length - 1];

        if (firstCandle && lastCandle) {
          candle = {
            time: firstCandle.time,
            open: firstCandle.open,
            high: Math.max(...this.prevRealtimeDataArr.map(({ high }) => high)),
            low: Math.min(...this.prevRealtimeDataArr.map(({ low }) => low)),
            close: lastCandle.close,
            volume: this.prevRealtimeDataArr.reduce((total, currentCandle) => total + (currentCandle.volume ?? 0), 0),
          };
        }
      }
    }

    this.prevRealtimeData = data;

    update(candle);
  }
}

export { DataSourceProvider };


import React from 'react';

import { SymbolSearchModal } from '@widgets/Chart/components/MoexChart/components/SymbolSearchModal';

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

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

import 'moex-chart/dist/styles.css';

type TRProps = {
  fullName: string;
  indicativeData?: ChartIndicativeData | undefined;
  widgetId: number;
};

export default React.memo(({ fullName, indicativeData, widgetId }: TRProps) => {
  const {
    containerRef,
    isCompareOpen,
    isSymbolSearchOpen,
    compareManagerRef,
    setIsCompareOpen,
    setIsSymbolSearchOpen,
    setMainSymbol,
  } = useMoexChart({
    indicativeData,
    symbol: fullName,
  });

  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={setMainSymbol}
        />
      )}
    </div>
  );
});


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 { Contract } from '@modules/contracts';
import type { Dispatch, SetStateAction } from 'react';
import type { Widget } from 'types/Widgets';
import type { WidgetProperties } from '../properties/types';
import type { ChartContainerProps } from '../types';

interface UseChartComponentFacadeReturn {
  dropDownOpen: boolean;
  setDropdownOpen: Dispatch<SetStateAction<boolean>>;
  currentInstrument: string;
  isWidgetHeaderContextMenuOpen: boolean;
  setIsWidgetHeaderContextMenuOpen: Dispatch<SetStateAction<boolean>>;
  onDropInstruments: (val: string, withUpdate?: boolean) => void;
  addInstrumentFromModal: (instruments: Pick<Contract, 'issKey'>[]) => 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;

  // TODO временно реф из-за непредсказуемого изменения если используется useState,
  // нужен рефакторинг и вернуть обратно useState
  const currentInstrumentRef = useRef(initialInstrument);
  const [currentInstrument, setCurrentInstrument] = useState(initialInstrument);

  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(
    (newVal: string, withUpdate?: boolean, unbind = true): void => {
      if (!newVal) {
        return;
      }

      setIsWidgetHeaderContextMenuOpen(false);

      if (currentInstrumentRef.current === newVal) {
        return;
      }

      currentInstrumentRef.current = newVal;
      setCurrentInstrument(newVal);

      if (unbind) {
        dispatch(unbindWidgets({ widgetId }));
      }

      // при смене инструмента очищаем индикативные данные виджета график
      // т.к. логика для графика индикатива построена на наличии в widgetContentProps данных indicativeData
      saveContentProps({
        savedInstrument: newVal,
        withUpdate,
        cleanIndicativeData: true,
      });

      triggerRelatedWidgetsToUpdateRef.current(newVal);
    },
    [dispatch, saveContentProps, widgetId],
  );

  const addInstrumentFromModal = useCallback(
    (instruments: Pick<Contract, 'issKey'>[]) => {
      const issKey = instruments[0]?.issKey;

      if (!issKey) {
        return;
      }

      onInstrumentChange(issKey);
    },
    [onInstrumentChange],
  );

  const onInstrumentChangeFromBind = useCallback(
    (instrumentId: string) => {
      onInstrumentChange(instrumentId, true, false);
    },
    [onInstrumentChange],
  );

  const onDropInstruments = useCallback(
    (val: string, withUpdate?: boolean): void => {
      onInstrumentChange(val, 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) {
          addInstrumentFromModal([{ 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();
    };
  }, [addInstrumentFromModal, widgetId]);

  return {
    dropDownOpen,
    setDropdownOpen,
    currentInstrument,
    isWidgetHeaderContextMenuOpen,
    setIsWidgetHeaderContextMenuOpen,
    onDropInstruments,
    addInstrumentFromModal,
    isOver,
  };
}

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) => 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) {
      setCurrInstrument(DEFAULT_SYMBOL);
      return;
    }
    const instrKey = instruments.find((item) => item.issKey === fieldValue)?.issKey;
    if (instrKey) {
      setCurrInstrument(instrKey);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps -- Посмотреть этот момент.
  }, [publicContext, instruments, widget?.externalProperties, setCurrInstrument]);

  return {
    issKey: issKey ?? undefined,
  };
}

import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';

import { indicativeQuotesController } from '@api/controllers/indicativeQuotesController';
import api from '@api/index';
import { candleToBar } from '@utils/candleToBar';

import { ChartIndicativeData } from './types';
import { isIndicativeTicker } from './utils/isIndicativeTicker';

import type { Candle } from 'moex-chart';

dayjs.extend(utc);
export interface PeriodParams {
  from: number;
  to: number;
  countBack: number;
  firstDataRequest: boolean;
}
export interface HistoryMetadata {
  noData: boolean;
}
export type HistoryCallback = (candles: Candle[], metadata: HistoryMetadata) => void;
export type SubscribeBarsCallback = (candle: Candle) => void;

interface RequestBarsArgs {
  currencyPair: string;
  interval: string;
  periodParams: PeriodParams;
  onHistoryCallback?: HistoryCallback;
  ticker?: string;
  indicativeData?: ChartIndicativeData;
}

interface RequestRealtimeBarsArgs {
  currencyPair: string;
  interval: string;
  ticker?: string;
  indicativeData?: ChartIndicativeData;
  onRealtimeCallback?: SubscribeBarsCallback;
}

export async function requestBars({
  currencyPair,
  interval,
  periodParams,
  onHistoryCallback,
  ticker,
  indicativeData,
}: RequestBarsArgs): Promise<Candle[]> {
  const date = new Date(periodParams.to * 1000);
  const year = date.getUTCFullYear();
  const month = `0${date.getUTCMonth() + 1}`.slice(-2);
  const day = `0${date.getUTCDate()}`.slice(-2);
  const hours = `0${date.getUTCHours()}`.slice(-2);
  const minutes = `0${date.getUTCMinutes()}`.slice(-2);
  const seconds = `0${date.getUTCSeconds()}`.slice(-2);

  const hasIndicativeBoardInTicker = isIndicativeTicker(ticker);

  // если при инициализации графика были данные indicativeData и текущий инструмент совпадает
  // то отправляем запрос на индикатив
  // иначе на инструменты
  const isIndicativeInstrument = (indicativeData && indicativeData.key === ticker) || hasIndicativeBoardInTicker;

  if (isIndicativeInstrument) {
    const dateStr = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;

    try {
      const { data } = await indicativeQuotesController.getCandles({
        count: periodParams.countBack,
        key: currencyPair,
        date: dateStr,
        interval,
      });

      const candles = data.indicativeCandles.reverse().map(candleToBar);

      onHistoryCallback?.(candles, {
        noData: candles.length === 0,
      });

      return candles;
    } catch {
      onHistoryCallback?.([], {
        noData: true,
      });

      return [];
    }
  }

  const dateStr = `${year}-${month}-${day}%20${hours}:${minutes}:${seconds}`;

  try {
    const { data } = await api.getBars({
      currencyPair,
      date: dateStr,
      interval,
      count: periodParams.countBack,
      ticker,
    });

    const bars = data.reverse().map(candleToBar);

    onHistoryCallback?.(bars, {
      noData: bars.length === 0,
    });

    return bars;
  } catch {
    onHistoryCallback?.([], {
      noData: true,
    });

    return [];
  }
}

export async function requestRealtimeBars({
  currencyPair,
  interval,
  ticker,
  onRealtimeCallback,
  indicativeData,
}: RequestRealtimeBarsArgs): Promise<Candle | undefined> {
  const hasIndicativeBoardInTicker = isIndicativeTicker(ticker);

  // если при инициализации графика были данные indicativeData и текущий инструмент совпадает
  // то отправляем запрос на индикатив
  // иначе на инструменты
  const isIndicativeInstrument = (indicativeData && indicativeData.key === ticker) || hasIndicativeBoardInTicker;

  if (isIndicativeInstrument) {
    try {
      const { data } = await indicativeQuotesController.getCandles({
        count: 1,
        key: currencyPair,
        date: dayjs().utc().add(1, 'minute').format('YYYY-MM-DDTHH:mm:ss'),
        interval,
      });

      if (data.indicativeCandles.length === 0) {
        return undefined;
      }

      const sortedData = [...data.indicativeCandles].sort(
        (first, second) => new Date(first.end).valueOf() - new Date(second.end).valueOf(),
      );

      const firstCandle = sortedData[0];

      if (!firstCandle) {
        return undefined;
      }

      const bar = candleToBar(firstCandle);

      onRealtimeCallback?.(bar);

      return bar;
    } catch (error) {
      console.error('error from requestRealTimeBars indicativeQuotesController: ', error);

      return undefined;
    }
  }

  try {
    const { data } = await api.getBars({
      currencyPair,
      date: dayjs().utc().add(1, 'minute').format('YYYY-MM-DD%20HH:mm:ss'),
      interval,
      count: 1,
      ticker,
    });

    if (data.length === 0) {
      return undefined;
    }

    const sortedData = [...data].sort(
      (first, second) => new Date(first.end).valueOf() - new Date(second.end).valueOf(),
    );

    const firstCandle = sortedData[0];

    if (!firstCandle) {
      return undefined;
    }

    const bar = candleToBar(firstCandle);

    onRealtimeCallback?.(bar);

    return bar;
  } catch (error) {
    console.error('error from requestRealTimeBars: ', error);

    return undefined;
  }
}


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,
    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}`
    : contractsInstrumentName;

  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
            fullName={currentInstrument}
            indicativeData={indicativeData}
            widgetId={props.widgetId}
          />

          {isOpenEmptyAction && (
            <InstrumentSearch
              setOpen={setIsOpenEmptyAction}
              isOpen={isOpenEmptyAction}
              variant="single"
              widgetId={props.widgetId}
              // withNRD={false}
              addInstruments={addInstrumentFromModal}
            />
          )}
        </div>
      </WidgetContentWrapper>
    </DNDWrapper>
  );
};