Загрузка данных
import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type { IChartApi, SeriesAttachedParameter, Time } from 'lightweight-charts';
import type { SeriesApi } from '@core/Drawings/types';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartBackground: '#101010',
chartLineColor: '#2962ff',
},
}),
}));
function createEnvironment(paneIndex = 0, paneCount = 1, paneHeight = 100) {
const pane = {
paneIndex: jest.fn(() => paneIndex),
getHeight: jest.fn(() => paneHeight),
};
const panes = Array.from({ length: paneCount }, (_, index) => {
return index === paneIndex ? pane : {};
});
const chart = {
panes: jest.fn(() => panes),
} as unknown as IChartApi;
const series = {
getPane: jest.fn(() => pane),
} as unknown as SeriesApi;
return { chart, series, pane };
}
function attach<TId extends string>(
primitive: DrawingHandlesPrimitive<TId>,
chart: IChartApi,
series: SeriesApi,
) {
primitive.attached({
chart,
series,
requestUpdate: jest.fn(),
} as unknown as SeriesAttachedParameter<Time>);
}
function createTarget(horizontalPixelRatio = 2, verticalPixelRatio = 2) {
const context = {
save: jest.fn(),
restore: jest.fn(),
beginPath: jest.fn(),
arc: jest.fn(),
rect: jest.fn(),
moveTo: jest.fn(),
arcTo: jest.fn(),
closePath: jest.fn(),
fill: jest.fn(),
stroke: jest.fn(),
fillStyle: '',
strokeStyle: '',
lineWidth: 0,
};
const useBitmapCoordinateSpace = jest.fn(
(
callback: (scope: {
context: CanvasRenderingContext2D;
horizontalPixelRatio: number;
verticalPixelRatio: number;
bitmapSize: { width: number; height: number };
}) => void,
) => {
callback({
context: context as unknown as CanvasRenderingContext2D,
horizontalPixelRatio,
verticalPixelRatio,
bitmapSize: {
width: 400,
height: 100,
},
});
},
);
return {
context,
target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
useBitmapCoordinateSpace,
};
}
describe('DrawingHandlesPrimitive', () => {
it('должен возвращать top zOrder для pane', () => {
const primitive = new DrawingHandlesPrimitive(() => []);
expect(primitive.paneViews()).toHaveLength(1);
expect(primitive.paneViews()[0].zOrder?.()).toBe('top');
});
it('должен возвращать top zOrder для time axis', () => {
const primitive = new DrawingHandlesPrimitive(() => []);
expect(primitive.timeAxisPaneViews()).toHaveLength(1);
expect(primitive.timeAxisPaneViews()[0].zOrder?.()).toBe('top');
});
it('должен находить handle в зоне hit tolerance', () => {
const handle: DrawingHandle<'main'> = {
id: 'main',
x: 100,
y: 50,
};
const primitive = new DrawingHandlesPrimitive(() => [handle]);
expect(primitive.findHandle({ x: 108, y: 58 })).toBe(handle);
});
it('не должен находить handle за пределами hit tolerance', () => {
const primitive = new DrawingHandlesPrimitive(() => [
{
id: 'main',
x: 100,
y: 50,
},
]);
expect(primitive.findHandle({ x: 109, y: 50 })).toBeNull();
});
it('должен учитывать размер handle при вычислении hit area', () => {
const handle: DrawingHandle<'main'> = {
id: 'main',
x: 100,
y: 50,
size: 24,
};
const primitive = new DrawingHandlesPrimitive(() => [handle]);
expect(primitive.findHandle({ x: 112, y: 50 })).toBe(handle);
expect(primitive.findHandle({ x: 113, y: 50 })).toBeNull();
});
it('должен отдавать приоритет последнему handle при пересечении', () => {
const handles: DrawingHandle<'first' | 'second'>[] = [
{
id: 'first',
x: 100,
y: 50,
},
{
id: 'second',
x: 100,
y: 50,
},
];
const primitive = new DrawingHandlesPrimitive(() => handles);
expect(primitive.findHandle({ x: 100, y: 50 })?.id).toBe('second');
});
it('должен определять handle, пересекающий time axis', () => {
const { chart, series } = createEnvironment(0, 1, 100);
const primitive = new DrawingHandlesPrimitive(() => [
{
id: 'main',
x: 50,
y: 100,
},
]);
attach(primitive, chart, series);
expect(primitive.isTimeAxisHit({ x: 50, y: 104 })).toBe(true);
});
it('не должен считать точку внутри pane попаданием по time axis', () => {
const { chart, series } = createEnvironment(0, 1, 100);
const primitive = new DrawingHandlesPrimitive(() => [
{
id: 'main',
x: 50,
y: 100,
},
]);
attach(primitive, chart, series);
expect(primitive.isTimeAxisHit({ x: 50, y: 99 })).toBe(false);
});
it('не должен работать с time axis для pane, который не является последним', () => {
const { chart, series } = createEnvironment(0, 2, 100);
const primitive = new DrawingHandlesPrimitive(() => [
{
id: 'main',
x: 50,
y: 100,
},
]);
attach(primitive, chart, series);
expect(primitive.isTimeAxisHit({ x: 50, y: 104 })).toBe(false);
});
it('не должен считать handle пересекающим time axis, если он находится выше границы', () => {
const { chart, series } = createEnvironment(0, 1, 100);
const primitive = new DrawingHandlesPrimitive(() => [
{
id: 'main',
x: 50,
y: 80,
},
]);
attach(primitive, chart, series);
expect(primitive.isTimeAxisHit({ x: 50, y: 101 })).toBe(false);
});
it('должен рисовать circle по умолчанию', () => {
const { context, target } = createTarget();
const primitive = new DrawingHandlesPrimitive(() => [
{
id: 'main',
x: 100,
y: 50,
},
]);
primitive.paneViews()[0].renderer().draw(target);
expect(context.arc).toHaveBeenCalledWith(200, 100, 10, 0, Math.PI * 2);
expect(context.fill).toHaveBeenCalledTimes(1);
expect(context.stroke).toHaveBeenCalledTimes(1);
expect(context.fillStyle).toBe('#101010');
expect(context.strokeStyle).toBe('#2962ff');
expect(context.lineWidth).toBe(4);
});
it('должен рисовать rounded handle', () => {
const { context, target } = createTarget();
const primitive = new DrawingHandlesPrimitive(() => [
{
id: 'main',
x: 100,
y: 50,
shape: 'rounded',
},
]);
primitive.paneViews()[0].renderer().draw(target);
expect(context.arc).not.toHaveBeenCalled();
expect(context.arcTo).toHaveBeenCalled();
expect(context.closePath).toHaveBeenCalled();
expect(context.fill).toHaveBeenCalledTimes(1);
expect(context.stroke).toHaveBeenCalledTimes(1);
});
it('должен использовать custom strokeColor и borderWidth', () => {
const { context, target } = createTarget();
const primitive = new DrawingHandlesPrimitive(() => [
{
id: 'main',
x: 100,
y: 50,
strokeColor: '#ff0000',
borderWidth: 3,
},
]);
primitive.paneViews()[0].renderer().draw(target);
expect(context.strokeStyle).toBe('#ff0000');
expect(context.lineWidth).toBe(6);
});
it('не должен вызывать canvas при отсутствии handles', () => {
const { target, useBitmapCoordinateSpace } = createTarget();
const primitive = new DrawingHandlesPrimitive(() => []);
primitive.paneViews()[0].renderer().draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('должен рисовать пересекающую границу часть handle на time axis', () => {
const { chart, series } = createEnvironment(0, 1, 100);
const { context, target } = createTarget();
const primitive = new DrawingHandlesPrimitive(() => [
{
id: 'main',
x: 50,
y: 100,
},
]);
attach(primitive, chart, series);
primitive.timeAxisPaneViews()[0].renderer().draw(target);
expect(context.arc).toHaveBeenCalled();
});
it('не должен рисовать handle на time axis для непоследнего pane', () => {
const { chart, series } = createEnvironment(0, 2, 100);
const { target, useBitmapCoordinateSpace } = createTarget();
const primitive = new DrawingHandlesPrimitive(() => [
{
id: 'main',
x: 50,
y: 100,
},
]);
attach(primitive, chart, series);
primitive.timeAxisPaneViews()[0].renderer().draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('должен очищать chart и series после detached', () => {
const { chart, series } = createEnvironment();
const primitive = new DrawingHandlesPrimitive(() => []);
attach(primitive, chart, series);
primitive.detached();
expect(primitive.isTimeAxisHit({ x: 50, y: 101 })).toBe(false);
});
});
import {
clampPointToContainer,
getAnchorFromPoint,
getContainerSize,
getPointerPoint,
getPriceDelta,
getPriceFromYCoordinate,
getPriceRangeInContainer,
getRawPointerPoint,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
normalizeBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import type { SeriesApi } from '@core/Drawings/types';
import type { IChartApi, Time } from 'lightweight-charts';
function createContainer(width = 400, height = 300, left = 10, top = 20) {
const container = document.createElement('div');
jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({
x: left,
y: top,
left,
top,
right: left + width,
bottom: top + height,
width,
height,
toJSON: jest.fn(),
});
return container;
}
function createChart() {
const timeScale = {
coordinateToTime: jest.fn((coordinate: number) => coordinate),
timeToCoordinate: jest.fn((time: Time) => Number(time)),
logicalToCoordinate: jest.fn((logical: number) => logical * 10),
};
const chart = {
timeScale: jest.fn(() => timeScale),
} as unknown as IChartApi;
return { chart, timeScale };
}
function createSeries(data: readonly { time: Time }[] = []) {
const series = {
coordinateToPrice: jest.fn((coordinate: number) => coordinate),
priceToCoordinate: jest.fn((price: number) => price),
data: jest.fn(() => data),
} as unknown as SeriesApi;
return series;
}
describe('Drawing helpers', () => {
it('должен получать цену из y coordinate', () => {
const series = createSeries();
expect(getPriceFromYCoordinate(series, 50)).toBe(50);
expect(series.coordinateToPrice).toHaveBeenCalledWith(50);
});
it('должен получать y coordinate из цены', () => {
const series = createSeries();
expect(getYCoordinateFromPrice(series, 100)).toBe(100);
expect(series.priceToCoordinate).toHaveBeenCalledWith(100);
});
it('должен получать время из x coordinate', () => {
const { chart, timeScale } = createChart();
expect(getTimeFromXCoordinate(chart, 120)).toBe(120);
expect(timeScale.coordinateToTime).toHaveBeenCalledWith(120);
});
it('должен возвращать прямую координату времени', () => {
const { chart } = createChart();
expect(getXCoordinateFromTime(chart, 100 as Time)).toBe(100);
});
it('должен возвращать null без series, если timeToCoordinate не дал координату', () => {
const { chart, timeScale } = createChart();
timeScale.timeToCoordinate.mockReturnValue(null);
expect(getXCoordinateFromTime(chart, 100 as Time)).toBeNull();
});
it('должен проецировать отсутствующее время через ближайшую свечу', () => {
const { chart, timeScale } = createChart();
timeScale.timeToCoordinate.mockReturnValue(null);
const series = createSeries([
{ time: 10 as Time },
{ time: 20 as Time },
{ time: 30 as Time },
]);
expect(getXCoordinateFromTime(chart, 24 as Time, series)).toBe(10);
expect(timeScale.logicalToCoordinate).toHaveBeenCalledWith(1);
});
it('должен выбирать предыдущую свечу при одинаковом расстоянии', () => {
const { chart, timeScale } = createChart();
timeScale.timeToCoordinate.mockReturnValue(null);
const series = createSeries([
{ time: 10 as Time },
{ time: 20 as Time },
{ time: 30 as Time },
]);
expect(getXCoordinateFromTime(chart, 25 as Time, series)).toBe(10);
});
it('должен использовать первую свечу для времени до диапазона', () => {
const { chart, timeScale } = createChart();
timeScale.timeToCoordinate.mockReturnValue(null);
const series = createSeries([
{ time: 10 as Time },
{ time: 20 as Time },
]);
expect(getXCoordinateFromTime(chart, 5 as Time, series)).toBe(0);
});
it('должен использовать последнюю свечу для времени после диапазона', () => {
const { chart, timeScale } = createChart();
timeScale.timeToCoordinate.mockReturnValue(null);
const series = createSeries([
{ time: 10 as Time },
{ time: 20 as Time },
]);
expect(getXCoordinateFromTime(chart, 30 as Time, series)).toBe(10);
});
it('должен возвращать null при отсутствии валидных свечей', () => {
const { chart, timeScale } = createChart();
timeScale.timeToCoordinate.mockReturnValue(null);
const series = createSeries([]);
expect(getXCoordinateFromTime(chart, 30 as Time, series)).toBeNull();
});
it('должен получать размеры container', () => {
const container = createContainer(500, 250);
expect(getContainerSize(container)).toEqual({
width: 500,
height: 250,
});
});
it('должен ограничивать point границами container', () => {
const container = createContainer(400, 300);
expect(clampPointToContainer({ x: -20, y: 350 }, container)).toEqual({
x: 0,
y: 300,
});
});
it('должен получать raw pointer point относительно container', () => {
const container = createContainer(400, 300, 10, 20);
expect(
getRawPointerPoint(container, {
clientX: 110,
clientY: 70,
} as MouseEvent),
).toEqual({
x: 100,
y: 50,
});
});
it('должен ограничивать pointer point границами container', () => {
const container = createContainer(400, 300, 10, 20);
expect(
getPointerPoint(container, {
clientX: 500,
clientY: -10,
} as MouseEvent),
).toEqual({
x: 400,
y: 0,
});
});
it('должен определять близость point', () => {
expect(isNearPoint({ x: 108, y: 92 }, 100, 100, 8)).toBe(true);
expect(isNearPoint({ x: 109, y: 100 }, 100, 100, 8)).toBe(false);
});
it('должен определять point внутри bounds', () => {
const bounds = {
left: 10,
right: 100,
top: 20,
bottom: 80,
};
expect(isPointInBounds({ x: 50, y: 50 }, bounds)).toBe(true);
expect(isPointInBounds({ x: 5, y: 50 }, bounds)).toBe(false);
expect(isPointInBounds({ x: 5, y: 50 }, bounds, 5)).toBe(true);
});
it('должен нормализовать и ограничивать bounds', () => {
const container = createContainer(400, 300);
expect(normalizeBounds(450, -20, 350, -50, container)).toEqual({
left: 0,
right: 400,
top: 0,
bottom: 300,
});
});
it('должен смещать время на количество пикселей', () => {
const { chart } = createChart();
expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBe(120);
});
it('должен возвращать null при невозможности получить исходную координату времени', () => {
const { chart, timeScale } = createChart();
timeScale.timeToCoordinate.mockReturnValue(null);
expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBeNull();
});
it('должен вычислять price delta', () => {
const series = createSeries();
expect(getPriceDelta(series, 20, 70)).toBe(50);
});
it('должен возвращать 0 price delta при отсутствии цены', () => {
const series = createSeries();
(series.coordinateToPrice as jest.Mock).mockReturnValueOnce(null);
expect(getPriceDelta(series, 20, 70)).toBe(0);
});
it('должен вычислять диапазон цены в container', () => {
const container = createContainer(400, 300);
const series = createSeries();
(series.coordinateToPrice as jest.Mock)
.mockReturnValueOnce(150)
.mockReturnValueOnce(50);
expect(getPriceRangeInContainer(series, container)).toEqual({
min: 50,
max: 150,
});
});
it('должен возвращать null диапазона при нулевой высоте', () => {
const container = createContainer(400, 0);
const series = createSeries();
expect(getPriceRangeInContainer(series, container)).toBeNull();
});
it('должен создавать anchor из point', () => {
const { chart } = createChart();
const series = createSeries();
expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toEqual({
time: 100,
price: 200,
});
});
it('должен возвращать null anchor при отсутствии времени', () => {
const { chart, timeScale } = createChart();
const series = createSeries();
timeScale.coordinateToTime.mockReturnValue(null);
expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toBeNull();
});
it('должен возвращать null anchor при отсутствии цены', () => {
const { chart } = createChart();
const series = createSeries();
(series.coordinateToPrice as jest.Mock).mockReturnValue(null);
expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toBeNull();
});
});
import { getOrderedSideValue, updateViews } from '@core/Drawings/utils';
describe('Drawing utils', () => {
describe('updateViews', () => {
it('должен обновлять все views', () => {
const first = {
update: jest.fn(),
};
const second = {
update: jest.fn(),
};
updateViews([first, second]);
expect(first.update).toHaveBeenCalledTimes(1);
expect(second.update).toHaveBeenCalledTimes(1);
});
it('должен корректно работать с пустым массивом', () => {
expect(() => updateViews([])).not.toThrow();
});
});
describe('getOrderedSideValue', () => {
it('должен возвращать null без startValue', () => {
expect(getOrderedSideValue(null, 'end', 10, 20, 'start')).toBeNull();
});
it('должен возвращать null без endValue', () => {
expect(getOrderedSideValue('start', null, 10, 20, 'end')).toBeNull();
});
it('должен использовать исходный порядок без координат', () => {
expect(getOrderedSideValue('A', 'B', null, 20, 'start')).toBe('A');
expect(getOrderedSideValue('A', 'B', 10, null, 'end')).toBe('B');
});
it('должен возвращать startValue для левой координаты', () => {
expect(getOrderedSideValue('A', 'B', 10, 20, 'start')).toBe('A');
expect(getOrderedSideValue('A', 'B', 10, 20, 'end')).toBe('B');
});
it('должен менять значения местами при обратном порядке координат', () => {
expect(getOrderedSideValue('A', 'B', 20, 10, 'start')).toBe('B');
expect(getOrderedSideValue('A', 'B', 20, 10, 'end')).toBe('A');
});
it('должен считать одинаковые координаты прямым порядком', () => {
expect(getOrderedSideValue('A', 'B', 10, 10, 'start')).toBe('A');
expect(getOrderedSideValue('A', 'B', 10, 10, 'end')).toBe('B');
});
});
});
import {
DRAWING_KEYBOARD_SHORTCUTS,
DRAWING_POINTER_SHORTCUTS,
findDrawingPointerShortcut,
} from '@core/Drawings/shortcuts';
import { DrawingsNames } from '@src/constants';
import { Keys } from '@src/core/Hotkeys';
import type { MouseEventParams } from 'lightweight-charts';
function createEvent({
shiftKey = false,
altKey = false,
ctrlKey = false,
metaKey = false,
}: {
shiftKey?: boolean;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
}): MouseEventParams {
return {
sourceEvent: {
shiftKey,
altKey,
ctrlKey,
metaKey,
},
} as unknown as MouseEventParams;
}
describe('Drawing shortcuts', () => {
it('должен содержать keyboard shortcut для trend line', () => {
expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
drawingName: DrawingsNames.trendLine,
keys: [Keys.alt, Keys.t],
});
});
it('должен содержать keyboard shortcut для horizontal line', () => {
expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
drawingName: DrawingsNames.horizontalLine,
keys: [Keys.alt, Keys.h],
});
});
it('должен содержать keyboard shortcut для vertical line', () => {
expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
drawingName: DrawingsNames.verticalLine,
keys: [Keys.alt, Keys.v],
});
});
it('должен содержать keyboard shortcut для fibonacci', () => {
expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
drawingName: DrawingsNames.fibonacciRetracement,
keys: [Keys.alt, Keys.f],
});
});
it('должен содержать keyboard shortcut для rectangle', () => {
expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
drawingName: DrawingsNames.rectangle,
keys: [Keys.alt, Keys.shift, Keys.r],
});
});
it('должен содержать pointer shortcut для ruler', () => {
expect(DRAWING_POINTER_SHORTCUTS).toEqual([
{
drawingName: DrawingsNames.ruler,
modifiers: [Keys.shift],
},
]);
});
it('должен находить ruler при нажатом Shift', () => {
expect(
findDrawingPointerShortcut(
createEvent({
shiftKey: true,
}),
),
).toEqual({
drawingName: DrawingsNames.ruler,
modifiers: [Keys.shift],
});
});
it('не должен находить shortcut без Shift', () => {
expect(findDrawingPointerShortcut(createEvent({}))).toBeUndefined();
});
it('не должен находить Shift shortcut при дополнительном Alt', () => {
expect(
findDrawingPointerShortcut(
createEvent({
shiftKey: true,
altKey: true,
}),
),
).toBeUndefined();
});
it('не должен находить shortcut без sourceEvent', () => {
expect(findDrawingPointerShortcut({} as MouseEventParams)).toBeUndefined();
});
});
import { BehaviorSubject } from 'rxjs';
import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
import { SeriesDrawingBase } from '@core/Drawings/SeriesDrawingBase';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { DrawingInteraction } from '@core/Drawings/SeriesDrawingBase';
import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { SettingsTab, SettingsValues } from '@src/types';
import type {
IChartApi,
IPrimitivePaneView,
ISeriesPrimitiveAxisView,
PrimitiveHoveredItem,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
class TestDrawing extends SeriesDrawingBase<SettingsValues, 'main'> {
protected mode: 'idle' | 'ready' = 'ready';
protected settings: SettingsValues = {
color: '#ffffff',
};
public handlePoint: Point = {
x: 50,
y: 50,
};
public updateAllViews = jest.fn();
public pointerDownCalls = 0;
public clickCalls = 0;
public doubleClickCalls = 0;
public contextMenuCalls = 0;
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getState() {
return {
mode: this.mode,
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as { mode?: 'idle' | 'ready' };
this.mode = nextState.mode ?? this.mode;
}
public getSettingsTabs(): SettingsTab[] {
return [];
}
public paneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [];
}
public getRenderData() {
return null;
}
public setMode(mode: 'idle' | 'ready'): void {
this.mode = mode;
}
public markReady(): void {
this.mode = 'ready';
this.resolveReady?.();
}
public findHandle(point: Point): DrawingHandle<'main'> | null {
return this.getDrawingHandleAtPoint(point);
}
protected getDrawingHandles(): readonly DrawingHandle<'main'>[] {
return [
{
id: 'main',
...this.handlePoint,
},
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (Math.abs(x - this.handlePoint.x) > 8 || Math.abs(y - this.handlePoint.y) > 8) {
return null;
}
return {
cursorStyle: 'move',
externalId: 'test',
zOrder: 'top',
};
}
protected getGeometry() {
return null;
}
protected getTimeAxisSegments(): AxisSegment[] {
return [];
}
protected getPriceAxisSegments(): AxisSegment[] {
return [];
}
protected getTimeAxisLabel(_kind: string): AxisLabel | null {
return null;
}
protected getPriceAxisLabel(_kind: string): AxisLabel | null {
return null;
}
protected handlePointerDown(): void {
this.pointerDownCalls += 1;
}
protected handleClick(): void {
this.clickCalls += 1;
}
protected handleDoubleClick(): void {
this.doubleClickCalls += 1;
}
protected handleContextMenu(): void {
this.contextMenuCalls += 1;
}
}
function createEnvironment({
selected = false,
locked = false,
width = 400,
height = 300,
}: {
selected?: boolean;
locked?: boolean;
width?: number;
height?: number;
} = {}) {
let selectedValue = selected;
let lockedValue = locked;
const selected$ = new BehaviorSubject(selected);
const locked$ = new BehaviorSubject(locked);
const interaction: DrawingInteraction = {
selected$,
locked$,
isSelected: jest.fn(() => selectedValue),
isLocked: jest.fn(() => lockedValue),
select: jest.fn(() => {
selectedValue = true;
selected$.next(true);
}),
deselect: jest.fn(() => {
selectedValue = false;
selected$.next(false);
}),
};
const container = document.createElement('div');
jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: width,
bottom: height,
width,
height,
toJSON: jest.fn(),
});
const pane = {
paneIndex: jest.fn(() => 0),
getHeight: jest.fn(() => height),
};
const chart = {
panes: jest.fn(() => [pane]),
applyOptions: jest.fn(),
} as unknown as IChartApi;
const series = {
attachPrimitive: jest.fn(),
detachPrimitive: jest.fn(),
getPane: jest.fn(() => pane),
} as unknown as SeriesApi;
return {
container,
interaction,
chart,
series,
setSelected(value: boolean) {
selectedValue = value;
selected$.next(value);
},
setLocked(value: boolean) {
lockedValue = value;
locked$.next(value);
},
};
}
function createPointerEvent(
target: Node,
x: number,
y: number,
button = 0,
): PointerEvent {
return {
target,
clientX: x,
clientY: y,
button,
pointerId: 1,
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
} as unknown as PointerEvent;
}
function createMouseEvent(target: Node, x: number, y: number): MouseEvent {
return {
target,
clientX: x,
clientY: y,
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
} as unknown as MouseEvent;
}
describe('SeriesDrawingBase', () => {
it('должен возвращать копию settings', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
const first = drawing.getSettings();
first.color = '#000000';
expect(drawing.getSettings()).toEqual({
color: '#ffffff',
});
});
it('должен объединять новые settings с текущими', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
drawing.updateSettings({
lineWidth: 2,
});
expect(drawing.getSettings()).toEqual({
color: '#ffffff',
lineWidth: 2,
});
});
it('должен уведомлять подписчика settings', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
const callback = jest.fn();
const subscription = drawing.subscribeSettings(callback);
expect(callback).toHaveBeenCalledWith({
color: '#ffffff',
});
drawing.updateSettings({
color: '#ff0000',
});
expect(callback).toHaveBeenLastCalledWith({
color: '#ff0000',
});
subscription.unsubscribe();
});
it('должен скрывать drawing', () => {
const env = createEnvironment({
selected: true,
});
const drawing = new TestDrawing(env);
drawing.hide();
expect(drawing.findHandle({ x: 50, y: 50 })).toBeNull();
expect(drawing.updateAllViews).toHaveBeenCalled();
expect(env.chart.applyOptions).toHaveBeenCalled();
});
it('должен снова показывать drawing', () => {
const env = createEnvironment({
selected: true,
});
const drawing = new TestDrawing(env);
drawing.hide();
drawing.show();
expect(drawing.findHandle({ x: 50, y: 50 })?.id).toBe('main');
});
it('должен показывать handle для selected drawing', () => {
const env = createEnvironment({
selected: true,
});
const drawing = new TestDrawing(env);
expect(drawing.findHandle({ x: 50, y: 50 })?.id).toBe('main');
});
it('не должен показывать handle для невыбранного ready drawing', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
expect(drawing.findHandle({ x: 50, y: 50 })).toBeNull();
});
it('должен показывать handle во время создания', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
drawing.setMode('idle');
expect(drawing.findHandle({ x: 50, y: 50 })?.id).toBe('main');
});
it('не должен показывать handle для locked drawing', () => {
const env = createEnvironment({
selected: true,
locked: true,
});
const drawing = new TestDrawing(env);
expect(drawing.findHandle({ x: 50, y: 50 })).toBeNull();
});
it('должен прикреплять DrawingHandlesPrimitive при attached', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
drawing.attached({
chart: env.chart,
series: env.series,
requestUpdate: jest.fn(),
} as unknown as SeriesAttachedParameter<Time>);
expect(env.series.attachPrimitive).toHaveBeenCalledTimes(1);
expect(env.series.attachPrimitive).toHaveBeenCalledWith(expect.any(DrawingHandlesPrimitive));
drawing.detached();
});
it('должен отсоединять DrawingHandlesPrimitive при detached', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
drawing.attached({
chart: env.chart,
series: env.series,
requestUpdate: jest.fn(),
} as unknown as SeriesAttachedParameter<Time>);
const handlesPrimitive = (env.series.attachPrimitive as jest.Mock).mock.calls[0][0];
drawing.detached();
expect(env.series.detachPrimitive).toHaveBeenCalledWith(handlesPrimitive);
});
it('должен возвращать false для object tree в idle', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
drawing.setMode('idle');
expect(drawing.shouldShowInObjectTree()).toBe(false);
});
it('должен возвращать true для object tree в ready', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
expect(drawing.shouldShowInObjectTree()).toBe(true);
});
it('waitTillReady должен сразу завершаться для ready drawing', async () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
await expect(drawing.waitTillReady()).resolves.toBeUndefined();
});
it('waitTillReady должен ожидать перехода в ready', async () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
drawing.setMode('idle');
const promise = drawing.waitTillReady();
drawing.markReady();
await expect(promise).resolves.toBeUndefined();
});
it('должен возвращать исходный cursor для unlocked drawing', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
expect(drawing.hitTest(50, 50)?.cursorStyle).toBe('move');
});
it('должен заменять cursor на pointer для locked drawing', () => {
const env = createEnvironment({
locked: true,
});
const drawing = new TestDrawing(env);
expect(drawing.hitTest(50, 50)?.cursorStyle).toBe('pointer');
});
it('должен передавать pointerDown в drawing для unlocked состояния', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
drawing.pointerDown(createPointerEvent(env.container, 50, 50));
expect(drawing.pointerDownCalls).toBe(1);
});
it('не должен обрабатывать событие вне container', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
const outside = document.createElement('div');
drawing.pointerDown(createPointerEvent(outside, 50, 50));
expect(drawing.pointerDownCalls).toBe(0);
});
it('должен выбирать locked drawing при pointerDown по нему', () => {
const env = createEnvironment({
locked: true,
});
const drawing = new TestDrawing(env);
drawing.pointerDown(createPointerEvent(env.container, 50, 50));
expect(env.interaction.select).toHaveBeenCalledTimes(1);
expect(drawing.pointerDownCalls).toBe(0);
});
it('должен вызывать click handler', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
drawing.click(createMouseEvent(env.container, 50, 50));
expect(drawing.clickCalls).toBe(1);
});
it('должен вызывать doubleClick handler', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
drawing.doubleClick(createMouseEvent(env.container, 50, 50));
expect(drawing.doubleClickCalls).toBe(1);
});
it('должен вызывать contextMenu handler', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
drawing.contextMenu(createMouseEvent(env.container, 50, 50));
expect(drawing.contextMenuCalls).toBe(1);
});
it('должен принимать событие на выступающей части handle поверх time axis', () => {
const env = createEnvironment({
selected: true,
height: 100,
});
const drawing = new TestDrawing(env);
drawing.handlePoint = {
x: 50,
y: 100,
};
drawing.attached({
chart: env.chart,
series: env.series,
requestUpdate: jest.fn(),
} as unknown as SeriesAttachedParameter<Time>);
const handlesPrimitive = (env.series.attachPrimitive as jest.Mock).mock.calls[0][0] as DrawingHandlesPrimitive;
handlesPrimitive.attached({
chart: env.chart,
series: env.series,
requestUpdate: jest.fn(),
} as unknown as SeriesAttachedParameter<Time>);
const outside = document.createElement('div');
expect(drawing.isHit(createMouseEvent(outside, 50, 104))).toBe(true);
drawing.detached();
});
it('должен rebind drawing на новую series', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
const newSeries = {
attachPrimitive: jest.fn(),
detachPrimitive: jest.fn(),
} as unknown as SeriesApi;
drawing.rebind(newSeries);
expect(env.series.detachPrimitive).toHaveBeenCalledWith(drawing);
expect(newSeries.attachPrimitive).toHaveBeenCalledWith(drawing);
});
it('должен detach drawing при destroy', () => {
const env = createEnvironment();
const drawing = new TestDrawing(env);
drawing.destroy();
expect(env.series.detachPrimitive).toHaveBeenCalledWith(drawing);
});
});
import { BehaviorSubject } from 'rxjs';
import { Diapson } from '@core/Drawings/diapson/diapson';
import { DiapsonPaneRenderer } from '@core/Drawings/diapson/paneRenderer';
import { DiapsonPaneView } from '@core/Drawings/diapson/paneView';
import { createDefaultSettings, getDiapsonSettingsTabs } from '@core/Drawings/diapson/settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { DrawingInteraction } from '@core/Drawings/SeriesDrawingBase';
import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type { IChartApi, PrimitiveHoveredItem, Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartBackground: '#101010',
chartLineColor: '#2962ff',
diapsonStrokeFill: '#ff9800',
diapsonAreaFill: '#ff980033',
axisRangeTooltipText: '#ffffff',
axisRangeTooltipFill: '#333333',
axisMarkerAreaFill: '#2962ff44',
axisMarkerLabelFill: '#2962ff',
chartPriceLineText: '#ffffff',
},
}),
}));
jest.mock('@src/translations', () => ({
t: (key: string) => key,
}));
jest.mock('@src/types/defaults', () => ({
Defaults: {
dateFormat: 'date',
timeFormat: '24h',
showTime: true,
},
}));
jest.mock('@src/utils', () => ({
formatPercent: (value: number) => `${value}%`,
formatPrice: (value: number) => `price:${value}`,
formatSignedNumber: (value: number) => (value > 0 ? `+${value}` : String(value)),
formatVolume: (value: number) => `volume:${value}`,
}));
jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
class TestDiapson extends Diapson {
public getHandles(): readonly DrawingHandle[] {
return this.getDrawingHandles();
}
public getTimeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public getPriceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public getTimeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public getPriceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
public getHovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public getGeometryForTest() {
return this.getGeometry();
}
public move(event: PointerEvent): void {
this.handlePointerMove(event);
}
public up(event: PointerEvent): void {
this.handlePointerUp(event);
}
}
function createEnvironment({
selected = false,
locked = false,
width = 400,
height = 300,
data = [],
}: {
selected?: boolean;
locked?: boolean;
width?: number;
height?: number;
data?: readonly Record<string, unknown>[];
} = {}) {
let selectedValue = selected;
let lockedValue = locked;
const selected$ = new BehaviorSubject(selected);
const locked$ = new BehaviorSubject(locked);
const interaction: DrawingInteraction = {
selected$,
locked$,
isSelected: jest.fn(() => selectedValue),
isLocked: jest.fn(() => lockedValue),
select: jest.fn(() => {
selectedValue = true;
selected$.next(true);
}),
deselect: jest.fn(() => {
selectedValue = false;
selected$.next(false);
}),
};
const container = document.createElement('div');
jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: width,
bottom: height,
width,
height,
toJSON: jest.fn(),
});
const timeScale = {
coordinateToTime: jest.fn((coordinate: number) => coordinate),
timeToCoordinate: jest.fn((time: Time) => Number(time)),
logicalToCoordinate: jest.fn((logical: number) => logical),
};
const pane = {
paneIndex: jest.fn(() => 0),
getHeight: jest.fn(() => height),
};
const chart = {
timeScale: jest.fn(() => timeScale),
applyOptions: jest.fn(),
panes: jest.fn(() => [pane]),
} as unknown as IChartApi;
const series = {
coordinateToPrice: jest.fn((coordinate: number) => coordinate),
priceToCoordinate: jest.fn((price: number) => price),
data: jest.fn(() => data),
attachPrimitive: jest.fn(),
detachPrimitive: jest.fn(),
getPane: jest.fn(() => pane),
} as unknown as SeriesApi;
return {
chart,
series,
container,
interaction,
timeScale,
setSelected(value: boolean) {
selectedValue = value;
selected$.next(value);
},
setLocked(value: boolean) {
lockedValue = value;
locked$.next(value);
},
};
}
function createPointerEvent(
target: Node,
x: number,
y: number,
pointerId = 1,
button = 0,
): PointerEvent {
return {
target,
clientX: x,
clientY: y,
pointerId,
button,
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
} as unknown as PointerEvent;
}
function createMouseEvent(target: Node, x: number, y: number): MouseEvent {
return {
target,
clientX: x,
clientY: y,
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
} as unknown as MouseEvent;
}
function createDiapson(
rangeMode: 'date' | 'price' = 'date',
options: {
selected?: boolean;
data?: readonly Record<string, unknown>[];
stepSize?: number;
stepLabel?: string;
} = {},
) {
const env = createEnvironment({
selected: options.selected,
data: options.data,
});
const removeSelf = jest.fn();
const openSettings = jest.fn();
const drawing = new TestDiapson({
chart: env.chart,
series: env.series,
container: env.container,
interaction: env.interaction,
rangeMode,
removeSelf,
openSettings,
stepSize: options.stepSize,
stepLabel: options.stepLabel,
});
return {
...env,
drawing,
removeSelf,
openSettings,
};
}
function setReady(
drawing: TestDiapson,
{
rangeMode = 'date',
startTime = 50,
endTime = 150,
startPrice = 50,
endPrice = 150,
}: {
rangeMode?: 'date' | 'price';
startTime?: number;
endTime?: number;
startPrice?: number;
endPrice?: number;
} = {},
) {
drawing.setState({
mode: 'ready',
rangeMode,
startTime: startTime as Time,
endTime: endTime as Time,
startPrice,
endPrice,
});
}
function createCanvasTarget() {
const context = {
save: jest.fn(),
restore: jest.fn(),
beginPath: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
stroke: jest.fn(),
fill: jest.fn(),
fillRect: jest.fn(),
quadraticCurveTo: jest.fn(),
closePath: jest.fn(),
fillText: jest.fn(),
measureText: jest.fn((text: string) => ({
width: text.length * 5,
})),
fillStyle: '',
strokeStyle: '',
lineWidth: 0,
font: '',
textAlign: '',
textBaseline: '',
canvas: {
width: 800,
height: 600,
},
};
const useBitmapCoordinateSpace = jest.fn(
(
callback: (scope: {
context: CanvasRenderingContext2D;
horizontalPixelRatio: number;
verticalPixelRatio: number;
}) => void,
) => {
callback({
context: context as unknown as CanvasRenderingContext2D,
horizontalPixelRatio: 2,
verticalPixelRatio: 2,
});
},
);
return {
context,
target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
useBitmapCoordinateSpace,
};
}
describe('Diapson', () => {
it('должен прикреплять primitive при создании', () => {
const { drawing, series } = createDiapson();
expect(series.attachPrimitive).toHaveBeenCalledWith(drawing);
});
it('должен начинаться в idle', () => {
const { drawing } = createDiapson();
expect(drawing.isCreationPending()).toBe(true);
expect(drawing.getState()).toMatchObject({
hidden: false,
mode: 'idle',
rangeMode: 'date',
startTime: null,
endTime: null,
startPrice: null,
endPrice: null,
});
});
it('должен начинать drawing первым pointerDown', () => {
const { drawing, container } = createDiapson();
drawing.pointerDown(createPointerEvent(container, 50, 60));
expect(drawing.getState()).toMatchObject({
mode: 'drawing',
startTime: 50,
endTime: 50,
startPrice: 60,
endPrice: 60,
});
});
it('должен завершать drawing вторым pointerDown', () => {
const { drawing, container } = createDiapson();
drawing.pointerDown(createPointerEvent(container, 50, 60));
drawing.pointerDown(createPointerEvent(container, 150, 160));
expect(drawing.getState()).toMatchObject({
mode: 'ready',
startTime: 50,
endTime: 150,
startPrice: 60,
endPrice: 160,
});
expect(drawing.isCreationPending()).toBe(false);
});
it('должен удалять слишком маленький диапазон', () => {
const { drawing, container, removeSelf } = createDiapson();
drawing.pointerDown(createPointerEvent(container, 50, 50));
drawing.pointerDown(createPointerEvent(container, 53, 53));
expect(removeSelf).toHaveBeenCalledTimes(1);
});
it('waitTillReady должен завершаться после создания', async () => {
const { drawing, container } = createDiapson();
const readyPromise = drawing.waitTillReady();
drawing.pointerDown(createPointerEvent(container, 50, 50));
drawing.pointerDown(createPointerEvent(container, 150, 150));
await expect(readyPromise).resolves.toBeUndefined();
});
it('должен сбрасывать drawing при изменении range mode', () => {
const { drawing, interaction } = createDiapson();
setReady(drawing);
drawing.setRangeMode('price');
expect(drawing.getState()).toMatchObject({
mode: 'idle',
rangeMode: 'price',
startTime: null,
endTime: null,
startPrice: null,
endPrice: null,
});
expect(interaction.deselect).toHaveBeenCalled();
});
it('не должен сбрасываться при установке текущего range mode', () => {
const { drawing } = createDiapson();
setReady(drawing);
drawing.setRangeMode('date');
expect(drawing.getState()).toMatchObject({
mode: 'ready',
startTime: 50,
endTime: 150,
});
});
it('должен возвращать нормализованную geometry', () => {
const { drawing } = createDiapson();
setReady(drawing, {
startTime: 150,
endTime: 50,
startPrice: 160,
endPrice: 60,
});
expect(drawing.getGeometryForTest()).toEqual({
left: 50,
right: 150,
top: 60,
bottom: 160,
width: 100,
height: 100,
startPoint: {
x: 150,
y: 160,
},
endPoint: {
x: 50,
y: 60,
},
});
});
it('должен возвращать start и end handles', () => {
const { drawing } = createDiapson({
} as never);
setReady(drawing);
expect(drawing.getHandles()).toEqual([
expect.objectContaining({
id: 'end',
x: 150,
y: 150,
shape: 'circle',
}),
expect.objectContaining({
id: 'start',
x: 50,
y: 50,
shape: 'circle',
}),
]);
});
it('должен возвращать пустые handles без geometry', () => {
const { drawing } = createDiapson();
expect(drawing.getHandles()).toEqual([]);
});
it('должен возвращать render data', () => {
const { drawing } = createDiapson();
setReady(drawing);
expect(drawing.getRenderData()).toMatchObject({
left: 50,
right: 150,
top: 50,
bottom: 150,
width: 100,
height: 100,
rangeMode: 'date',
showFill: true,
borderColor: '#ff9800',
fillColor: '#ff980033',
});
});
it('не должен возвращать render data для hidden drawing', () => {
const { drawing } = createDiapson();
drawing.setState({
hidden: true,
mode: 'ready',
startTime: 50,
endTime: 150,
startPrice: 50,
endPrice: 150,
});
expect(drawing.getRenderData()).toBeNull();
});
it('должен возвращать time axis segment для selected drawing', () => {
const { drawing } = createDiapson('date', {
selected: true,
});
setReady(drawing);
expect(drawing.getTimeSegments()).toEqual([
{
from: 50,
to: 150,
color: '#2962ff44',
},
]);
});
it('должен возвращать price axis segment для selected drawing', () => {
const { drawing } = createDiapson('date', {
selected: true,
});
setReady(drawing);
expect(drawing.getPriceSegments()).toEqual([
{
from: 50,
to: 150,
color: '#2962ff44',
},
]);
});
it('не должен возвращать axis segments для невыбранного ready drawing', () => {
const { drawing } = createDiapson();
setReady(drawing);
expect(drawing.getTimeSegments()).toEqual([]);
expect(drawing.getPriceSegments()).toEqual([]);
});
it('должен возвращать time axis labels', () => {
const { drawing } = createDiapson('date', {
selected: true,
});
setReady(drawing);
expect(drawing.getTimeLabel('left')).toEqual({
coordinate: 50,
text: 'date:50',
textColor: '#ffffff',
backgroundColor: '#2962ff',
});
expect(drawing.getTimeLabel('right')).toEqual({
coordinate: 150,
text: 'date:150',
textColor: '#ffffff',
backgroundColor: '#2962ff',
});
});
it('должен возвращать price axis labels', () => {
const { drawing } = createDiapson('date', {
selected: true,
});
setReady(drawing);
expect(drawing.getPriceLabel('top')).toEqual({
coordinate: 50,
text: 'price:50',
textColor: '#ffffff',
backgroundColor: '#2962ff',
});
expect(drawing.getPriceLabel('bottom')).toEqual({
coordinate: 150,
text: 'price:150',
textColor: '#ffffff',
backgroundColor: '#2962ff',
});
});
it('не должен возвращать label неизвестного kind', () => {
const { drawing } = createDiapson('date', {
selected: true,
});
setReady(drawing);
expect(drawing.getTimeLabel('unknown')).toBeNull();
expect(drawing.getPriceLabel('unknown')).toBeNull();
});
it('должен показывать move cursor для невыбранного диапазона', () => {
const { drawing } = createDiapson();
setReady(drawing);
expect(drawing.getHovered(100, 100)).toEqual({
cursorStyle: 'move',
externalId: 'diapson-date',
zOrder: 'top',
});
});
it('должен показывать resize cursor для выбранного handle', () => {
const { drawing } = createDiapson('date', {
selected: true,
});
setReady(drawing);
expect(drawing.getHovered(50, 50)).toEqual({
cursorStyle: 'nwse-resize',
externalId: 'diapson-date',
zOrder: 'top',
});
});
it('должен выбирать диапазон первым кликом', () => {
const { drawing, container, interaction } = createDiapson();
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 100, 100));
expect(interaction.select).toHaveBeenCalledTimes(1);
expect(drawing.getState()).toMatchObject({
mode: 'ready',
});
});
it('должен перемещать весь выбранный диапазон', () => {
const { drawing, container } = createDiapson('date', {
selected: true,
});
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 100, 100, 1));
drawing.move(createPointerEvent(container, 120, 130, 1));
expect(drawing.getState()).toMatchObject({
mode: 'dragging',
startTime: 70,
endTime: 170,
startPrice: 80,
endPrice: 180,
});
drawing.up(createPointerEvent(container, 120, 130, 1));
expect(drawing.getState()).toMatchObject({
mode: 'ready',
});
});
it('должен resize start handle', () => {
const { drawing, container } = createDiapson('date', {
selected: true,
});
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 50, 50, 1));
drawing.move(createPointerEvent(container, 70, 80, 1));
drawing.up(createPointerEvent(container, 70, 80, 1));
expect(drawing.getState()).toMatchObject({
mode: 'ready',
startTime: 70,
startPrice: 80,
endTime: 150,
endPrice: 150,
});
});
it('должен resize end handle', () => {
const { drawing, container } = createDiapson('date', {
selected: true,
});
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 150, 150, 1));
drawing.move(createPointerEvent(container, 180, 190, 1));
drawing.up(createPointerEvent(container, 180, 190, 1));
expect(drawing.getState()).toMatchObject({
startTime: 50,
startPrice: 50,
endTime: 180,
endPrice: 190,
});
});
it('не должен двигаться при другом pointerId', () => {
const { drawing, container } = createDiapson('date', {
selected: true,
});
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 100, 100, 1));
drawing.move(createPointerEvent(container, 120, 120, 2));
expect(drawing.getState()).toMatchObject({
startTime: 50,
endTime: 150,
startPrice: 50,
endPrice: 150,
});
});
it('должен открывать настройки по double click внутри диапазона', () => {
const { drawing, container, openSettings } = createDiapson();
setReady(drawing);
drawing.doubleClick(createMouseEvent(container, 100, 100));
expect(openSettings).toHaveBeenCalledTimes(1);
});
it('не должен открывать настройки по double click вне диапазона', () => {
const { drawing, container, openSettings } = createDiapson();
setReady(drawing);
drawing.doubleClick(createMouseEvent(container, 300, 250));
expect(openSettings).not.toHaveBeenCalled();
});
it('должен формировать price metrics', () => {
const { drawing } = createDiapson('price', {
stepSize: 5,
stepLabel: 'pts',
});
setReady(drawing, {
rangeMode: 'price',
startPrice: 100,
endPrice: 120,
});
expect(drawing.getRenderData()?.labelLines).toEqual([
'+20 (20%) 4 pts',
]);
});
it('должен корректно формировать отрицательные price metrics', () => {
const { drawing } = createDiapson('price', {
stepSize: 5,
stepLabel: 'pts',
});
setReady(drawing, {
rangeMode: 'price',
startPrice: 100,
endPrice: 80,
});
expect(drawing.getRenderData()?.labelLines).toEqual([
'-20 (-20%) -4 pts',
]);
});
it('должен формировать date metrics и volume', () => {
const data = [
{
time: 1000,
volume: 10,
},
{
time: 1060,
volume: 20,
},
{
time: 1120,
volume: 30,
},
];
const { drawing } = createDiapson('date', {
data,
});
setReady(drawing, {
rangeMode: 'date',
startTime: 1000,
endTime: 1120,
});
expect(drawing.getRenderData()?.labelLines).toEqual([
'2 bars, 2m',
'Vol volume:60',
]);
});
it('должен учитывать volume из customValues', () => {
const data = [
{
time: 1000,
customValues: {
volume: 10,
},
},
{
time: 1060,
customValues: {
volume: 20,
},
},
];
const { drawing } = createDiapson('date', {
data,
});
setReady(drawing, {
startTime: 1000,
endTime: 1060,
});
expect(drawing.getRenderData()?.labelLines).toEqual([
'1 bars, 1m',
'Vol volume:30',
]);
});
});
describe('DiapsonPaneRenderer', () => {
it('не должен рисовать без render data', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
const renderer = new DiapsonPaneRenderer({
getRenderData: () => null,
} as Diapson);
renderer.draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('должен рисовать date range', () => {
const { context, target } = createCanvasTarget();
const renderer = new DiapsonPaneRenderer({
getRenderData: () => ({
left: 10,
right: 90,
top: 20,
bottom: 80,
width: 80,
height: 60,
startPoint: {
x: 10,
y: 20,
},
endPoint: {
x: 90,
y: 80,
},
rangeMode: 'date',
showFill: true,
labelLines: [],
borderColor: '#ff0000',
fillColor: '#ff000033',
labelTextColor: '#ffffff',
labelBackgroundColor: '#000000',
fontSize: 10,
isBold: false,
isItalic: false,
}),
} as Diapson);
renderer.draw(target);
expect(context.fillRect).toHaveBeenCalledWith(20, 40, 160, 120);
expect(context.moveTo).toHaveBeenCalledWith(20, 40);
expect(context.lineTo).toHaveBeenCalledWith(20, 160);
expect(context.moveTo).toHaveBeenCalledWith(180, 40);
expect(context.lineTo).toHaveBeenCalledWith(180, 160);
expect(context.moveTo).toHaveBeenCalledWith(20, 100);
expect(context.lineTo).toHaveBeenCalledWith(180, 100);
});
it('должен рисовать price range', () => {
const { context, target } = createCanvasTarget();
const renderer = new DiapsonPaneRenderer({
getRenderData: () => ({
left: 10,
right: 90,
top: 20,
bottom: 80,
width: 80,
height: 60,
startPoint: {
x: 10,
y: 20,
},
endPoint: {
x: 90,
y: 80,
},
rangeMode: 'price',
showFill: true,
labelLines: [],
borderColor: '#ff0000',
fillColor: '#ff000033',
labelTextColor: '#ffffff',
labelBackgroundColor: '#000000',
fontSize: 10,
isBold: false,
isItalic: false,
}),
} as Diapson);
renderer.draw(target);
expect(context.moveTo).toHaveBeenCalledWith(20, 40);
expect(context.lineTo).toHaveBeenCalledWith(180, 40);
expect(context.moveTo).toHaveBeenCalledWith(20, 160);
expect(context.lineTo).toHaveBeenCalledWith(180, 160);
expect(context.moveTo).toHaveBeenCalledWith(100, 40);
expect(context.lineTo).toHaveBeenCalledWith(100, 160);
});
it('должен рисовать label lines', () => {
const { context, target } = createCanvasTarget();
const renderer = new DiapsonPaneRenderer({
getRenderData: () => ({
left: 10,
right: 90,
top: 20,
bottom: 80,
width: 80,
height: 60,
startPoint: {
x: 10,
y: 20,
},
endPoint: {
x: 90,
y: 80,
},
rangeMode: 'date',
showFill: true,
labelLines: ['First', 'Second'],
borderColor: '#ff0000',
fillColor: '#ff000033',
labelTextColor: '#ffffff',
labelBackgroundColor: '#000000',
fontSize: 10,
isBold: true,
isItalic: true,
}),
} as Diapson);
renderer.draw(target);
expect(context.fillText).toHaveBeenCalledTimes(2);
expect(context.fillText).toHaveBeenCalledWith('First', expect.any(Number), expect.any(Number));
expect(context.fillText).toHaveBeenCalledWith('Second', expect.any(Number), expect.any(Number));
expect(context.font).toContain('italic');
expect(context.font).toContain('700');
});
});
describe('DiapsonPaneView', () => {
it('должен возвращать renderer', () => {
const drawing = {
getRenderData: jest.fn(),
} as unknown as Diapson;
const view = new DiapsonPaneView(drawing);
expect(view.renderer()).toBeInstanceOf(DiapsonPaneRenderer);
});
it('должен иметь top zOrder', () => {
const drawing = {
getRenderData: jest.fn(),
} as unknown as Diapson;
const view = new DiapsonPaneView(drawing);
expect(view.zOrder()).toBe('top');
});
});
describe('Diapson settings', () => {
it('должен создавать настройки по умолчанию', () => {
expect(createDefaultSettings()).toEqual({
borderColor: '#ff9800',
fillColor: '#ff980033',
labelTextColor: '#ffffff',
labelBackgroundColor: '#333333',
fontSize: 10,
isBold: false,
isItalic: false,
});
});
it('должен создавать style и text tabs', () => {
const tabs = getDiapsonSettingsTabs(createDefaultSettings());
expect(tabs.map((tab) => tab.key)).toEqual([
'style',
'text',
]);
});
it('должен передавать текущие style значения', () => {
const settings = {
...createDefaultSettings(),
borderColor: '#111111',
fillColor: '#222222',
};
const styleTab = getDiapsonSettingsTabs(settings).find((tab) => tab.key === 'style');
expect(styleTab?.fields).toEqual([
expect.objectContaining({
key: 'borderColor',
defaultValue: '#111111',
}),
expect.objectContaining({
key: 'fillColor',
defaultValue: '#222222',
}),
]);
});
it('должен передавать текущие text значения', () => {
const settings = {
...createDefaultSettings(),
fontSize: 18,
isBold: true,
isItalic: true,
labelTextColor: '#123456',
labelBackgroundColor: '#654321',
};
const textTab = getDiapsonSettingsTabs(settings).find((tab) => tab.key === 'text');
expect(textTab?.fields).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'fontSize',
defaultValue: 18,
min: 8,
max: 24,
}),
expect.objectContaining({
key: 'isBold',
defaultValue: true,
}),
expect.objectContaining({
key: 'isItalic',
defaultValue: true,
}),
expect.objectContaining({
key: 'labelTextColor',
defaultValue: '#123456',
}),
expect.objectContaining({
key: 'labelBackgroundColor',
defaultValue: '#654321',
}),
]),
);
});
});