Загрузка данных
import { BehaviorSubject, Subscription } from 'rxjs';
import { Chart } from '@core/Chart';
import { CHART_PANE_LEGEND_CONTAINER } from '@src/constants';
import * as utils from '@src/utils';
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', () => {
let clipboardWrite: jest.Mock;
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(() => {
clipboardWrite = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { write: clipboardWrite },
});
Object.defineProperty(globalThis, 'ClipboardItem', {
configurable: true,
value: class {
constructor(public data: Record<string, Blob>) {}
},
});
});
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');
const blob = new Blob(['chart'], { type: 'image/png' });
visibleLegend.classList.add(CHART_PANE_LEGEND_CONTAINER);
hiddenLegend.classList.add(CHART_PANE_LEGEND_CONTAINER);
displayNoneLegend.classList.add(CHART_PANE_LEGEND_CONTAINER);
hiddenLegend.hidden = true;
displayNoneLegend.style.display = 'none';
chartElement.append(visibleLegend, hiddenLegend, displayNoneLegend);
chart.lwcChart.takeScreenshot.mockReturnValue(canvas);
chart.lwcChart.chartElement.mockReturnValue(chartElement);
const drawTextLayer = jest.spyOn(utils, 'drawTextLayer').mockImplementation();
const canvasToBlob = jest.spyOn(utils, 'canvasToBlob').mockResolvedValue(blob);
await chart.copyImage();
expect(chart.lwcChart.takeScreenshot).toHaveBeenCalledWith(true, false);
expect(chart.lwcChart.chartElement).toHaveBeenCalledTimes(1);
expect(drawTextLayer).toHaveBeenCalledTimes(1);
expect(drawTextLayer).toHaveBeenCalledWith(canvas, visibleLegend, chartElement);
expect(canvasToBlob).toHaveBeenCalledWith(canvas);
expect(clipboardWrite).toHaveBeenCalledTimes(1);
const items = clipboardWrite.mock.calls[0][0] as Array<{ data: Record<string, Blob> }>;
expect(items[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.append(hiddenLegend);
chart.lwcChart.takeScreenshot.mockReturnValue(canvas);
chart.lwcChart.chartElement.mockReturnValue(chartElement);
const drawTextLayer = jest.spyOn(utils, 'drawTextLayer').mockImplementation();
jest.spyOn(utils, 'canvasToBlob').mockResolvedValue(new Blob(['chart'], { type: 'image/png' }));
await chart.copyImage();
expect(drawTextLayer).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);
jest.spyOn(utils, 'canvasToBlob').mockRejectedValue(new Error('Failed to create chart image'));
await expect(chart.copyImage()).rejects.toThrow('Failed to create chart image');
expect(clipboardWrite).not.toHaveBeenCalled();
});
});
});
import { canvasToBlob, drawTextLayer } from '@src/utils';
describe('chart image utils', () => {
describe('drawTextLayer', () => {
it('не должен рисовать текст без canvas context', () => {
const canvas = document.createElement('canvas');
const container = document.createElement('div');
const root = document.createElement('div');
const createTreeWalker = jest.spyOn(document, 'createTreeWalker');
jest.spyOn(canvas, 'getContext').mockReturnValue(null);
drawTextLayer(canvas, container, root);
expect(createTreeWalker).not.toHaveBeenCalled();
});
it('должен рисовать текст DOM элемента на canvas', () => {
const canvas = document.createElement('canvas');
const container = document.createElement('div');
const textElement = document.createElement('span');
const root = document.createElement('div');
canvas.width = 200;
canvas.height = 100;
textElement.textContent = 'APPL 124.51';
container.append(textElement);
const context = {
save: jest.fn(),
scale: jest.fn(),
restore: jest.fn(),
fillText: jest.fn(),
font: '',
fillStyle: '',
textBaseline: '',
};
jest.spyOn(canvas, 'getContext').mockReturnValue(context as unknown as CanvasRenderingContext2D);
jest.spyOn(root, 'getBoundingClientRect').mockReturnValue({
left: 10,
top: 20,
width: 100,
height: 50,
right: 110,
bottom: 70,
x: 10,
y: 20,
toJSON: () => ({}),
});
const selectNodeContents = jest.fn();
jest.spyOn(document, 'createRange').mockReturnValue({
selectNodeContents,
getBoundingClientRect: () => ({
left: 30,
top: 40,
width: 50,
height: 12,
right: 80,
bottom: 52,
x: 30,
y: 40,
toJSON: () => ({}),
}),
} as unknown as Range);
jest.spyOn(window, 'getComputedStyle').mockReturnValue({
fontStyle: 'italic',
fontWeight: '700',
fontSize: '12px',
fontFamily: 'Inter',
color: 'rgb(1, 2, 3)',
} as CSSStyleDeclaration);
drawTextLayer(canvas, container, root);
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) => 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('должен выбрасывать ошибку, если Blob не создан', async () => {
const canvas = document.createElement('canvas');
Object.defineProperty(canvas, 'toBlob', {
configurable: true,
value: (callback: BlobCallback) => callback(null),
});
await expect(canvasToBlob(canvas)).rejects.toThrow('Failed to create chart image');
});
});
});
import { fireEvent, render, screen } from '@testing-library/react';
import { SnapshotMenu } from '@src/components/Menu/SnapshotMenu';
jest.mock('@src/components/Icon', () => ({
CopyIcon: () => <span data-testid="copy-icon" />,
}));
jest.mock('@src/translations', () => ({
t: (key: string) => key,
}));
describe('SnapshotMenu', () => {
it('должен отображать действие копирования изображения', () => {
render(<SnapshotMenu onCopyImage={jest.fn()} />);
expect(screen.getByText('Copy image')).toBeInTheDocument();
expect(screen.getByTestId('copy-icon')).toBeInTheDocument();
});
it('должен вызывать onCopyImage при выборе действия', () => {
const onCopyImage = jest.fn().mockResolvedValue(undefined);
render(<SnapshotMenu onCopyImage={onCopyImage} />);
fireEvent.click(screen.getByText('Copy image'));
expect(onCopyImage).toHaveBeenCalledTimes(1);
});
});
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { Tooltip } from '@src/components/Tooltip';
import { CHART_DRAWING_TOOLTIP } from '@src/constants';
jest.mock('exchange-elements/v2', () => ({
Tooltip: ({
children,
direction,
location,
showMessageOnFocus,
tooltipClassName,
}: {
children: ReactNode;
direction?: boolean;
location?: string;
showMessageOnFocus?: boolean;
tooltipClassName?: string;
}) => (
<div
data-testid="exchange-tooltip"
data-direction={String(direction)}
data-location={location}
data-focus={String(showMessageOnFocus)}
className={tooltipClassName}
>
{children}
</div>
),
}));
describe('Tooltip', () => {
it('должен устанавливать direction=false по умолчанию и передавать остальные props', () => {
render(
<Tooltip label="Tooltip" location="top" showMessageOnFocus>
<span>content</span>
</Tooltip>,
);
const tooltip = screen.getByTestId('exchange-tooltip');
expect(tooltip).toHaveAttribute('data-direction', 'false');
expect(tooltip).toHaveAttribute('data-location', 'top');
expect(tooltip).toHaveAttribute('data-focus', 'true');
expect(tooltip).toHaveClass(CHART_DRAWING_TOOLTIP);
expect(screen.getByText('content')).toBeInTheDocument();
});
it('должен позволять переопределить direction и добавлять переданный класс', () => {
render(
<Tooltip label="Tooltip" direction tooltipClassName="custom-tooltip">
<span>content</span>
</Tooltip>,
);
const tooltip = screen.getByTestId('exchange-tooltip');
expect(tooltip).toHaveAttribute('data-direction', 'true');
expect(tooltip).toHaveClass(CHART_DRAWING_TOOLTIP);
expect(tooltip).toHaveClass('custom-tooltip');
});
});
import { fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { BehaviorSubject } from 'rxjs';
import { Header } from '@src/components/Header';
jest.mock('exchange-elements/v2', () => ({
Button: ({ onClick, label, disabled }: { onClick?: () => void; label?: ReactNode; disabled?: boolean }) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
>
{label}
</button>
),
Divider: () => <hr />,
}));
jest.mock('@src/components/IndicatorsSelect', () => ({
IndicatorsSelect: () => <div />,
}));
jest.mock('@src/components/Tooltip', () => ({
Tooltip: ({ children, label }: { children: ReactNode; label?: string }) => (
<div data-tooltip={label}>{children}</div>
),
}));
jest.mock('@src/components/Dropdown', () => ({
Dropdown: ({ children, selectedValue }: { children: ReactNode; selectedValue?: ReactNode }) => (
<div>
<div>{selectedValue}</div>
{children}
</div>
),
}));
jest.mock('@src/translations', () => ({
t: (key: string) => key,
}));
describe('Header', () => {
it('должен отображать меню снимка и вызывать copyImage', () => {
const copyImage = jest.fn().mockResolvedValue(undefined);
const fullscreen = {
isFullscreen: false,
onChange: jest.fn(() => jest.fn()),
toggle: jest.fn(),
};
render(
<Header
timeframes={[]}
selectedTimeframeObs={new BehaviorSubject(null) 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(document.querySelector('[data-tooltip="Snapshot"]')).not.toBeNull();
fireEvent.click(screen.getByText('Copy image'));
expect(copyImage).toHaveBeenCalledTimes(1);
});
});
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,
});
expect(document.querySelectorAll('link[href*="fonts.googleapis.com"]')).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 { IMoexChart, 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',
});
});
it('должен передавать copyImage графика в Header', () => {
const copyImage = jest.fn().mockResolvedValue(undefined);
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.modalRenderer = {};
internals.fullscreen = {};
internals.eventManager = {
getTimeframeObs: jest.fn(),
setTimeframe: jest.fn(),
getSelectedSeries: jest.fn(),
setSeriesSelected: jest.fn(),
};
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 header = headerRenderer.renderComponent.mock.calls[0][0] as ReactElement<{
copyImage: () => Promise<void>;
}>;
expect(header.props.copyImage).toBe(copyImage);
});
});