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


import {
  CHART_DRAWING_TOOLBAR_CONTAINER,
  CHART_MODAL_CONTAINER_CLASSNAME,
  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_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 } = 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.pointerEvents).toBe('none');
    });

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







import { MouseEventParams, Time } from 'lightweight-charts';

import { BehaviorSubject } from 'rxjs';

import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { Legend, LegendModel, LegendParams } from '@core/Legend';

import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';

import type { OHLCConfig } from '@src/types';

describe('Legend', () => {
  let crosshairMoveHandler: ((param: MouseEventParams) => void) | null;
  let indicators: BehaviorSubject<Map<string, Indicator>>;
  let mainSeriesSubject: BehaviorSubject<SeriesStrategies | null>;
  let mainSeries: SeriesStrategies;
  let eventManager: EventManager;
  let getLegendData: jest.Mock;
  let subscribeDataChanged: jest.Mock;
  let unsubscribeDataChanged: jest.Mock;
  let legend: Legend | null;

  const config: OHLCConfig = {
    show: true,
    showAbsoluteChange: false,
    showPercentageChange: true,
  };

  beforeEach(() => {
    crosshairMoveHandler = null;
    legend = null;

    getLegendData = jest.fn().mockReturnValue({
      open: {
        value: 100,
        color: 'white',
        name: 'Open',
      },
      absoluteChange: {
        value: 10,
        color: 'green',
        name: 'Absolute',
      },
      percentageChange: {
        value: '10%',
        color: 'green',
        name: 'Percentage',
      },
    });

    subscribeDataChanged = jest.fn();
    unsubscribeDataChanged = jest.fn();

    mainSeries = {
      getLegendData,
      subscribeDataChanged,
      unsubscribeDataChanged,
    } as unknown as SeriesStrategies;

    eventManager = {
      symbolName: jest.fn().mockReturnValue(new BehaviorSubject('AAPL')),
    } as unknown as EventManager;

    indicators = new BehaviorSubject<Map<string, Indicator>>(new Map());
    mainSeriesSubject = new BehaviorSubject<SeriesStrategies | null>(mainSeries);
  });

  afterEach(() => {
    legend?.destroy();
  });

  function createLegend(): Legend {
    const subscribeChartEvent = jest.fn((event, handler) => {
      if (event === 'crosshairMove') {
        crosshairMoveHandler = handler as (param: MouseEventParams) => void;
      }

      return () => {};
    }) as unknown as LegendParams['subscribeChartEvent'];

    legend = new Legend({
      config,
      eventManager,
      indicators,
      subscribeChartEvent,
      mainSeries: mainSeriesSubject,
      paneId: 1,
      paneIndex: () => 0,
      openIndicatorSettings: jest.fn(),
    });

    return legend;
  }

  function createCrosshairParam(paneIndex = 0): MouseEventParams {
    return {
      point: {
        x: 10,
        y: 20,
      },
      time: 100 as Time,
      paneIndex,
      seriesData: new Map([['main-series', {}]]),
    } as unknown as MouseEventParams;
  }

  function getMainSeriesValues(model: LegendModel) {
    return model[0].values as unknown as Map<
      string,
      {
        value: unknown;
        color: string;
        name?: string;
      }
    >;
  }

  it('должен добавлять абсолютное и относительное изменение в модель основной серии', () => {
    const currentLegend = createLegend();

    let model: LegendModel = [];

    currentLegend.getLegendViewModel().subscribe((value) => {
      model = value;
    });

    const values = getMainSeriesValues(model);

    expect(values.get('absoluteChange')?.value).toBe(10);
    expect(values.get('percentageChange')?.value).toBe('10%');
  });

  it('должен обновлять абсолютное и относительное изменение данными под курсором', () => {
    getLegendData.mockImplementation((param?: MouseEventParams) => {
      if (param) {
        return {
          absoluteChange: {
            value: 20,
            color: 'green',
            name: 'Absolute',
          },
          percentageChange: {
            value: '20%',
            color: 'green',
            name: 'Percentage',
          },
        };
      }

      return {
        absoluteChange: {
          value: 10,
          color: 'green',
          name: 'Absolute',
        },
        percentageChange: {
          value: '10%',
          color: 'green',
          name: 'Percentage',
        },
      };
    });

    const currentLegend = createLegend();

    crosshairMoveHandler?.(createCrosshairParam());

    let model: LegendModel = [];

    currentLegend.getLegendViewModel().subscribe((value) => {
      model = value;
    });

    const values = getMainSeriesValues(model);

    expect(values.get('absoluteChange')?.value).toBe(20);
    expect(values.get('percentageChange')?.value).toBe('20%');
  });

  it('должен отображать тултип при наведении на текущий pane', () => {
    const currentLegend = createLegend();

    let visible = false;
    let position = null;

    currentLegend.subscribeCursorVisability((value) => {
      visible = value;
    });

    currentLegend.subscribeCursorPosition((value) => {
      position = value;
    });

    crosshairMoveHandler?.(createCrosshairParam());

    expect(visible).toBe(true);
    expect(position).toEqual({
      x: 10,
      y: 20,
    });
  });

  it('должен скрывать тултип при наведении на другой pane', () => {
    const currentLegend = createLegend();

    let visible = true;

    currentLegend.subscribeCursorVisability((value) => {
      visible = value;
    });

    crosshairMoveHandler?.(createCrosshairParam(1));

    expect(visible).toBe(false);
  });

  it('должен подписываться и отписываться от изменений основной серии', () => {
    const currentLegend = createLegend();

    expect(subscribeDataChanged).toHaveBeenCalledTimes(1);

    currentLegend.destroy();
    legend = null;

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

  it('должен возвращать конфигурацию легенды', () => {
    const currentLegend = createLegend();

    expect(currentLegend.getConfig()).toBe(config);
  });
});