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


import { EventManager } from '../EventManager';

import { Timeframes } from '@src/types/timeframes';

describe('EventManager', () => {
  function createEventManager(symbolId = ''): EventManager {
    return new EventManager({
      initialTimeframe: Timeframes['10s'],
      initialSeries: 'Candlestick',
      initialSymbolInfo: {
        symbolId,
        symbol: symbolId ? 'SBER' : '',
        symbolName: symbolId ? 'Sberbank' : '',
      },
    });
  }

  it('должен поддерживать состояние без основного инструмента', () => {
    const eventManager = createEventManager();
    const hasSymbolValues: boolean[] = [];

    const subscription = eventManager.hasSymbol().subscribe((hasSymbol) => {
      hasSymbolValues.push(hasSymbol);
    });

    expect(hasSymbolValues).toEqual([false]);

    let symbolId = 'initial';

    const symbolSubscription = eventManager.symbolId().subscribe((value) => {
      symbolId = value;
    });

    expect(symbolId).toBe('');

    subscription.unsubscribe();
    symbolSubscription.unsubscribe();
    eventManager.destroy();
  });

  it('должен менять состояние наличия основного инструмента', () => {
    const eventManager = createEventManager();
    const values: boolean[] = [];

    const subscription = eventManager.hasSymbol().subscribe((value) => {
      values.push(value);
    });

    eventManager.setSymbol({
      symbolId: 'SBER',
      symbol: 'SBER',
      symbolName: 'Sberbank',
    });

    eventManager.clearSymbol();

    expect(values).toEqual([false, true, false]);
    expect(eventManager.exportChartSettings().symbolInfo).toEqual({
      symbolId: '',
      symbol: '',
      symbolName: '',
    });

    subscription.unsubscribe();
    eventManager.destroy();
  });

  it('не должен изменять состояние при установке невалидного инструмента', () => {
    const eventManager = createEventManager();

    eventManager.setSymbol({
      symbolId: '',
    });

    expect(eventManager.exportChartSettings().symbolInfo).toEqual({
      symbolId: '',
      symbol: '',
      symbolName: '',
    });

    eventManager.destroy();
  });

  it('не должен повторно очищать уже пустой основной инструмент', () => {
    const eventManager = createEventManager();
    const undoRedo = eventManager.getUndoRedo();
    const pushSpy = jest.spyOn(undoRedo, 'push');

    eventManager.clearSymbol();

    expect(pushSpy).not.toHaveBeenCalled();

    eventManager.destroy();
  });
});

















import { IChartApi } from 'lightweight-charts';
import { of } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorManager } from '@core/IndicatorManager';
import { Pane } from '@core/Pane';
import { PaneManager } from '@core/PaneManager';

import { IndicatorsIds } from '@src/constants';

jest.mock('@core/Indicator');

jest.mock('@src/core/Indicators', () => ({
  indicatorsMap: jest.fn(
    () =>
      new Proxy(
        {},
        {
          get: () => ({
            newPane: false,
            series: [],
          }),
        },
      ),
  ),
}));

jest.mock('@src/utils', () => ({
  applyNextIndicatorColors: jest.fn((config) => config),
  getIndicatorColors: jest.fn(() => []),
}));

describe('IndicatorManager', () => {
  const IndicatorMock = Indicator as jest.MockedClass<typeof Indicator>;

  let DOM: jest.Mocked<Pick<DOMModel, 'setEntity' | 'removeEntity'>>;
  let paneManager: jest.Mocked<Pick<PaneManager, 'getMainPane' | 'getPaneById' | 'addPane'>>;
  let eventManager: jest.Mocked<Pick<EventManager, 'symbolId' | 'symbol'>>;
  let mainPane: jest.Mocked<Pick<Pane, 'getId'>>;

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

    IndicatorMock.mockImplementation(
      (params) =>
        ({
          id: params.id,
          destroy: jest.fn(),
          getConfig: jest.fn(() => params.config),
          getIndicatorType: jest.fn(() => params.type),
        }) as unknown as Indicator,
    );

    mainPane = {
      getId: jest.fn(() => 0),
    };

    paneManager = {
      getMainPane: jest.fn(() => mainPane as unknown as Pane),
      getPaneById: jest.fn(),
      addPane: jest.fn(() => mainPane as unknown as Pane),
    };

    eventManager = {
      symbolId: jest.fn(() => of('SBER')),
      symbol: jest.fn(() => of('SBER')),
    };

    DOM = {
      setEntity: jest.fn((factory) => factory(1, jest.fn(), jest.fn())),
      removeEntity: jest.fn(),
    };
  });

  function createManager(): IndicatorManager {
    return new IndicatorManager({
      eventManager: eventManager as unknown as EventManager,
      dataSource: {} as DataSource,
      lwcChart: {} as IChartApi,
      paneManager: paneManager as unknown as PaneManager,
      DOM: DOM as unknown as DOMModel,
    });
  }

  it('не должен добавлять индикатор без типа', () => {
    const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
    const manager = createManager();

    manager.addIndicator({});

    expect(DOM.setEntity).not.toHaveBeenCalled();
    expect(manager.getIndicators().value.size).toBe(0);

    consoleSpy.mockRestore();
  });

  it('должен добавлять индикатор и публиковать его в entities', () => {
    const manager = createManager();
    let entities: Indicator[] = [];

    const subscription = manager.entities().subscribe((value) => {
      entities = value;
    });

    manager.addIndicator({
      id: 'ema-1',
      indicatorType: IndicatorsIds.EMA,
    });

    expect(manager.getIndicators().value.has('ema-1')).toBe(true);
    expect(entities).toHaveLength(1);
    expect(paneManager.getMainPane).toHaveBeenCalled();

    subscription.unsubscribe();
  });

  it('должен удалять индикатор из состояния до его уничтожения', () => {
    const manager = createManager();

    manager.addIndicator({
      id: 'ema-1',
      indicatorType: IndicatorsIds.EMA,
    });

    const indicator = manager.getIndicators().value.get('ema-1')!;

    manager.deleteIndicator('ema-1');

    expect(manager.getIndicators().value.has('ema-1')).toBe(false);
    expect(DOM.removeEntity).toHaveBeenCalledWith(indicator);
    expect(indicator.destroy).toHaveBeenCalledTimes(1);
  });

  it('не должен выполнять удаление для неизвестного id', () => {
    const manager = createManager();

    manager.deleteIndicator('unknown');

    expect(DOM.removeEntity).not.toHaveBeenCalled();
  });
});

















