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


import { createElement, type ReactElement, type ReactNode } from 'react';

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

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

jest.mock('@src/constants', () => ({
  CHART_DRAWING_TOOLTIP: 'chart-drawing-tooltip',
}));

interface TooltipElementProps {
  direction?: boolean;
  location?: string;
  showMessageOnFocus?: boolean;
  tooltipClassName?: string;
  children?: ReactNode;
}

describe('Tooltip', () => {
  it('должен использовать direction=false по умолчанию и передавать остальные props', () => {
    const children = createElement('span', null, 'content');

    const element = Tooltip({
      label: 'Tooltip label',
      location: 'top',
      showMessageOnFocus: true,
      children,
    } as Parameters<typeof Tooltip>[0]) as ReactElement<TooltipElementProps>;

    expect(element.props.direction).toBe(false);
    expect(element.props.location).toBe('top');
    expect(element.props.showMessageOnFocus).toBe(true);
    expect(element.props.children).toBe(children);
    expect(element.props.tooltipClassName).toContain(CHART_DRAWING_TOOLTIP);
  });

  it('должен позволять переопределить direction и объединять tooltipClassName', () => {
    const element = Tooltip({
      label: 'Tooltip label',
      direction: true,
      tooltipClassName: 'custom-tooltip',
      children: createElement('span'),
    } as Parameters<typeof Tooltip>[0]) as ReactElement<TooltipElementProps>;

    expect(element.props.direction).toBe(true);
    expect(element.props.tooltipClassName).toContain(CHART_DRAWING_TOOLTIP);
    expect(element.props.tooltipClassName).toContain('custom-tooltip');
  });
});

















import type { ReactElement } from 'react';

import { MenuList, type MenuOption } from '@src/components/Menu/MenuList';
import { SnapshotMenu } from '@src/components/Menu/SnapshotMenu';
import { t } from '@src/translations';

type SnapshotAction = 'copy-image';

interface SnapshotMenuElementProps {
  mode: 'single';
  value?: SnapshotAction;
  options: MenuOption<SnapshotAction>[];
  onClick: (action: SnapshotAction) => void;
}

