Загрузка данных
import { BehaviorSubject } from 'rxjs';
import type { BaseDrawingParams } from '@core/Drawings/SeriesDrawingBase';
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;
}
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 timeScale = {
coordinateToTime: jest.fn((coordinate: Coordinate) => Number(coordinate) as Time),
timeToCoordinate: jest.fn((time: Time) => Number(time) as Coordinate),
coordinateToLogical: jest.fn((coordinate: Coordinate) => Number(coordinate) as Logical),
logicalToCoordinate: jest.fn((logical: Logical) => Number(logical) as Coordinate),
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 series = {
coordinateToPrice: jest.fn((coordinate: Coordinate) => Number(coordinate)),
priceToCoordinate: jest.fn((price: number) => price as Coordinate),
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,
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() {
const context = {
canvas: { width: 800, height: 600 },
save: jest.fn(),
restore: jest.fn(),
beginPath: jest.fn(),
closePath: jest.fn(),
moveTo: jest.fn(),
lineTo: 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: 2,
verticalPixelRatio: 2,
bitmapSize: { width: 800, height: 600 },
}),
);
return {
context,
useBitmapCoordinateSpace,
target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
};
}
import { Rectangle, type RectangleRenderData } from '@core/Drawings/rectangle/rectangle';
import { RectanglePaneRenderer } from '@core/Drawings/rectangle/paneRenderer';
import { RectanglePaneView } from '@core/Drawings/rectangle/paneView';
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/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 {
RegressionTrend,
type RegressionTrendRenderData,
} from '@core/Drawings/regressionTrend/regressionTrend';
import { RegressionTrendPaneRenderer } from '@core/Drawings/regressionTrend/paneRenderer';
import { RegressionTrendPaneView } from '@core/Drawings/regressionTrend/paneView';
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/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',
borderWidth: 2,
},
{
id: 'end',
x: 40,
y: 27,
shape: 'circle',
borderWidth: 2,
},
]);
});
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 { Ruler, type RulerRenderData } from '@core/Drawings/ruler/ruler';
import { RulerPaneRenderer } from '@core/Drawings/ruler/paneRenderer';
import { RulerPaneView } from '@core/Drawings/ruler/paneView';
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/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();
new TestRuler({
...env,
removeSelf,
resetTriggers: [reset$],
});
expect(removeSelf).not.toHaveBeenCalled();
reset$.next(true);
expect(removeSelf).toHaveBeenCalledTimes(1);
});
});
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 { SliderPosition, type SliderRenderData } from '@core/Drawings/sliderPosition/sliderPosition';
import { SliderPaneRenderer } from '@core/Drawings/sliderPosition/paneRenderer';
import { SliderPaneView } from '@core/Drawings/sliderPosition/paneView';
import { createDefaultSettings, getSliderPositionSettingsTabs } from '@core/Drawings/sliderPosition/settings';
import { createCanvasTarget, createDrawingTestEnvironment, 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: {
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/utils', () => ({
formatPrice: (value: number) => String(value),
formatPercent: (value: number) => `${value}%`,
formatSignedNumber: (value: number) => String(value),
}));
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,
tickSize: 1,
});
}
describe('SliderPosition', () => {
it('считает Risk/Reward Ratio', () => {
const { slider } = createSlider();
setReady(slider);
expect(slider.getState().riskRewardRatio).toBe(2);
});
it('поддерживает RR = 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,
});
});
it('возвращает RR = 0 когда Target совпадает с Entry', () => {
const { slider } = createSlider();
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 100,
});
expect(slider.getState().riskRewardRatio).toBe(0);
});
it('возвращает RR = 0 когда Stop совпадает с Entry', () => {
const { slider } = createSlider();
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 100,
targetPrice: 120,
});
expect(slider.getState().riskRewardRatio).toBe(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 handle имеет приоритет над Target при совпадении', () => {
const { slider, container } = createSlider('long', true);
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 90,
targetPrice: 100,
});
slider.pointerDown(createPointerEvent(container, 10, 100));
slider.pointerMove(createPointerEvent(container, 10, 95));
expect(slider.getState()).toMatchObject({
entryPrice: 95,
targetPrice: 100,
});
});
it('Entry handle имеет приоритет над Stop при совпадении', () => {
const { slider, container } = createSlider('long', true);
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 100,
targetPrice: 120,
});
slider.pointerDown(createPointerEvent(container, 10, 100));
slider.pointerMove(createPointerEvent(container, 10, 110));
expect(slider.getState()).toMatchObject({
entryPrice: 110,
stopPrice: 100,
});
});
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(100);
});
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 позиции', () => {
const { slider } = createSlider('short');
slider.setState({
mode: 'ready',
startTime: 10 as Time,
endTime: 100 as Time,
entryPrice: 100,
stopPrice: 110,
targetPrice: 80,
});
expect(slider.getState().riskRewardRatio).toBe(2);
expect(slider.getRenderData()).toMatchObject({
targetLabelDirection: 'down',
stopLabelDirection: 'up',
});
});
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('использует normal zOrder для selected axis pane areas', () => {
const { slider } = createSlider();
expect(slider.timeAxisPaneViews()[0].zOrder?.()).toBe('normal');
expect(slider.priceAxisPaneViews()[0].zOrder?.()).toBe('normal');
});
it('time labels показываются только selected drawing', () => {
const { slider } = createSlider();
setReady(slider);
expect(slider.timeLabel('start')).toBeNull();
});
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);
});
});
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: '#0a0',
targetLabelDirection: 'up',
stopLabelDirection: 'down',
showLabels: true,
lineColor: '#fff',
positiveFillColor: '#0f03',
negativeFillColor: '#f003',
textColor: '#fff',
fontSize: 10,
isBold: false,
isItalic: false,
};
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).toHaveBeenCalled();
});
});
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', () => {
expect(createDefaultSettings()).toEqual({
lineColor: '#999999',
positiveFillColor: '#00aa0044',
negativeFillColor: '#aa000044',
textColor: '#ffffff',
fontSize: 10,
isBold: false,
isItalic: false,
});
});
it('создаёт style и text tabs', () => {
expect(getSliderPositionSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
});
});
import { Text, type TextRenderData } from '@core/Drawings/text/text';
import { TextPaneRenderer } from '@core/Drawings/text/paneRenderer';
import { TextPaneView } from '@core/Drawings/text/paneView';
import { createDefaultSettings, getTextSettingsTabs } from '@core/Drawings/text/settings';
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/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 { Traectory } from '@core/Drawings/traectory/traectory';
import { TraectoryPaneRenderer } from '@core/Drawings/traectory/paneRenderer';
import { TraectoryPaneView } from '@core/Drawings/traectory/paneView';
import { createDefaultSettings, getTraectorySettingsTabs } from '@core/Drawings/traectory/settings';
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', size: 10, borderWidth: 2 },
{ id: '1', x: 50, y: 60, shape: 'circle', size: 10, borderWidth: 2 },
{ id: '0', x: 10, y: 20, shape: 'circle', size: 10, borderWidth: 2 },
]);
});
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 {
VolumeProfile,
type VolumeProfileRenderData,
} from '@core/Drawings/volumeProfile/volumeProfile';
import { VolumeProfilePaneRenderer } from '@core/Drawings/volumeProfile/paneRenderer';
import { VolumeProfilePaneView } from '@core/Drawings/volumeProfile/paneView';
import {
createDefaultSettings,
getVolumeProfileSettingsTabs,
} from '@core/Drawings/volumeProfile/settings';
import { createCanvasTarget, createDrawingTestEnvironment, 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: {
volumeProfileAreaFill: '#11111122',
volumeProfileBuyFill: '#00aa00',
volumeProfileSellFill: '#aa0000',
volumeProfilePocLine: '#ffaa00',
axisMarkerAreaFill: '#2962ff44',
axisMarkerLabelFill: '#2962ff',
chartPriceLineText: '#ffffff',
},
}),
}));
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 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', size: 10, borderWidth: 2 },
{ id: 'start', x: 10, y: 9, shape: 'circle', size: 10, borderWidth: 2 },
]);
});
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('отображается в Object Tree', () => {
const { profile } = createProfile('visibleRange');
expect(profile.shouldShowInObjectTree()).toBe(true);
});
it('не имеет axis views', () => {
const { profile } = createProfile('visibleRange');
expect(profile.timeAxisPaneViews()).toEqual([]);
expect(profile.priceAxisPaneViews()).toEqual([]);
expect(profile.timeAxisViews()).toEqual([]);
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',
size: 10,
borderWidth: 2,
});
});
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('подписывается и отписывается от 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']);
});
});