import { IChartApi, PriceScaleMode } from 'lightweight-charts';
import { BehaviorSubject } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorManager } from '@core/IndicatorManager';
import { Pane } from '@core/Pane';
import { PaneManager } from '@core/PaneManager';
import { PriceScale } from '@core/PriceScale';

import { CompareMode, Direction } from '@src/types';
import { Timeframes } from '@src/types/timeframes';

import { CompareManager } from '../CompareManager';

jest.mock('@core/Indicator');

describe('CompareManager', () => {
  const IndicatorMock = Indicator as jest.MockedClass<typeof Indicator>;

  let manager: CompareManager;
  let indicatorManager: jest.Mocked<Pick<IndicatorManager, 'addEntity' | 'getIndicators' | 'removeEntity'>>;
  let paneManager: jest.Mocked<
    Pick<PaneManager, 'getMainPane' | 'getPaneById' | 'addPane' | 'setPriceScaleSideVisible' | 'invalidate'>
  >;
  let dataSource: jest.Mocked<Pick<DataSource, 'waitUntilReady'>>;
  let mainPane: jest.Mocked<Pick<Pane, 'getId' | 'getPriceScale'>>;
  let secondaryPane: jest.Mocked<Pick<Pane, 'getId'>>;
  let rightPriceScale: jest.Mocked<Pick<PriceScale, 'getMode' | 'setMode'>>;

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

    IndicatorMock.mockImplementation(
      (params) =>
        ({
          id: params.id,
          destroy: jest.fn(),
          getConfig: jest.fn(() => params.config),
          getIndicatorType: jest.fn(() => params.type),
        }) as unknown as Indicator,
    );

    rightPriceScale = {
      getMode: jest.fn(() => PriceScaleMode.Normal),
      setMode: jest.fn(),
    };

    mainPane = {
      getId: jest.fn(() => 0),
      getPriceScale: jest.fn(() => rightPriceScale as unknown as PriceScale),
    };

    secondaryPane = {
      getId: jest.fn(() => 1),
    };

    indicatorManager = {
      addEntity: jest.fn((factory) => factory(1, jest.fn(), jest.fn())),
      getIndicators: jest.fn(() => new BehaviorSubject(new Map())),
      removeEntity: jest.fn(),
    };

    paneManager = {
      getMainPane: jest.fn(() => mainPane as unknown as Pane),
      getPaneById: jest.fn(),
      addPane: jest.fn(() => secondaryPane as unknown as Pane),
      setPriceScaleSideVisible: jest.fn(),
      invalidate: jest.fn(),
    };

    dataSource = {
      waitUntilReady: jest.fn(() => Promise.resolve()),
    };

    const eventManager = {
      timeframe: jest.fn(() => new BehaviorSubject(Timeframes['10s'])),
    } as unknown as EventManager;

    manager = new CompareManager({
      chart: {} as IChartApi,
      eventManager,
      dataSource: dataSource as unknown as DataSource,
      indicatorManager: indicatorManager as unknown as IndicatorManager,
      paneManager: paneManager as unknown as PaneManager,
    });
  });

  afterEach(() => {
    manager.destroy();
  });

  it('должен добавлять compare-серию типа Line', async () => {
    await manager.setSymbolMode(
      'Candlestick',
      {
        symbolId: 'SBER',
        symbol: 'SBER',
        symbolName: 'Sberbank',
      },
      CompareMode.Absolute,
    );

    expect(IndicatorMock).toHaveBeenCalledTimes(1);

    const params = IndicatorMock.mock.calls[0][0];

    expect(params.config.series[0]?.name).toBe('Line');
    expect(params.associatedPane).toBe(mainPane);
    expect(dataSource.waitUntilReady).toHaveBeenCalledWith('SBER');

    expect(manager.getAllEntities()).toHaveLength(1);
  });

  it('должен создавать отдельный pane для режима NewPane', async () => {
    await manager.setSymbolMode(
      'Line',
      {
        symbolId: 'SBER',
      },
      CompareMode.NewPane,
    );

    expect(paneManager.addPane).toHaveBeenCalledTimes(1);
    expect(IndicatorMock.mock.calls[0][0].associatedPane).toBe(secondaryPane);
  });

  it('не должен добавлять NewScale при существующем compare', async () => {
    await manager.setSymbolMode(
      'Line',
      {
        symbolId: 'SBER',
      },
      CompareMode.Absolute,
    );

    await manager.setSymbolMode(
      'Line',
      {
        symbolId: 'GAZP',
      },
      CompareMode.NewScale,
    );

    expect(indicatorManager.addEntity).toHaveBeenCalledTimes(1);
  });

  it('должен удалять compare-сущность', async () => {
    await manager.setSymbolMode(
      'Line',
      {
        symbolId: 'SBER',
      },
      CompareMode.Absolute,
    );

    const entity = manager.getAllEntities()[0].entity;

    manager.removeSymbol('SBER');

    expect(indicatorManager.removeEntity).toHaveBeenCalledWith(entity);
    expect(entity.destroy).toHaveBeenCalledTimes(1);
    expect(manager.getAllEntities()).toHaveLength(0);
  });

  it('должен управлять видимостью левой шкалы', async () => {
    await manager.setSymbolMode(
      'Line',
      {
        symbolId: 'SBER',
      },
      CompareMode.NewScale,
    );

    expect(paneManager.setPriceScaleSideVisible).toHaveBeenCalledWith(Direction.Left, true);
  });
});

