describe('SnapshotMenu', () => {
  it('должен отображать действие копирования изображения', () => {
    const onCopyImage = jest.fn(() => Promise.resolve());

    const element = SnapshotMenu({
      onCopyImage,
    }) as ReactElement<SnapshotMenuElementProps>;

    expect(element.type).toBe(MenuList);
    expect(element.props.mode).toBe('single');
    expect(element.props.value).toBeUndefined();
    expect(element.props.options).toHaveLength(1);

    expect(element.props.options[0]).toEqual(
      expect.objectContaining({
        value: 'copy-image',
        label: t('Copy image'),
      }),
    );

    expect(element.props.options[0].icon).toBeDefined();
  });

  it('должен вызывать onCopyImage при выборе действия копирования', () => {
    const onCopyImage = jest.fn(() => Promise.resolve());

    const element = SnapshotMenu({
      onCopyImage,
    }) as ReactElement<SnapshotMenuElementProps>;

    element.props.onClick('copy-image');

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

















import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { BehaviorSubject } from 'rxjs';

import { Header } from '@src/components/Header';

jest.mock('exchange-elements/v2', () => {
  const React = jest.requireActual('react') as typeof import('react');

  return {
    Button: ({
      onClick,
      label,
      disabled,
      className,
    }: {
      onClick?: () => void;
      label?: import('react').ReactNode;
      disabled?: boolean;
      className?: string;
    }) =>
      React.createElement(
        'button',
        {
          type: 'button',
          onClick,
          disabled,
          className,
        },
        label,
      ),

    Divider: () =>
      React.createElement('div', {
        'data-testid': 'divider',
      }),
  };
});

jest.mock('@src/components/Tooltip', () => {
  const React = jest.requireActual('react') as typeof import('react');

  return {
    Tooltip: ({
      label,
      children,
    }: {
      label?: string;
      children?: import('react').ReactNode;
    }) =>
      React.createElement(
        'div',
        {
          'data-tooltip': label,
        },
        children,
      ),
  };
});

jest.mock('@src/components/IndicatorsSelect', () => {
  const React = jest.requireActual('react') as typeof import('react');

  return {
    IndicatorsSelect: () =>
      React.createElement('div', {
        'data-testid': 'indicators-select',
      }),
  };
});

jest.mock('@src/components/Dropdown', () => {
  const React = jest.requireActual('react') as typeof import('react');

  return {
    Dropdown: ({
      selectedValue,
      children,
    }: {
      selectedValue?: import('react').ReactNode;
      children?: import('react').ReactNode;
    }) =>
      React.createElement(
        'div',
        {
          'data-testid': 'dropdown',
        },
        React.createElement(
          'div',
          {
            'data-testid': 'dropdown-trigger',
          },
          selectedValue,
        ),
        children,
      ),
  };
});

jest.mock('@src/components/Menu', () => {
  const React = jest.requireActual('react') as typeof import('react');

  return {
    TimeframesMenu: () => React.createElement('div'),

    SeriesMenu: () => React.createElement('div'),

    SnapshotMenu: ({
      onCopyImage,
    }: {
      onCopyImage: () => Promise<void>;
    }) =>
      React.createElement(
        'button',
        {
          type: 'button',
          'data-testid': 'copy-image-action',
          onClick: () => {
            void onCopyImage();
          },
        },
        'copy image',
      ),
  };
});

jest.mock('@src/components/Icon', () => {
  const React = jest.requireActual('react') as typeof import('react');

  const createIcon = (testId: string) => () =>
    React.createElement('span', {
      'data-testid': testId,
    });

  return {
    BarIcon: createIcon('bar-icon'),
    BurgerMenuIcon: createIcon('burger-icon'),
    CameraIcon: createIcon('camera-icon'),
    CandleStickIcon: createIcon('candlestick-icon'),
    FullscreenIcon: createIcon('fullscreen-icon'),
    LineIcon: createIcon('line-icon'),
    PlusCircleIcon: createIcon('plus-icon'),
    RedoIcon: createIcon('redo-icon'),
    SearchIcon: createIcon('search-icon'),
    UndoIcon: createIcon('undo-icon'),
  };
});

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

jest.mock('@src/utils', () => ({
  useObservable: (
    observable: { getValue?: () => unknown } | undefined,
    defaultValue?: unknown,
  ) => observable?.getValue?.() ?? defaultValue,
}));

describe('Header', () => {
  let container: HTMLDivElement;
  let root: Root;

  beforeEach(() => {
    (
      globalThis as {
        IS_REACT_ACT_ENVIRONMENT?: boolean;
      }
    ).IS_REACT_ACT_ENVIRONMENT = true;

    container = document.createElement('div');

    document.body.appendChild(container);

    root = createRoot(container);
  });

  afterEach(() => {
    act(() => {
      root.unmount();
    });

    container.remove();
  });

  it('должен отображать меню снимка и передавать действие копирования изображения', () => {
    const copyImage = jest.fn(() => Promise.resolve());

    const fullscreen = {
      isFullscreen: false,
      onChange: jest.fn(() => jest.fn()),
      toggle: jest.fn(),
    };

    act(() => {
      root.render(
        <Header
          timeframes={[]}
          selectedTimeframeObs={
            new BehaviorSubject('1m') as never
          }
          setTimeframe={jest.fn()}
          seriesTypes={[]}
          setSelectedSeries={jest.fn()}
          selectedSeriesObs={
            new BehaviorSubject('Candlestick') as never
          }
          showSettingsModal={undefined}
          addIndicatorToChart={jest.fn()}
          toggleToolbarVisible={jest.fn(() => false)}
          showCompareButton={false}
          showSymbolSearchButton={false}
          undoRedo={undefined}
          showMenuButton={false}
          showFullscreenButton={false}
          fullscreen={fullscreen as never}
          copyImage={copyImage}
          isMXT={false}
        />,
      );
    });

    expect(
      container.querySelector(
        '[data-testid="camera-icon"]',
      ),
    ).not.toBeNull();

    expect(
      container.querySelector(
        '[data-tooltip="Snapshot"]',
      ),
    ).not.toBeNull();

    const copyButton =
      container.querySelector<HTMLButtonElement>(
        '[data-testid="copy-image-action"]',
      );

    expect(copyButton).not.toBeNull();

    act(() => {
      copyButton?.click();
    });

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

















import {
  canvasToBlob,
  drawTextLayer,
} from '@src/utils';

describe('chart image utils', () => {
  afterEach(() => {
    jest.restoreAllMocks();
  });

  describe('drawTextLayer', () => {
    it('не должен рисовать текст, если canvas context недоступен', () => {
      const canvas = document.createElement('canvas');
      const container = document.createElement('div');
      const relativeTo = document.createElement('div');

      Object.defineProperty(canvas, 'getContext', {
        configurable: true,
        value: jest.fn(() => null),
      });

      const createTreeWalkerSpy = jest.spyOn(
        document,
        'createTreeWalker',
      );

      drawTextLayer(
        canvas,
        container,
        relativeTo,
      );

      expect(
        createTreeWalkerSpy,
      ).not.toHaveBeenCalled();
    });

    it('должен переносить текст и его вычисленные стили на canvas', () => {
      const canvas = document.createElement('canvas');

      canvas.width = 200;
      canvas.height = 100;

      const container = document.createElement('div');
      const textElement = document.createElement('span');
      const emptyElement = document.createElement('span');
      const relativeTo = document.createElement('div');

      textElement.textContent = 'APPL 124.51';

      textElement.style.fontStyle = 'italic';
      textElement.style.fontWeight = '700';
      textElement.style.fontSize = '12px';
      textElement.style.fontFamily = 'Inter';
      textElement.style.color = 'rgb(1, 2, 3)';

      emptyElement.textContent = '   ';

      container.append(
        textElement,
        emptyElement,
      );

      const context = {
        save: jest.fn(),
        scale: jest.fn(),
        restore: jest.fn(),
        fillText: jest.fn(),
        font: '',
        fillStyle: '',
        textBaseline: '',
      };

      Object.defineProperty(canvas, 'getContext', {
        configurable: true,
        value: jest.fn(() => context),
      });

      jest
        .spyOn(
          relativeTo,
          'getBoundingClientRect',
        )
        .mockReturnValue({
          left: 10,
          top: 20,
          right: 110,
          bottom: 70,
          width: 100,
          height: 50,
          x: 10,
          y: 20,
          toJSON: () => ({}),
        });

      const selectNodeContents = jest.fn();

      jest
        .spyOn(document, 'createRange')
        .mockReturnValue({
          selectNodeContents,

          getBoundingClientRect: () => ({
            left: 30,
            top: 40,
            right: 80,
            bottom: 52,
            width: 50,
            height: 12,
            x: 30,
            y: 40,
            toJSON: () => ({}),
          }),
        } as unknown as Range);

      drawTextLayer(
        canvas,
        container,
        relativeTo,
      );

      expect(
        context.save,
      ).toHaveBeenCalledTimes(1);

      expect(
        context.scale,
      ).toHaveBeenCalledWith(2, 2);

      expect(
        selectNodeContents,
      ).toHaveBeenCalledTimes(1);

      expect(context.font).toBe(
        'italic 700 12px Inter',
      );

      expect(context.fillStyle).toBe(
        'rgb(1, 2, 3)',
      );

      expect(context.textBaseline).toBe(
        'top',
      );

      expect(
        context.fillText,
      ).toHaveBeenCalledWith(
        'APPL 124.51',
        20,
        20,
      );

      expect(
        context.restore,
      ).toHaveBeenCalledTimes(1);
    });
  });

  describe('canvasToBlob', () => {
    it('должен возвращать PNG Blob', async () => {
      const canvas =
        document.createElement('canvas');

      const blob = new Blob(
        ['image'],
        {
          type: 'image/png',
        },
      );

      const toBlob = jest.fn(
        (
          callback: BlobCallback,
          type?: string,
        ) => {
          callback(blob);
        },
      );

      Object.defineProperty(
        canvas,
        'toBlob',
        {
          configurable: true,
          value: toBlob,
        },
      );

      await expect(
        canvasToBlob(canvas),
      ).resolves.toBe(blob);

      expect(toBlob).toHaveBeenCalledWith(
        expect.any(Function),
        'image/png',
      );
    });

    it('должен отклонять Promise, если canvas не создал Blob', async () => {
      const canvas =
        document.createElement('canvas');

      const toBlob = jest.fn(
        (callback: BlobCallback) => {
          callback(null);
        },
      );

      Object.defineProperty(
        canvas,
        'toBlob',
        {
          configurable: true,
          value: toBlob,
        },
      );

      await expect(
        canvasToBlob(canvas),
      ).rejects.toThrow(
        'Failed to create chart image',
      );
    });
  });
});

















import {
  CHART_DRAWING_TOOLBAR_CONTAINER,
  CHART_MODAL_CONTAINER_CLASSNAME,
  CHART_PANE_EMPTY_STATE_CONTAINER,
  CHART_PANE_LEGEND_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_LEGEND_CONTAINER:
    'chart-pane-legend-container',

  CHART_PANE_OVERLAY_CONTAINER:
    'chart-pane-overlay-container',

  CHART_ROOT_CLASSNAME:
    'chart-root',

  CHART_SCROLLABLE_CLASSNAME:
    'chart-scrollable',
}));

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

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

      expect(
        legendContainer.classList.contains(
          CHART_PANE_LEGEND_CONTAINER,
        ),
      ).toBe(true);

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

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

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

      ContainerManager.clearContainers(
        parentContainer,
      );

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

















import type { ReactElement } from 'react';

import {
  MoexChart,
  type IMoexChart,
} 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',
    });
  });

  it('должен передавать copyImage графика в Header', () => {
    const copyImage = jest.fn(
      () => Promise.resolve(),
    );

    const headerRenderer = {
      renderComponent: jest.fn(),
    };

    const drawingToolbarRenderer = {
      renderComponent: jest.fn(),
    };

    const drawingsCollectionManager = {
      selectedDrawing: jest.fn(),

      updateSelectedDrawingSettings:
        jest.fn(),

      toggleSelectedDrawingLock:
        jest.fn(),

      openSelectedDrawingSettings:
        jest.fn(),

      deleteSelectedDrawing:
        jest.fn(),
    };

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

    const internals =
      chart as unknown as {
        chart: {
          copyImage:
            () => Promise<void>;

          getDrawingsCollectionManager:
            () => typeof drawingsCollectionManager;
        };

        drawingToolbarRenderer:
          typeof drawingToolbarRenderer;

        headerRenderer:
          typeof headerRenderer;

        toolbarRenderer: undefined;

        controlBarRenderer: undefined;

        footerRenderer: undefined;

        eventManager: {
          getTimeframeObs: jest.Mock;
          setTimeframe: jest.Mock;
          getSelectedSeries: jest.Mock;
          setSeriesSelected: jest.Mock;
        };

        modalRenderer: object;
        fullscreen: object;

        renderAttachments: (
          config: IMoexChart,
          toggleToolbar:
            () => boolean,
        ) => void;
      };

    internals.chart = {
      copyImage,

      getDrawingsCollectionManager:
        () =>
          drawingsCollectionManager,
    };

    internals.drawingToolbarRenderer =
      drawingToolbarRenderer;

    internals.headerRenderer =
      headerRenderer;

    internals.toolbarRenderer =
      undefined;

    internals.controlBarRenderer =
      undefined;

    internals.footerRenderer =
      undefined;

    internals.eventManager = {
      getTimeframeObs: jest.fn(),
      setTimeframe: jest.fn(),
      getSelectedSeries: jest.fn(),
      setSeriesSelected: jest.fn(),
    };

    internals.modalRenderer = {};

    internals.fullscreen = {};

    const config = {
      chartCollectionPreset: {
        supportedTimeframes: [],

        supportedChartSeriesTypes: [],

        showSettingsButton: false,

        showMenuButton: false,

        showFullscreenButton: false,

        undoRedoEnabled: false,

        showCompareButton: false,

        openCompareModal:
          undefined,

        openSymbolSearchModal:
          undefined,

        theme: 'tr',
      },
    } as unknown as IMoexChart;

    internals.renderAttachments(
      config,
      jest.fn(() => false),
    );

    const headerElement =
      headerRenderer
        .renderComponent
        .mock
        .calls[0][0] as ReactElement<{
          copyImage:
            () => Promise<void>;
        }>;

    expect(
      headerElement.props.copyImage,
    ).toBe(copyImage);
  });
});

















