Загрузка данных
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);
});
});