import { PriceScaleMode } from 'lightweight-charts';
import { BehaviorSubject, of } from 'rxjs';

import { DrawingsManagerCollection } from '@core/DrawingsManagerCollection';
import { Pane } from '@core/Pane';
import { PaneManager } from '@core/PaneManager';
import { PriceAxisLabels } from '@core/PriceAxisLabels';

import { IndicatorsIds } from '@src/constants';
import { PaneSnapshot } from '@src/types/snapshot';

jest.mock('@core/Pane');
jest.mock('@core/DrawingsManagerCollection');
jest.mock('@core/PriceAxisLabels');

describe('PaneManager', () => {
  const PaneMock = Pane as jest.MockedClass<typeof Pane>;

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

    PaneMock.mockImplementation(
      (params) =>
        ({
          getId: jest.fn(() => params.id),
          paneIndex: jest.fn(() => params.id),
          getMainSerie: jest.fn(() => new BehaviorSubject(null)),
          setDrawingsSnapshot: jest.fn(),
          getDrawingsSnapshot: jest.fn(() => []),
          schedulePaneContainerSync: jest.fn(),
          refreshPriceScaleControls: jest.fn(),
          resetPriceScalesAutoScale: jest.fn(),
          destroy: jest.fn(),
          getSnapshot: jest.fn(() => ({
            isMain: params.isMainPane,
            id: params.id,
            indicators: [],
            drawings: [],
          })),
          getPriceScale: jest.fn(() => ({
            getMode: jest.fn(() => PriceScaleMode.Normal),
            setVisible: jest.fn(),
          })),
        }) as unknown as Pane,
    );

    (DrawingsManagerCollection as jest.MockedClass<typeof DrawingsManagerCollection>).mockImplementation(
      () =>
        ({
          destroy: jest.fn(),
          handlePaneClick: jest.fn(),
          addDrawingManager: jest.fn(),
          removeDrawingManager: jest.fn(),
          setActiveTool: jest.fn(),
          getActiveToolValue: jest.fn(),
          getIsEndlessMode: jest.fn(),
          activateDrawingTool: jest.fn(),
        }) as unknown as DrawingsManagerCollection,
    );

    (PriceAxisLabels as jest.MockedClass<typeof PriceAxisLabels>).mockImplementation(
      () =>
        ({
          destroy: jest.fn(),
          invalidate: jest.fn(),
          setVisibleLogicalRange: jest.fn(),
        }) as unknown as PriceAxisLabels,
    );
  });

  function createManager(panesSnapshot: PaneSnapshot[]): PaneManager {
    const params = {
      panesSnapshot,
      lwcChart: {
        subscribeClick: jest.fn(),
        unsubscribeClick: jest.fn(),
        removePane: jest.fn(),
      },
      eventManager: {
        symbol: jest.fn(() => of('')),
      },
      dataSource: null,
      DOM: {},
      ohlcConfig: {},
      subscribeChartEvent: jest.fn(),
      tooltipConfig: {},
      chartContainer: document.createElement('div'),
      modalRenderer: {},
      hotkeys: {},
    } as unknown as ConstructorParameters<typeof PaneManager>[0];

    return new PaneManager(params);
  }

  it('не должен восстанавливать пустой secondary pane', () => {
    const manager = createManager([
      {
        isMain: true,
        id: 0,
        indicators: [],
        drawings: [],
      },
      {
        isMain: false,
        id: 1,
        indicators: [],
        drawings: [],
      },
      {
        isMain: false,
        id: 2,
        indicators: [
          {
            indicatorType: IndicatorsIds.RSI,
          },
        ],
        drawings: [],
      },
    ]);

    expect(manager.getPanes().has(0)).toBe(true);
    expect(manager.getPanes().has(1)).toBe(false);
    expect(manager.getPanes().has(2)).toBe(true);
    expect(manager.getPanes().size).toBe(2);

    manager.destroy();
  });
});

















