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


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

type EntityFactory = (
  zIndex: number,
  moveUp: (id: string) => void,
  moveDown: (id: string) => void,
) => Indicator;

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

  let manager: IndicatorManager;
  let mainPane: Pane;

  let setEntity: jest.Mock;
  let removeEntity: jest.Mock;
  let getMainPane: jest.Mock;
  let getPaneById: jest.Mock;
  let addPane: jest.Mock;
  let symbolId: jest.Mock;
  let symbol: jest.Mock;

  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),
    } as unknown as Pane;

    getMainPane = jest.fn(() => mainPane);
    getPaneById = jest.fn();
    addPane = jest.fn(() => mainPane);

    symbolId = jest.fn(() => of('SBER'));
    symbol = jest.fn(() => of('SBER'));

    setEntity = jest.fn((factory: EntityFactory) => factory(1, jest.fn(), jest.fn()));
    removeEntity = jest.fn();

    const paneManager = {
      getMainPane,
      getPaneById,
      addPane,
    } as unknown as PaneManager;

    const eventManager = {
      symbolId,
      symbol,
    } as unknown as EventManager;

    const DOM = {
      setEntity,
      removeEntity,
    } as unknown as DOMModel;

    manager = new IndicatorManager({
      eventManager,
      dataSource: {} as DataSource,
      lwcChart: {} as IChartApi,
      paneManager,
      DOM,
    });
  });

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

    manager.addIndicator({});

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

    consoleSpy.mockRestore();
  });

  it('должен добавлять индикатор', () => {
    manager.addIndicator({
      id: 'ema-1',
      indicatorType: IndicatorsIds.EMA,
    });

    expect(manager.getIndicators().value.has('ema-1')).toBe(true);
    expect(getMainPane).toHaveBeenCalledTimes(1);
    expect(IndicatorMock).toHaveBeenCalledTimes(1);
  });

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

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

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

    expect(entities).toHaveLength(1);
    expect(entities[0]).toBe(manager.getIndicators().value.get('ema-1'));

    subscription.unsubscribe();
  });

  it('должен использовать pane из snapshot', () => {
    const pane = {
      getId: jest.fn(() => 3),
    } as unknown as Pane;

    getPaneById.mockReturnValue(pane);

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

    expect(getPaneById).toHaveBeenCalledWith(3);
    expect(addPane).not.toHaveBeenCalled();
  });

  it('должен создать pane если pane из snapshot отсутствует', () => {
    getPaneById.mockReturnValue(undefined);

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

    expect(getPaneById).toHaveBeenCalledWith(3);
    expect(addPane).toHaveBeenCalledTimes(1);
  });

  it('должен удалить индикатор из состояния и DOM', () => {
    manager.addIndicator({
      id: 'ema-1',
      indicatorType: IndicatorsIds.EMA,
    });

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

    if (!indicator) {
      throw new Error('Индикатор не был добавлен');
    }

    manager.deleteIndicator('ema-1');

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

  it('не должен удалять несуществующий индикатор', () => {
    manager.deleteIndicator('unknown');

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

















import { IChartApi, PriceScaleMode, SeriesType } 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 { CompareManager } from '@src/core/CompareManager';
import { CompareMode, Direction } from '@src/types';
import { PriceScaleSide } from '@src/types/snapshot';
import { Timeframes } from '@src/types/timeframes';

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

type IndicatorFactory = (
  zIndex: number,
  moveUp: (id: string) => void,
  moveDown: (id: string) => void,
) => Indicator;

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

  let manager: CompareManager;
  let mainPane: Pane;
  let secondaryPane: Pane;
  let rightPriceScale: PriceScale;

  let addEntity: jest.Mock;
  let getIndicators: jest.Mock;
  let removeEntity: jest.Mock;

  let getMainPane: jest.Mock;
  let getPaneById: jest.Mock;
  let addPane: jest.Mock;
  let setPriceScaleSideVisible: jest.Mock;
  let invalidate: jest.Mock;

  let waitUntilReady: jest.Mock;

  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(),
    } as unknown as PriceScale;

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

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

    addEntity = jest.fn((factory: IndicatorFactory) => factory(1, jest.fn(), jest.fn()));
    getIndicators = jest.fn(() => new BehaviorSubject<Map<string, Indicator>>(new Map()));
    removeEntity = jest.fn();

    getMainPane = jest.fn(() => mainPane);
    getPaneById = jest.fn();
    addPane = jest.fn(() => secondaryPane);
    setPriceScaleSideVisible = jest.fn();
    invalidate = jest.fn();

    waitUntilReady = jest.fn((_symbolRaw: string) => Promise.resolve());

    const indicatorManager = {
      addEntity,
      getIndicators,
      removeEntity,
    } as unknown as IndicatorManager;

    const paneManager = {
      getMainPane,
      getPaneById,
      addPane,
      setPriceScaleSideVisible,
      invalidate,
    } as unknown as PaneManager;

    const dataSource = {
      waitUntilReady,
    } as unknown as DataSource;

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

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

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

  it('должен добавлять compare-серию типа Line', async () => {
    await manager.setSymbolMode(
      'Line' as SeriesType,
      {
        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(waitUntilReady).toHaveBeenCalledWith('SBER');
    expect(manager.getAllEntities()).toHaveLength(1);
  });

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

    expect(addEntity).not.toHaveBeenCalled();
    expect(manager.getAllEntities()).toHaveLength(0);
  });

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

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

  it('должен использовать существующий pane при восстановлении NewPane', async () => {
    getPaneById.mockReturnValue(secondaryPane);

    await manager.setSymbolMode(
      'Line' as SeriesType,
      {
        symbolId: 'SBER',
      },
      CompareMode.NewPane,
      1,
    );

    expect(getPaneById).toHaveBeenCalledWith(1);
    expect(addPane).not.toHaveBeenCalled();
  });

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

    await manager.setSymbolMode(
      'Line' as SeriesType,
      {
        symbolId: 'SBER',
      },
      CompareMode.Absolute,
    );

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

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

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

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

  it('должен включать левую шкалу для NewScale', async () => {
    await manager.setSymbolMode(
      'Line' as SeriesType,
      {
        symbolId: 'SBER',
      },
      CompareMode.NewScale,
    );

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

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

    const [entry] = manager.getAllEntities();

    if (!entry) {
      throw new Error('Compare-сущность не была добавлена');
    }

    manager.removeSymbol('SBER');

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

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

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

    expect(manager.getAllEntities()).toHaveLength(2);

    manager.clear();

    expect(manager.getAllEntities()).toHaveLength(0);
    expect(removeEntity).toHaveBeenCalledTimes(2);
  });
});