Загрузка данных
import { BehaviorSubject } from 'rxjs';
import type { BaseDrawingParams } from '@src/core/Drawings/DrawingBase';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type { Coordinate, Logical, Time } from 'lightweight-charts';
interface EnvironmentOptions {
selected?: boolean;
locked?: boolean;
seriesData?: readonly unknown[];
width?: number;
height?: number;
}
interface CanvasTargetOptions {
horizontalPixelRatio?: number;
verticalPixelRatio?: number;
bitmapWidth?: number;
bitmapHeight?: number;
}
export function createDrawingTestEnvironment({
selected = false,
locked = false,
seriesData = [],
width = 400,
height = 300,
}: EnvironmentOptions = {}) {
let selectedValue = selected;
let lockedValue = locked;
const selected$ = new BehaviorSubject(selected);
const locked$ = new BehaviorSubject(locked);
const interaction = {
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);
}),
} as unknown as BaseDrawingParams['interaction'];
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 coordinateToLogical = jest.fn((coordinate: Coordinate): Logical | null => Number(coordinate) as Logical);
const logicalToCoordinate = jest.fn((logical: Logical): Coordinate | null => Number(logical) as Coordinate);
const timeScale = {
coordinateToTime,
timeToCoordinate,
coordinateToLogical,
logicalToCoordinate,
getVisibleLogicalRange: jest.fn(() => ({
from: 0 as Logical,
to: Math.max(seriesData.length - 1, 0) as Logical,
})),
subscribeVisibleLogicalRangeChange: jest.fn(),
unsubscribeVisibleLogicalRangeChange: jest.fn(),
};
const pane = {
paneIndex: jest.fn(() => 0),
getHeight: jest.fn(() => height),
};
const chart = {
timeScale: jest.fn(() => timeScale),
applyOptions: jest.fn(),
panes: jest.fn(() => [pane]),
subscribeClick: jest.fn(),
unsubscribeClick: jest.fn(),
subscribeCrosshairMove: jest.fn(),
unsubscribeCrosshairMove: jest.fn(),
} as unknown as BaseDrawingParams['chart'];
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(() => seriesData),
attachPrimitive: jest.fn(),
detachPrimitive: jest.fn(),
getPane: jest.fn(() => pane),
} as unknown as BaseDrawingParams['series'];
return {
chart,
series,
container,
interaction,
timeScale,
pane,
coordinateToPrice,
priceToCoordinate,
setSelected(value: boolean) {
selectedValue = value;
selected$.next(value);
},
setLocked(value: boolean) {
lockedValue = value;
locked$.next(value);
},
};
}
export 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;
}
export function createMouseEvent(target: Node, x: number, y: number, detail = 1): MouseEvent {
return {
target,
clientX: x,
clientY: y,
detail,
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
} as unknown as MouseEvent;
}
export function createCanvasTarget({
horizontalPixelRatio = 2,
verticalPixelRatio = 2,
bitmapWidth = 800,
bitmapHeight = 600,
}: CanvasTargetOptions = {}) {
const context = {
canvas: {
width: bitmapWidth,
height: bitmapHeight,
},
save: jest.fn(),
restore: jest.fn(),
beginPath: jest.fn(),
closePath: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
quadraticCurveTo: jest.fn(),
arc: jest.fn(),
arcTo: jest.fn(),
rect: jest.fn(),
clip: jest.fn(),
fill: jest.fn(),
stroke: jest.fn(),
fillRect: jest.fn(),
strokeRect: jest.fn(),
fillText: jest.fn(),
setLineDash: jest.fn(),
translate: jest.fn(),
rotate: jest.fn(),
measureText: jest.fn((text: string) => ({
width: text.length * 8,
})),
fillStyle: '',
strokeStyle: '',
lineWidth: 0,
globalAlpha: 1,
font: '',
textAlign: 'start',
textBaseline: 'alphabetic',
};
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: bitmapWidth,
height: bitmapHeight,
},
});
},
);
return {
context,
useBitmapCoordinateSpace,
target: {
useBitmapCoordinateSpace,
} as unknown as CanvasRenderingTarget2D,
};
}
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 {
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 { 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 { AxisLine } from '@core/Drawings/axisLine/axisLine';
import { AxisLinePaneRenderer } from '@core/Drawings/axisLine/paneRenderer';
import { AxisLinePaneView } from '@core/Drawings/axisLine/paneView';
import { createDefaultSettings, getAxisLineSettingsTabs } from '@core/Drawings/axisLine/settings';
import {
createCanvasTarget,
createDrawingTestEnvironment,
createMouseEvent,
createPointerEvent,
} from '../drawingTestUtils';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartBackground: '#ffffff',
chartLineColor: '#3366ff',
chartPriceLineText: '#ffffff',
axisMarkerLabelFill: '#112233',
},
}),
}));
jest.mock('@src/types/defaults', () => ({
Defaults: {
dateFormat: 'date-format',
timeFormat: '24h',
showTime: true,
},
}));
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => String(value),
}));
jest.mock('@src/utils/formatter', () => ({
formatDate: () => 'formatted-date',
}));
jest.mock('@src/translations', () => ({
t: (value: string) => value,
}));
class TestAxisLine extends AxisLine {
public handles(): readonly DrawingHandle<'main'>[] {
return this.getDrawingHandles();
}
public hovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public timeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public priceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
public pointerDown(event: PointerEvent): void {
this.handlePointerDown(event);
}
public pointerMove(event: PointerEvent): void {
this.handlePointerMove(event);
}
public pointerUp(event: PointerEvent): void {
this.handlePointerUp(event);
}
public doubleClick(event: MouseEvent): void {
this.handleDoubleClick(event);
}
}
function createAxisLine(direction: 'vertical' | 'horizontal', selected = false) {
const env = createDrawingTestEnvironment({ selected });
const openSettings = jest.fn();
const drawing = new TestAxisLine({
...env,
direction,
openSettings,
});
return {
...env,
drawing,
openSettings,
};
}
describe('AxisLine', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('должен начинаться в idle', () => {
const { drawing } = createAxisLine('vertical');
expect(drawing.isCreationPending()).toBe(true);
expect(drawing.getState()).toMatchObject({
mode: 'idle',
time: null,
price: null,
});
});
it('должен создавать vertical line', () => {
const { drawing, container, timeScale } = createAxisLine('vertical');
drawing.pointerDown(createPointerEvent(container, 80, 120));
expect(timeScale.coordinateToTime).toHaveBeenCalledWith(80);
expect(drawing.getState()).toMatchObject({
mode: 'ready',
time: 80,
price: null,
});
});
it('должен создавать horizontal line', () => {
const { drawing, container, coordinateToPrice } = createAxisLine('horizontal');
drawing.pointerDown(createPointerEvent(container, 80, 120));
expect(coordinateToPrice).toHaveBeenCalledWith(120);
expect(drawing.getState()).toMatchObject({
mode: 'ready',
time: null,
price: 120,
});
});
it('не должен начинать drawing правой кнопкой', () => {
const { drawing, container } = createAxisLine('vertical');
drawing.pointerDown(createPointerEvent(container, 80, 120, 1, 2));
expect(drawing.getState()).toMatchObject({
mode: 'idle',
time: null,
});
});
it('должен восстанавливать state', () => {
const { drawing } = createAxisLine('horizontal');
drawing.setState({
hidden: true,
mode: 'ready',
price: 150,
settings: {
lineColor: '#ff0000',
text: 'Test',
},
});
expect(drawing.getState()).toMatchObject({
hidden: true,
mode: 'ready',
price: 150,
settings: {
lineColor: '#ff0000',
text: 'Test',
},
});
});
it('не должен возвращать render data для hidden line', () => {
const { drawing } = createAxisLine('horizontal');
drawing.setState({
hidden: true,
mode: 'ready',
price: 100,
});
expect(drawing.getRenderData()).toBeNull();
});
it('должен возвращать render data vertical line', () => {
const { drawing } = createAxisLine('vertical');
drawing.setState({
mode: 'ready',
time: 100 as Time,
});
expect(drawing.getRenderData()).toEqual({
direction: 'vertical',
coordinate: 100,
lineColor: '#3366ff',
text: '',
fontSize: 14,
isBold: false,
isItalic: false,
textColor: '#3366ff',
});
});
it('должен возвращать render data horizontal line', () => {
const { drawing } = createAxisLine('horizontal');
drawing.setState({
mode: 'ready',
price: 120,
});
expect(drawing.getRenderData()).toEqual({
direction: 'horizontal',
coordinate: 120,
lineColor: '#3366ff',
text: '',
fontSize: 14,
isBold: false,
isItalic: false,
textColor: '#3366ff',
});
});
it('должен размещать vertical handle по центру высоты', () => {
const { drawing } = createAxisLine('vertical');
drawing.setState({
mode: 'ready',
time: 80 as Time,
});
expect(drawing.handles()).toEqual([
{
id: 'main',
x: 80,
y: 150,
shape: 'rounded',
},
]);
});
it('должен размещать horizontal handle по центру ширины', () => {
const { drawing } = createAxisLine('horizontal');
drawing.setState({
mode: 'ready',
price: 120,
});
expect(drawing.handles()).toEqual([
{
id: 'main',
x: 200,
y: 120,
shape: 'rounded',
},
]);
});
it('должен возвращать time axis view только для vertical line', () => {
const { drawing } = createAxisLine('vertical');
expect(drawing.timeAxisViews()).toHaveLength(1);
expect(drawing.priceAxisViews()).toHaveLength(0);
});
it('должен возвращать price axis view только для horizontal line', () => {
const { drawing } = createAxisLine('horizontal');
expect(drawing.timeAxisViews()).toHaveLength(0);
expect(drawing.priceAxisViews()).toHaveLength(1);
});
it('должен возвращать time label для selected vertical line', () => {
const { drawing } = createAxisLine('vertical', true);
drawing.setState({
mode: 'ready',
time: 80 as Time,
});
expect(drawing.timeLabel('main')).toEqual({
coordinate: 80,
text: 'formatted-date',
textColor: '#ffffff',
backgroundColor: '#112233',
});
});
it('должен возвращать price label для selected horizontal line', () => {
const { drawing } = createAxisLine('horizontal', true);
drawing.setState({
mode: 'ready',
price: 120,
});
expect(drawing.priceLabel('main')).toEqual({
coordinate: 120,
text: '120',
textColor: '#ffffff',
backgroundColor: '#112233',
});
});
it('должен сохранять time label для unselected vertical line', () => {
const { drawing } = createAxisLine('vertical');
drawing.setState({
mode: 'ready',
time: 80 as Time,
});
expect(drawing.timeLabel('main')).toEqual({
coordinate: 80,
text: 'formatted-date',
textColor: '#ffffff',
backgroundColor: '#112233',
});
});
it('должен сохранять price label для unselected horizontal line', () => {
const { drawing } = createAxisLine('horizontal');
drawing.setState({
mode: 'ready',
price: 120,
});
expect(drawing.priceLabel('main')).toEqual({
coordinate: 120,
text: '120',
textColor: '#ffffff',
backgroundColor: '#112233',
});
});
it('не должен возвращать time label для hidden vertical line', () => {
const { drawing } = createAxisLine('vertical');
drawing.setState({
hidden: true,
mode: 'ready',
time: 80 as Time,
});
expect(drawing.timeLabel('main')).toBeNull();
});
it('не должен возвращать price label для hidden horizontal line', () => {
const { drawing } = createAxisLine('horizontal');
drawing.setState({
hidden: true,
mode: 'ready',
price: 120,
});
expect(drawing.priceLabel('main')).toBeNull();
});
it('не должен возвращать time label до завершения создания vertical line', () => {
const { drawing } = createAxisLine('vertical');
drawing.setState({
mode: 'idle',
time: 80 as Time,
});
expect(drawing.timeLabel('main')).toBeNull();
});
it('не должен возвращать price label до завершения создания horizontal line', () => {
const { drawing } = createAxisLine('horizontal');
drawing.setState({
mode: 'idle',
price: 120,
});
expect(drawing.priceLabel('main')).toBeNull();
});
it('не должен возвращать time label для horizontal line', () => {
const { drawing } = createAxisLine('horizontal');
drawing.setState({
mode: 'ready',
price: 120,
});
expect(drawing.timeLabel('main')).toBeNull();
});
it('не должен возвращать price label для vertical line', () => {
const { drawing } = createAxisLine('vertical');
drawing.setState({
mode: 'ready',
time: 80 as Time,
});
expect(drawing.priceLabel('main')).toBeNull();
});
it('не должен возвращать label для неизвестного kind', () => {
const { drawing } = createAxisLine('vertical');
drawing.setState({
mode: 'ready',
time: 80 as Time,
});
expect(drawing.timeLabel('unknown')).toBeNull();
expect(drawing.priceLabel('unknown')).toBeNull();
});
it('должен определять hover vertical line', () => {
const { drawing } = createAxisLine('vertical');
drawing.setState({
mode: 'ready',
time: 50 as Time,
});
expect(drawing.hovered(54, 20)).toEqual({
cursorStyle: 'ew-resize',
externalId: 'axis-line',
zOrder: 'top',
});
});
it('должен определять hover horizontal line', () => {
const { drawing } = createAxisLine('horizontal');
drawing.setState({
mode: 'ready',
price: 50,
});
expect(drawing.hovered(20, 54)).toEqual({
cursorStyle: 'ns-resize',
externalId: 'axis-line',
zOrder: 'top',
});
});
it('не должен определять hover вдали от line', () => {
const { drawing } = createAxisLine('vertical');
drawing.setState({
mode: 'ready',
time: 50 as Time,
});
expect(drawing.hovered(70, 20)).toBeNull();
});
it('должен выбирать unselected line', () => {
const { drawing, interaction, container } = createAxisLine('vertical');
drawing.setState({
mode: 'ready',
time: 50 as Time,
});
drawing.pointerDown(createPointerEvent(container, 50, 20));
expect(interaction.select).toHaveBeenCalledTimes(1);
});
it('должен перемещать vertical line', () => {
const { drawing, container } = createAxisLine('vertical', true);
drawing.setState({
mode: 'ready',
time: 50 as Time,
});
drawing.pointerDown(createPointerEvent(container, 50, 20, 1));
drawing.pointerMove(createPointerEvent(container, 90, 20, 1));
expect(drawing.getState()).toMatchObject({
mode: 'dragging',
time: 90,
});
drawing.pointerUp(createPointerEvent(container, 90, 20, 1));
expect(drawing.getState()).toMatchObject({
mode: 'ready',
time: 90,
});
});
it('должен перемещать horizontal line', () => {
const { drawing, container } = createAxisLine('horizontal', true);
drawing.setState({
mode: 'ready',
price: 50,
});
drawing.pointerDown(createPointerEvent(container, 20, 50, 1));
drawing.pointerMove(createPointerEvent(container, 20, 90, 1));
expect(drawing.getState()).toMatchObject({
mode: 'dragging',
price: 90,
});
});
it('не должен реагировать на другой pointerId', () => {
const { drawing, container } = createAxisLine('vertical', true);
drawing.setState({
mode: 'ready',
time: 50 as Time,
});
drawing.pointerDown(createPointerEvent(container, 50, 20, 1));
drawing.pointerMove(createPointerEvent(container, 90, 20, 2));
expect(drawing.getState()).toMatchObject({
time: 50,
});
});
it('должен открывать settings по double click', () => {
const { drawing, container, openSettings } = createAxisLine('vertical');
drawing.setState({
mode: 'ready',
time: 50 as Time,
});
drawing.doubleClick(createMouseEvent(container, 50, 20));
expect(openSettings).toHaveBeenCalledTimes(1);
});
it('должен обновлять settings через Base', () => {
const { drawing } = createAxisLine('vertical');
drawing.updateSettings({
lineColor: '#ff0000',
text: 'Updated',
});
expect(drawing.getSettings()).toMatchObject({
lineColor: '#ff0000',
text: 'Updated',
fontSize: 14,
});
});
});
describe('AxisLinePaneRenderer', () => {
it('не должен рисовать без render data', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
const renderer = new AxisLinePaneRenderer({
getRenderData: () => null,
} as unknown as AxisLine);
renderer.draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('должен рисовать vertical line', () => {
const { context, target } = createCanvasTarget({
bitmapWidth: 300,
bitmapHeight: 200,
});
const renderer = new AxisLinePaneRenderer({
getRenderData: () => ({
direction: 'vertical',
coordinate: 20,
lineColor: '#ff0000',
text: '',
fontSize: 14,
isBold: false,
isItalic: false,
textColor: '#ffffff',
}),
} as unknown as AxisLine);
renderer.draw(target);
expect(context.fillRect).toHaveBeenCalledWith(38, 0, 4, 200);
});
it('должен рисовать horizontal line', () => {
const { context, target } = createCanvasTarget({
bitmapWidth: 300,
bitmapHeight: 200,
});
const renderer = new AxisLinePaneRenderer({
getRenderData: () => ({
direction: 'horizontal',
coordinate: 30,
lineColor: '#ff0000',
text: '',
fontSize: 14,
isBold: false,
isItalic: false,
textColor: '#ffffff',
}),
} as unknown as AxisLine);
renderer.draw(target);
expect(context.fillRect).toHaveBeenCalledWith(0, 58, 300, 4);
});
it('должен рисовать vertical text с поворотом', () => {
const { context, target } = createCanvasTarget({
bitmapWidth: 300,
bitmapHeight: 200,
});
const renderer = new AxisLinePaneRenderer({
getRenderData: () => ({
direction: 'vertical',
coordinate: 20,
lineColor: '#ff0000',
text: 'Test',
fontSize: 14,
isBold: true,
isItalic: true,
textColor: '#ffffff',
}),
} as unknown as AxisLine);
renderer.draw(target);
expect(context.translate).toHaveBeenCalledWith(40, 100);
expect(context.rotate).toHaveBeenCalledWith(-Math.PI / 2);
expect(context.font).toContain('italic');
expect(context.font).toContain('700');
expect(context.fillText).toHaveBeenCalledWith('Test', 0, expect.any(Number));
});
});
describe('AxisLinePaneView', () => {
it('должен возвращать renderer и top zOrder', () => {
const view = new AxisLinePaneView({
getRenderData: jest.fn(),
} as unknown as AxisLine);
expect(view.renderer()).toBeInstanceOf(AxisLinePaneRenderer);
expect(view.zOrder()).toBe('top');
});
});
describe('AxisLine settings', () => {
it('должен создавать defaults', () => {
expect(createDefaultSettings()).toEqual({
lineColor: '#3366ff',
text: '',
fontSize: 14,
isBold: false,
isItalic: false,
textColor: '#3366ff',
});
});
it('должен создавать style и text tabs', () => {
expect(getAxisLineSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
});
});
import { CustomPriceAxisPaneView, CustomPriceAxisView } from '@core/Drawings/axis';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
function createTarget(verticalPixelRatio = 2) {
const context = {
save: jest.fn(),
restore: jest.fn(),
fillRect: jest.fn(),
fillStyle: '',
} as unknown as CanvasRenderingContext2D;
const useBitmapCoordinateSpace = jest.fn(
(
callback: (scope: {
context: CanvasRenderingContext2D;
horizontalPixelRatio: number;
verticalPixelRatio: number;
bitmapSize: { width: number; height: number };
}) => void,
) => {
callback({
context,
horizontalPixelRatio: 2,
verticalPixelRatio,
bitmapSize: { width: 300, height: 200 },
});
},
);
return {
context,
target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
useBitmapCoordinateSpace,
};
}
function createViewport(width = 400, height = 300): HTMLElement {
const viewport = document.createElement('div');
jest.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
width,
height,
x: 0,
y: 0,
top: 0,
left: 0,
right: width,
bottom: height,
toJSON: () => ({}),
});
return viewport;
}
describe('CustomPriceAxisPaneView', () => {
it('должен использовать bottom zOrder по умолчанию', () => {
const view = new CustomPriceAxisPaneView({
getAxisSegments: () => [],
});
expect(view.zOrder()).toBe('bottom');
});
it('должен использовать переданный zOrder', () => {
const view = new CustomPriceAxisPaneView({
getAxisSegments: () => [],
zOrder: 'top',
});
expect(view.zOrder()).toBe('top');
});
it('должен возвращать один экземпляр renderer', () => {
const view = new CustomPriceAxisPaneView({
getAxisSegments: () => [],
});
expect(view.renderer()).toBe(view.renderer());
});
it('не должен рисовать при отсутствии сегментов', () => {
const { target, useBitmapCoordinateSpace } = createTarget();
const view = new CustomPriceAxisPaneView({
getAxisSegments: () => [],
});
view.renderer().draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('должен рисовать сегмент по вертикали', () => {
const { context, target } = createTarget();
const view = new CustomPriceAxisPaneView({
getAxisSegments: () => [
{
from: 10,
to: 30,
color: '#ff0000',
},
],
});
view.renderer().draw(target);
expect(context.fillStyle).toBe('#ff0000');
expect(context.fillRect).toHaveBeenCalledWith(0, 20, 300, 40);
expect(context.save).toHaveBeenCalledTimes(1);
expect(context.restore).toHaveBeenCalledTimes(1);
});
it('должен корректно рисовать сегмент при обратном порядке координат', () => {
const { context, target } = createTarget();
const view = new CustomPriceAxisPaneView({
getAxisSegments: () => [
{
from: 30,
to: 10,
color: '#00ff00',
},
],
});
view.renderer().draw(target);
expect(context.fillRect).toHaveBeenCalledWith(0, 20, 300, 40);
});
it('должен рисовать несколько сегментов', () => {
const { context, target } = createTarget();
const view = new CustomPriceAxisPaneView({
getAxisSegments: () => [
{
from: 10,
to: 20,
color: '#ff0000',
},
{
from: 30,
to: 50,
color: '#00ff00',
},
],
});
view.renderer().draw(target);
expect(context.fillRect).toHaveBeenNthCalledWith(1, 0, 20, 300, 20);
expect(context.fillRect).toHaveBeenNthCalledWith(2, 0, 60, 300, 40);
});
});
describe('CustomPriceAxisView', () => {
it('должен возвращать данные label внутри viewport', () => {
const viewport = createViewport(50, 300);
const getAxisLabel = jest.fn(() => ({
coordinate: 125,
text: '100.50',
textColor: '#ffffff',
backgroundColor: '#000000',
}));
const view = new CustomPriceAxisView({
getAxisLabel,
labelKind: 'entry',
viewport,
});
expect(view.visible()).toBe(true);
expect(view.tickVisible()).toBe(false);
expect(view.coordinate()).toBe(125);
expect(view.text()).toBe('100.50');
expect(view.textColor()).toBe('#ffffff');
expect(view.backColor()).toBe('#000000');
expect(getAxisLabel).toHaveBeenCalledWith('entry');
});
it('должен возвращать значения по умолчанию при отсутствии label', () => {
const view = new CustomPriceAxisView({
getAxisLabel: () => null,
labelKind: 'entry',
viewport: createViewport(),
});
expect(view.visible()).toBe(false);
expect(view.tickVisible()).toBe(false);
expect(view.coordinate()).toBe(0);
expect(view.text()).toBe('');
expect(view.textColor()).toBe('');
expect(view.backColor()).toBe('');
});
it('не должен быть видимым при пустом тексте', () => {
const view = new CustomPriceAxisView({
getAxisLabel: () => ({
coordinate: 100,
text: '',
textColor: '#ffffff',
backgroundColor: '#000000',
}),
labelKind: 'entry',
viewport: createViewport(),
});
expect(view.visible()).toBe(false);
});
it.each([-1, 301])('не должен быть видимым вне viewport по вертикали: %s', (coordinate) => {
const view = new CustomPriceAxisView({
getAxisLabel: () => ({
coordinate,
text: '100.50',
textColor: '#ffffff',
backgroundColor: '#000000',
}),
labelKind: 'entry',
viewport: createViewport(400, 300),
});
expect(view.visible()).toBe(false);
});
it.each([0, 300])('должен быть видимым на границе viewport по вертикали: %s', (coordinate) => {
const view = new CustomPriceAxisView({
getAxisLabel: () => ({
coordinate,
text: '100.50',
textColor: '#ffffff',
backgroundColor: '#000000',
}),
labelKind: 'entry',
viewport: createViewport(400, 300),
});
expect(view.visible()).toBe(true);
});
it('не должен быть видимым при некорректной координате', () => {
const view = new CustomPriceAxisView({
getAxisLabel: () => ({
coordinate: Number.NaN,
text: '100.50',
textColor: '#ffffff',
backgroundColor: '#000000',
}),
labelKind: 'entry',
viewport: createViewport(),
});
expect(view.visible()).toBe(false);
});
});
import { CustomTimeAxisPaneView, CustomTimeAxisView } from '@core/Drawings/axis';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
function createTarget(horizontalPixelRatio = 2) {
const context = {
save: jest.fn(),
restore: jest.fn(),
fillRect: jest.fn(),
fillStyle: '',
} as unknown as CanvasRenderingContext2D;
const useBitmapCoordinateSpace = jest.fn(
(
callback: (scope: {
context: CanvasRenderingContext2D;
horizontalPixelRatio: number;
verticalPixelRatio: number;
bitmapSize: { width: number; height: number };
}) => void,
) => {
callback({
context,
horizontalPixelRatio,
verticalPixelRatio: 2,
bitmapSize: { width: 300, height: 200 },
});
},
);
return {
context,
target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
useBitmapCoordinateSpace,
};
}
function createViewport(width = 400, height = 300): HTMLElement {
const viewport = document.createElement('div');
jest.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
width,
height,
x: 0,
y: 0,
top: 0,
left: 0,
right: width,
bottom: height,
toJSON: () => ({}),
});
return viewport;
}
describe('CustomTimeAxisPaneView', () => {
it('должен использовать bottom zOrder по умолчанию', () => {
const view = new CustomTimeAxisPaneView({
getAxisSegments: () => [],
});
expect(view.zOrder()).toBe('bottom');
});
it('должен использовать переданный zOrder', () => {
const view = new CustomTimeAxisPaneView({
getAxisSegments: () => [],
zOrder: 'normal',
});
expect(view.zOrder()).toBe('normal');
});
it('должен возвращать один экземпляр renderer', () => {
const view = new CustomTimeAxisPaneView({
getAxisSegments: () => [],
});
expect(view.renderer()).toBe(view.renderer());
});
it('не должен рисовать при отсутствии сегментов', () => {
const { target, useBitmapCoordinateSpace } = createTarget();
const view = new CustomTimeAxisPaneView({
getAxisSegments: () => [],
});
view.renderer().draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('должен рисовать сегмент по горизонтали', () => {
const { context, target } = createTarget();
const view = new CustomTimeAxisPaneView({
getAxisSegments: () => [
{
from: 10,
to: 30,
color: '#ff0000',
},
],
});
view.renderer().draw(target);
expect(context.fillStyle).toBe('#ff0000');
expect(context.fillRect).toHaveBeenCalledWith(20, 0, 40, 200);
expect(context.save).toHaveBeenCalledTimes(1);
expect(context.restore).toHaveBeenCalledTimes(1);
});
it('должен корректно рисовать сегмент при обратном порядке координат', () => {
const { context, target } = createTarget();
const view = new CustomTimeAxisPaneView({
getAxisSegments: () => [
{
from: 30,
to: 10,
color: '#00ff00',
},
],
});
view.renderer().draw(target);
expect(context.fillRect).toHaveBeenCalledWith(20, 0, 40, 200);
});
it('должен рисовать несколько сегментов', () => {
const { context, target } = createTarget();
const view = new CustomTimeAxisPaneView({
getAxisSegments: () => [
{
from: 10,
to: 20,
color: '#ff0000',
},
{
from: 30,
to: 50,
color: '#00ff00',
},
],
});
view.renderer().draw(target);
expect(context.fillRect).toHaveBeenNthCalledWith(1, 20, 0, 20, 200);
expect(context.fillRect).toHaveBeenNthCalledWith(2, 60, 0, 40, 200);
});
});
describe('CustomTimeAxisView', () => {
it('должен возвращать данные label внутри viewport', () => {
const viewport = createViewport(300, 50);
const getAxisLabel = jest.fn(() => ({
coordinate: 150,
text: '09:30',
textColor: '#ffffff',
backgroundColor: '#000000',
}));
const view = new CustomTimeAxisView({
getAxisLabel,
labelKind: 'start',
viewport,
});
expect(view.visible()).toBe(true);
expect(view.tickVisible()).toBe(false);
expect(view.coordinate()).toBe(150);
expect(view.text()).toBe('09:30');
expect(view.textColor()).toBe('#ffffff');
expect(view.backColor()).toBe('#000000');
expect(getAxisLabel).toHaveBeenCalledWith('start');
});
it('должен возвращать значения по умолчанию при отсутствии label', () => {
const view = new CustomTimeAxisView({
getAxisLabel: () => null,
labelKind: 'start',
viewport: createViewport(),
});
expect(view.visible()).toBe(false);
expect(view.tickVisible()).toBe(false);
expect(view.coordinate()).toBe(0);
expect(view.text()).toBe('');
expect(view.textColor()).toBe('');
expect(view.backColor()).toBe('');
});
it('не должен быть видимым при пустом тексте', () => {
const view = new CustomTimeAxisView({
getAxisLabel: () => ({
coordinate: 150,
text: '',
textColor: '#ffffff',
backgroundColor: '#000000',
}),
labelKind: 'start',
viewport: createViewport(),
});
expect(view.visible()).toBe(false);
});
it.each([-1, 401])('не должен быть видимым вне viewport по горизонтали: %s', (coordinate) => {
const view = new CustomTimeAxisView({
getAxisLabel: () => ({
coordinate,
text: '09:30',
textColor: '#ffffff',
backgroundColor: '#000000',
}),
labelKind: 'start',
viewport: createViewport(400, 300),
});
expect(view.visible()).toBe(false);
});
it.each([0, 400])('должен быть видимым на границе viewport по горизонтали: %s', (coordinate) => {
const view = new CustomTimeAxisView({
getAxisLabel: () => ({
coordinate,
text: '09:30',
textColor: '#ffffff',
backgroundColor: '#000000',
}),
labelKind: 'start',
viewport: createViewport(400, 300),
});
expect(view.visible()).toBe(true);
});
it('не должен быть видимым при некорректной координате', () => {
const view = new CustomTimeAxisView({
getAxisLabel: () => ({
coordinate: Number.NaN,
text: '09:30',
textColor: '#ffffff',
backgroundColor: '#000000',
}),
labelKind: 'start',
viewport: createViewport(),
});
expect(view.visible()).toBe(false);
});
});
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 {
createCanvasTarget,
createDrawingTestEnvironment,
createMouseEvent,
createPointerEvent,
} from '../drawingTestUtils';
import type { DiapsonRangeMode, DiapsonRenderData } from '@core/Drawings/diapson/diapson';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { 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 handles(): readonly DrawingHandle[] {
return this.getDrawingHandles();
}
public timeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public timeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public priceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
public hovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public geometry() {
return this.getGeometry();
}
public pointerMove(event: PointerEvent): void {
this.handlePointerMove(event);
}
public pointerUp(event: PointerEvent): void {
this.handlePointerUp(event);
}
}
function createDiapson(
rangeMode: DiapsonRangeMode = 'date',
options: {
selected?: boolean;
data?: readonly Record<string, unknown>[];
stepSize?: number;
stepLabel?: string;
} = {},
) {
const env = createDrawingTestEnvironment({
selected: options.selected,
seriesData: options.data,
});
const removeSelf = jest.fn();
const openSettings = jest.fn();
const drawing = new TestDiapson({
...env,
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,
};
}
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({
mode: 'idle',
rangeMode: 'date',
startTime: null,
endTime: null,
startPrice: null,
endPrice: null,
});
});
it('должен создавать диапазон двумя 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,
});
});
it('должен удалять слишком маленький диапазон', () => {
const { drawing, container, removeSelf } = createDiapson();
drawing.pointerDown(createPointerEvent(container, 50, 50));
drawing.pointerDown(createPointerEvent(container, 53, 53));
expect(removeSelf).toHaveBeenCalledTimes(1);
});
it('должен менять rangeMode со сбросом drawing', () => {
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('не должен сбрасываться при текущем rangeMode', () => {
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.geometry()).toEqual({
left: 50,
right: 150,
top: 60,
bottom: 160,
width: 100,
height: 100,
startPoint: { x: 150, y: 160 },
endPoint: { x: 50, y: 60 },
});
});
it('должен возвращать end и start handles', () => {
const { drawing } = createDiapson();
setReady(drawing);
expect(drawing.handles()).toEqual([
{
id: 'end',
x: 150,
y: 150,
shape: 'circle',
},
{
id: 'start',
x: 50,
y: 50,
shape: 'circle',
},
]);
});
it('должен возвращать render data', () => {
const { drawing } = createDiapson();
setReady(drawing);
expect(drawing.getRenderData()).toMatchObject({
left: 50,
right: 150,
top: 50,
bottom: 150,
rangeMode: 'date',
borderColor: '#ff9800',
fillColor: '#ff980033',
});
});
it('должен возвращать axis segments selected drawing', () => {
const { drawing } = createDiapson('date', { selected: true });
setReady(drawing);
expect(drawing.timeSegments()).toEqual([
{
from: 50,
to: 150,
color: '#2962ff44',
},
]);
expect(drawing.priceSegments()).toEqual([
{
from: 50,
to: 150,
color: '#2962ff44',
},
]);
});
it('не должен возвращать axis segments unselected drawing', () => {
const { drawing } = createDiapson();
setReady(drawing);
expect(drawing.timeSegments()).toEqual([]);
expect(drawing.priceSegments()).toEqual([]);
});
it('должен возвращать axis labels', () => {
const { drawing } = createDiapson('date', { selected: true });
setReady(drawing);
expect(drawing.timeLabel('left')).toMatchObject({
coordinate: 50,
text: 'date:50',
});
expect(drawing.timeLabel('right')).toMatchObject({
coordinate: 150,
text: 'date:150',
});
expect(drawing.priceLabel('top')).toMatchObject({
coordinate: 50,
text: 'price:50',
});
expect(drawing.priceLabel('bottom')).toMatchObject({
coordinate: 150,
text: 'price:150',
});
});
it('должен возвращать move для body', () => {
const { drawing } = createDiapson();
setReady(drawing);
expect(drawing.hovered(100, 100)?.cursorStyle).toBe('move');
});
it('должен возвращать resize для selected handle', () => {
const { drawing } = createDiapson('date', { selected: true });
setReady(drawing);
expect(drawing.hovered(50, 50)?.cursorStyle).toBe('nwse-resize');
});
it('должен выбирать диапазон первым нажатием', () => {
const { drawing, container, interaction } = createDiapson();
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 100, 100));
expect(interaction.select).toHaveBeenCalledTimes(1);
});
it('должен перемещать весь диапазон', () => {
const { drawing, container } = createDiapson('date', { selected: true });
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 100, 100, 1));
drawing.pointerMove(createPointerEvent(container, 120, 130, 1));
expect(drawing.getState()).toMatchObject({
startTime: 70,
endTime: 170,
startPrice: 80,
endPrice: 180,
});
drawing.pointerUp(createPointerEvent(container, 120, 130, 1));
expect(drawing.getState().mode).toBe('ready');
});
it('должен resize start', () => {
const { drawing, container } = createDiapson('date', { selected: true });
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 50, 50, 1));
drawing.pointerMove(createPointerEvent(container, 70, 80, 1));
drawing.pointerUp(createPointerEvent(container, 70, 80, 1));
expect(drawing.getState()).toMatchObject({
startTime: 70,
startPrice: 80,
endTime: 150,
endPrice: 150,
});
});
it('должен resize end', () => {
const { drawing, container } = createDiapson('date', { selected: true });
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 150, 150, 1));
drawing.pointerMove(createPointerEvent(container, 180, 190, 1));
expect(drawing.getState()).toMatchObject({
startTime: 50,
startPrice: 50,
endTime: 180,
endPrice: 190,
});
});
it('должен открывать settings', () => {
const { drawing, container, openSettings } = createDiapson();
setReady(drawing);
drawing.doubleClick(createMouseEvent(container, 100, 100));
expect(openSettings).toHaveBeenCalledTimes(1);
});
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('должен формировать date metrics', () => {
const { drawing } = createDiapson('date', {
data: [
{ time: 1000, volume: 10 },
{ time: 1060, volume: 20 },
{ time: 1120, volume: 30 },
],
});
setReady(drawing, {
startTime: 1000,
endTime: 1120,
});
expect(drawing.getRenderData()?.labelLines).toEqual(['2 bars, 2m', 'Vol volume:60']);
});
it('не должен возвращать render data hidden drawing', () => {
const { drawing } = createDiapson();
setReady(drawing);
drawing.hide();
expect(drawing.getRenderData()).toBeNull();
});
});
describe('DiapsonPaneRenderer', () => {
it('не должен рисовать без data', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
new DiapsonPaneRenderer(createRendererDiapson(null)).draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('должен рисовать date range', () => {
const { target, context } = createCanvasTarget();
const data = createRenderData('date');
new DiapsonPaneRenderer(createRendererDiapson(data)).draw(target);
expect(context.fillRect).toHaveBeenCalledWith(20, 40, 160, 120);
expect(context.stroke).toHaveBeenCalled();
});
it('должен рисовать price range', () => {
const { target, context } = createCanvasTarget();
const data = createRenderData('price');
new DiapsonPaneRenderer(createRendererDiapson(data)).draw(target);
expect(context.moveTo).toHaveBeenCalledWith(20, 40);
expect(context.lineTo).toHaveBeenCalledWith(180, 40);
});
it('должен рисовать labels', () => {
const { target, context } = createCanvasTarget();
const data = createRenderData('date', {
labelLines: ['First', 'Second'],
isBold: true,
isItalic: true,
});
new DiapsonPaneRenderer(createRendererDiapson(data)).draw(target);
expect(context.fillText).toHaveBeenCalledTimes(2);
expect(context.font).toContain('italic');
expect(context.font).toContain('700');
});
});
describe('DiapsonPaneView', () => {
it('должен возвращать renderer и top', () => {
const view = new DiapsonPaneView(createRendererDiapson(null));
expect(view.renderer()).toBeInstanceOf(DiapsonPaneRenderer);
expect(view.zOrder()).toBe('top');
});
});
describe('Diapson settings', () => {
it('должен создавать defaults', () => {
expect(createDefaultSettings()).toEqual({
borderColor: '#ff9800',
fillColor: '#ff980033',
labelTextColor: '#ffffff',
labelBackgroundColor: '#333333',
fontSize: 10,
isBold: false,
isItalic: false,
});
});
it('должен создавать style и text tabs', () => {
expect(getDiapsonSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
});
});
import { FibonacciRetracement } from '@core/Drawings/fibonacciRetracement/fibonacciRetracement';
import { FibonacciRetracementPaneRenderer } from '@core/Drawings/fibonacciRetracement/paneRenderer';
import { FibonacciRetracementPaneView } from '@core/Drawings/fibonacciRetracement/paneView';
import {
cloneFibonacciRetracementSettings,
createDefaultSettings,
formatLevelLabel,
getFibonacciRetracementSettingsTabs,
getFibonacciRetracementSettingsValues,
getVisibleFibonacciLevels,
mergeFibonacciRetracementSettings,
} from '@core/Drawings/fibonacciRetracement/settings';
import {
createCanvasTarget,
createDrawingTestEnvironment,
createMouseEvent,
createPointerEvent,
} from '../drawingTestUtils';
import type { FibonacciRetracementRenderData } from '@core/Drawings/fibonacciRetracement/fibonacciRetracement';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartBackground: '#101010',
chartLineColor: '#2962ff',
chartCrosshairLine: '#999999',
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', () => ({
formatPrice: (value: number) => `price:${value}`,
}));
jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
class TestFibonacciRetracement extends FibonacciRetracement {
public handles(): readonly DrawingHandle[] {
return this.getDrawingHandles();
}
public timeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public timeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public priceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
public hovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public pointerMove(event: PointerEvent): void {
this.handlePointerMove(event);
}
public pointerUp(event: PointerEvent): void {
this.handlePointerUp(event);
}
}
function createDrawing(selected = false) {
const env = createDrawingTestEnvironment({ selected });
const removeSelf = jest.fn();
const openSettings = jest.fn();
const drawing = new TestFibonacciRetracement({
...env,
removeSelf,
openSettings,
});
return {
...env,
drawing,
removeSelf,
openSettings,
};
}
function setReady(drawing: TestFibonacciRetracement): void {
drawing.setState({
mode: 'ready',
startTime: 50 as Time,
endTime: 150 as Time,
startPrice: 50,
endPrice: 150,
});
}
function createRendererDrawing(data: FibonacciRetracementRenderData | null): FibonacciRetracement {
return {
getRenderData: () => data,
} as unknown as FibonacciRetracement;
}
describe('FibonacciRetracement', () => {
it('должен прикреплять primitive', () => {
const { drawing, series } = createDrawing();
expect(series.attachPrimitive).toHaveBeenCalledWith(drawing);
});
it('должен начинаться в idle', () => {
const { drawing } = createDrawing();
expect(drawing.isCreationPending()).toBe(true);
expect(drawing.getState()).toMatchObject({
mode: 'idle',
startTime: null,
endTime: null,
startPrice: null,
endPrice: null,
});
});
it('должен создаваться двумя pointerDown', () => {
const { drawing, container } = createDrawing();
drawing.pointerDown(createPointerEvent(container, 50, 50));
drawing.pointerDown(createPointerEvent(container, 150, 150));
expect(drawing.getState()).toMatchObject({
mode: 'ready',
startTime: 50,
endTime: 150,
startPrice: 50,
endPrice: 150,
});
});
it('должен удалять слишком маленький retracement', () => {
const { drawing, container, removeSelf } = createDrawing();
drawing.pointerDown(createPointerEvent(container, 50, 50));
drawing.pointerDown(createPointerEvent(container, 53, 53));
expect(removeSelf).toHaveBeenCalledTimes(1);
});
it('должен возвращать end и start handles', () => {
const { drawing } = createDrawing();
setReady(drawing);
expect(drawing.handles()).toEqual([
{
id: 'end',
x: 150,
y: 150,
},
{
id: 'start',
x: 50,
y: 50,
},
]);
});
it('должен рассчитывать levels', () => {
const { drawing } = createDrawing();
setReady(drawing);
const data = drawing.getRenderData();
expect(data?.levels).toHaveLength(11);
expect(data?.levels.find(({ id }) => id === '0')?.price).toBe(150);
expect(data?.levels.find(({ id }) => id === '1')?.price).toBe(50);
expect(data?.backgroundOpacity).toBe(0.18);
});
it('должен учитывать reverse', () => {
const { drawing } = createDrawing();
setReady(drawing);
drawing.updateSettings({ reverse: true });
const data = drawing.getRenderData();
expect(data?.levels.find(({ id }) => id === '0')?.price).toBe(50);
expect(data?.levels.find(({ id }) => id === '1')?.price).toBe(150);
});
it('должен возвращать selected axis segments', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.timeSegments()).toEqual([
{
from: 50,
to: 150,
color: '#2962ff44',
},
]);
expect(drawing.priceSegments()).toHaveLength(1);
});
it('не должен возвращать axis segments unselected drawing', () => {
const { drawing } = createDrawing();
setReady(drawing);
expect(drawing.timeSegments()).toEqual([]);
expect(drawing.priceSegments()).toEqual([]);
});
it('должен возвращать axis labels', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.timeLabel('start')).toMatchObject({
coordinate: 50,
text: 'date:50',
});
expect(drawing.timeLabel('end')).toMatchObject({
coordinate: 150,
text: 'date:150',
});
});
it('должен возвращать pointer для selected handle', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.hovered(50, 50)).toEqual({
cursorStyle: 'pointer',
externalId: 'fibonacci-retracement-position',
zOrder: 'top',
});
});
it('должен выбирать unselected retracement', () => {
const { drawing, interaction, container } = createDrawing();
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 100, 100));
expect(interaction.select).toHaveBeenCalledTimes(1);
});
it('должен перемещать start handle', () => {
const { drawing, container } = createDrawing(true);
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 50, 50, 1));
drawing.pointerMove(createPointerEvent(container, 70, 80, 1));
drawing.pointerUp(createPointerEvent(container, 70, 80, 1));
expect(drawing.getState()).toMatchObject({
startTime: 70,
startPrice: 80,
endTime: 150,
endPrice: 150,
});
});
it('должен перемещать end handle', () => {
const { drawing, container } = createDrawing(true);
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 150, 150, 1));
drawing.pointerMove(createPointerEvent(container, 180, 170, 1));
expect(drawing.getState()).toMatchObject({
endTime: 180,
endPrice: 170,
});
});
it('должен открывать settings', () => {
const { drawing, container, openSettings } = createDrawing();
setReady(drawing);
drawing.doubleClick(createMouseEvent(container, 100, 100));
expect(openSettings).toHaveBeenCalledTimes(1);
});
it('не должен возвращать render data hidden drawing', () => {
const { drawing } = createDrawing();
setReady(drawing);
drawing.hide();
expect(drawing.getRenderData()).toBeNull();
});
});
describe('FibonacciRetracementPaneRenderer', () => {
it('не должен рисовать без data', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
new FibonacciRetracementPaneRenderer(createRendererDrawing(null)).draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('должен рисовать levels и background', () => {
const data: FibonacciRetracementRenderData = {
startPoint: { x: 10, y: 20 },
endPoint: { x: 90, y: 80 },
left: 10,
right: 90,
top: 20,
bottom: 80,
width: 80,
height: 60,
levels: [
{
id: '0',
value: 0,
price: 100,
y: 20,
color: '#ff0000',
text: '0 (100)',
},
{
id: '1',
value: 1,
price: 50,
y: 80,
color: '#00ff00',
text: '1 (50)',
},
],
areas: [
{
top: 20,
bottom: 80,
color: '#00ff00',
},
],
showBackground: true,
backgroundOpacity: 0.18,
reverse: false,
labelsPosition: 'right',
showPrices: true,
showLevelValues: true,
fontSize: 12,
isBold: false,
isItalic: false,
};
const { target, context } = createCanvasTarget();
new FibonacciRetracementPaneRenderer(createRendererDrawing(data)).draw(target);
expect(context.fillRect).toHaveBeenCalled();
expect(context.stroke).toHaveBeenCalled();
expect(context.fillText).toHaveBeenCalledTimes(2);
});
});
describe('FibonacciRetracementPaneView', () => {
it('должен возвращать renderer и top', () => {
const view = new FibonacciRetracementPaneView(createRendererDrawing(null));
expect(view.renderer()).toBeInstanceOf(FibonacciRetracementPaneRenderer);
expect(view.zOrder()).toBe('top');
});
});
describe('FibonacciRetracement settings', () => {
it('должен создавать независимые defaults', () => {
const first = createDefaultSettings();
const second = createDefaultSettings();
first.levels[0].visible = false;
expect(second.levels[0].visible).toBe(true);
});
it('должен deep clone settings', () => {
const settings = createDefaultSettings();
const clone = cloneFibonacciRetracementSettings(settings);
clone.levels[0].color = '#000000';
expect(settings.levels[0].color).not.toBe('#000000');
});
it('должен merge level settings', () => {
const result = mergeFibonacciRetracementSettings(createDefaultSettings(), {
reverse: true,
level_0_236_visible: false,
level_0_236_color: '#123456',
});
const level = result.levels.find(({ id }) => id === '0.236');
expect(result.reverse).toBe(true);
expect(level?.visible).toBe(false);
expect(level?.color).toBe('#123456');
});
it('должен возвращать только visible levels', () => {
const settings = createDefaultSettings();
settings.levels[0].visible = false;
expect(getVisibleFibonacciLevels(settings)).toHaveLength(settings.levels.length - 1);
});
it('должен форматировать level label', () => {
expect(formatLevelLabel(0.618)).toBe('0,618');
expect(formatLevelLabel(1)).toBe('1');
});
it('должен создавать style, text и levels tabs', () => {
expect(getFibonacciRetracementSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual([
'style',
'text',
'levels',
]);
});
it('должен преобразовывать settings values', () => {
expect(getFibonacciRetracementSettingsValues(createDefaultSettings())).toMatchObject({
showBackground: true,
backgroundOpacity: 18,
reverse: false,
});
});
});
import { LineDrawing } from '@core/Drawings/line/lineDrawing';
import { LineDrawingPaneRenderer } from '@core/Drawings/line/paneRenderer';
import { LineDrawingPaneView } from '@core/Drawings/line/paneView';
import { createDefaultSettings, getLineDrawingSettingsTabs } from '@core/Drawings/line/settings';
import { LineMarker } from '@src/types';
import {
createCanvasTarget,
createDrawingTestEnvironment,
createMouseEvent,
createPointerEvent,
} from '../drawingTestUtils';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { LineDrawingRenderData } from '@core/Drawings/line/lineDrawing';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartLineColor: '#2962ff',
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', () => ({
formatPrice: (value: number) => `price:${value}`,
}));
jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
class TestLineDrawing extends LineDrawing {
public handles(): readonly DrawingHandle[] {
return this.getDrawingHandles();
}
public timeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public timeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public priceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
public hovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public pointerMove(event: PointerEvent): void {
this.handlePointerMove(event);
}
public pointerUp(event: PointerEvent): void {
this.handlePointerUp(event);
}
}
function createDrawing(selected = false) {
const env = createDrawingTestEnvironment({ selected });
const removeSelf = jest.fn();
const openSettings = jest.fn();
const drawing = new TestLineDrawing({
...env,
removeSelf,
openSettings,
});
return {
...env,
drawing,
removeSelf,
openSettings,
};
}
function setReady(drawing: TestLineDrawing): void {
drawing.setState({
mode: 'ready',
startAnchor: {
time: 50 as Time,
price: 50,
},
endAnchor: {
time: 150 as Time,
price: 150,
},
});
}
function createRendererDrawing(data: LineDrawingRenderData | null): LineDrawing {
return {
getRenderData: () => data,
} as unknown as LineDrawing;
}
describe('LineDrawing', () => {
it('должен прикреплять primitive', () => {
const { drawing, series } = createDrawing();
expect(series.attachPrimitive).toHaveBeenCalledWith(drawing);
});
it('должен создаваться двумя pointerDown', () => {
const { drawing, container } = createDrawing();
drawing.pointerDown(createPointerEvent(container, 50, 50));
drawing.pointerDown(createPointerEvent(container, 150, 150));
expect(drawing.getState()).toMatchObject({
mode: 'ready',
startAnchor: { time: 50, price: 50 },
endAnchor: { time: 150, price: 150 },
});
});
it('должен удалять слишком короткую line', () => {
const { drawing, container, removeSelf } = createDrawing();
drawing.pointerDown(createPointerEvent(container, 50, 50));
drawing.pointerDown(createPointerEvent(container, 52, 52));
expect(removeSelf).toHaveBeenCalledTimes(1);
});
it('должен возвращать два handles', () => {
const { drawing } = createDrawing();
setReady(drawing);
expect(drawing.handles()).toEqual([
{
id: 'start',
x: 50,
y: 50,
shape: 'circle',
},
{
id: 'end',
x: 150,
y: 150,
shape: 'circle',
},
]);
});
it('должен возвращать render data', () => {
const { drawing } = createDrawing();
setReady(drawing);
expect(drawing.getRenderData()).toMatchObject({
startPoint: { x: 50, y: 50 },
endPoint: { x: 150, y: 150 },
lineColor: '#2962ff',
fontSize: 14,
});
});
it('должен возвращать selected axis segments', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.timeSegments()).toEqual([
{
from: 50,
to: 150,
color: '#2962ff44',
},
]);
expect(drawing.priceSegments()).toEqual([
{
from: 50,
to: 150,
color: '#2962ff44',
},
]);
});
it('должен возвращать axis labels', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.timeLabel('start')).toMatchObject({
coordinate: 50,
text: 'date:50',
});
expect(drawing.priceLabel('end')).toMatchObject({
coordinate: 150,
text: 'price:150',
});
});
it('должен определять handles и body', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.hovered(50, 50)?.cursorStyle).toBe('move');
expect(drawing.hovered(100, 100)?.cursorStyle).toBe('grab');
expect(drawing.hovered(300, 100)).toBeNull();
});
it('должен выбирать unselected line', () => {
const { drawing, container, interaction } = createDrawing();
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 100, 100));
expect(interaction.select).toHaveBeenCalledTimes(1);
});
it('должен перемещать start handle', () => {
const { drawing, container } = createDrawing(true);
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 50, 50));
drawing.pointerMove(createPointerEvent(container, 70, 80));
drawing.pointerUp(createPointerEvent(container, 70, 80));
expect(drawing.getState()).toMatchObject({
startAnchor: {
time: 70,
price: 80,
},
endAnchor: {
time: 150,
price: 150,
},
});
});
it('должен перемещать end handle', () => {
const { drawing, container } = createDrawing(true);
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 150, 150));
drawing.pointerMove(createPointerEvent(container, 170, 180));
expect(drawing.getState()).toMatchObject({
endAnchor: {
time: 170,
price: 180,
},
});
});
it('должен перемещать всю line', () => {
const { drawing, container } = createDrawing(true);
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 100, 100));
drawing.pointerMove(createPointerEvent(container, 120, 130));
expect(drawing.getState()).toMatchObject({
startAnchor: {
time: 70,
price: 80,
},
endAnchor: {
time: 170,
price: 180,
},
});
});
it('должен открывать settings', () => {
const { drawing, container, openSettings } = createDrawing();
setReady(drawing);
drawing.doubleClick(createMouseEvent(container, 100, 100));
expect(openSettings).toHaveBeenCalledTimes(1);
});
it('не должен возвращать render data hidden drawing', () => {
const { drawing } = createDrawing();
setReady(drawing);
drawing.hide();
expect(drawing.getRenderData()).toBeNull();
});
});
describe('LineDrawingPaneRenderer', () => {
it('не должен рисовать без data', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
new LineDrawingPaneRenderer(createRendererDrawing(null)).draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('должен рисовать line и text', () => {
const data: LineDrawingRenderData = {
startPoint: { x: 10, y: 20 },
endPoint: { x: 90, y: 80 },
left: 10,
right: 90,
top: 20,
bottom: 80,
lineColor: '#ff0000',
startMarker: LineMarker.arrow,
endMarker: LineMarker.arrow,
fontSize: 14,
text: 'Test',
isBold: true,
isItalic: true,
textColor: '#ffffff',
};
const { target, context } = createCanvasTarget();
new LineDrawingPaneRenderer(createRendererDrawing(data)).draw(target);
expect(context.stroke).toHaveBeenCalled();
expect(context.fillText).toHaveBeenCalled();
expect(context.translate).toHaveBeenCalled();
});
});
describe('LineDrawingPaneView', () => {
it('должен возвращать renderer и top', () => {
const view = new LineDrawingPaneView(createRendererDrawing(null));
expect(view.renderer()).toBeInstanceOf(LineDrawingPaneRenderer);
expect(view.zOrder()).toBe('top');
});
});
describe('LineDrawing settings', () => {
it('должен создавать defaults', () => {
expect(createDefaultSettings()).toEqual({
startMarker: LineMarker.normal,
endMarker: LineMarker.normal,
lineColor: '#2962ff',
fontSize: 14,
text: '',
isBold: false,
isItalic: false,
textColor: '#2962ff',
});
});
it('должен применять default marker override', () => {
expect(
createDefaultSettings({
startMarker: LineMarker.arrow,
}),
).toMatchObject({
startMarker: LineMarker.arrow,
endMarker: LineMarker.normal,
});
});
it('должен создавать style и text tabs', () => {
expect(getLineDrawingSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
});
});
import { ParallelChannelPaneRenderer } from '@core/Drawings/parallelChannel/paneRenderer';
import { ParallelChannelPaneView } from '@core/Drawings/parallelChannel/paneView';
import { ParallelChannel } from '@core/Drawings/parallelChannel/parallelChannel';
import { createDefaultSettings, getParallelChannelSettingsTabs } from '@core/Drawings/parallelChannel/settings';
import {
createCanvasTarget,
createDrawingTestEnvironment,
createMouseEvent,
createPointerEvent,
} from '../drawingTestUtils';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { ParallelChannelRenderData } from '@core/Drawings/parallelChannel/parallelChannel';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartLineColor: '#2962ff',
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', () => ({
formatPrice: (value: number) => `price:${value}`,
}));
jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
class TestParallelChannel extends ParallelChannel {
public handles(): readonly DrawingHandle[] {
return this.getDrawingHandles();
}
public timeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public timeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public priceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
public hovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public pointerMove(event: PointerEvent): void {
this.handlePointerMove(event);
}
public pointerUp(event: PointerEvent): void {
this.handlePointerUp(event);
}
}
function createDrawing(selected = false) {
const env = createDrawingTestEnvironment({ selected });
const openSettings = jest.fn();
const drawing = new TestParallelChannel({
...env,
openSettings,
});
return {
...env,
drawing,
openSettings,
};
}
function setReady(drawing: TestParallelChannel): void {
drawing.setState({
mode: 'ready',
startAnchor: {
time: 50 as Time,
price: 50,
},
endAnchor: {
time: 150 as Time,
price: 80,
},
priceOffset: 60,
});
}
function createRendererDrawing(data: ParallelChannelRenderData | null): ParallelChannel {
return {
getRenderData: () => data,
} as unknown as ParallelChannel;
}
describe('ParallelChannel', () => {
it('должен создаваться за три этапа', () => {
const { drawing, container } = createDrawing();
drawing.pointerDown(createPointerEvent(container, 50, 50));
expect(drawing.getState().mode).toBe('drawing-line');
drawing.pointerDown(createPointerEvent(container, 150, 80));
expect(drawing.getState().mode).toBe('drawing-channel');
drawing.pointerDown(createPointerEvent(container, 100, 130));
expect(drawing.getState().mode).toBe('ready');
});
it('должен восстанавливать dragging как ready', () => {
const { drawing } = createDrawing();
drawing.setState({
mode: 'dragging',
});
expect(drawing.getState().mode).toBe('ready');
});
it('должен возвращать шесть handles', () => {
const { drawing } = createDrawing();
setReady(drawing);
expect(drawing.handles()).toEqual([
{
id: 'parallel-end',
x: 150,
y: 140,
shape: 'circle',
},
{
id: 'parallel-middle',
x: 100,
y: 125,
shape: 'circle',
},
{
id: 'parallel-start',
x: 50,
y: 110,
shape: 'circle',
},
{
id: 'main-end',
x: 150,
y: 80,
shape: 'circle',
},
{
id: 'main-middle',
x: 100,
y: 65,
shape: 'circle',
},
{
id: 'main-start',
x: 50,
y: 50,
shape: 'circle',
},
]);
});
it('должен возвращать selected axis segments', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.timeSegments()).toEqual([
{
from: 50,
to: 150,
color: '#2962ff44',
},
]);
expect(drawing.priceSegments()).toEqual([
{
from: 50,
to: 140,
color: '#2962ff44',
},
]);
});
it('должен возвращать time labels', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.timeLabel('start')).toMatchObject({
coordinate: 50,
text: 'date:50',
});
expect(drawing.timeLabel('end')).toMatchObject({
coordinate: 150,
text: 'date:150',
});
});
it('должен возвращать price labels', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.priceLabel('main-start')?.text).toBe('price:50');
expect(drawing.priceLabel('main-end')?.text).toBe('price:80');
expect(drawing.priceLabel('parallel-start')?.text).toBe('price:110');
expect(drawing.priceLabel('parallel-end')?.text).toBe('price:140');
});
it('должен возвращать pointer unselected channel', () => {
const { drawing } = createDrawing();
setReady(drawing);
expect(drawing.hovered(100, 90)?.cursorStyle).toBe('pointer');
});
it('должен возвращать move selected handle', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.hovered(50, 50)?.cursorStyle).toBe('move');
});
it('должен возвращать grab внутри selected channel', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.hovered(100, 90)?.cursorStyle).toBe('grab');
});
it('должен выбирать unselected channel', () => {
const { drawing, container, interaction } = createDrawing();
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 100, 90));
expect(interaction.select).toHaveBeenCalledTimes(1);
});
it('должен перемещать main-start', () => {
const { drawing, container } = createDrawing(true);
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 50, 50));
drawing.pointerMove(createPointerEvent(container, 60, 60));
expect(drawing.getState()).toMatchObject({
startAnchor: {
time: 60,
price: 60,
},
endAnchor: {
time: 150,
price: 80,
},
priceOffset: 60,
});
});
it('должен перемещать parallel-middle', () => {
const { drawing, container } = createDrawing(true);
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 100, 125));
drawing.pointerMove(createPointerEvent(container, 100, 145));
expect(drawing.getState().priceOffset).toBe(80);
});
it('должен перемещать channel целиком', () => {
const { drawing, container } = createDrawing(true);
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 100, 90));
drawing.pointerMove(createPointerEvent(container, 120, 100));
expect(drawing.getState()).toMatchObject({
startAnchor: {
time: 70,
price: 60,
},
endAnchor: {
time: 170,
price: 90,
},
priceOffset: 60,
});
});
it('должен открывать settings', () => {
const { drawing, container, openSettings } = createDrawing(true);
setReady(drawing);
drawing.doubleClick(createMouseEvent(container, 100, 90));
expect(openSettings).toHaveBeenCalledTimes(1);
});
it('не должен возвращать data hidden channel', () => {
const { drawing } = createDrawing();
setReady(drawing);
drawing.hide();
expect(drawing.getRenderData()).toBeNull();
});
});
describe('ParallelChannelPaneRenderer', () => {
it('не должен рисовать без data', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
new ParallelChannelPaneRenderer(createRendererDrawing(null)).draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('должен рисовать channel и text', () => {
const data: ParallelChannelRenderData = {
startPoint: { x: 10, y: 20 },
mainMiddlePoint: { x: 50, y: 30 },
endPoint: { x: 90, y: 40 },
parallelStartPoint: { x: 10, y: 80 },
parallelMiddlePoint: { x: 50, y: 90 },
parallelEndPoint: { x: 90, y: 100 },
middleStartPoint: { x: 10, y: 50 },
middleEndPoint: { x: 90, y: 70 },
left: 10,
right: 90,
top: 20,
bottom: 100,
lineColor: '#ff0000',
backgroundColor: '#00ff0033',
showMiddleLine: true,
fontSize: 14,
text: 'Channel',
isBold: true,
isItalic: false,
textColor: '#ffffff',
};
const { target, context } = createCanvasTarget();
new ParallelChannelPaneRenderer(createRendererDrawing(data)).draw(target);
expect(context.fill).toHaveBeenCalled();
expect(context.stroke).toHaveBeenCalled();
expect(context.setLineDash).toHaveBeenCalled();
expect(context.fillText).toHaveBeenCalled();
});
});
describe('ParallelChannelPaneView', () => {
it('должен возвращать renderer и top', () => {
const view = new ParallelChannelPaneView(createRendererDrawing(null));
expect(view.renderer()).toBeInstanceOf(ParallelChannelPaneRenderer);
expect(view.zOrder()).toBe('top');
});
});
describe('ParallelChannel settings', () => {
it('должен создавать defaults', () => {
expect(createDefaultSettings()).toEqual({
lineColor: '#2962ff',
backgroundColor: '#2962ff44',
showMiddleLine: true,
fontSize: 14,
text: '',
isBold: false,
isItalic: false,
textColor: '#2962ff',
});
});
it('должен создавать style и text tabs', () => {
expect(getParallelChannelSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
});
});
import { RayPaneRenderer } from '@core/Drawings/ray/paneRenderer';
import { RayPaneView } from '@core/Drawings/ray/paneView';
import { Ray } from '@core/Drawings/ray/ray';
import { createDefaultSettings, getRaySettingTabs } from '@core/Drawings/ray/settings';
import {
createCanvasTarget,
createDrawingTestEnvironment,
createMouseEvent,
createPointerEvent,
} from '../drawingTestUtils';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { RayRenderData } from '@core/Drawings/ray/ray';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartLineColor: '#2962ff',
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', () => ({
formatPrice: (value: number) => `price:${value}`,
}));
jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
class TestRay extends Ray {
public handles(): readonly DrawingHandle[] {
return this.getDrawingHandles();
}
public timeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public timeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public priceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
public hovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public pointerMove(event: PointerEvent): void {
this.handlePointerMove(event);
}
public pointerUp(event: PointerEvent): void {
this.handlePointerUp(event);
}
}
function createDrawing(selected = false) {
const env = createDrawingTestEnvironment({ selected });
const removeSelf = jest.fn();
const openSettings = jest.fn();
const drawing = new TestRay({
...env,
removeSelf,
openSettings,
});
return {
...env,
drawing,
removeSelf,
openSettings,
};
}
function setReady(drawing: TestRay): void {
drawing.setState({
mode: 'ready',
startAnchor: {
time: 50 as Time,
price: 50,
},
directionAnchor: {
time: 150 as Time,
price: 100,
},
});
}
function createRendererDrawing(data: RayRenderData | null): Ray {
return {
getRenderData: () => data,
} as unknown as Ray;
}
describe('Ray', () => {
it('должен прикреплять primitive', () => {
const { drawing, series } = createDrawing();
expect(series.attachPrimitive).toHaveBeenCalledWith(drawing);
});
it('должен создаваться двумя pointerDown', () => {
const { drawing, container } = createDrawing();
drawing.pointerDown(createPointerEvent(container, 50, 50));
drawing.pointerDown(createPointerEvent(container, 150, 100));
expect(drawing.getState()).toMatchObject({
mode: 'ready',
startAnchor: {
time: 50,
price: 50,
},
directionAnchor: {
time: 150,
price: 100,
},
});
});
it('должен удалять слишком короткий ray', () => {
const { drawing, container, removeSelf } = createDrawing();
drawing.pointerDown(createPointerEvent(container, 50, 50));
drawing.pointerDown(createPointerEvent(container, 52, 52));
expect(removeSelf).toHaveBeenCalledTimes(1);
});
it('должен продолжаться до границы container', () => {
const { drawing } = createDrawing();
setReady(drawing);
expect(drawing.getRenderData()).toMatchObject({
startPoint: {
x: 50,
y: 50,
},
directionPoint: {
x: 150,
y: 100,
},
rayEndPoint: {
x: 400,
y: 225,
},
});
});
it('должен возвращать start и direction handles', () => {
const { drawing } = createDrawing();
setReady(drawing);
expect(drawing.handles()).toEqual([
{
id: 'start',
x: 50,
y: 50,
shape: 'circle',
strokeColor: '#2962ff',
},
{
id: 'direction',
x: 150,
y: 100,
shape: 'circle',
strokeColor: '#2962ff',
},
]);
});
it('должен возвращать selected axis segments', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.timeSegments()).toEqual([
{
from: 50,
to: 150,
color: '#2962ff44',
},
]);
expect(drawing.priceSegments()).toEqual([
{
from: 50,
to: 100,
color: '#2962ff44',
},
]);
});
it('должен возвращать axis labels', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.timeLabel('direction')).toMatchObject({
coordinate: 150,
text: 'date:150',
});
expect(drawing.priceLabel('start')).toMatchObject({
coordinate: 50,
text: 'price:50',
});
});
it('должен определять handles и body', () => {
const { drawing } = createDrawing(true);
setReady(drawing);
expect(drawing.hovered(50, 50)?.cursorStyle).toBe('move');
expect(drawing.hovered(200, 125)?.cursorStyle).toBe('grab');
expect(drawing.hovered(200, 200)).toBeNull();
});
it('должен выбирать unselected ray', () => {
const { drawing, interaction, container } = createDrawing();
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 200, 125));
expect(interaction.select).toHaveBeenCalledTimes(1);
});
it('должен перемещать start handle', () => {
const { drawing, container } = createDrawing(true);
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 50, 50));
drawing.pointerMove(createPointerEvent(container, 70, 80));
drawing.pointerUp(createPointerEvent(container, 70, 80));
expect(drawing.getState()).toMatchObject({
startAnchor: {
time: 70,
price: 80,
},
directionAnchor: {
time: 150,
price: 100,
},
});
});
it('должен перемещать direction handle', () => {
const { drawing, container } = createDrawing(true);
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 150, 100));
drawing.pointerMove(createPointerEvent(container, 180, 130));
expect(drawing.getState()).toMatchObject({
directionAnchor: {
time: 180,
price: 130,
},
});
});
it('должен перемещать ray целиком', () => {
const { drawing, container } = createDrawing(true);
setReady(drawing);
drawing.pointerDown(createPointerEvent(container, 200, 125));
drawing.pointerMove(createPointerEvent(container, 220, 155));
expect(drawing.getState()).toMatchObject({
startAnchor: {
time: 70,
price: 80,
},
directionAnchor: {
time: 170,
price: 130,
},
});
});
it('должен открывать settings', () => {
const { drawing, container, openSettings } = createDrawing();
setReady(drawing);
drawing.doubleClick(createMouseEvent(container, 200, 125));
expect(openSettings).toHaveBeenCalledTimes(1);
});
it('не должен возвращать data hidden ray', () => {
const { drawing } = createDrawing();
setReady(drawing);
drawing.hide();
expect(drawing.getRenderData()).toBeNull();
});
});
describe('RayPaneRenderer', () => {
it('не должен рисовать без data', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
new RayPaneRenderer(createRendererDrawing(null)).draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('должен рисовать ray и text', () => {
const data: RayRenderData = {
startPoint: { x: 10, y: 20 },
directionPoint: { x: 50, y: 40 },
rayEndPoint: { x: 100, y: 65 },
left: 10,
right: 100,
top: 20,
bottom: 65,
lineColor: '#ff0000',
text: 'Ray',
fontSize: 14,
isBold: true,
isItalic: true,
textColor: '#ffffff',
};
const { target, context } = createCanvasTarget();
new RayPaneRenderer(createRendererDrawing(data)).draw(target);
expect(context.moveTo).toHaveBeenCalledWith(20, 40);
expect(context.lineTo).toHaveBeenCalledWith(200, 130);
expect(context.fillText).toHaveBeenCalled();
expect(context.translate).toHaveBeenCalled();
expect(context.rotate).toHaveBeenCalled();
});
});
describe('RayPaneView', () => {
it('должен возвращать renderer и top', () => {
const view = new RayPaneView(createRendererDrawing(null));
expect(view.renderer()).toBeInstanceOf(RayPaneRenderer);
expect(view.zOrder()).toBe('top');
});
});
describe('Ray settings', () => {
it('должен создавать defaults', () => {
expect(createDefaultSettings()).toEqual({
lineColor: '#2962ff',
text: '',
fontSize: 14,
isBold: false,
isItalic: false,
textColor: '#2962ff',
});
});
it('должен создавать style и text tabs', () => {
expect(getRaySettingTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
});
});
import { RectanglePaneRenderer } from '@core/Drawings/rectangle/paneRenderer';
import { RectanglePaneView } from '@core/Drawings/rectangle/paneView';
import { Rectangle, type RectangleRenderData } from '@core/Drawings/rectangle/rectangle';
import { createDefaultSettings, getRectangleSettingsTabs } from '@core/Drawings/rectangle/settings';
import {
createCanvasTarget,
createDrawingTestEnvironment,
createMouseEvent,
createPointerEvent,
} from '../drawingTestUtils';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
rectangleBorderFill: '#2962ff',
rectangleAreaFill: '#2962ff22',
axisMarkerAreaFill: '#2962ff44',
axisMarkerLabelFill: '#2962ff',
chartPriceLineText: '#ffffff',
},
}),
}));
jest.mock('@src/translations', () => ({ t: (value: string) => value }));
jest.mock('@src/types/defaults', () => ({
Defaults: {
dateFormat: 'date',
timeFormat: '24h',
showTime: true,
},
}));
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => `price:${value}`,
}));
jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
class TestRectangle extends Rectangle {
public handles(): readonly DrawingHandle[] {
return this.getDrawingHandles();
}
public timeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public timeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public priceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
public hovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public pointerDown(event: PointerEvent): void {
this.handlePointerDown(event);
}
public pointerMove(event: PointerEvent): void {
this.handlePointerMove(event);
}
public pointerUp(event: PointerEvent): void {
this.handlePointerUp(event);
}
public doubleClick(event: MouseEvent): void {
this.handleDoubleClick(event);
}
}
function createRectangle(selected = false) {
const env = createDrawingTestEnvironment({ selected });
const removeSelf = jest.fn();
const openSettings = jest.fn();
const rectangle = new TestRectangle({
...env,
removeSelf,
openSettings,
});
return { ...env, rectangle, removeSelf, openSettings };
}
function setReady(rectangle: TestRectangle): void {
rectangle.setState({
mode: 'ready',
startTime: 50 as Time,
endTime: 150 as Time,
startPrice: 50,
endPrice: 150,
});
}
describe('Rectangle', () => {
it('прикрепляет primitive', () => {
const { rectangle, series } = createRectangle();
expect(series.attachPrimitive).toHaveBeenCalledWith(rectangle);
});
it('создаётся двумя нажатиями', () => {
const { rectangle, container } = createRectangle();
rectangle.pointerDown(createPointerEvent(container, 50, 50));
rectangle.pointerDown(createPointerEvent(container, 150, 150));
expect(rectangle.getState()).toMatchObject({
mode: 'ready',
startTime: 50,
endTime: 150,
startPrice: 50,
endPrice: 150,
});
});
it('удаляет слишком маленький прямоугольник', () => {
const { rectangle, container, removeSelf } = createRectangle();
rectangle.pointerDown(createPointerEvent(container, 50, 50));
rectangle.pointerDown(createPointerEvent(container, 54, 54));
expect(removeSelf).toHaveBeenCalledTimes(1);
});
it('возвращает геометрию', () => {
const { rectangle } = createRectangle();
setReady(rectangle);
expect(rectangle.getRenderData()).toMatchObject({
left: 50,
right: 150,
top: 50,
bottom: 150,
width: 100,
height: 100,
});
});
it('возвращает восемь handles правильного типа', () => {
const { rectangle } = createRectangle();
setReady(rectangle);
expect(rectangle.handles()).toEqual([
{ id: 'w', x: 50, y: 100, shape: 'rounded' },
{ id: 'sw', x: 50, y: 150, shape: 'circle' },
{ id: 's', x: 100, y: 150, shape: 'rounded' },
{ id: 'se', x: 150, y: 150, shape: 'circle' },
{ id: 'e', x: 150, y: 100, shape: 'rounded' },
{ id: 'ne', x: 150, y: 50, shape: 'circle' },
{ id: 'n', x: 100, y: 50, shape: 'rounded' },
{ id: 'nw', x: 50, y: 50, shape: 'circle' },
]);
});
it.each([
['nw', 50, 50, 'nwse-resize'],
['se', 150, 150, 'nwse-resize'],
['ne', 150, 50, 'nesw-resize'],
['sw', 50, 150, 'nesw-resize'],
['n', 100, 50, 'ns-resize'],
['s', 100, 150, 'ns-resize'],
['e', 150, 100, 'ew-resize'],
['w', 50, 100, 'ew-resize'],
])('возвращает корректный cursor для %s', (_, x, y, cursor) => {
const { rectangle } = createRectangle(true);
setReady(rectangle);
expect(rectangle.hovered(x as number, y as number)?.cursorStyle).toBe(cursor);
});
it('возвращает pointer для невыбранного rectangle', () => {
const { rectangle } = createRectangle();
setReady(rectangle);
expect(rectangle.hovered(100, 100)?.cursorStyle).toBe('pointer');
});
it('возвращает grab внутри выбранного rectangle', () => {
const { rectangle } = createRectangle(true);
setReady(rectangle);
expect(rectangle.hovered(100, 100)?.cursorStyle).toBe('grab');
});
it('возвращает axis segments для selected rectangle', () => {
const { rectangle } = createRectangle(true);
setReady(rectangle);
expect(rectangle.timeSegments()).toEqual([{ from: 50, to: 150, color: '#2962ff44' }]);
expect(rectangle.priceSegments()).toEqual([{ from: 50, to: 150, color: '#2962ff44' }]);
});
it('не возвращает axis segments для ready unselected rectangle', () => {
const { rectangle } = createRectangle();
setReady(rectangle);
expect(rectangle.timeSegments()).toEqual([]);
expect(rectangle.priceSegments()).toEqual([]);
});
it('возвращает axis labels', () => {
const { rectangle } = createRectangle(true);
setReady(rectangle);
expect(rectangle.timeLabel('left')).toMatchObject({
coordinate: 50,
text: 'date:50',
});
expect(rectangle.timeLabel('right')).toMatchObject({
coordinate: 150,
text: 'date:150',
});
expect(rectangle.priceLabel('top')).toMatchObject({
coordinate: 50,
text: 'price:50',
});
expect(rectangle.priceLabel('bottom')).toMatchObject({
coordinate: 150,
text: 'price:150',
});
});
it('выбирает rectangle первым нажатием', () => {
const { rectangle, interaction, container } = createRectangle();
setReady(rectangle);
rectangle.pointerDown(createPointerEvent(container, 100, 100));
expect(interaction.select).toHaveBeenCalledTimes(1);
});
it('изменяет левую границу через w handle', () => {
const { rectangle, container } = createRectangle(true);
setReady(rectangle);
rectangle.pointerDown(createPointerEvent(container, 50, 100));
rectangle.pointerMove(createPointerEvent(container, 30, 100));
rectangle.pointerUp(createPointerEvent(container, 30, 100));
expect(rectangle.getState()).toMatchObject({
mode: 'ready',
startTime: 30,
endTime: 150,
});
});
it('изменяет верхний левый угол', () => {
const { rectangle, container } = createRectangle(true);
setReady(rectangle);
rectangle.pointerDown(createPointerEvent(container, 50, 50));
rectangle.pointerMove(createPointerEvent(container, 30, 20));
expect(rectangle.getRenderData()).toMatchObject({
left: 30,
top: 20,
right: 150,
bottom: 150,
});
});
it('перемещает rectangle целиком', () => {
const { rectangle, container } = createRectangle(true);
setReady(rectangle);
rectangle.pointerDown(createPointerEvent(container, 100, 100));
rectangle.pointerMove(createPointerEvent(container, 120, 130));
expect(rectangle.getState()).toMatchObject({
startTime: 70,
endTime: 170,
startPrice: 80,
endPrice: 180,
});
});
it('ограничивает перемещение границами контейнера', () => {
const { rectangle, container } = createRectangle(true);
setReady(rectangle);
rectangle.pointerDown(createPointerEvent(container, 100, 100));
rectangle.pointerMove(createPointerEvent(container, -500, -500));
expect(rectangle.getRenderData()).toMatchObject({
left: 0,
top: 0,
right: 100,
bottom: 100,
});
});
it('открывает settings по double click', () => {
const { rectangle, container, openSettings } = createRectangle();
setReady(rectangle);
rectangle.doubleClick(createMouseEvent(container, 100, 100));
expect(openSettings).toHaveBeenCalledTimes(1);
});
it('не возвращает render data когда hidden', () => {
const { rectangle } = createRectangle();
setReady(rectangle);
rectangle.hide();
expect(rectangle.getRenderData()).toBeNull();
});
});
describe('RectanglePaneRenderer', () => {
it('ничего не рисует без render data', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
const renderer = new RectanglePaneRenderer({
getRenderData: () => null,
} as unknown as Rectangle);
renderer.draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('рисует fill, border и text', () => {
const data: RectangleRenderData = {
left: 10,
right: 100,
top: 20,
bottom: 80,
width: 90,
height: 60,
borderColor: '#fff',
fillColor: '#111',
text: 'Rectangle',
fontSize: 14,
isBold: false,
isItalic: false,
textColor: '#fff',
};
const { target, context } = createCanvasTarget();
const renderer = new RectanglePaneRenderer({
getRenderData: () => data,
} as unknown as Rectangle);
renderer.draw(target);
expect(context.fillRect).toHaveBeenCalled();
expect(context.strokeRect).toHaveBeenCalled();
expect(context.fillText).toHaveBeenCalledWith('Rectangle', 20, expect.any(Number));
});
});
describe('RectanglePaneView', () => {
it('возвращает renderer и top zOrder', () => {
const view = new RectanglePaneView({} as Rectangle);
expect(view.renderer()).toBeInstanceOf(RectanglePaneRenderer);
expect(view.zOrder()).toBe('top');
});
});
describe('Rectangle settings', () => {
it('создаёт default settings', () => {
expect(createDefaultSettings()).toEqual({
borderColor: '#2962ff',
fillColor: '#2962ff22',
text: '',
fontSize: 14,
isBold: false,
isItalic: false,
textColor: '#2962ff',
});
});
it('создаёт style и text tabs', () => {
expect(getRectangleSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
});
});
import { RegressionTrendPaneRenderer } from '@core/Drawings/regressionTrend/paneRenderer';
import { RegressionTrendPaneView } from '@core/Drawings/regressionTrend/paneView';
import { RegressionTrend, type RegressionTrendRenderData } from '@core/Drawings/regressionTrend/regressionTrend';
import { createDefaultSettings, getRegressionTrendSettingsTabs } from '@core/Drawings/regressionTrend/settings';
import {
createCanvasTarget,
createDrawingTestEnvironment,
createMouseEvent,
createPointerEvent,
} from '../drawingTestUtils';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartLineColor: '#2962ff',
chartCrosshairLine: '#999999',
sliderPositiveFill: '#00aa0044',
sliderNegativeFill: '#aa000044',
axisMarkerAreaFill: '#2962ff44',
axisMarkerLabelFill: '#2962ff',
chartPriceLineText: '#ffffff',
},
}),
}));
jest.mock('@src/types/defaults', () => ({
Defaults: {
dateFormat: 'date',
timeFormat: '24h',
showTime: true,
},
}));
jest.mock('@src/translations', () => ({ t: (value: string) => value }));
jest.mock('@src/utils', () => ({ formatPrice: (value: number) => `price:${value}` }));
jest.mock('@src/utils/formatter', () => ({ formatDate: (value: number) => `date:${value}` }));
const seriesData = [
{ time: 10 as Time, close: 10 },
{ time: 20 as Time, close: 20 },
{ time: 30 as Time, close: 15 },
{ time: 40 as Time, close: 30 },
];
class TestRegressionTrend extends RegressionTrend {
public handles(): readonly DrawingHandle[] {
return this.getDrawingHandles();
}
public timeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public timeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public priceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
public hovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public pointerDown(event: PointerEvent): void {
this.handlePointerDown(event);
}
public pointerMove(event: PointerEvent): void {
this.handlePointerMove(event);
}
public pointerUp(event: PointerEvent): void {
this.handlePointerUp(event);
}
public doubleClick(event: MouseEvent): void {
this.handleDoubleClick(event);
}
}
function createRegression(selected = false) {
const env = createDrawingTestEnvironment({ selected, seriesData });
const openSettings = jest.fn();
const regression = new TestRegressionTrend({
...env,
openSettings,
});
regression.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 40 as Time,
});
return { ...env, regression, openSettings };
}
describe('RegressionTrend', () => {
it('рассчитывает linear regression', () => {
const { regression } = createRegression();
const data = regression.getRenderData();
expect(data?.baseStartPrice).toBeCloseTo(10.5);
expect(data?.baseEndPrice).toBeCloseTo(27);
expect(data?.correlation).toBeCloseTo(0.8315218406);
});
it('строит upper и lower channel на двух отклонениях', () => {
const { regression } = createRegression();
const data = regression.getRenderData();
const deviation = 4.1079191813;
expect(data?.upperStartPoint.y).toBeCloseTo(10.5 + deviation * 2);
expect(data?.lowerStartPoint.y).toBeCloseTo(10.5 - deviation * 2);
expect(data?.upperEndPoint.y).toBeCloseTo(27 + deviation * 2);
expect(data?.lowerEndPoint.y).toBeCloseTo(27 - deviation * 2);
});
it('возвращает два handles на base line', () => {
const { regression } = createRegression();
expect(regression.handles()).toEqual([
{
id: 'start',
x: 10,
y: 10.5,
shape: 'circle',
},
{
id: 'end',
x: 40,
y: 27,
shape: 'circle',
},
]);
});
it('показывает channel в ready', () => {
const { regression } = createRegression();
expect(regression.getRenderData()?.showChannel).toBe(true);
});
it('не показывает channel во время drawing', () => {
const { regression } = createRegression();
regression.setState({ mode: 'drawing' });
expect(regression.getRenderData()?.showChannel).toBe(false);
});
it('нормализует dragging state в ready при setState', () => {
const { regression } = createRegression();
regression.setState({ mode: 'dragging' });
expect(regression.getState().mode).toBe('ready');
});
it('возвращает axis segments', () => {
const { regression } = createRegression(true);
expect(regression.timeSegments()).toEqual([{ from: 10, to: 40, color: '#2962ff44' }]);
expect(regression.priceSegments()).toEqual([{ from: 10.5, to: 27, color: '#2962ff44' }]);
});
it('возвращает axis labels', () => {
const { regression } = createRegression(true);
expect(regression.timeLabel('start')).toMatchObject({
coordinate: 10,
text: 'date:10',
});
expect(regression.priceLabel('end')).toMatchObject({
coordinate: 27,
text: 'price:27',
});
});
it('возвращает pointer для unselected channel', () => {
const { regression } = createRegression();
expect(regression.hovered(25, 18.75)?.cursorStyle).toBe('pointer');
});
it('возвращает ew-resize для selected handle', () => {
const { regression } = createRegression(true);
expect(regression.hovered(10, 10.5)?.cursorStyle).toBe('ew-resize');
});
it('возвращает grab для selected body', () => {
const { regression } = createRegression(true);
expect(regression.hovered(25, 18.75)?.cursorStyle).toBe('grab');
});
it('перемещает start edge на ближайший bar', () => {
const { regression, container } = createRegression(true);
regression.pointerDown(createPointerEvent(container, 10, 10.5));
regression.pointerMove(createPointerEvent(container, 20, 20));
regression.pointerUp(createPointerEvent(container, 20, 20));
expect(regression.getState()).toMatchObject({
mode: 'ready',
startTime: 20,
endTime: 40,
});
});
it('не позволяет схлопнуть range до одного bar', () => {
const { regression, container } = createRegression(true);
regression.pointerDown(createPointerEvent(container, 10, 10.5));
regression.pointerMove(createPointerEvent(container, 40, 30));
expect(regression.getState().startTime).toBe(10);
});
it('открывает настройки только для selected trend', () => {
const { regression, container, openSettings } = createRegression(true);
regression.doubleClick(createMouseEvent(container, 25, 18.75));
expect(openSettings).toHaveBeenCalledTimes(1);
});
it('возвращает null когда hidden', () => {
const { regression } = createRegression();
regression.hide();
expect(regression.getRenderData()).toBeNull();
});
});
describe('RegressionTrendPaneRenderer', () => {
it('ничего не рисует без данных', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
const renderer = new RegressionTrendPaneRenderer({
getRenderData: () => null,
} as unknown as RegressionTrend);
renderer.draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('рисует channel, middle line и correlation', () => {
const data: RegressionTrendRenderData = {
baseStartPoint: { x: 10, y: 20 },
baseEndPoint: { x: 100, y: 40 },
upperStartPoint: { x: 10, y: 10 },
upperEndPoint: { x: 100, y: 30 },
lowerStartPoint: { x: 10, y: 30 },
lowerEndPoint: { x: 100, y: 50 },
baseStartPrice: 20,
baseEndPrice: 40,
correlation: 0.9,
left: 10,
right: 100,
top: 10,
bottom: 50,
lineColor: '#fff',
middleLineColor: '#aaa',
upperFillColor: '#0f03',
lowerFillColor: '#f003',
showMiddleLine: true,
showCorrelation: true,
showChannel: true,
};
const { target, context } = createCanvasTarget();
const renderer = new RegressionTrendPaneRenderer({
getRenderData: () => data,
} as unknown as RegressionTrend);
renderer.draw(target);
expect(context.fill).toHaveBeenCalledTimes(2);
expect(context.stroke).toHaveBeenCalled();
expect(context.setLineDash).toHaveBeenCalled();
expect(context.fillText).toHaveBeenCalledWith('0.9', expect.any(Number), expect.any(Number));
});
});
describe('RegressionTrendPaneView', () => {
it('возвращает renderer и top', () => {
const view = new RegressionTrendPaneView({} as RegressionTrend);
expect(view.renderer()).toBeInstanceOf(RegressionTrendPaneRenderer);
expect(view.zOrder()).toBe('top');
});
});
describe('RegressionTrend settings', () => {
it('создаёт defaults', () => {
expect(createDefaultSettings()).toEqual({
lineColor: '#2962ff',
middleLineColor: '#999999',
upperFillColor: '#00aa0044',
lowerFillColor: '#aa000044',
showMiddleLine: true,
showCorrelation: true,
});
});
it('имеет только style tab', () => {
expect(getRegressionTrendSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style']);
});
});
import { BehaviorSubject } from 'rxjs';
import { RulerPaneRenderer } from '@core/Drawings/ruler/paneRenderer';
import { RulerPaneView } from '@core/Drawings/ruler/paneView';
import { Ruler, type RulerRenderData } from '@core/Drawings/ruler/ruler';
import { Direction } from '@src/types';
import { createCanvasTarget, createDrawingTestEnvironment } from '../drawingTestUtils';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartLineColor: '#00aa00',
chartLineColorAlternative: '#aa0000',
rulerPositiveFill: '#00aa0044',
rulerNegativeFill: '#aa000044',
chartPriceLineText: '#ffffff',
axisMarkerAreaFill: '#2962ff44',
axisMarkerLabelFill: '#2962ff',
},
}),
}));
jest.mock('@src/translations', () => ({ t: (value: string) => value }));
jest.mock('@src/types/defaults', () => ({
Defaults: {
dateFormat: 'date',
timeFormat: '24h',
showTime: true,
},
}));
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => `price:${value}`,
formatVolume: (value: number) => `volume:${value}`,
}));
jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
const seriesData = [
{ time: 10 as Time, close: 10, volume: 100 },
{ time: 20 as Time, close: 20, customValues: { volume: 200 } },
{ time: 30 as Time, close: 30, volume: 300 },
];
class TestRuler extends Ruler {
public timeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public timeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public priceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
}
function createRuler() {
const env = createDrawingTestEnvironment({ seriesData });
const removeSelf = jest.fn();
const ruler = new TestRuler({
...env,
removeSelf,
});
return { ...env, ruler, removeSelf };
}
function setReady(ruler: TestRuler, startPrice = 10, endPrice = 30): void {
ruler.setState({
mode: 'ready',
startAnchor: { time: 10 as Time, price: startPrice },
endAnchor: { time: 30 as Time, price: endPrice },
});
}
describe('Ruler', () => {
it('не отображается в Object Tree', () => {
const { ruler } = createRuler();
expect(ruler.shouldShowInObjectTree()).toBe(false);
});
it('не имеет settings tabs', () => {
const { ruler } = createRuler();
expect(ruler.getSettingsTabs()).toEqual([]);
});
it('считает изменение цены, процент, bars и volume', () => {
const { ruler } = createRuler();
setReady(ruler);
expect(ruler.getRenderData().infoLines).toEqual(['price:20 (price:200%)', '2 bars,', 'Vol volume:600']);
});
it('берёт volume из customValues', () => {
const { ruler } = createRuler();
setReady(ruler);
expect(ruler.getRenderData().infoLines[2]).toBe('Vol volume:600');
});
it('выбирает positive цвета для роста', () => {
const { ruler } = createRuler();
setReady(ruler, 10, 30);
expect(ruler.getRenderData()).toMatchObject({
lineColor: '#00aa00',
fillColor: '#00aa0044',
});
});
it('выбирает negative цвета для падения', () => {
const { ruler } = createRuler();
setReady(ruler, 30, 10);
expect(ruler.getRenderData()).toMatchObject({
lineColor: '#aa0000',
fillColor: '#aa000044',
});
});
it('определяет направление стрелок', () => {
const { ruler } = createRuler();
setReady(ruler);
expect(ruler.getRenderData()).toMatchObject({
horizontalArrowSide: Direction.Right,
verticalArrowSide: Direction.Bottom,
});
});
it('возвращает axis bounds', () => {
const { ruler } = createRuler();
setReady(ruler);
expect(ruler.getTimeBounds()).toEqual({ left: 10, right: 30 });
expect(ruler.getPriceBounds()).toEqual({ top: 10, bottom: 30 });
});
it('возвращает axis segments', () => {
const { ruler } = createRuler();
setReady(ruler);
expect(ruler.timeSegments()).toEqual([{ from: 10, to: 30, color: '#2962ff44' }]);
expect(ruler.priceSegments()).toEqual([{ from: 10, to: 30, color: '#2962ff44' }]);
});
it('возвращает axis labels', () => {
const { ruler } = createRuler();
setReady(ruler);
expect(ruler.timeLabel('start')).toMatchObject({
coordinate: 10,
text: 'date:10',
});
expect(ruler.priceLabel('end')).toMatchObject({
coordinate: 30,
text: 'price:30',
});
});
it('возвращает autoscaleInfo когда ruler входит в logical range', () => {
const { ruler } = createRuler();
setReady(ruler);
expect(ruler.autoscaleInfo(0 as never, 50 as never)).toEqual({
priceRange: {
minValue: 10,
maxValue: 30,
},
});
});
it('не влияет на autoscale вне logical range', () => {
const { ruler } = createRuler();
setReady(ruler);
expect(ruler.autoscaleInfo(100 as never, 200 as never)).toBeNull();
});
it('не влияет на autoscale во время placingEnd', () => {
const { ruler } = createRuler();
ruler.setState({
mode: 'placingEnd',
startAnchor: { time: 10 as Time, price: 10 },
endAnchor: { time: 30 as Time, price: 30 },
});
expect(ruler.autoscaleInfo(0 as never, 50 as never)).toBeNull();
});
it('вызывает removeSelf после reset trigger', () => {
const reset$ = new BehaviorSubject(false);
const env = createDrawingTestEnvironment({ seriesData });
const removeSelf = jest.fn();
const ruler = new TestRuler({
...env,
removeSelf,
resetTriggers: [reset$],
});
expect(removeSelf).not.toHaveBeenCalled();
reset$.next(true);
expect(removeSelf).toHaveBeenCalledTimes(1);
ruler.destroy();
});
});
describe('RulerPaneRenderer', () => {
it('не рисует hidden ruler', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
const renderer = new RulerPaneRenderer({
getRenderData: () => ({
hidden: true,
startPoint: null,
endPoint: null,
}),
} as unknown as Ruler);
renderer.draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('рисует область, стрелки и info lines', () => {
const data: RulerRenderData = {
hidden: false,
startPoint: { x: 10, y: 20 },
endPoint: { x: 100, y: 80 },
lineColor: '#fff',
fillColor: '#111',
textColor: '#fff',
infoLines: ['one', 'two', 'three'],
horizontalArrowSide: Direction.Right,
verticalArrowSide: Direction.Bottom,
};
const { target, context } = createCanvasTarget();
const renderer = new RulerPaneRenderer({
getRenderData: () => data,
} as unknown as Ruler);
renderer.draw(target);
expect(context.fillRect).toHaveBeenCalled();
expect(context.stroke).toHaveBeenCalled();
expect(context.fillText).toHaveBeenCalledTimes(3);
});
});
describe('RulerPaneView', () => {
it('возвращает renderer и top', () => {
const view = new RulerPaneView({} as Ruler);
expect(view.renderer()).toBeInstanceOf(RulerPaneRenderer);
expect(view.zOrder()).toBe('top');
});
});
import { SliderPaneRenderer } from '@core/Drawings/sliderPosition/paneRenderer';
import { SliderPaneView } from '@core/Drawings/sliderPosition/paneView';
import { createDefaultSettings, getSliderPositionSettingsTabs } from '@core/Drawings/sliderPosition/settings';
import { SliderPosition, type SliderRenderData } from '@core/Drawings/sliderPosition/sliderPosition';
import { createCanvasTarget, createDrawingTestEnvironment, createPointerEvent } from '../drawingTestUtils';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { Coordinate, MouseEventParams, PrimitiveHoveredItem, Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartCrosshairLine: '#999999',
sliderPositiveFill: '#00aa0044',
sliderNegativeFill: '#aa000044',
chartPriceLineText: '#ffffff',
chartCandleUp: '#00aa00',
chartCandleDown: '#aa0000',
axisMarkerAreaFill: '#2962ff44',
axisMarkerLabelFill: '#2962ff',
axisMarkerLabelDefaultFill: '#777777',
axisMarkerLabelPositiveFill: '#00aa00',
axisMarkerLabelNegativeFill: '#aa0000',
},
}),
}));
jest.mock('@src/translations', () => ({
t: (value: string) => value,
}));
jest.mock('@src/types/defaults', () => ({
Defaults: {
dateFormat: 'date',
timeFormat: '24h',
showTime: true,
},
}));
jest.mock('@src/utils', () => ({
formatPrice: (value: number, precision = 4) => {
const factor = 10 ** precision;
return String(Math.round(value * factor) / factor);
},
formatPercent: (value: number) => {
const factor = 100;
return `${Math.round(value * factor) / factor}%`;
},
formatSignedNumber: (value: number) => String(value),
getPercentPrecision: () => 2,
getPricePrecisionStep: () => 0.01,
}));
jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
class TestSliderPosition extends SliderPosition {
public handles(): readonly DrawingHandle[] {
return this.getDrawingHandles();
}
public timeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public timeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public priceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
public hovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public pointerDown(event: PointerEvent): void {
this.handlePointerDown(event);
}
public pointerMove(event: PointerEvent): void {
this.handlePointerMove(event);
}
public pointerUp(event: PointerEvent): void {
this.handlePointerUp(event);
}
}
function createSlider(side: 'long' | 'short' = 'long', selected = false) {
const seriesData = [
{
time: 10 as Time,
close: 100,
},
{
time: 100 as Time,
close: 110,
},
];
const env = createDrawingTestEnvironment({
selected,
seriesData,
});
const removeSelf = jest.fn();
const openSettings = jest.fn();
const slider = new TestSliderPosition({
...env,
side,
removeSelf,
openSettings,
});
return {
...env,
slider,
removeSelf,
openSettings,
};
}
function setReady(slider: TestSliderPosition): void {
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 120,
amount: 1000,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 25,
lotSize: 1,
leverage: 5,
quantityPrecision: 3,
},
});
}
function createInitialEvent(x: number, y: number, time: Time): MouseEventParams<Time> {
return {
point: { x, y },
time,
} as unknown as MouseEventParams<Time>;
}
describe('SliderPosition', () => {
it('считает Risk/Reward Ratio', () => {
const { slider } = createSlider();
setReady(slider);
expect(slider.getState().riskRewardRatio).toBe(2);
});
it('при риске 25% и RR 1 считает 1000 -> 1250/750', () => {
const { slider } = createSlider();
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 110,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 25,
lotSize: 1,
leverage: 5,
quantityPrecision: 3,
},
});
expect(slider.getRenderData()).toMatchObject({
targetText: 'Target: 10 (10%), Amount: 1250',
centerText: 'Open P&L: 10, Qty: 25\nRisk/Reward Ratio: 1',
stopText: 'Stop: 10 (10%), Amount: 750',
});
});
it('ограничивает Qty через leverage', () => {
const { slider } = createSlider();
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 110,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 25,
lotSize: 1,
leverage: 1,
quantityPrecision: 3,
},
});
expect(slider.getRenderData()).toMatchObject({
targetText: 'Target: 10 (10%), Amount: 1100',
centerText: 'Open P&L: 10, Qty: 10\nRisk/Reward Ratio: 1',
stopText: 'Stop: 10 (10%), Amount: 900',
});
});
it('всегда трактует Risk как процент от размера счёта', () => {
const { slider } = createSlider();
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 120,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 2,
lotSize: 1,
leverage: 10,
quantityPrecision: 3,
},
});
expect(slider.getRenderData()).toMatchObject({
targetText: 'Target: 20 (20%), Amount: 1040',
centerText: 'Open P&L: 10, Qty: 2\nRisk/Reward Ratio: 2',
stopText: 'Stop: 10 (10%), Amount: 980',
});
});
it('учитывает Lot size', () => {
const { slider } = createSlider();
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 110,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 25,
lotSize: 5,
leverage: 5,
quantityPrecision: 3,
},
});
expect(slider.getRenderData()).toMatchObject({
targetText: 'Target: 10 (10%), Amount: 1250',
centerText: 'Open P&L: 10, Qty: 5\nRisk/Reward Ratio: 1',
stopText: 'Stop: 10 (10%), Amount: 750',
});
});
it('QTY precision округляет Qty до расчёта Amount', () => {
const { slider } = createSlider();
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 93,
targetPrice: 114,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 10,
lotSize: 1,
leverage: 10,
quantityPrecision: 2,
},
});
expect(slider.getRenderData()).toMatchObject({
targetText: 'Target: 14 (14%), Amount: 1200.06',
centerText: 'Open P&L: 10, Qty: 14.29\nRisk/Reward Ratio: 2',
stopText: 'Stop: 7 (7%), Amount: 899.97',
});
});
it('изменение Target не меняет Qty', () => {
const { slider } = createSlider();
setReady(slider);
expect(slider.getRenderData()?.centerText).toContain('Qty: 25');
slider.setState({
targetPrice: 140,
});
expect(slider.getRenderData()).toMatchObject({
targetText: 'Target: 40 (40%), Amount: 2000',
centerText: 'Open P&L: 10, Qty: 25\nRisk/Reward Ratio: 4',
stopText: 'Stop: 10 (10%), Amount: 750',
});
});
it('изменение Stop пересчитывает Qty', () => {
const { slider } = createSlider();
setReady(slider);
slider.setState({
stopPrice: 95,
});
expect(slider.getRenderData()).toMatchObject({
targetText: 'Target: 20 (20%), Amount: 2000',
centerText: 'Open P&L: 10, Qty: 50\nRisk/Reward Ratio: 4',
stopText: 'Stop: 5 (5%), Amount: 750',
});
});
it('симметрично считает short позицию', () => {
const { slider } = createSlider('short');
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 110,
targetPrice: 80,
settings: {
...createDefaultSettings(),
accountSize: 1000,
risk: 25,
lotSize: 1,
leverage: 5,
quantityPrecision: 3,
},
});
expect(slider.getRenderData()).toMatchObject({
targetText: 'Target: 20 (20%), Amount: 1500',
centerText: 'Open P&L: -10, Qty: 25\nRisk/Reward Ratio: 2',
stopText: 'Stop: 10 (10%), Amount: 750',
targetLabelDirection: 'down',
stopLabelDirection: 'up',
});
});
it('разрешает Risk/Reward Ratio = 0', () => {
const { slider } = createSlider();
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 90,
riskRewardRatio: 0,
});
expect(slider.getState()).toMatchObject({
targetPrice: 100,
riskRewardRatio: 0,
});
expect(slider.getRenderData()?.centerText).toContain('Risk/Reward Ratio: 0');
});
it('использует минимальный шаг цены только для Stop', () => {
const { slider } = createSlider();
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 100,
targetPrice: 100,
});
expect(slider.getState()).toMatchObject({
entryPrice: 100,
stopPrice: 99.99,
targetPrice: 100,
riskRewardRatio: 0,
});
});
it('для short использует минимальный шаг цены только для Stop', () => {
const { slider } = createSlider('short');
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 100,
targetPrice: 100,
});
expect(slider.getState()).toMatchObject({
entryPrice: 100,
stopPrice: 100.01,
targetPrice: 100,
riskRewardRatio: 0,
});
});
it('возвращает четыре handles в правильном порядке', () => {
const { slider } = createSlider();
setReady(slider);
expect(slider.handles()).toEqual([
{
id: 'target',
x: 10,
y: 120,
},
{
id: 'stop',
x: 10,
y: 90,
},
{
id: 'end',
x: 100,
y: 100,
},
{
id: 'entry',
x: 10,
y: 100,
shape: 'circle',
},
]);
});
it('Entry имеет приоритет при точном совпадении с Target', () => {
const { slider, container, priceToCoordinate } = createSlider('long', true);
priceToCoordinate.mockImplementation((price: number) => {
return (price === 101 ? 100 : price) as Coordinate;
});
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 101,
});
slider.pointerDown(createPointerEvent(container, 10, 100));
slider.pointerMove(createPointerEvent(container, 10, 95));
expect(slider.getState()).toMatchObject({
entryPrice: 95,
targetPrice: 101,
});
});
it('Entry имеет приоритет при точном совпадении со Stop', () => {
const { slider, container, priceToCoordinate } = createSlider('long', true);
priceToCoordinate.mockImplementation((price: number) => {
return (price === 99 ? 100 : price) as Coordinate;
});
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 99,
targetPrice: 120,
});
slider.pointerDown(createPointerEvent(container, 10, 100));
slider.pointerMove(createPointerEvent(container, 10, 110));
expect(slider.getState()).toMatchObject({
entryPrice: 110,
stopPrice: 99,
});
});
it('позволяет вытянуть Target из overlap', () => {
const { slider, container, priceToCoordinate } = createSlider('long', true);
priceToCoordinate.mockImplementation((price: number) => {
return (price === 101 ? 100 : price) as Coordinate;
});
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 101,
});
slider.pointerDown(createPointerEvent(container, 10, 101));
slider.pointerMove(createPointerEvent(container, 10, 110));
expect(slider.getState().targetPrice).toBe(110);
});
it('позволяет вытянуть Stop из overlap', () => {
const { slider, container, priceToCoordinate } = createSlider('long', true);
priceToCoordinate.mockImplementation((price: number) => {
return (price === 99 ? 100 : price) as Coordinate;
});
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 99,
targetPrice: 120,
});
slider.pointerDown(createPointerEvent(container, 10, 99));
slider.pointerMove(createPointerEvent(container, 10, 90));
expect(slider.getState().stopPrice).toBe(90);
});
it('Entry не пересекает Stop и Target', () => {
const { slider, container } = createSlider('long', true);
setReady(slider);
slider.pointerDown(createPointerEvent(container, 10, 100));
slider.pointerMove(createPointerEvent(container, 10, 200));
expect(slider.getState().entryPrice).toBe(120);
});
it('оставляет минимальный шаг между Stop и Entry', () => {
const { slider, container } = createSlider('long', true);
setReady(slider);
slider.pointerDown(createPointerEvent(container, 10, 90));
slider.pointerMove(createPointerEvent(container, 10, 100));
expect(slider.getState().stopPrice).toBe(99.99);
});
it('Target может совпасть с Entry', () => {
const { slider, container } = createSlider('long', true);
setReady(slider);
slider.pointerDown(createPointerEvent(container, 10, 120));
slider.pointerMove(createPointerEvent(container, 10, 100));
expect(slider.getState().targetPrice).toBe(100);
});
it('для short оставляет минимальный шаг между Stop и Entry', () => {
const { slider, container } = createSlider('short', true);
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 110,
targetPrice: 80,
});
slider.pointerDown(createPointerEvent(container, 10, 110));
slider.pointerMove(createPointerEvent(container, 10, 100));
expect(slider.getState().stopPrice).toBe(100.01);
});
it('для short Target может совпасть с Entry', () => {
const { slider, container } = createSlider('short', true);
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 110,
targetPrice: 80,
});
slider.pointerDown(createPointerEvent(container, 10, 80));
slider.pointerMove(createPointerEvent(container, 10, 100));
expect(slider.getState().targetPrice).toBe(100);
});
it.each(['long', 'short'] as const)('создаёт %s позицию с ограниченной начальной высотой', (side) => {
const seriesData = [
{
time: 150 as Time,
close: 150,
},
];
const env = createDrawingTestEnvironment({
seriesData,
height: 300,
});
const slider = new TestSliderPosition({
...env,
side,
initialEvent: createInitialEvent(100, 150, 150 as Time),
});
const priceHandles = slider.handles().filter(({ id }) => {
return id === 'target' || id === 'stop';
});
expect(slider.getState().mode).toBe('ready');
expect(priceHandles).toHaveLength(2);
expect(priceHandles.every(({ y }) => Math.abs(y - 150) <= 60)).toBe(true);
});
it.each([
['long', 20],
['long', 280],
['short', 20],
['short', 280],
] as const)('не выводит handles %s позиции за pane при y=%s', (side, entryY) => {
const seriesData = [
{
time: entryY as Time,
close: entryY,
},
];
const env = createDrawingTestEnvironment({
seriesData,
height: 300,
});
const slider = new TestSliderPosition({
...env,
side,
initialEvent: createInitialEvent(100, entryY, entryY as Time),
});
const priceHandles = slider.handles().filter(({ id }) => {
return id === 'target' || id === 'stop';
});
expect(slider.getState().mode).toBe('ready');
expect(priceHandles.every(({ y }) => y >= 8 && y <= 292)).toBe(true);
});
it('возвращает selected axis areas', () => {
const { slider } = createSlider('long', true);
setReady(slider);
expect(slider.timeSegments()).toEqual([
{
from: 10,
to: 100,
color: '#2962ff44',
},
]);
expect(slider.priceSegments()).toEqual([
{
from: 100,
to: 120,
color: '#2962ff44',
},
{
from: 90,
to: 100,
color: '#2962ff44',
},
]);
});
it('time labels показываются только у selected drawing', () => {
const { slider } = createSlider('long', true);
setReady(slider);
expect(slider.timeLabel('start')).toMatchObject({
coordinate: 10,
text: 'date:10',
});
expect(slider.timeLabel('end')).toMatchObject({
coordinate: 100,
text: 'date:100',
});
});
it('price labels доступны независимо от selection', () => {
const { slider } = createSlider();
setReady(slider);
expect(slider.priceLabel('target')).toMatchObject({
coordinate: 120,
text: '120',
backgroundColor: '#00aa00',
});
expect(slider.priceLabel('entry')).toMatchObject({
coordinate: 100,
backgroundColor: '#777777',
});
expect(slider.priceLabel('stop')).toMatchObject({
coordinate: 90,
backgroundColor: '#aa0000',
});
});
it('возвращает правильные cursors', () => {
const { slider } = createSlider('long', true);
setReady(slider);
expect(slider.hovered(10, 120)?.cursorStyle).toBe('ns-resize');
expect(slider.hovered(10, 90)?.cursorStyle).toBe('ns-resize');
expect(slider.hovered(100, 100)?.cursorStyle).toBe('ew-resize');
expect(slider.hovered(10, 100)?.cursorStyle).toBe('grab');
});
it('выбирает unselected slider по body', () => {
const { slider, interaction, container } = createSlider();
setReady(slider);
slider.pointerDown(createPointerEvent(container, 50, 100));
expect(interaction.select).toHaveBeenCalledTimes(1);
});
it('изменяет end time', () => {
const { slider, container } = createSlider('long', true);
setReady(slider);
slider.pointerDown(createPointerEvent(container, 100, 100));
slider.pointerMove(createPointerEvent(container, 150, 100));
expect(slider.getState().endTime).toBe(150);
});
it('поддерживает legacy amount как accountSize', () => {
const { slider } = createSlider();
slider.setState({
amount: 2500,
});
expect(slider.getState().settings.accountSize).toBe(2500);
});
});
describe('SliderPaneRenderer', () => {
it('не рисует без data', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
new SliderPaneRenderer({
getRenderData: () => null,
} as unknown as SliderPosition).draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('рисует profit/loss, labels и три границы', () => {
const data: SliderRenderData = {
startX: 10,
endX: 100,
leftX: 10,
rightX: 100,
entryY: 100,
stopY: 120,
targetY: 80,
entryPrice: 100,
stopPrice: 90,
targetPrice: 120,
profitTop: 80,
profitBottom: 100,
lossTop: 100,
lossBottom: 120,
targetText: 'target',
centerText: 'center',
stopText: 'stop',
centerBoxColor: '#00aa00',
targetLabelDirection: 'up',
stopLabelDirection: 'down',
showLabels: true,
lineColor: '#ffffff',
lineWidth: 1,
positiveFillColor: '#00ff0033',
negativeFillColor: '#ff000033',
textColor: '#ffffff',
fontSize: 10,
};
const { target, context } = createCanvasTarget();
new SliderPaneRenderer({
getRenderData: () => data,
} as unknown as SliderPosition).draw(target);
expect(context.fillRect).toHaveBeenCalledTimes(2);
expect(context.fillText).toHaveBeenCalled();
expect(context.stroke).toHaveBeenCalledTimes(3);
});
});
describe('SliderPaneView', () => {
it('возвращает renderer и top', () => {
const view = new SliderPaneView({} as SliderPosition);
expect(view.renderer()).toBeInstanceOf(SliderPaneRenderer);
expect(view.zOrder()).toBe('top');
});
});
describe('SliderPosition settings', () => {
it('создаёт defaults без boolean-настроек', () => {
expect(createDefaultSettings()).toEqual({
accountSize: 1000,
risk: 25,
lotSize: 1,
leverage: 10000,
quantityPrecision: 3,
lineColor: '#999999',
lineWidth: 1,
positiveFillColor: '#00aa0044',
negativeFillColor: '#aa000044',
textColor: '#ffffff',
fontSize: 10,
});
});
it('создаёт только Arguments и Style tabs', () => {
const tabs = getSliderPositionSettingsTabs(createDefaultSettings(), {
entryPrice: 100,
targetPrice: 120,
stopPrice: 90,
});
expect(tabs.map(({ key }) => key)).toEqual(['inputs', 'style']);
});
it('обновляет Entry, Target и Stop через настройки', () => {
const { slider } = createSlider();
setReady(slider);
slider.updateSettings({
entryPrice: 105,
targetPrice: 105,
stopPrice: 105,
});
expect(slider.getState()).toMatchObject({
entryPrice: 105,
targetPrice: 105,
stopPrice: 104.99,
});
});
});
import { TextPaneRenderer } from '@core/Drawings/text/paneRenderer';
import { TextPaneView } from '@core/Drawings/text/paneView';
import { createDefaultSettings, getTextSettingsTabs } from '@core/Drawings/text/settings';
import { Text, type TextRenderData } from '@core/Drawings/text/text';
import {
createCanvasTarget,
createDrawingTestEnvironment,
createMouseEvent,
createPointerEvent,
} from '../drawingTestUtils';
import type { AxisLabel } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartPriceLineText: '#ffffff',
chartLineColor: '#2962ff',
axisMarkerLabelFill: '#2962ff',
},
}),
}));
jest.mock('@src/translations', () => ({ t: (value: string) => value }));
jest.mock('@src/types/defaults', () => ({
Defaults: {
dateFormat: 'date',
timeFormat: '24h',
showTime: true,
},
}));
jest.mock('@src/utils', () => ({
formatDate: (value: number) => `date:${value}`,
formatPrice: (value: number) => `price:${value}`,
}));
class TestText extends Text {
public hovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public timeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public priceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
public pointerDown(event: PointerEvent): void {
this.handlePointerDown(event);
}
public pointerMove(event: PointerEvent): void {
this.handlePointerMove(event);
}
public pointerUp(event: PointerEvent): void {
this.handlePointerUp(event);
}
public doubleClick(event: MouseEvent): void {
this.handleDoubleClick(event);
}
}
let getContextSpy: jest.SpyInstance;
beforeAll(() => {
getContextSpy = jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
() =>
({
measureText: (value: string) => ({ width: value.length * 8 }),
}) as unknown as CanvasRenderingContext2D,
);
});
afterAll(() => {
getContextSpy.mockRestore();
});
function createText(selected = false) {
const env = createDrawingTestEnvironment({ selected });
const removeSelf = jest.fn();
const openSettings = jest.fn();
const text = new TestText({
...env,
removeSelf,
openSettings,
});
return { ...env, text, removeSelf, openSettings };
}
function setReady(text: TestText): void {
text.setState({
mode: 'ready',
point: { time: 50 as Time, price: 60 },
settings: {
...createDefaultSettings(),
text: 'Test',
},
});
}
describe('Text', () => {
it('создаётся одним pointer down', () => {
const { text, container } = createText();
text.pointerDown(createPointerEvent(container, 50, 60));
expect(text.getState()).toMatchObject({
mode: 'ready',
point: {
time: 50,
price: 60,
},
});
});
it('правильно измеряет text block', () => {
const { text } = createText();
setReady(text);
expect(text.getRenderData()).toMatchObject({
point: { x: 50, y: 60 },
width: 44,
height: 26,
lines: ['Test'],
lineHeight: 14,
});
});
it('поддерживает multiline text', () => {
const { text } = createText();
text.setState({
mode: 'ready',
point: { time: 50 as Time, price: 60 },
settings: {
...createDefaultSettings(),
text: 'One\nTwo',
},
});
expect(text.getRenderData()).toMatchObject({
lines: ['One', 'Two'],
height: 40,
});
});
it('показывает selection border только selected', () => {
const { text, setSelected } = createText();
setReady(text);
expect(text.getRenderData()?.showSelectionBorder).toBe(false);
setSelected(true);
expect(text.getRenderData()?.showSelectionBorder).toBe(true);
});
it('возвращает move cursor внутри текста', () => {
const { text } = createText();
setReady(text);
expect(text.hovered(60, 70)?.cursorStyle).toBe('move');
expect(text.hovered(200, 200)).toBeNull();
});
it('выбирает text первым нажатием', () => {
const { text, container, interaction } = createText();
setReady(text);
text.pointerDown(createPointerEvent(container, 60, 70));
expect(interaction.select).toHaveBeenCalledTimes(1);
});
it('перемещает selected text', () => {
const { text, container } = createText(true);
setReady(text);
text.pointerDown(createPointerEvent(container, 60, 70));
text.pointerMove(createPointerEvent(container, 80, 90));
text.pointerUp(createPointerEvent(container, 80, 90));
expect(text.getState()).toMatchObject({
mode: 'ready',
point: {
time: 70,
price: 80,
},
});
});
it('не позволяет text выйти за пределы контейнера', () => {
const { text, container } = createText(true);
setReady(text);
text.pointerDown(createPointerEvent(container, 60, 70));
text.pointerMove(createPointerEvent(container, 1000, 1000));
const data = text.getRenderData();
expect(data?.right).toBeLessThanOrEqual(400);
expect(data?.bottom).toBeLessThanOrEqual(300);
});
it('показывает axis labels только selected', () => {
const { text } = createText(true);
setReady(text);
expect(text.timeLabel('main')).toMatchObject({
coordinate: 50,
text: 'date:50',
});
expect(text.priceLabel('main')).toMatchObject({
coordinate: 60,
text: 'price:60',
});
});
it('не имеет axis pane segments', () => {
const { text } = createText(true);
setReady(text);
expect(text.timeAxisPaneViews()).toEqual([]);
expect(text.priceAxisPaneViews()).toEqual([]);
});
it('открывает settings по double click selected text', () => {
const { text, container, openSettings } = createText(true);
setReady(text);
text.doubleClick(createMouseEvent(container, 60, 70));
expect(openSettings).toHaveBeenCalledTimes(1);
});
it('не возвращает render data когда hidden', () => {
const { text } = createText();
setReady(text);
text.hide();
expect(text.getRenderData()).toBeNull();
});
});
describe('TextPaneRenderer', () => {
it('ничего не рисует без данных', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
new TextPaneRenderer({
getRenderData: () => null,
} as unknown as Text).draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('рисует background, border, text и selection', () => {
const data: TextRenderData = {
point: { x: 10, y: 20 },
left: 10,
right: 80,
top: 20,
bottom: 60,
width: 70,
height: 40,
lines: ['Test'],
font: '14px Inter',
lineHeight: 14,
text: 'Test',
fontSize: 14,
isBold: false,
isItalic: false,
textColor: '#fff',
backgroundColor: '#111',
borderColor: '#222',
showSelectionBorder: true,
};
const { target, context } = createCanvasTarget();
new TextPaneRenderer({
getRenderData: () => data,
} as unknown as Text).draw(target);
expect(context.fill).toHaveBeenCalled();
expect(context.stroke).toHaveBeenCalled();
expect(context.fillText).toHaveBeenCalledWith('Test', expect.any(Number), expect.any(Number));
});
});
describe('TextPaneView', () => {
it('переиспользует renderer и возвращает top', () => {
const view = new TextPaneView({} as Text);
const renderer = view.renderer();
expect(renderer).toBeInstanceOf(TextPaneRenderer);
expect(view.renderer()).toBe(renderer);
expect(view.zOrder()).toBe('top');
});
});
describe('Text settings', () => {
it('создаёт defaults', () => {
expect(createDefaultSettings()).toEqual({
text: 'Text',
fontSize: 14,
isBold: false,
isItalic: false,
textColor: '#ffffff',
backgroundColor: '#00000000',
borderColor: '#00000000',
});
});
it('создаёт style и text tabs', () => {
expect(getTextSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
});
});
import { TraectoryPaneRenderer } from '@core/Drawings/traectory/paneRenderer';
import { TraectoryPaneView } from '@core/Drawings/traectory/paneView';
import { createDefaultSettings, getTraectorySettingsTabs } from '@core/Drawings/traectory/settings';
import { Traectory } from '@core/Drawings/traectory/traectory';
import {
createCanvasTarget,
createDrawingTestEnvironment,
createMouseEvent,
createPointerEvent,
} from '../drawingTestUtils';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartLineColor: '#2962ff',
axisMarkerAreaFill: '#2962ff44',
},
}),
}));
jest.mock('@src/translations', () => ({ t: (value: string) => value }));
class TestTraectory extends Traectory {
public handles(): readonly DrawingHandle[] {
return this.getDrawingHandles();
}
public timeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public hovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public pointerDown(event: PointerEvent): void {
this.handlePointerDown(event);
}
public pointerMove(event: PointerEvent): void {
this.handlePointerMove(event);
}
public pointerUp(event: PointerEvent): void {
this.handlePointerUp(event);
}
public click(event: MouseEvent): void {
this.handleClick(event);
}
public doubleClick(event: MouseEvent): void {
this.handleDoubleClick(event);
}
public contextMenu(event: MouseEvent): void {
this.handleContextMenu(event);
}
}
function createTraectory(selected = false) {
const env = createDrawingTestEnvironment({ selected });
const removeSelf = jest.fn();
const openSettings = jest.fn();
const traectory = new TestTraectory({
...env,
removeSelf,
openSettings,
});
return { ...env, traectory, removeSelf, openSettings };
}
function setReady(traectory: TestTraectory): void {
traectory.setState({
mode: 'ready',
points: [
{ time: 10 as Time, price: 20 },
{ time: 50 as Time, price: 60 },
{ time: 100 as Time, price: 40 },
],
});
}
describe('Traectory', () => {
it('начинает drawing первым pointer down', () => {
const { traectory, container } = createTraectory();
traectory.pointerDown(createPointerEvent(container, 10, 20));
expect(traectory.getState()).toMatchObject({
mode: 'drawing',
points: [{ time: 10, price: 20 }],
});
});
it('добавляет точки обычным click', () => {
const { traectory, container } = createTraectory();
traectory.pointerDown(createPointerEvent(container, 10, 20));
traectory.click(createMouseEvent(container, 50, 60));
expect(traectory.getState().points).toEqual([
{ time: 10, price: 20 },
{ time: 50, price: 60 },
]);
});
it('не добавляет одну и ту же точку дважды', () => {
const { traectory, container } = createTraectory();
traectory.pointerDown(createPointerEvent(container, 10, 20));
traectory.click(createMouseEvent(container, 10, 20));
expect(traectory.getState().points).toHaveLength(1);
});
it('заканчивает drawing double click', () => {
const { traectory, container } = createTraectory();
traectory.pointerDown(createPointerEvent(container, 10, 20));
traectory.click(createMouseEvent(container, 50, 60));
traectory.doubleClick(createMouseEvent(container, 50, 60, 2));
expect(traectory.getState().mode).toBe('ready');
expect(traectory.getRenderData()?.showArrow).toBe(true);
});
it('заканчивает drawing context menu', () => {
const { traectory, container } = createTraectory();
traectory.pointerDown(createPointerEvent(container, 10, 20));
traectory.click(createMouseEvent(container, 50, 60));
traectory.contextMenu(createMouseEvent(container, 50, 60));
expect(traectory.getState().mode).toBe('ready');
});
it('удаляется при завершении с одной точкой', () => {
const { traectory, container, removeSelf } = createTraectory();
traectory.pointerDown(createPointerEvent(container, 10, 20));
traectory.doubleClick(createMouseEvent(container, 10, 20, 2));
expect(removeSelf).toHaveBeenCalledTimes(1);
});
it('возвращает handles в обратном порядке', () => {
const { traectory } = createTraectory();
setReady(traectory);
expect(traectory.handles()).toEqual([
{ id: '2', x: 100, y: 40, shape: 'circle' },
{ id: '1', x: 50, y: 60, shape: 'circle' },
{ id: '0', x: 10, y: 20, shape: 'circle' },
]);
});
it('возвращает axis segments', () => {
const { traectory } = createTraectory(true);
setReady(traectory);
expect(traectory.timeSegments()).toEqual([{ from: 10, to: 100, color: '#2962ff44' }]);
expect(traectory.priceSegments()).toEqual([{ from: 20, to: 60, color: '#2962ff44' }]);
});
it('не имеет axis labels', () => {
const { traectory } = createTraectory(true);
setReady(traectory);
expect(traectory.timeAxisViews()).toEqual([]);
expect(traectory.priceAxisViews()).toEqual([]);
});
it('определяет segment как drawing hit', () => {
const { traectory } = createTraectory();
setReady(traectory);
expect(traectory.hovered(30, 40)?.cursorStyle).toBe('move');
expect(traectory.hovered(300, 200)).toBeNull();
});
it('перемещает отдельную точку', () => {
const { traectory, container } = createTraectory(true);
setReady(traectory);
traectory.pointerDown(createPointerEvent(container, 50, 60));
traectory.pointerMove(createPointerEvent(container, 70, 80));
traectory.pointerUp(createPointerEvent(container, 70, 80));
expect(traectory.getState().points[1]).toEqual({
time: 70,
price: 80,
});
});
it('перемещает всю trajectory', () => {
const { traectory, container } = createTraectory(true);
setReady(traectory);
traectory.pointerDown(createPointerEvent(container, 30, 40));
traectory.pointerMove(createPointerEvent(container, 40, 50));
expect(traectory.getState().points).toEqual([
{ time: 20, price: 30 },
{ time: 60, price: 70 },
{ time: 110, price: 50 },
]);
});
it('открывает settings по double click', () => {
const { traectory, container, openSettings } = createTraectory(true);
setReady(traectory);
traectory.doubleClick(createMouseEvent(container, 30, 40, 2));
expect(openSettings).toHaveBeenCalledTimes(1);
});
it('не возвращает render data когда hidden', () => {
const { traectory } = createTraectory();
setReady(traectory);
traectory.hide();
expect(traectory.getRenderData()).toBeNull();
});
});
describe('TraectoryPaneRenderer', () => {
it('не рисует без data', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
new TraectoryPaneRenderer({
getRenderData: () => null,
} as unknown as Traectory).draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('рисует trajectory', () => {
const { traectory } = createTraectory();
setReady(traectory);
const { target, context } = createCanvasTarget();
new TraectoryPaneRenderer(traectory).draw(target);
expect(context.stroke).toHaveBeenCalled();
});
});
describe('TraectoryPaneView', () => {
it('возвращает renderer и top', () => {
const view = new TraectoryPaneView({} as Traectory);
expect(view.renderer()).toBeInstanceOf(TraectoryPaneRenderer);
expect(view.zOrder()).toBe('top');
});
});
describe('Traectory settings', () => {
it('создаёт default lineColor', () => {
expect(createDefaultSettings()).toEqual({
lineColor: '#2962ff',
});
});
it('имеет один style tab', () => {
expect(getTraectorySettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style']);
});
});
import { VolumeProfilePaneRenderer } from '@core/Drawings/volumeProfile/paneRenderer';
import { VolumeProfilePaneView } from '@core/Drawings/volumeProfile/paneView';
import { createDefaultSettings, getVolumeProfileSettingsTabs } from '@core/Drawings/volumeProfile/settings';
import { VolumeProfile, type VolumeProfileRenderData } from '@core/Drawings/volumeProfile/volumeProfile';
import { createCanvasTarget, createDrawingTestEnvironment, createPointerEvent } from '../drawingTestUtils';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { MouseEventParams, PrimitiveHoveredItem, Time } from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
volumeProfileAreaFill: '#11111122',
volumeProfileBuyFill: '#00aa00',
volumeProfileSellFill: '#aa0000',
volumeProfilePocLine: '#ffaa00',
axisMarkerAreaFill: '#2962ff44',
axisMarkerLabelFill: '#2962ff',
chartPriceLineText: '#ffffff',
},
}),
}));
jest.mock('@src/types/defaults', () => ({
Defaults: {
dateFormat: 'date',
timeFormat: '24h',
showTime: true,
},
}));
jest.mock('@src/translations', () => ({ t: (value: string) => value }));
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => `price:${value}`,
}));
jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
const candles = [
{ time: 10 as Time, open: 10, high: 14, low: 9, close: 12, volume: 100 },
{ time: 20 as Time, open: 12, high: 16, low: 10, close: 11, volume: 50 },
{ time: 30 as Time, open: 11, high: 25, low: 10, close: 20, customValues: { volume: 200 } },
{ time: 40 as Time, open: 20, high: 31, low: 18, close: 30, volume: 150 },
];
class TestVolumeProfile extends VolumeProfile {
public handles(): readonly DrawingHandle[] {
return this.getDrawingHandles();
}
public timeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public timeLabel(kind: string): AxisLabel | null {
return this.getTimeAxisLabel(kind);
}
public priceLabel(kind: string): AxisLabel | null {
return this.getPriceAxisLabel(kind);
}
public hovered(x: number, y: number): PrimitiveHoveredItem | null {
return this.getHoveredItem(x, y);
}
public pointerDown(event: PointerEvent): void {
this.handlePointerDown(event);
}
public pointerMove(event: PointerEvent): void {
this.handlePointerMove(event);
}
public pointerUp(event: PointerEvent): void {
this.handlePointerUp(event);
}
}
function createProfile(profileKind: 'fixedRange' | 'visibleRange' = 'fixedRange', selected = false) {
const env = createDrawingTestEnvironment({ selected, seriesData: candles });
const removeSelf = jest.fn();
const openSettings = jest.fn();
const profile = new TestVolumeProfile({
...env,
profileKind,
removeSelf,
openSettings,
});
return { ...env, profile, removeSelf, openSettings };
}
function createInitialEvent(sourceEvent: PointerEvent): MouseEventParams {
return { sourceEvent } as unknown as MouseEventParams;
}
function setFixedReady(profile: TestVolumeProfile): void {
profile.setState({
mode: 'ready',
startAnchor: { time: 10 as Time, price: 10 },
endAnchor: { time: 40 as Time, price: 30 },
});
}
describe('VolumeProfile fixedRange', () => {
it('является creation pending до завершения', () => {
const { profile } = createProfile();
expect(profile.isCreationPending()).toBe(true);
});
it('рассчитывает 24 profile rows', () => {
const { profile } = createProfile();
setFixedReady(profile);
expect(profile.getRenderData()?.rows).toHaveLength(24);
});
it('рассчитывает min/max по high и low candles', () => {
const { profile } = createProfile();
setFixedReady(profile);
expect(profile.getRenderData()).toMatchObject({
left: 10,
right: 40,
top: 9,
bottom: 31,
});
});
it('использует customValues.volume раньше обычного volume', () => {
const { profile } = createProfile();
setFixedReady(profile);
const rows = profile.getRenderData()?.rows ?? [];
const hasVolume = rows.some(({ buyWidth, sellWidth }) => buyWidth > 0 || sellWidth > 0);
expect(hasVolume).toBe(true);
});
it('рассчитывает POC', () => {
const { profile } = createProfile();
setFixedReady(profile);
expect(profile.getRenderData()?.pocY).not.toBeNull();
});
it('возвращает два edge handles', () => {
const { profile } = createProfile();
setFixedReady(profile);
expect(profile.handles()).toEqual([
{ id: 'end', x: 40, y: 31, shape: 'circle' },
{ id: 'start', x: 10, y: 9, shape: 'circle' },
]);
});
it('возвращает selected axis segments', () => {
const { profile } = createProfile('fixedRange', true);
setFixedReady(profile);
expect(profile.timeSegments()).toEqual([{ from: 10, to: 40, color: '#2962ff44' }]);
expect(profile.priceSegments()).toEqual([{ from: 9, to: 31, color: '#2962ff44' }]);
});
it('возвращает axis labels', () => {
const { profile } = createProfile('fixedRange', true);
setFixedReady(profile);
expect(profile.timeLabel('start')).toMatchObject({
coordinate: 10,
text: 'date:10',
});
expect(profile.priceLabel('start')).toMatchObject({
coordinate: 9,
text: 'price:9',
});
expect(profile.priceLabel('end')).toMatchObject({
coordinate: 31,
text: 'price:31',
});
});
it('создаётся двумя pointer down', () => {
const { profile, container } = createProfile();
profile.pointerDown(createPointerEvent(container, 10, 10));
profile.pointerDown(createPointerEvent(container, 40, 30));
expect(profile.getState().mode).toBe('ready');
});
it('удаляет слишком маленький profile', () => {
const { profile, container, removeSelf } = createProfile();
profile.pointerDown(createPointerEvent(container, 10, 10));
profile.pointerDown(createPointerEvent(container, 14, 14));
expect(removeSelf).toHaveBeenCalledTimes(1);
});
it('перемещает start handle', () => {
const { profile, container } = createProfile('fixedRange', true);
setFixedReady(profile);
profile.pointerDown(createPointerEvent(container, 10, 9));
profile.pointerMove(createPointerEvent(container, 20, 15));
profile.pointerUp(createPointerEvent(container, 20, 15));
expect(profile.getState().startAnchor).toEqual({
time: 20,
price: 15,
});
});
it('выбирает unselected profile', () => {
const { profile, container, interaction } = createProfile();
setFixedReady(profile);
profile.pointerDown(createPointerEvent(container, 25, 20));
expect(interaction.select).toHaveBeenCalledTimes(1);
});
it('возвращает pointer/grab в зависимости от selection', () => {
const unselected = createProfile();
setFixedReady(unselected.profile);
expect(unselected.profile.hovered(25, 20)?.cursorStyle).toBe('pointer');
const selected = createProfile('fixedRange', true);
setFixedReady(selected.profile);
expect(selected.profile.hovered(25, 20)?.cursorStyle).toBe('grab');
});
});
describe('VolumeProfile visibleRange', () => {
it('сразу находится в ready', () => {
const { profile } = createProfile('visibleRange');
expect(profile.getState().mode).toBe('ready');
expect(profile.isCreationPending()).toBe(false);
});
it('использует initialEvent как начало profile и остаётся в ready', () => {
const env = createDrawingTestEnvironment({
selected: false,
seriesData: candles,
});
const sourceEvent = createPointerEvent(env.container, 200, 20);
const profile = new TestVolumeProfile({
...env,
profileKind: 'visibleRange',
initialEvent: createInitialEvent(sourceEvent),
});
expect(profile.getState()).toMatchObject({
mode: 'ready',
visibleRangeStartRatio: 0.5,
});
expect(profile.isCreationPending()).toBe(false);
expect(profile.getRenderData()).toMatchObject({
left: 200,
right: 400,
width: 200,
profileKind: 'visibleRange',
});
});
it('показывает время левой точки на time axis', () => {
const { profile } = createProfile('visibleRange', true);
profile.setState({ visibleRangeStartRatio: 0.5 });
expect(profile.timeLabel('start')).toMatchObject({
coordinate: 200,
text: 'date:200',
});
expect(profile.timeLabel('end')).toBeNull();
});
it('отображается в Object Tree', () => {
const { profile } = createProfile('visibleRange');
expect(profile.shouldShowInObjectTree()).toBe(true);
});
it('имеет только start time axis view', () => {
const { profile } = createProfile('visibleRange');
expect(profile.timeAxisPaneViews()).toEqual([]);
expect(profile.priceAxisPaneViews()).toEqual([]);
expect(profile.timeAxisViews()).toHaveLength(1);
expect(profile.priceAxisViews()).toEqual([]);
});
it('clamp visibleRangeStartRatio до 0.95', () => {
const { profile } = createProfile('visibleRange');
profile.setState({ visibleRangeStartRatio: 2 });
expect(profile.getState().visibleRangeStartRatio).toBe(0.95);
});
it('меняет левую границу через ratio', () => {
const { profile } = createProfile('visibleRange');
profile.setState({ visibleRangeStartRatio: 0.5 });
expect(profile.getRenderData()).toMatchObject({
left: 200,
right: 400,
width: 200,
profileKind: 'visibleRange',
});
});
it('имеет только POC handle', () => {
const { profile, setSelected } = createProfile('visibleRange');
setSelected(true);
const handles = profile.handles();
expect(handles).toHaveLength(1);
expect(handles[0]).toMatchObject({
id: 'poc',
x: 0,
shape: 'circle',
});
});
it('POC handle имеет ew-resize cursor', () => {
const { profile, setSelected } = createProfile('visibleRange');
setSelected(true);
const [handle] = profile.handles();
expect(profile.hovered(handle.x, handle.y)?.cursorStyle).toBe('ew-resize');
});
it('перемещает начало visible profile через POC', () => {
const { profile, container, setSelected } = createProfile('visibleRange');
setSelected(true);
const [handle] = profile.handles();
profile.pointerDown(createPointerEvent(container, handle.x, handle.y));
profile.pointerMove(createPointerEvent(container, 300, handle.y));
expect(profile.getState().visibleRangeStartRatio).toBe(0.75);
});
it('обновляет time label после перемещения POC handle', () => {
const { profile, container, setSelected } = createProfile('visibleRange');
setSelected(true);
const [handle] = profile.handles();
profile.pointerDown(createPointerEvent(container, handle.x, handle.y));
profile.pointerMove(createPointerEvent(container, 300, handle.y));
expect(profile.timeLabel('start')).toMatchObject({
coordinate: 300,
text: 'date:300',
});
});
it('подписывается и отписывается от visible range', () => {
const { profile, timeScale } = createProfile('visibleRange');
expect(timeScale.subscribeVisibleLogicalRangeChange).toHaveBeenCalledTimes(1);
profile.destroy();
expect(timeScale.unsubscribeVisibleLogicalRangeChange).toHaveBeenCalledTimes(1);
});
});
describe('VolumeProfilePaneRenderer', () => {
it('не рисует без render data', () => {
const { target, useBitmapCoordinateSpace } = createCanvasTarget();
new VolumeProfilePaneRenderer({
getRenderData: () => null,
} as unknown as VolumeProfile).draw(target);
expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
});
it('рисует background, rows и POC', () => {
const data: VolumeProfileRenderData = {
startPoint: { x: 10, y: 10 },
endPoint: { x: 100, y: 100 },
left: 10,
right: 100,
top: 10,
bottom: 100,
width: 90,
height: 90,
profileKind: 'fixedRange',
rows: [
{
top: 10,
height: 10,
buyWidth: 30,
sellWidth: 20,
},
],
pocY: 50,
areaFillColor: '#111',
buyFillColor: '#0a0',
sellFillColor: '#a00',
pocLineColor: '#fa0',
};
const { target, context } = createCanvasTarget();
new VolumeProfilePaneRenderer({
getRenderData: () => data,
} as unknown as VolumeProfile).draw(target);
expect(context.fillRect).toHaveBeenCalledTimes(3);
expect(context.stroke).toHaveBeenCalledTimes(1);
});
it('рисует visibleRange справа налево и POC на всю ширину canvas', () => {
const data: VolumeProfileRenderData = {
startPoint: { x: 200, y: 10 },
endPoint: { x: 400, y: 100 },
left: 200,
right: 400,
top: 10,
bottom: 100,
width: 200,
height: 90,
profileKind: 'visibleRange',
rows: [{ top: 10, height: 10, buyWidth: 30, sellWidth: 20 }],
pocY: 50,
areaFillColor: '#111',
buyFillColor: '#0a0',
sellFillColor: '#a00',
pocLineColor: '#fa0',
};
const { target, context } = createCanvasTarget();
new VolumeProfilePaneRenderer({
getRenderData: () => data,
} as unknown as VolumeProfile).draw(target);
expect(context.moveTo).toHaveBeenCalledWith(0, 100);
expect(context.lineTo).toHaveBeenCalledWith(800, 100);
});
});
describe('VolumeProfilePaneView', () => {
it('возвращает renderer и top', () => {
const view = new VolumeProfilePaneView({} as VolumeProfile);
expect(view.renderer()).toBeInstanceOf(VolumeProfilePaneRenderer);
expect(view.zOrder()).toBe('top');
});
});
describe('VolumeProfile settings', () => {
it('создаёт defaults', () => {
expect(createDefaultSettings()).toEqual({
areaFillColor: '#11111122',
buyFillColor: '#00aa00',
sellFillColor: '#aa0000',
pocLineColor: '#ffaa00',
});
});
it('имеет один style tab', () => {
expect(getVolumeProfileSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style']);
});
});