import { BehaviorSubject, of } from 'rxjs';

import { ContainerManager } from '@core/ContainerManager';
import { DrawingsManager } from '@core/DrawingsManager';
import { Indicator } from '@core/Indicator';
import { Legend } from '@core/Legend';
import { Pane } from '@core/Pane';
import { PriceScale, PriceScaleControls } from '@core/PriceScale';
import { ReactRenderer } from '@core/ReactRenderer';
import { TooltipService } from '@core/Tooltip';

import { IndicatorsIds } from '@src/constants';

jest.mock('@core/ContainerManager');
jest.mock('@core/DrawingsManager');
jest.mock('@core/Legend');
jest.mock('@core/PriceScale');
jest.mock('@core/ReactRenderer');
jest.mock('@core/Tooltip');

jest.mock('@src/translations', () => ({
  t: (key: string) => key,
}));

describe('Pane', () => {
  const createPaneContainersMock = ContainerManager.createPaneContainers as jest.MockedFunction<
    typeof ContainerManager.createPaneContainers
  >;

  let hasSymbol$: BehaviorSubject<boolean>;
  let paneElement: HTMLTableRowElement;
  let emptyStateContainer: HTMLDivElement;
  let onDelete: jest.Mock;

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

    Object.defineProperty(global, 'requestAnimationFrame', {
      writable: true,
      value: (callback: FrameRequestCallback) => {
        callback(0);
        return 1;
      },
    });

    Object.defineProperty(global, 'cancelAnimationFrame', {
      writable: true,
      value: jest.fn(),
    });

    emptyStateContainer = document.createElement('div');
    emptyStateContainer.hidden = true;

    createPaneContainersMock.mockReturnValue({
      legendContainer: document.createElement('div'),
      paneOverlayContainer: document.createElement('div'),
      paneEmptyStateContainer: emptyStateContainer,
    });

    (ReactRenderer as jest.MockedClass<typeof ReactRenderer>).mockImplementation(
      () =>
        ({
          renderComponent: jest.fn(),
          destroy: jest.fn(),
        }) as unknown as ReactRenderer,
    );

    (Legend as jest.MockedClass<typeof Legend>).mockImplementation(
      () =>
        ({
          getConfig: jest.fn(() => ({})),
          getLegendViewModel: jest.fn(() => of([])),
          destroy: jest.fn(),
        }) as unknown as Legend,
    );

    (TooltipService as jest.MockedClass<typeof TooltipService>).mockImplementation(
      () =>
        ({
          getTooltipViewModel: jest.fn(() => of({})),
          getConfig: jest.fn(() => ({})),
          destroy: jest.fn(),
        }) as unknown as TooltipService,
    );

    (PriceScale as jest.MockedClass<typeof PriceScale>).mockImplementation(
      () =>
        ({
          getSnapshot: jest.fn(() => ({})),
          enableAutoScale: jest.fn(),
        }) as unknown as PriceScale,
    );

    (PriceScaleControls as jest.MockedClass<typeof PriceScaleControls>).mockImplementation(
      () =>
        ({
          refresh: jest.fn(),
          mount: jest.fn(),
          destroy: jest.fn(),
        }) as unknown as PriceScaleControls,
    );

    (DrawingsManager as jest.MockedClass<typeof DrawingsManager>).mockImplementation(
      () =>
        ({
          entities: jest.fn(() => of([])),
          destroy: jest.fn(),
          getSnapshot: jest.fn(() => []),
          setSnapshot: jest.fn(),
        }) as unknown as DrawingsManager,
    );

    hasSymbol$ = new BehaviorSubject(false);
    onDelete = jest.fn();

    paneElement = document.createElement('tr');
    paneElement.append(document.createElement('td'), document.createElement('td'), document.createElement('td'));
  });

  function createPane(): Pane {
    const mainSeries$ = new BehaviorSubject(null);

    return new Pane({
      id: 1,
      isMainPane: false,
      lwcChart: {
        addPane: jest.fn(() => ({
          getHTMLElement: jest.fn(() => paneElement),
          paneIndex: jest.fn(() => 1),
        })),
      } as never,
      eventManager: {
        hasSymbol: jest.fn(() => hasSymbol$),
        getChartOptionsModel: jest.fn(() => of({})),
        getTimeframeObs: jest.fn(() => of('10s')),
      } as never,
      DOM: {} as never,
      ohlcConfig: {} as never,
      dataSource: null,
      basedOn: {
        getMainSerie: jest.fn(() => mainSeries$),
      } as never,
      subscribeChartEvent: jest.fn() as never,
      tooltipConfig: {},
      onDelete,
      chartContainer: document.createElement('div'),
      modalRenderer: {} as never,
      onPriceScaleStateChange: jest.fn(),
      leftPriceScaleVisible: false,
      rightPriceScaleVisible: true,
      hotkeys: {} as never,
      addDrawingManager: jest.fn(),
      removeDrawingManager: jest.fn(),
      setActiveTool: jest.fn(),
      getActiveTool: jest.fn(),
      getIsEndlessMode: jest.fn(),
      continueDrawing: jest.fn(),
    });
  }

  it('должен показывать empty state для индикатора без основного инструмента', () => {
    const pane = createPane();

    const indicator = {
      getIndicatorType: jest.fn(() => IndicatorsIds.RSI),
      recreateSeries: jest.fn(),
    } as unknown as Indicator;

    pane.setIndicator('rsi', indicator);

    expect(emptyStateContainer.hidden).toBe(false);

    pane.destroy();
  });

  it('должен скрывать empty state после добавления основного инструмента', () => {
    const pane = createPane();

    pane.setIndicator(
      'rsi',
      {
        getIndicatorType: jest.fn(() => IndicatorsIds.RSI),
        recreateSeries: jest.fn(),
      } as unknown as Indicator,
    );

    expect(emptyStateContainer.hidden).toBe(false);

    hasSymbol$.next(true);

    expect(emptyStateContainer.hidden).toBe(true);

    pane.destroy();
  });

  it('не должен показывать empty state только для compare-сущности', () => {
    const pane = createPane();

    pane.setIndicator(
      'compare',
      {
        getIndicatorType: jest.fn(() => undefined),
        recreateSeries: jest.fn(),
      } as unknown as Indicator,
    );

    expect(emptyStateContainer.hidden).toBe(true);

    pane.destroy();
  });

  it('должен удалить secondary pane после удаления последнего индикатора', () => {
    const pane = createPane();

    pane.setIndicator(
      'rsi',
      {
        getIndicatorType: jest.fn(() => IndicatorsIds.RSI),
        recreateSeries: jest.fn(),
      } as unknown as Indicator,
    );

    pane.removeIndicator('rsi');

    expect(onDelete).toHaveBeenCalledTimes(1);

    pane.destroy();
  });
});

