import { BehaviorSubject, Subscription } from 'rxjs';

import { Chart } from '@core/Chart';
import { CHART_PANE_LEGEND_CONTAINER } from '@src/constants';
import { canvasToBlob, drawTextLayer } from '@src/utils';

jest.mock('@src/utils', () => ({
  ...jest.requireActual('@src/utils'),
  canvasToBlob: jest.fn(),
  drawTextLayer: jest.fn(),
}));

interface ChartInternals {
  eventManager: {
    symbolId: () => BehaviorSubject<string>;
    getInterval: () => BehaviorSubject<null>;
  };

  compareManager: {
    itemsObs: () => BehaviorSubject<{ symbolId: string }[]>;
  };

  dataSource: {
    setSymbols: jest.Mock;
    loadTill: jest.Mock;
  };

  lwcChart: {
    timeScale: () => {
      getVisibleRange: () => null;
    };
    takeScreenshot: jest.Mock;
    chartElement: jest.Mock;
  };

  subscriptions: Subscription;

  activeSymbolIds: string[];

  currentInterval: null;

  setupDataSourceSubs: () => void;

  copyImage: () => Promise<void>;
}

describe('Chart', () => {
  const canvasToBlobMock =
    canvasToBlob as jest.MockedFunction<typeof canvasToBlob>;

  const drawTextLayerMock =
    drawTextLayer as jest.MockedFunction<typeof drawTextLayer>;

  let clipboardDescriptor: PropertyDescriptor | undefined;
  let clipboardItemDescriptor: PropertyDescriptor | undefined;

  function createChartInternals(): ChartInternals {
    return Object.assign(Object.create(Chart.prototype), {
      eventManager: {
        symbolId: () => new BehaviorSubject(''),
        getInterval: () => new BehaviorSubject(null),
      },

      compareManager: {
        itemsObs: () =>
          new BehaviorSubject([
            {
              symbolId: 'SBER',
            },
            {
              symbolId: 'SBER',
            },
          ]),
      },

      dataSource: {
        setSymbols: jest.fn(),
        loadTill: jest.fn(() => Promise.resolve()),
      },

      lwcChart: {
        timeScale: () => ({
          getVisibleRange: () => null,
        }),

        takeScreenshot: jest.fn(),

        chartElement: jest.fn(),
      },

      subscriptions: new Subscription(),

      activeSymbolIds: [],

      currentInterval: null,
    }) as ChartInternals;
  }

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

    clipboardDescriptor = Object.getOwnPropertyDescriptor(
      navigator,
      'clipboard',
    );

    clipboardItemDescriptor = Object.getOwnPropertyDescriptor(
      globalThis,
      'ClipboardItem',
    );
  });

  afterEach(() => {
    if (clipboardDescriptor) {
      Object.defineProperty(
        navigator,
        'clipboard',
        clipboardDescriptor,
      );
    } else {
      Reflect.deleteProperty(
        navigator,
        'clipboard',
      );
    }

    if (clipboardItemDescriptor) {
      Object.defineProperty(
        globalThis,
        'ClipboardItem',
        clipboardItemDescriptor,
      );
    } else {
      Reflect.deleteProperty(
        globalThis,
        'ClipboardItem',
      );
    }
  });

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

    chart.setupDataSourceSubs();

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

    chart.subscriptions.unsubscribe();
  });

  describe('copyImage', () => {
    it('должен копировать изображение графика вместе с видимой легендой', async () => {
      const chart =
        createChartInternals();

      const canvas =
        document.createElement('canvas');

      const chartElement =
        document.createElement('div');

      const visibleLegend =
        document.createElement('div');

      const hiddenLegend =
        document.createElement('div');

      const displayNoneLegend =
        document.createElement('div');

      visibleLegend.classList.add(
        CHART_PANE_LEGEND_CONTAINER,
      );

      hiddenLegend.classList.add(
        CHART_PANE_LEGEND_CONTAINER,
      );

      hiddenLegend.hidden = true;

      displayNoneLegend.classList.add(
        CHART_PANE_LEGEND_CONTAINER,
      );

      displayNoneLegend.style.display =
        'none';

      chartElement.append(
        visibleLegend,
        hiddenLegend,
        displayNoneLegend,
      );

      chart.lwcChart.takeScreenshot.mockReturnValue(
        canvas,
      );

      chart.lwcChart.chartElement.mockReturnValue(
        chartElement,
      );

      const blob = new Blob(
        ['chart'],
        {
          type: 'image/png',
        },
      );

      canvasToBlobMock.mockResolvedValue(
        blob,
      );

      const clipboardWrite =
        jest.fn(() =>
          Promise.resolve(),
        );

      class ClipboardItemMock {
        public readonly data: Record<
          string,
          Blob
        >;

        constructor(
          data: Record<string, Blob>,
        ) {
          this.data = data;
        }
      }

      Object.defineProperty(
        navigator,
        'clipboard',
        {
          configurable: true,
          value: {
            write: clipboardWrite,
          },
        },
      );

      Object.defineProperty(
        globalThis,
        'ClipboardItem',
        {
          configurable: true,
          value: ClipboardItemMock,
        },
      );

      await chart.copyImage();

      expect(
        chart.lwcChart.takeScreenshot,
      ).toHaveBeenCalledWith(
        true,
        false,
      );

      expect(
        chart.lwcChart.chartElement,
      ).toHaveBeenCalledTimes(1);

      expect(
        drawTextLayerMock,
      ).toHaveBeenCalledTimes(1);

      expect(
        drawTextLayerMock,
      ).toHaveBeenCalledWith(
        canvas,
        visibleLegend,
        chartElement,
      );

      expect(
        drawTextLayerMock,
      ).not.toHaveBeenCalledWith(
        canvas,
        hiddenLegend,
        chartElement,
      );

      expect(
        drawTextLayerMock,
      ).not.toHaveBeenCalledWith(
        canvas,
        displayNoneLegend,
        chartElement,
      );

      expect(
        canvasToBlobMock,
      ).toHaveBeenCalledWith(
        canvas,
      );

      expect(
        clipboardWrite,
      ).toHaveBeenCalledTimes(1);

      const clipboardItems =
        clipboardWrite.mock.calls[0][0] as unknown as ClipboardItemMock[];

      expect(
        clipboardItems,
      ).toHaveLength(1);

      expect(
        clipboardItems[0].data,
      ).toEqual({
        'image/png': blob,
      });
    });

    it('не должен копировать скрытые легенды', async () => {
      const chart =
        createChartInternals();

      const canvas =
        document.createElement('canvas');

      const chartElement =
        document.createElement('div');

      const hiddenLegend =
        document.createElement('div');

      hiddenLegend.classList.add(
        CHART_PANE_LEGEND_CONTAINER,
      );

      hiddenLegend.hidden = true;

      chartElement.appendChild(
        hiddenLegend,
      );

      chart.lwcChart.takeScreenshot.mockReturnValue(
        canvas,
      );

      chart.lwcChart.chartElement.mockReturnValue(
        chartElement,
      );

      canvasToBlobMock.mockResolvedValue(
        new Blob(['chart'], {
          type: 'image/png',
        }),
      );

      const clipboardWrite =
        jest.fn(() =>
          Promise.resolve(),
        );

      Object.defineProperty(
        navigator,
        'clipboard',
        {
          configurable: true,
          value: {
            write: clipboardWrite,
          },
        },
      );

      Object.defineProperty(
        globalThis,
        'ClipboardItem',
        {
          configurable: true,
          value: class {
            constructor(
              public readonly data: Record<
                string,
                Blob
              >,
            ) {}
          },
        },
      );

      await chart.copyImage();

      expect(
        drawTextLayerMock,
      ).not.toHaveBeenCalled();

      expect(
        clipboardWrite,
      ).toHaveBeenCalledTimes(1);
    });

    it('не должен записывать изображение в clipboard, если Blob не удалось создать', async () => {
      const chart =
        createChartInternals();

      const canvas =
        document.createElement('canvas');

      const chartElement =
        document.createElement('div');

      chart.lwcChart.takeScreenshot.mockReturnValue(
        canvas,
      );

      chart.lwcChart.chartElement.mockReturnValue(
        chartElement,
      );

      const error = new Error(
        'Failed to create chart image',
      );

      canvasToBlobMock.mockRejectedValue(
        error,
      );

      const clipboardWrite =
        jest.fn(() =>
          Promise.resolve(),
        );

      Object.defineProperty(
        navigator,
        'clipboard',
        {
          configurable: true,
          value: {
            write: clipboardWrite,
          },
        },
      );

      Object.defineProperty(
        globalThis,
        'ClipboardItem',
        {
          configurable: true,
          value: class {},
        },
      );

      await expect(
        chart.copyImage(),
      ).rejects.toThrow(
        'Failed to create chart image',
      );

      expect(
        clipboardWrite,
      ).not.toHaveBeenCalled();
    });
  });
});