Загрузка данных
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;
}
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 { 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('не должен возвращать label для unselected line', () => {
const { drawing } = createAxisLine('vertical');
drawing.setState({
mode: 'ready',
time: 80 as Time,
});
expect(drawing.timeLabel('main')).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 { 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',
size: 12,
borderWidth: 2,
},
{
id: 'start',
x: 50,
y: 50,
shape: 'circle',
size: 12,
borderWidth: 2,
},
]);
});
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 { LineDrawingRenderData } from '@core/Drawings/line/lineDrawing';
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',
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',
borderWidth: 2,
},
{
id: 'end',
x: 150,
y: 150,
shape: 'circle',
borderWidth: 2,
},
]);
});
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 { ParallelChannel } from '@core/Drawings/parallelChannel/parallelChannel';
import { ParallelChannelPaneRenderer } from '@core/Drawings/parallelChannel/paneRenderer';
import { ParallelChannelPaneView } from '@core/Drawings/parallelChannel/paneView';
import {
createDefaultSettings,
getParallelChannelSettingsTabs,
} from '@core/Drawings/parallelChannel/settings';
import {
createCanvasTarget,
createDrawingTestEnvironment,
createMouseEvent,
createPointerEvent,
} from './drawingTestUtils';
import type { ParallelChannelRenderData } from '@core/Drawings/parallelChannel/parallelChannel';
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',
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',
borderWidth: 2,
},
{
id: 'parallel-middle',
x: 100,
y: 125,
shape: 'circle',
borderWidth: 2,
},
{
id: 'parallel-start',
x: 50,
y: 110,
shape: 'circle',
borderWidth: 2,
},
{
id: 'main-end',
x: 150,
y: 80,
shape: 'circle',
borderWidth: 2,
},
{
id: 'main-middle',
x: 100,
y: 65,
shape: 'circle',
borderWidth: 2,
},
{
id: 'main-start',
x: 50,
y: 50,
shape: 'circle',
borderWidth: 2,
},
]);
});
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 { Ray } from '@core/Drawings/ray/ray';
import { RayPaneRenderer } from '@core/Drawings/ray/paneRenderer';
import { RayPaneView } from '@core/Drawings/ray/paneView';
import { createDefaultSettings, getRaySettingTabs } from '@core/Drawings/ray/settings';
import {
createCanvasTarget,
createDrawingTestEnvironment,
createMouseEvent,
createPointerEvent,
} from './drawingTestUtils';
import type { RayRenderData } from '@core/Drawings/ray/ray';
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',
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',
borderWidth: 2,
strokeColor: '#2962ff',
},
{
id: 'direction',
x: 150,
y: 100,
shape: 'circle',
borderWidth: 2,
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']);
});
});