import { BehaviorSubject, Subscription } from 'rxjs';

import { Chart } from '@core/Chart';

import { Intervals } from '@src/types';

describe('Chart', () => {
  interface ChartInternals {
    eventManager: {
      symbolId: () => BehaviorSubject<string>;
      getInterval: () => BehaviorSubject<Intervals | null>;
    };
    compareManager: {
      itemsObs: () => BehaviorSubject<{ symbolId: string }[]>;
    };
    dataSource: {
      setSymbols: jest.Mock;
      loadTill: jest.Mock;
      loadMoreHistory: jest.Mock;
    };
    lwcChart: {
      timeScale: () => {
        getVisibleRange: () => null;
        getVisibleLogicalRange: () => null;
      };
    };
    subscriptions: Subscription;
    activeSymbolIds: string[];
    currentInterval: Intervals | null;
    historyBatchRunning: boolean;
    setupDataSourceSubs: () => void;
    scheduleHistoryBatch: () => void;
  }

  function createChartInternals(): ChartInternals {
    return Object.assign(Object.create(Chart.prototype), {
      eventManager: {
        symbolId: () => new BehaviorSubject(''),
        getInterval: () => new BehaviorSubject<Intervals | null>(null),
      },
      compareManager: {
        itemsObs: () =>
          new BehaviorSubject([
            {
              symbolId: 'SBER',
            },
            {
              symbolId: 'SBER',
            },
          ]),
      },
      dataSource: {
        setSymbols: jest.fn(),
        loadTill: jest.fn(() => Promise.resolve()),
        loadMoreHistory: jest.fn(() => Promise.resolve()),
      },
      lwcChart: {
        timeScale: () => ({
          getVisibleRange: () => null,
          getVisibleLogicalRange: () => null,
        }),
      },
      subscriptions: new Subscription(),
      activeSymbolIds: [],
      currentInterval: null,
      historyBatchRunning: false,
    }) as ChartInternals;
  }

  it('должен исключать пустой основной symbolId и удалять дубликаты', () => {
    const chart = createChartInternals();

    chart.setupDataSourceSubs();

    expect(chart.dataSource.setSymbols).toHaveBeenLastCalledWith(['SBER']);

    chart.subscriptions.unsubscribe();
  });

  it('не должен запускать загрузку истории без активных инструментов', () => {
    const chart = createChartInternals();
    const requestAnimationFrameSpy = jest
      .spyOn(global, 'requestAnimationFrame')
      .mockImplementation(() => 1);

    chart.scheduleHistoryBatch();

    expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
    expect(chart.dataSource.loadMoreHistory).not.toHaveBeenCalled();

    requestAnimationFrameSpy.mockRestore();
  });
});

















import { MoexChart } from '@core/MoexChart';

