Загрузка данных
import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { SeriesApi } from '@core/Drawings/types';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type {
IChartApi,
IPrimitivePaneRenderer,
IPrimitivePaneView,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
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(),
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,
};
}
function getView(views: readonly IPrimitivePaneView[]): IPrimitivePaneView {
const [view] = views;
if (!view) {
throw new Error('Primitive pane view is not defined');
}
return view;
}
function getRenderer(views: readonly IPrimitivePaneView[]): IPrimitivePaneRenderer {
const renderer = getView(views).renderer();
if (!renderer) {
throw new Error('Primitive pane renderer is not defined');
}
return renderer;
}
describe('DrawingHandlesPrimitive', () => {
it('должен возвращать top zOrder для pane', () => {
const primitive = new DrawingHandlesPrimitive(() => []);
expect(getView(primitive.paneViews()).zOrder?.()).toBe('top');
});
it('должен возвращать top zOrder для time axis', () => {
const primitive = new DrawingHandlesPrimitive(() => []);
expect(getView(primitive.timeAxisPaneViews()).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('не должен определять time axis hit для handle выше границы pane', () => {
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,
},
]);
getRenderer(primitive.paneViews()).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',
},
]);
getRenderer(primitive.paneViews()).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,
},
]);
getRenderer(primitive.paneViews()).draw(target);
expect(context.strokeStyle).toBe('#ff0000');
expect(context.lineWidth).toBe(6);
});
it('не должен вызывать canvas при отсутствии handles', () => {
const { target, useBitmapCoordinateSpace } = createTarget();
const primitive = new DrawingHandlesPrimitive(() => []);
getRenderer(primitive.paneViews()).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);
getRenderer(primitive.timeAxisPaneViews()).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);
getRenderer(primitive.timeAxisPaneViews()).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 { Coordinate, IChartApi, Logical, 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 coordinateToTime = jest.fn((coordinate: Coordinate): Time | null => Number(coordinate) as Time);
const timeToCoordinate = jest.fn((time: Time): Coordinate | null => Number(time) as Coordinate);
const logicalToCoordinate = jest.fn((logical: Logical): Coordinate | null => {
return (Number(logical) * 10) as Coordinate;
});
const timeScale = {
coordinateToTime,
timeToCoordinate,
logicalToCoordinate,
};
const chart = {
timeScale: jest.fn(() => timeScale),
} as unknown as IChartApi;
return {
chart,
timeScale,
};
}
function createSeries(data: readonly { time: Time }[] = []) {
const coordinateToPrice = jest.fn((coordinate: Coordinate): number | null => Number(coordinate));
const priceToCoordinate = jest.fn((price: number): Coordinate | null => price as Coordinate);
const dataMock = jest.fn(() => data);
const series = {
coordinateToPrice,
priceToCoordinate,
data: dataMock,
} as unknown as SeriesApi;
return {
series,
coordinateToPrice,
priceToCoordinate,
dataMock,
};
}
describe('Drawing helpers', () => {
it('должен получать цену из y coordinate', () => {
const { series, coordinateToPrice } = createSeries();
expect(getPriceFromYCoordinate(series, 50)).toBe(50);
expect(coordinateToPrice).toHaveBeenCalledWith(50);
});
it('должен получать y coordinate из цены', () => {
const { series, priceToCoordinate } = createSeries();
expect(getYCoordinateFromPrice(series, 100)).toBe(100);
expect(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, если координата времени отсутствует', () => {
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 при отсутствии данных series', () => {
const { chart, timeScale } = createChart();
timeScale.timeToCoordinate.mockReturnValue(null);
const { series } = createSeries();
expect(getXCoordinateFromTime(chart, 30 as Time, series)).toBeNull();
});
it('должен возвращать null при невозможности спроецировать logical coordinate', () => {
const { chart, timeScale } = createChart();
timeScale.timeToCoordinate.mockReturnValue(null);
timeScale.logicalToCoordinate.mockReturnValue(null);
const { series } = createSeries([{ time: 10 as Time }]);
expect(getXCoordinateFromTime(chart, 10 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);
const event = {
clientX: 110,
clientY: 70,
} as unknown as MouseEvent;
expect(getRawPointerPoint(container, event)).toEqual({
x: 100,
y: 50,
});
});
it('должен ограничивать pointer point границами container', () => {
const container = createContainer(400, 300, 10, 20);
const event = {
clientX: 500,
clientY: -10,
} as unknown as MouseEvent;
expect(getPointerPoint(container, event)).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('должен учитывать границу tolerance при проверке близости', () => {
expect(isNearPoint({ x: 108, y: 108 }, 100, 100, 8)).toBe(true);
});
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, coordinateToPrice } = createSeries();
coordinateToPrice.mockReturnValueOnce(null);
expect(getPriceDelta(series, 20, 70)).toBe(0);
});
it('должен возвращать 0 price delta при отсутствии конечной цены', () => {
const { series, coordinateToPrice } = createSeries();
coordinateToPrice.mockReturnValueOnce(20).mockReturnValueOnce(null);
expect(getPriceDelta(series, 20, 70)).toBe(0);
});
it('должен вычислять диапазон цены в container', () => {
const container = createContainer(400, 300);
const { series, coordinateToPrice } = createSeries();
coordinateToPrice.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('должен возвращать null диапазона при отсутствии цены', () => {
const container = createContainer(400, 300);
const { series, coordinateToPrice } = createSeries();
coordinateToPrice.mockReturnValueOnce(null);
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, coordinateToPrice } = createSeries();
coordinateToPrice.mockReturnValue(null);
expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toBeNull();
});
});
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 { DiapsonRenderData, DiapsonRangeMode } from '@core/Drawings/diapson/diapson';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { DrawingInteraction } from '@core/Drawings/SeriesDrawingBase';
import type { AxisLabel, AxisSegment, SeriesApi } from '@core/Drawings/types';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type { Coordinate, IChartApi, Logical, 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 coordinateToTime = jest.fn((coordinate: Coordinate): Time | null => Number(coordinate) as Time);
const timeToCoordinate = jest.fn((time: Time): Coordinate | null => Number(time) as Coordinate);
const logicalToCoordinate = jest.fn((logical: Logical): Coordinate | null => Number(logical) as Coordinate);
const timeScale = {
coordinateToTime,
timeToCoordinate,
logicalToCoordinate,
};
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 coordinateToPrice = jest.fn((coordinate: Coordinate): number | null => Number(coordinate));
const priceToCoordinate = jest.fn((price: number): Coordinate | null => price as Coordinate);
const series = {
coordinateToPrice,
priceToCoordinate,
data: jest.fn(() => data),
attachPrimitive: jest.fn(),
detachPrimitive: jest.fn(),
getPane: jest.fn(() => pane),
} as unknown as SeriesApi;
return {
chart,
series,
container,
interaction,
timeScale,
coordinateToPrice,
priceToCoordinate,
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: DiapsonRangeMode = '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?: DiapsonRangeMode;
startTime?: number;
endTime?: number;
startPrice?: number;
endPrice?: number;
} = {},
) {
drawing.setState({
mode: 'ready',
rangeMode,
startTime: startTime as Time,
endTime: endTime as Time,
startPrice,
endPrice,
});
}
function createRendererDiapson(data: DiapsonRenderData | null): Diapson {
return {
getRenderData: () => data,
} as unknown as Diapson;
}
function createRenderData(
rangeMode: DiapsonRangeMode,
overrides: Partial<DiapsonRenderData> = {},
): DiapsonRenderData {
return {
left: 10,
right: 90,
top: 20,
bottom: 80,
width: 80,
height: 60,
startPoint: {
x: 10,
y: 20,
},
endPoint: {
x: 90,
y: 80,
},
rangeMode,
showFill: true,
labelLines: [],
borderColor: '#ff0000',
fillColor: '#ff000033',
labelTextColor: '#ffffff',
labelBackgroundColor: '#000000',
fontSize: 10,
isBold: false,
isItalic: false,
...overrides,
};
}
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();
setReady(drawing);
expect(drawing.getHandles()).toEqual([
{
id: 'end',
x: 150,
y: 150,
shape: 'circle',
size: 12,
borderWidth: 2,
},
{
id: 'start',
x: 50,
y: 50,
shape: 'circle',
size: 12,
borderWidth: 2,
},
]);
});
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(createRendererDiapson(null));
renderer.draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('должен рисовать date range', () => {
const { context, target } = createCanvasTarget();
const data = createRenderData('date');
const renderer = new DiapsonPaneRenderer(createRendererDiapson(data));
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 data = createRenderData('price');
const renderer = new DiapsonPaneRenderer(createRendererDiapson(data));
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 data = createRenderData('date', {
labelLines: ['First', 'Second'],
isBold: true,
isItalic: true,
});
const renderer = new DiapsonPaneRenderer(createRendererDiapson(data));
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');
});
it('не должен рисовать fill при showFill false', () => {
const { context, target } = createCanvasTarget();
const data = createRenderData('date', {
showFill: false,
});
const renderer = new DiapsonPaneRenderer(createRendererDiapson(data));
renderer.draw(target);
expect(context.fillRect).not.toHaveBeenCalled();
});
});
describe('DiapsonPaneView', () => {
it('должен возвращать renderer', () => {
const drawing = createRendererDiapson(null);
const view = new DiapsonPaneView(drawing);
expect(view.renderer()).toBeInstanceOf(DiapsonPaneRenderer);
});
it('должен иметь top zOrder', () => {
const drawing = createRendererDiapson(null);
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',
}),
]),
);
});
});