describe('MoexChart', () => {
  it('должен очищать основной инструмент через EventManager', () => {
    const clearSymbol = jest.fn();

    const chart = Object.create(MoexChart.prototype) as MoexChart;

    (chart as unknown as { eventManager: { clearSymbol: () => void } }).eventManager = {
      clearSymbol,
    };

    chart.clearSymbol();

    expect(clearSymbol).toHaveBeenCalledTimes(1);
  });

  it('должен устанавливать основной инструмент через EventManager', () => {
    const setSymbol = jest.fn();

    const chart = Object.create(MoexChart.prototype) as MoexChart;

    (chart as unknown as { eventManager: { setSymbol: typeof setSymbol } }).eventManager = {
      setSymbol,
    };

    chart.setSymbol({
      symbolId: 'SBER',
      symbol: 'SBER',
      symbolName: 'Sberbank',
    });

    expect(setSymbol).toHaveBeenCalledWith({
      symbolId: 'SBER',
      symbol: 'SBER',
      symbolName: 'Sberbank',
    });
  });
});

















import { EventManager } from '@core/EventManager';
import { TimeScaleHoverController } from '@core/TimescaleHoverController';

describe('TimeScaleHoverController', () => {
  let chartContainer: HTMLDivElement;
  let controlBarContainer: HTMLDivElement;
  let eventManager: jest.Mocked<Pick<EventManager, 'setControlBarVisible'>>;

  beforeEach(() => {
    chartContainer = document.createElement('div');
    controlBarContainer = document.createElement('div');

    jest.spyOn(chartContainer, 'getBoundingClientRect').mockReturnValue({
      x: 0,
      y: 0,
      top: 0,
      left: 0,
      right: 1000,
      bottom: 500,
      width: 1000,
      height: 500,
      toJSON: jest.fn(),
    });

    eventManager = {
      setControlBarVisible: jest.fn(),
    };
  });

  it('должен показывать ControlBar при наведении на нижнюю центральную область', () => {
    const controller = new TimeScaleHoverController({
      eventManager: eventManager as unknown as EventManager,
      chartContainer,
      controlBarContainer,
    });

    chartContainer.dispatchEvent(
      new MouseEvent('mousemove', {
        clientX: 500,
        clientY: 480,
      }),
    );

    expect(eventManager.setControlBarVisible).toHaveBeenLastCalledWith(true);

    controller.destroy();
  });

  it('должен скрывать ControlBar вне активной области', () => {
    const controller = new TimeScaleHoverController({
      eventManager: eventManager as unknown as EventManager,
      chartContainer,
      controlBarContainer,
    });

    chartContainer.dispatchEvent(
      new MouseEvent('mousemove', {
        clientX: 100,
        clientY: 100,
      }),
    );

    expect(eventManager.setControlBarVisible).toHaveBeenLastCalledWith(false);

    controller.destroy();
  });

  it('должен показывать ControlBar при наведении на него', () => {
    const controller = new TimeScaleHoverController({
      eventManager: eventManager as unknown as EventManager,
      chartContainer,
      controlBarContainer,
    });

    controlBarContainer.dispatchEvent(new MouseEvent('mouseenter'));

    expect(eventManager.setControlBarVisible).toHaveBeenLastCalledWith(true);

    controller.destroy();
  });
});

















import {
  CHART_DRAWING_TOOLBAR_CONTAINER,
  CHART_MODAL_CONTAINER_CLASSNAME,
  CHART_PANE_EMPTY_STATE_CONTAINER,
  CHART_PANE_OVERLAY_CONTAINER,
  CHART_ROOT_CLASSNAME,
} from '@src/constants';

import { ContainerManager } from '../ContainerManager';

jest.mock('@src/constants', () => ({
  CHART_DRAWING_TOOLBAR_CONTAINER: 'chart-drawing-toolbar-container',
  CHART_MODAL_CONTAINER_CLASSNAME: 'chart-modal-container',
  CHART_PANE_EMPTY_STATE_CONTAINER: 'chart-pane-empty-state-container',
  CHART_PANE_OVERLAY_CONTAINER: 'chart-pane-overlay-container',
  CHART_ROOT_CLASSNAME: 'chart-root',
}));

describe('ContainerManager', () => {
  let parentContainer: HTMLDivElement;

  beforeEach(() => {
    document.querySelectorAll('link[href*="fonts.googleapis.com"]').forEach((link) => link.remove());

    parentContainer = document.createElement('div');
    document.body.appendChild(parentContainer);
  });

  afterEach(() => {
    parentContainer.remove();
  });

  describe('createContainers', () => {
    it('должен создавать основные контейнеры графика с корректной структурой', () => {
      const {
        headerContainer,
        footerContainer,
        chartContainer,
        chartAreaContainer,
        toolBarContainer,
        modalContainer,
        controlBarContainer,
        drawingToolbarContainer,
      } = ContainerManager.createContainers({
        parentContainer,
        showBottomPanel: true,
        showMenuButton: true,
      });

      expect(parentContainer.classList.contains(CHART_ROOT_CLASSNAME)).toBe(true);

      expect(parentContainer.children[0]).toBe(headerContainer);
      expect(parentContainer.children[1]).toBe(chartContainer);
      expect(parentContainer.children[2]).toBe(footerContainer);

      expect(chartContainer.contains(toolBarContainer)).toBe(true);
      expect(chartContainer.contains(chartAreaContainer)).toBe(true);
      expect(chartContainer.contains(modalContainer)).toBe(true);

      expect(chartAreaContainer.contains(controlBarContainer)).toBe(true);
      expect(chartAreaContainer.contains(drawingToolbarContainer)).toBe(true);

      expect(drawingToolbarContainer.classList.contains(CHART_DRAWING_TOOLBAR_CONTAINER)).toBe(true);

      expect(modalContainer.classList.contains(CHART_MODAL_CONTAINER_CLASSNAME)).toBe(true);
      expect(modalContainer.hidden).toBe(true);
    });

    it('должен создавать сетку с нижней панелью', () => {
      ContainerManager.createContainers({
        parentContainer,
        showBottomPanel: true,
        showMenuButton: true,
      });

      expect(parentContainer.style.gridTemplateRows).toBe('36px minmax(0, 1fr) 32px');
    });

    it('должен создавать сетку без нижней панели', () => {
      const { footerContainer } = ContainerManager.createContainers({
        parentContainer,
        showBottomPanel: false,
        showMenuButton: true,
      });

      expect(parentContainer.style.gridTemplateRows).toBe('36px minmax(0, 1fr)');
      expect(parentContainer.contains(footerContainer)).toBe(false);
    });

    it('должен отображать панель инструментов при включённой кнопке меню', () => {
      const { chartContainer, toolBarContainer } = ContainerManager.createContainers({
        parentContainer,
        showMenuButton: true,
      });

      expect(chartContainer.contains(toolBarContainer)).toBe(true);
      expect(chartContainer.style.gridTemplateColumns).toBe('42px minmax(0, 1fr)');
    });

    it('должен скрывать панель инструментов при выключенной кнопке меню', () => {
      const { chartContainer, toolBarContainer } = ContainerManager.createContainers({
        parentContainer,
        showMenuButton: false,
      });

      expect(chartContainer.contains(toolBarContainer)).toBe(false);
      expect(chartContainer.style.gridTemplateColumns).toBe('minmax(0, 1fr)');
    });

    it('должен переключать видимость панели инструментов', () => {
      const { chartContainer, toolBarContainer, toggleToolbar } = ContainerManager.createContainers({
        parentContainer,
        showMenuButton: true,
      });

      expect(toggleToolbar()).toBe(false);
      expect(chartContainer.contains(toolBarContainer)).toBe(false);
      expect(chartContainer.style.gridTemplateColumns).toBe('minmax(0, 1fr)');

      expect(toggleToolbar()).toBe(true);
      expect(chartContainer.contains(toolBarContainer)).toBe(true);
      expect(chartContainer.style.gridTemplateColumns).toBe('42px minmax(0, 1fr)');
    });

    it('должен удалять старое содержимое родительского контейнера перед созданием графика', () => {
      const oldElement = document.createElement('div');

      parentContainer.appendChild(oldElement);

      ContainerManager.createContainers({
        parentContainer,
        showMenuButton: true,
      });

      expect(parentContainer.contains(oldElement)).toBe(false);
    });

    it('должен добавлять шрифт только один раз', () => {
      ContainerManager.createContainers({
        parentContainer,
        showMenuButton: true,
      });

      ContainerManager.createContainers({
        parentContainer,
        showMenuButton: true,
      });

      const fontLinks = document.querySelectorAll('link[href*="fonts.googleapis.com"]');

      expect(fontLinks).toHaveLength(1);
      expect(fontLinks[0]?.getAttribute('href')).toBe(
        'https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap',
      );
    });
  });

  describe('createPaneContainers', () => {
    it('должен создавать контейнеры панели с корректными настройками', () => {
      const { legendContainer, paneOverlayContainer, paneEmptyStateContainer } =
        ContainerManager.createPaneContainers();

      expect(legendContainer.style.position).toBe('absolute');
      expect(legendContainer.style.pointerEvents).toBe('none');

      expect(paneOverlayContainer.classList.contains(CHART_PANE_OVERLAY_CONTAINER)).toBe(true);
      expect(paneOverlayContainer.style.position).toBe('absolute');
      expect(paneOverlayContainer.style.inset).toBe('0px');
      expect(paneOverlayContainer.style.pointerEvents).toBe('none');

      expect(paneEmptyStateContainer.classList.contains(CHART_PANE_EMPTY_STATE_CONTAINER)).toBe(true);
      expect(paneEmptyStateContainer.hidden).toBe(true);
    });

    it('должен создавать новый набор контейнеров для каждого pane', () => {
      const first = ContainerManager.createPaneContainers();
      const second = ContainerManager.createPaneContainers();

      expect(first.legendContainer).not.toBe(second.legendContainer);
      expect(first.paneOverlayContainer).not.toBe(second.paneOverlayContainer);
      expect(first.paneEmptyStateContainer).not.toBe(second.paneEmptyStateContainer);
    });

    it('должен размещать слои графика в корректном порядке', () => {
      const { controlBarContainer, drawingToolbarContainer, modalContainer } =
        ContainerManager.createContainers({
          parentContainer,
          showMenuButton: true,
        });

      const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();

      const controlBarZIndex = Number(controlBarContainer.style.zIndex);
      const paneOverlayZIndex = Number(paneOverlayContainer.style.zIndex);
      const drawingZIndex = Number(drawingToolbarContainer.style.zIndex);
      const legendZIndex = Number(legendContainer.style.zIndex);
      const modalZIndex = Number(modalContainer.style.zIndex);

      expect(controlBarZIndex).toBe(paneOverlayZIndex);
      expect(legendZIndex).toBeGreaterThan(paneOverlayZIndex);
      expect(drawingZIndex).toBeGreaterThan(legendZIndex);
      expect(modalZIndex).toBeGreaterThan(drawingZIndex);
    });
  });

  describe('clearContainers', () => {
    it('должен очищать содержимое родительского контейнера', () => {
      parentContainer.innerHTML = '<div>content</div>';

      ContainerManager.clearContainers(parentContainer);

      expect(parentContainer.innerHTML).toBe('');
    });

    it('не должен падать при очистке уже пустого контейнера', () => {
      expect(() => ContainerManager.clearContainers(parentContainer)).not.toThrow();

      expect(parentContainer.innerHTML).toBe('');
    });
  });
});

















import React from 'react';
import { createRoot, Root } from 'react-dom/client';

import { ReactRenderer } from '../ReactRenderer';

jest.mock('react-dom/client', () => ({
  createRoot: jest.fn(),
}));

describe('ReactRenderer', () => {
  const createRootMock = createRoot as jest.MockedFunction<typeof createRoot>;

  let container: HTMLDivElement;
  let root: jest.Mocked<Pick<Root, 'render' | 'unmount'>>;
  let requestAnimationFrameSpy: jest.SpyInstance;

  beforeEach(() => {
    container = document.createElement('div');

    root = {
      render: jest.fn(),
      unmount: jest.fn(),
    };

    createRootMock.mockReturnValue(root as unknown as Root);

    requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => {
      callback(0);

      return 1;
    });
  });

  afterEach(() => {
    jest.clearAllMocks();
    requestAnimationFrameSpy.mockRestore();
  });

  it('не должен создавать React root до первого рендера', () => {
    const renderer = new ReactRenderer(container);

    expect(renderer.isInitialized()).toBe(false);
    expect(createRootMock).not.toHaveBeenCalled();
  });

  it('должен создавать React root при первом рендере', () => {
    const renderer = new ReactRenderer(container);
    const component = React.createElement('div', null, 'content');

    renderer.render(component);

    expect(createRootMock).toHaveBeenCalledTimes(1);
    expect(createRootMock).toHaveBeenCalledWith(container);
    expect(root.render).toHaveBeenCalledWith(component);
    expect(renderer.isInitialized()).toBe(true);
  });

  it('не должен повторно создавать React root при последующих рендерах', () => {
    const renderer = new ReactRenderer(container);

    const firstComponent = React.createElement('div', null, 'first');
    const secondComponent = React.createElement('div', null, 'second');

    renderer.render(firstComponent);
    renderer.render(secondComponent);

    expect(createRootMock).toHaveBeenCalledTimes(1);
    expect(root.render).toHaveBeenCalledTimes(2);
    expect(root.render).toHaveBeenNthCalledWith(1, firstComponent);
    expect(root.render).toHaveBeenNthCalledWith(2, secondComponent);
  });

  it('должен делегировать renderComponent в render', () => {
    const renderer = new ReactRenderer(container);
    const component = React.createElement('div', null, 'content');

    renderer.renderComponent(component);

    expect(createRootMock).toHaveBeenCalledTimes(1);
    expect(root.render).toHaveBeenCalledWith(component);
  });

  it('должен очищать React root', () => {
    const renderer = new ReactRenderer(container);

    renderer.render(React.createElement('div'));

    expect(renderer.isInitialized()).toBe(true);

    renderer.clear();

    expect(renderer.isInitialized()).toBe(false);
    expect(root.unmount).toHaveBeenCalledTimes(1);
  });

  it('не должен выполнять unmount если renderer ещё не инициализирован', () => {
    const renderer = new ReactRenderer(container);

    renderer.clear();

    expect(root.unmount).not.toHaveBeenCalled();
    expect(renderer.isInitialized()).toBe(false);
  });

  it('должен очищаться через clearComponents', () => {
    const renderer = new ReactRenderer(container);

    renderer.render(React.createElement('div'));
    renderer.clearComponents();

    expect(renderer.isInitialized()).toBe(false);
    expect(root.unmount).toHaveBeenCalledTimes(1);
  });

  it('должен очищаться при destroy', () => {
    const renderer = new ReactRenderer(container);

    renderer.render(React.createElement('div'));
    renderer.destroy();

    expect(renderer.isInitialized()).toBe(false);
    expect(root.unmount).toHaveBeenCalledTimes(1);
  });

  it('должен позволять повторную инициализацию после clear', () => {
    const renderer = new ReactRenderer(container);

    renderer.render(React.createElement('div', null, 'first'));
    renderer.clear();

    const secondRoot = {
      render: jest.fn(),
      unmount: jest.fn(),
    };

    createRootMock.mockReturnValue(secondRoot as unknown as Root);

    renderer.render(React.createElement('div', null, 'second'));

    expect(createRootMock).toHaveBeenCalledTimes(2);
    expect(secondRoot.render).toHaveBeenCalledTimes(1);
    expect(renderer.isInitialized()).toBe(true);
  });
});