Загрузка данных
import {
DrawingBase,
type BaseDrawingParams,
} from '@src/core/Drawings/base';
import {
createDrawingTestEnvironment,
createMouseEvent,
createPointerEvent,
} from '../../drawingTestUtils';
import type {
Anchor,
AxisLabel,
AxisSegment,
Bounds,
Point,
UpdatableView,
} from '@core/Drawings/types';
import type {
IPrimitivePaneView,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
Time,
} from 'lightweight-charts';
import type { SettingsTab, SettingsValues } from '@src/types';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartPriceLineText: '#ffffff',
axisMarkerLabelFill: '#112233',
axisMarkerAreaFill: '#445566',
},
}),
}));
type TestMode = 'idle' | 'ready';
class TestDrawingBase extends DrawingBase<SettingsValues, 'main'> {
protected mode: TestMode = 'ready';
protected settings: SettingsValues = {
lineWidth: 1,
};
private geometry: Bounds | null = null;
private hoveredItem: PrimitiveHoveredItem | null = null;
public getRenderData(): Bounds | null {
return this.geometry;
}
public getState() {
return {
hidden: this.hidden,
mode: this.mode,
settings: this.settings,
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<{
hidden: boolean;
mode: TestMode;
}>;
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if (nextState.mode) {
this.mode = nextState.mode;
}
}
public getSettingsTabs(): SettingsTab[] {
return [];
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public setMode(mode: TestMode): void {
this.mode = mode;
}
public setGeometry(geometry: Bounds | null): void {
this.geometry = geometry;
}
public setHoveredItem(item: PrimitiveHoveredItem | null): void {
this.hoveredItem = item;
}
public createAnchor(point: Point): Anchor | null {
return this.createAnchorFromPoint(point);
}
public getAnchorPoint(anchor: Anchor | null): Point | null {
return this.getPointFromAnchor(anchor);
}
public createLabel(
coordinate: number | null,
text: string,
style?: Partial<Pick<AxisLabel, 'textColor' | 'backgroundColor'>>,
): AxisLabel | null {
return this.createAxisLabel(coordinate, text, style);
}
public createSegment(
from: number,
to: number,
color?: string,
): AxisSegment {
return this.createAxisSegment(from, to, color);
}
public getBoundsSegments(axis: 'time' | 'price'): AxisSegment[] {
return this.getBoundsAxisSegments(axis, () => this.geometry);
}
public getInteractiveLabel(
kind: string,
kinds: readonly string[],
getLabel: (kind: string) => AxisLabel | null,
): AxisLabel | null {
return this.getInteractiveAxisLabel(kind, kinds, getLabel);
}
public consume(event: MouseEvent): void {
this.consumeEvent(event);
}
public selectOnDown(
event: PointerEvent,
isHit: () => boolean,
): boolean {
return this.selectOnPointerDown(event, isHit);
}
public openSettings(
event: MouseEvent,
isHit: (point: Point) => boolean,
callback: (() => void) | undefined,
selectedOnly = false,
): void {
this.openSettingsOnDoubleClick(
event,
isHit,
callback,
selectedOnly,
);
}
public initialize(
paneView: IPrimitivePaneView & UpdatableView,
timeLabels: readonly string[],
priceLabels: readonly string[],
): void {
this.initializeViews(
paneView,
timeLabels,
priceLabels,
);
}
public triggerRender(): void {
this.render();
}
protected getHoveredItem(): PrimitiveHoveredItem | null {
return this.hoveredItem;
}
protected getGeometry(): Bounds | null {
return this.geometry;
}
protected getTimeAxisSegments(): AxisSegment[] {
return [];
}
protected getPriceAxisSegments(): AxisSegment[] {
return [];
}
protected getTimeAxisLabel(): AxisLabel | null {
return null;
}
protected getPriceAxisLabel(): AxisLabel | null {
return null;
}
}
function createDrawing(
options: Parameters<typeof createDrawingTestEnvironment>[0] = {},
) {
const env = createDrawingTestEnvironment(options);
const drawing = new TestDrawingBase(env);
return {
...env,
drawing,
};
}
function attachDrawing(
drawing: TestDrawingBase,
env: ReturnType<typeof createDrawingTestEnvironment>,
requestUpdate = jest.fn(),
): jest.Mock {
drawing.attached({
chart: env.chart,
series: env.series,
requestUpdate,
} as unknown as SeriesAttachedParameter<Time, keyof SeriesOptionsMap>);
requestUpdate.mockClear();
return requestUpdate;
}
describe('DrawingBase', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('anchors', () => {
it('должен создавать anchor из point', () => {
const { drawing } = createDrawing();
expect(
drawing.createAnchor({
x: 120,
y: 80,
}),
).toEqual({
time: 120,
price: 80,
});
});
it('должен возвращать null, если anchor создать невозможно', () => {
const { drawing, timeScale } = createDrawing();
timeScale.coordinateToTime.mockReturnValue(null);
expect(
drawing.createAnchor({
x: 120,
y: 80,
}),
).toBeNull();
});
it('должен преобразовывать anchor в point без округления', () => {
const { drawing } = createDrawing();
expect(
drawing.getAnchorPoint({
time: 120.25 as Time,
price: 80.75,
}),
).toEqual({
x: 120.25,
y: 80.75,
});
});
it('должен возвращать null для пустого anchor', () => {
const { drawing } = createDrawing();
expect(
drawing.getAnchorPoint(null),
).toBeNull();
});
});
describe('axis helpers', () => {
it('должен создавать axis label с theme цветами', () => {
const { drawing } = createDrawing();
expect(
drawing.createLabel(
100,
'100',
),
).toEqual({
coordinate: 100,
text: '100',
textColor: '#ffffff',
backgroundColor: '#112233',
});
});
it('должен поддерживать пользовательские цвета axis label', () => {
const { drawing } = createDrawing();
expect(
drawing.createLabel(
100,
'100',
{
textColor: '#111111',
backgroundColor: '#222222',
},
),
).toEqual({
coordinate: 100,
text: '100',
textColor: '#111111',
backgroundColor: '#222222',
});
});
it('должен возвращать null для label без coordinate', () => {
const { drawing } = createDrawing();
expect(
drawing.createLabel(
null,
'100',
),
).toBeNull();
});
it('должен возвращать null для label без текста', () => {
const { drawing } = createDrawing();
expect(
drawing.createLabel(
100,
'',
),
).toBeNull();
});
it('должен создавать axis segment', () => {
const { drawing } = createDrawing();
expect(
drawing.createSegment(
10,
20,
),
).toEqual({
from: 10,
to: 20,
color: '#445566',
});
});
it('должен строить time segment по bounds только для interactive axis', () => {
const {
drawing,
setSelected,
} = createDrawing();
drawing.setGeometry({
left: 10,
right: 120,
top: 30,
bottom: 80,
});
expect(
drawing.getBoundsSegments('time'),
).toEqual([]);
setSelected(true);
expect(
drawing.getBoundsSegments('time'),
).toEqual([
{
from: 10,
to: 120,
color: '#445566',
},
]);
});
it('должен строить price segment по bounds', () => {
const {
drawing,
setSelected,
} = createDrawing();
drawing.setGeometry({
left: 10,
right: 120,
top: 30,
bottom: 80,
});
setSelected(true);
expect(
drawing.getBoundsSegments('price'),
).toEqual([
{
from: 30,
to: 80,
color: '#445566',
},
]);
});
it('должен показывать interactive axis во время создания', () => {
const { drawing } = createDrawing();
drawing.setGeometry({
left: 10,
right: 120,
top: 30,
bottom: 80,
});
drawing.setMode('idle');
expect(
drawing.getBoundsSegments('time'),
).toHaveLength(1);
});
it('должен возвращать interactive label только для разрешённого kind', () => {
const {
drawing,
setSelected,
} = createDrawing();
setSelected(true);
const getLabel = jest.fn(
(kind: string): AxisLabel => ({
coordinate: 100,
text: kind,
textColor: '#ffffff',
backgroundColor: '#112233',
}),
);
expect(
drawing.getInteractiveLabel(
'start',
['start', 'end'],
getLabel,
),
).toEqual({
coordinate: 100,
text: 'start',
textColor: '#ffffff',
backgroundColor: '#112233',
});
expect(
drawing.getInteractiveLabel(
'unknown',
['start', 'end'],
getLabel,
),
).toBeNull();
});
});
describe('views', () => {
it('должен регистрировать pane и axis views через initializeViews', () => {
const { drawing } = createDrawing();
const paneView = {
update: jest.fn(),
renderer: jest.fn(),
zOrder: jest.fn(),
} as unknown as IPrimitivePaneView & UpdatableView;
drawing.initialize(
paneView,
['start', 'end'],
['price'],
);
expect(
drawing.paneViews(),
).toEqual([
paneView,
]);
expect(
drawing.timeAxisPaneViews(),
).toHaveLength(1);
expect(
drawing.priceAxisPaneViews(),
).toHaveLength(1);
expect(
drawing.timeAxisViews(),
).toHaveLength(2);
expect(
drawing.priceAxisViews(),
).toHaveLength(1);
});
it('должен обновлять зарегистрированный pane view', () => {
const { drawing } = createDrawing();
const paneView = {
update: jest.fn(),
renderer: jest.fn(),
zOrder: jest.fn(),
} as unknown as IPrimitivePaneView & UpdatableView;
drawing.initialize(
paneView,
[],
[],
);
drawing.updateAllViews();
expect(
paneView.update,
).toHaveBeenCalledTimes(1);
});
});
describe('settings', () => {
it('должен сразу отдавать текущие settings при подписке', () => {
const { drawing } = createDrawing();
const callback = jest.fn();
drawing.subscribeSettings(
callback,
);
expect(
callback,
).toHaveBeenCalledWith({
lineWidth: 1,
});
});
it('должен уведомлять подписчика после updateSettings', () => {
const { drawing } = createDrawing();
const callback = jest.fn();
drawing.subscribeSettings(
callback,
);
callback.mockClear();
drawing.updateSettings({
lineWidth: 3,
});
expect(
callback,
).toHaveBeenCalledWith({
lineWidth: 3,
});
});
});
describe('render lifecycle', () => {
it('должен вызывать updateAllViews и requestUpdate после render', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
const updateAllViews = jest.spyOn(
drawing,
'updateAllViews',
);
const requestUpdate = attachDrawing(
drawing,
env,
);
updateAllViews.mockClear();
drawing.hide();
expect(
drawing.getState().hidden,
).toBe(true);
expect(
updateAllViews,
).toHaveBeenCalledTimes(1);
expect(
requestUpdate,
).toHaveBeenCalledTimes(1);
});
it('должен возвращать drawing после show', () => {
const { drawing } = createDrawing();
drawing.hide();
drawing.show();
expect(
drawing.getState().hidden,
).toBe(false);
});
it('должен сразу завершать waitTillReady для ready drawing', async () => {
const { drawing } = createDrawing();
await expect(
drawing.waitTillReady(),
).resolves.toBeUndefined();
});
it('должен считать idle drawing ожидающим создания', () => {
const { drawing } = createDrawing();
drawing.setMode('idle');
expect(
drawing.isCreationPending(),
).toBe(true);
expect(
drawing.shouldShowInObjectTree(),
).toBe(false);
});
});
describe('interaction helpers', () => {
it('должен consume event', () => {
const {
drawing,
container,
} = createDrawing();
const event = createMouseEvent(
container,
10,
20,
);
drawing.consume(event);
expect(
event.preventDefault,
).toHaveBeenCalledTimes(1);
expect(
event.stopPropagation,
).toHaveBeenCalledTimes(1);
});
it('должен выбирать drawing при pointerDown по нему', () => {
const {
drawing,
container,
interaction,
} = createDrawing();
const event = createPointerEvent(
container,
10,
20,
);
expect(
drawing.selectOnDown(
event,
() => true,
),
).toBe(true);
expect(
interaction.select,
).toHaveBeenCalledTimes(1);
expect(
event.preventDefault,
).toHaveBeenCalledTimes(1);
expect(
event.stopPropagation,
).toHaveBeenCalledTimes(1);
});
it('не должен повторно обрабатывать selection для уже выбранного drawing', () => {
const {
drawing,
container,
setSelected,
} = createDrawing();
setSelected(true);
const event = createPointerEvent(
container,
10,
20,
);
expect(
drawing.selectOnDown(
event,
() => true,
),
).toBe(false);
});
it('должен открывать settings при double click по drawing', () => {
const {
drawing,
container,
} = createDrawing();
const openSettings = jest.fn();
const event = createMouseEvent(
container,
30,
40,
2,
);
drawing.openSettings(
event,
() => true,
openSettings,
);
expect(
openSettings,
).toHaveBeenCalledTimes(1);
expect(
event.preventDefault,
).toHaveBeenCalledTimes(1);
expect(
event.stopPropagation,
).toHaveBeenCalledTimes(1);
});
it('не должен открывать selectedOnly settings для невыбранного drawing', () => {
const {
drawing,
container,
} = createDrawing();
const openSettings = jest.fn();
const event = createMouseEvent(
container,
30,
40,
2,
);
drawing.openSettings(
event,
() => true,
openSettings,
true,
);
expect(
openSettings,
).not.toHaveBeenCalled();
});
it('должен заменять cursor на pointer для locked drawing', () => {
const {
drawing,
setLocked,
} = createDrawing();
drawing.setHoveredItem({
cursorStyle: 'move',
externalId: 'test',
zOrder: 'top',
});
setLocked(true);
expect(
drawing.hitTest(
10,
20,
),
).toEqual({
cursorStyle: 'pointer',
externalId: 'test',
zOrder: 'top',
});
});
});
describe('rebind', () => {
it('должен отвязывать drawing от старой series и привязывать к новой', () => {
const firstEnv = createDrawingTestEnvironment();
const secondEnv = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(firstEnv);
drawing.rebind(
secondEnv.series,
);
expect(
firstEnv.series.detachPrimitive,
).toHaveBeenCalledWith(
drawing,
);
expect(
secondEnv.series.attachPrimitive,
).toHaveBeenCalledWith(
drawing,
);
});
});
});
import {
TwoPointDrawingBase,
type TwoPointAnchorKind,
type TwoPointGeometry,
} from '@src/core/Drawings/base';
import { createDrawingTestEnvironment } from '../../drawingTestUtils';
import type {
Anchor,
AxisLabel,
AxisSegment,
Point,
} from '@core/Drawings/types';
import type {
PrimitiveHoveredItem,
Time,
} from 'lightweight-charts';
import type {
SettingsTab,
SettingsValues,
} from '@src/types';
class TestTwoPointDrawingBase extends TwoPointDrawingBase<SettingsValues> {
protected mode: 'idle' | 'ready' = 'ready';
protected settings: SettingsValues = {};
public getRenderData(): TwoPointGeometry | null {
return this.getTwoPointGeometry();
}
public getState() {
return {
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
};
}
public setState(): void {}
public getSettingsTabs(): SettingsTab[] {
return [];
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public setTestAnchors(
startAnchor: Anchor | null,
endAnchor: Anchor | null,
): void {
this.setAnchors(
startAnchor,
endAnchor,
);
}
public getTestAnchor(
kind: TwoPointAnchorKind,
): Anchor | null {
return this.getAnchor(kind);
}
public setTestAnchor(
kind: TwoPointAnchorKind,
anchor: Anchor | null,
): void {
this.setAnchor(
kind,
anchor,
);
}
public createTestAnchor(
point: Point,
): Anchor | null {
return this.createAnchor(point);
}
public setTestAnchorFromPoint(
kind: TwoPointAnchorKind,
point: Point,
): boolean {
return this.setAnchorFromPoint(
kind,
point,
);
}
public setTestAnchorsFromPoints(
startPoint: Point,
endPoint: Point,
): boolean {
return this.setAnchorsFromPoints(
startPoint,
endPoint,
);
}
public getTestTimeCoordinate(
kind: TwoPointAnchorKind,
): number | null {
return this.getAnchorTimeCoordinate(
kind,
);
}
public getTestPriceCoordinate(
kind: TwoPointAnchorKind,
): number | null {
return this.getAnchorPriceCoordinate(
kind,
);
}
public moveByPixels(
startAnchor: Anchor | null,
endAnchor: Anchor | null,
dragStartPoint: Point | null,
point: Point,
): boolean {
return this.moveAnchorsByPixels(
startAnchor,
endAnchor,
dragStartPoint,
point,
);
}
public moveWithinContainer(
startAnchor: Anchor | null,
endAnchor: Anchor | null,
dragStartPoint: Point | null,
point: Point,
bounds: {
left: number;
right: number;
top: number;
bottom: number;
},
): boolean {
return this.moveAnchorsWithinContainer(
startAnchor,
endAnchor,
dragStartPoint,
point,
bounds,
);
}
public getTestGeometry(): TwoPointGeometry | null {
return this.getTwoPointGeometry();
}
public restoreTestAnchor(
kind: TwoPointAnchorKind,
state: Partial<{
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
}>,
): void {
this.restoreAnchor(
kind,
state,
);
}
public getTestVolumeInRange(): number {
return this.getVolumeInRange();
}
public findTestIndexByTime(
time: Time,
): number {
return this.findIndexByTime(
time,
);
}
protected getHoveredItem(): PrimitiveHoveredItem | null {
return null;
}
protected getGeometry(): TwoPointGeometry | null {
return this.getTwoPointGeometry();
}
protected getTimeAxisSegments(): AxisSegment[] {
return [];
}
protected getPriceAxisSegments(): AxisSegment[] {
return [];
}
protected getTimeAxisLabel(): AxisLabel | null {
return null;
}
protected getPriceAxisLabel(): AxisLabel | null {
return null;
}
}
function createDrawing(
options: Parameters<typeof createDrawingTestEnvironment>[0] = {},
) {
const env = createDrawingTestEnvironment(options);
const drawing = new TestTwoPointDrawingBase(env);
return {
...env,
drawing,
};
}
describe('TwoPointDrawingBase', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('anchors', () => {
it('должен хранить start и end anchors', () => {
const { drawing } = createDrawing();
const startAnchor = {
time: 10 as Time,
price: 100,
};
const endAnchor = {
time: 20 as Time,
price: 200,
};
drawing.setTestAnchors(
startAnchor,
endAnchor,
);
expect(
drawing.getTestAnchor('start'),
).toEqual(startAnchor);
expect(
drawing.getTestAnchor('end'),
).toEqual(endAnchor);
});
it('должен создавать anchor из screen point', () => {
const { drawing } = createDrawing();
expect(
drawing.createTestAnchor({
x: 10,
y: 20,
}),
).toEqual({
time: 10,
price: 20,
});
});
it('должен изменять один anchor из point', () => {
const { drawing } = createDrawing();
expect(
drawing.setTestAnchorFromPoint(
'start',
{
x: 15,
y: 25,
},
),
).toBe(true);
expect(
drawing.getTestAnchor('start'),
).toEqual({
time: 15,
price: 25,
});
});
it('не должен частично менять anchors, если одна из точек не преобразовалась', () => {
const {
drawing,
timeScale,
} = createDrawing();
drawing.setTestAnchors(
{
time: 1 as Time,
price: 10,
},
{
time: 2 as Time,
price: 20,
},
);
timeScale.coordinateToTime
.mockReturnValueOnce(
100 as Time,
)
.mockReturnValueOnce(
null,
);
expect(
drawing.setTestAnchorsFromPoints(
{
x: 100,
y: 110,
},
{
x: 200,
y: 210,
},
),
).toBe(false);
expect(
drawing.getState(),
).toEqual({
startAnchor: {
time: 1,
price: 10,
},
endAnchor: {
time: 2,
price: 20,
},
});
});
});
describe('coordinates and geometry', () => {
it('должен сохранять дробные coordinates anchors', () => {
const { drawing } = createDrawing();
drawing.setTestAnchors(
{
time: 10.25 as Time,
price: 30.75,
},
{
time: 20.5 as Time,
price: 40.125,
},
);
expect(
drawing.getTestTimeCoordinate(
'start',
),
).toBe(10.25);
expect(
drawing.getTestPriceCoordinate(
'start',
),
).toBe(30.75);
});
it('должен строить нормализованную geometry из anchors', () => {
const { drawing } = createDrawing();
drawing.setTestAnchors(
{
time: 80 as Time,
price: 90,
},
{
time: 20 as Time,
price: 30,
},
);
expect(
drawing.getTestGeometry(),
).toEqual({
startPoint: {
x: 80,
y: 90,
},
endPoint: {
x: 20,
y: 30,
},
left: 20,
right: 80,
top: 30,
bottom: 90,
});
});
it('должен возвращать null geometry без двух anchors', () => {
const { drawing } = createDrawing();
drawing.setTestAnchor(
'start',
{
time: 10 as Time,
price: 20,
},
);
expect(
drawing.getTestGeometry(),
).toBeNull();
});
});
describe('moving', () => {
it('должен смещать оба anchors по времени и цене', () => {
const { drawing } = createDrawing();
const startAnchor = {
time: 10 as Time,
price: 100,
};
const endAnchor = {
time: 20 as Time,
price: 200,
};
expect(
drawing.moveByPixels(
startAnchor,
endAnchor,
{
x: 50,
y: 50,
},
{
x: 70,
y: 80,
},
),
).toBe(true);
expect(
drawing.getState(),
).toEqual({
startAnchor: {
time: 30,
price: 130,
},
endAnchor: {
time: 40,
price: 230,
},
});
});
it('должен ограничивать смещение границами container', () => {
const { drawing } = createDrawing({
width: 200,
height: 200,
});
expect(
drawing.moveWithinContainer(
{
time: 50 as Time,
price: 40,
},
{
time: 150 as Time,
price: 140,
},
{
x: 50,
y: 50,
},
{
x: 200,
y: 200,
},
{
left: 50,
right: 150,
top: 40,
bottom: 140,
},
),
).toBe(true);
expect(
drawing.getState(),
).toEqual({
startAnchor: {
time: 100,
price: 100,
},
endAnchor: {
time: 200,
price: 200,
},
});
});
it('не должен двигать anchors без dragStartPoint', () => {
const { drawing } = createDrawing();
expect(
drawing.moveByPixels(
{
time: 10 as Time,
price: 100,
},
{
time: 20 as Time,
price: 200,
},
null,
{
x: 30,
y: 40,
},
),
).toBe(false);
});
});
describe('state helpers', () => {
it('должен частично восстанавливать anchor из legacy state', () => {
const { drawing } = createDrawing();
drawing.setTestAnchor(
'start',
{
time: 10 as Time,
price: 100,
},
);
drawing.restoreTestAnchor(
'start',
{
startPrice: 150,
},
);
expect(
drawing.getTestAnchor('start'),
).toEqual({
time: 10,
price: 150,
});
});
it('должен очищать anchor, если state содержит null time', () => {
const { drawing } = createDrawing();
drawing.setTestAnchor(
'start',
{
time: 10 as Time,
price: 100,
},
);
drawing.restoreTestAnchor(
'start',
{
startTime: null,
},
);
expect(
drawing.getTestAnchor('start'),
).toBeNull();
});
});
describe('series helpers', () => {
it('должен находить index по времени', () => {
const { drawing } = createDrawing({
seriesData: [
{
time: 10,
},
{
time: 20,
},
{
time: 30,
},
],
});
expect(
drawing.findTestIndexByTime(
20 as Time,
),
).toBe(1);
});
it('должен суммировать volume из обычного и customValues поля', () => {
const { drawing } = createDrawing({
seriesData: [
{
time: 10,
volume: 5,
},
{
time: 20,
customValues: {
volume: 7,
},
},
{
time: 30,
volume: 3,
},
],
});
drawing.setTestAnchors(
{
time: 10 as Time,
price: 100,
},
{
time: 30 as Time,
price: 200,
},
);
expect(
drawing.getTestVolumeInRange(),
).toBe(15);
});
it('должен возвращать 0 volume без anchors', () => {
const { drawing } = createDrawing({
seriesData: [
{
time: 10,
volume: 5,
},
],
});
expect(
drawing.getTestVolumeInRange(),
).toBe(0);
});
});
});
import {
LinearDrawingBase,
type TwoPointGeometry,
} from '@src/core/Drawings/base';
import {
createDrawingTestEnvironment,
createPointerEvent,
} from '../../drawingTestUtils';
import type {
Anchor,
AxisLabel,
Point,
} from '@core/Drawings/types';
import type {
PrimitiveHoveredItem,
} from 'lightweight-charts';
import type {
SettingsTab,
SettingsValues,
} from '@src/types';
type HandleId = 'start' | 'end';
type TestMode =
| 'idle'
| 'drawing'
| 'ready'
| 'dragging-start'
| 'dragging-end'
| 'dragging-body';
interface TestState {
mode: TestMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
}
class TestLinearDrawingBase extends LinearDrawingBase<
SettingsValues,
HandleId,
TestState
> {
protected mode: TestMode = 'ready';
protected settings: SettingsValues = {};
public readonly finishDrawingMock = jest.fn();
public readonly readyPointerDownMock = jest.fn();
private strokeColor: string | undefined;
public getRenderData(): TwoPointGeometry | null {
return this.getTwoPointGeometry();
}
public getState(): TestState {
return {
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
};
}
public setState(): void {}
public getSettingsTabs(): SettingsTab[] {
return [];
}
public isCreationPending(): boolean {
return (
this.mode === 'idle' ||
this.mode === 'drawing'
);
}
public setMode(mode: TestMode): void {
this.mode = mode;
}
public setTestAnchors(
startAnchor: Anchor | null,
endAnchor: Anchor | null,
): void {
this.setAnchors(
startAnchor,
endAnchor,
);
}
public setStrokeColor(
color: string | undefined,
): void {
this.strokeColor = color;
}
public getHandles() {
return this.getDrawingHandles();
}
public getTimeSegments() {
return this.getTimeAxisSegments();
}
public getPriceSegments() {
return this.getPriceAxisSegments();
}
public isNearLine(
point: Point,
tolerance: number,
): boolean {
return this.isPointNearLine(
point,
tolerance,
);
}
public start(point: Point): void {
this.startDrawing(point);
}
public update(point: Point): void {
this.updateDrawing(point);
}
public startDrag(
mode: `dragging-${HandleId | 'body'}`,
point: Point,
pointerId: number,
): void {
this.startDragging(
mode,
point,
pointerId,
);
}
public finishDrag(): void {
this.finishDragging();
}
public pointerDown(
event: PointerEvent,
): void {
this.handlePointerDown(event);
}
public finishLinear(
geometry: TwoPointGeometry | null,
minimumSize: number,
removeSelf: () => void,
): void {
this.finishLinearDrawing(
geometry,
minimumSize,
removeSelf,
);
}
public getDragSnapshot(): TestState | null {
return this.dragStateSnapshot;
}
public getDragPointerId(): number | null {
return this.dragPointerId;
}
public getDragStartPoint(): Point | null {
return this.dragStartPoint;
}
protected getStartHandleId(): HandleId {
return 'start';
}
protected getEndHandleId(): HandleId {
return 'end';
}
protected getLineHandleStrokeColor(): string | undefined {
return this.strokeColor;
}
protected finishDrawing(): void {
this.finishDrawingMock();
}
protected handleReadyPointerDown(
event: PointerEvent,
point: Point,
): void {
this.readyPointerDownMock(
event,
point,
);
}
protected getHoveredItem(): PrimitiveHoveredItem | null {
return null;
}
protected getGeometry(): TwoPointGeometry | null {
return this.getTwoPointGeometry();
}
protected getTimeAxisLabel(): AxisLabel | null {
return null;
}
protected getPriceAxisLabel(): AxisLabel | null {
return null;
}
}
function createDrawing(
selected = false,
) {
const env = createDrawingTestEnvironment({
selected,
});
const drawing = new TestLinearDrawingBase(
env,
);
return {
...env,
drawing,
};
}
function setAnchors(
drawing: TestLinearDrawingBase,
): void {
drawing.setTestAnchors(
{
time: 10,
price: 20,
},
{
time: 110,
price: 120,
},
);
}
describe('LinearDrawingBase', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('handles', () => {
it('должен создавать start и end handles', () => {
const { drawing } =
createDrawing();
setAnchors(drawing);
expect(
drawing.getHandles(),
).toEqual([
{
id: 'start',
x: 10,
y: 20,
shape: 'circle',
},
{
id: 'end',
x: 110,
y: 120,
shape: 'circle',
},
]);
});
it('должен применять strokeColor к handles', () => {
const { drawing } =
createDrawing();
setAnchors(drawing);
drawing.setStrokeColor(
'#123456',
);
expect(
drawing.getHandles(),
).toEqual([
{
id: 'start',
x: 10,
y: 20,
shape: 'circle',
strokeColor: '#123456',
},
{
id: 'end',
x: 110,
y: 120,
shape: 'circle',
strokeColor: '#123456',
},
]);
});
});
describe('axis segments', () => {
it('не должен возвращать axis segments для ready unselected drawing', () => {
const { drawing } =
createDrawing();
setAnchors(drawing);
expect(
drawing.getTimeSegments(),
).toEqual([]);
expect(
drawing.getPriceSegments(),
).toEqual([]);
});
it('должен возвращать axis segments для selected drawing', () => {
const {
drawing,
setSelected,
} = createDrawing();
setAnchors(drawing);
setSelected(true);
expect(
drawing.getTimeSegments(),
).toHaveLength(1);
expect(
drawing.getTimeSegments()[0],
).toMatchObject({
from: 10,
to: 110,
});
expect(
drawing.getPriceSegments()[0],
).toMatchObject({
from: 20,
to: 120,
});
});
it('должен возвращать axis segments во время создания', () => {
const { drawing } =
createDrawing();
setAnchors(drawing);
drawing.setMode('drawing');
expect(
drawing.getTimeSegments(),
).toHaveLength(1);
});
});
describe('hit testing', () => {
it('должен определять point рядом с line', () => {
const { drawing } =
createDrawing();
drawing.setTestAnchors(
{
time: 0,
price: 0,
},
{
time: 100,
price: 0,
},
);
expect(
drawing.isNearLine(
{
x: 50,
y: 4,
},
5,
),
).toBe(true);
expect(
drawing.isNearLine(
{
x: 50,
y: 10,
},
5,
),
).toBe(false);
});
});
describe('drawing lifecycle', () => {
it('должен начинать drawing с одинаковых anchors', () => {
const { drawing } =
createDrawing();
drawing.setMode('idle');
drawing.start({
x: 20,
y: 30,
});
expect(
drawing.getState(),
).toEqual({
mode: 'drawing',
startAnchor: {
time: 20,
price: 30,
},
endAnchor: {
time: 20,
price: 30,
},
});
});
it('должен обновлять end anchor во время drawing', () => {
const { drawing } =
createDrawing();
drawing.setMode('idle');
drawing.start({
x: 20,
y: 30,
});
drawing.update({
x: 80,
y: 90,
});
expect(
drawing.getState().endAnchor,
).toEqual({
time: 80,
price: 90,
});
});
it('должен вызывать finishDrawing вторым pointerDown', () => {
const {
drawing,
container,
} = createDrawing();
drawing.setMode('idle');
drawing.pointerDown(
createPointerEvent(
container,
10,
20,
),
);
drawing.pointerDown(
createPointerEvent(
container,
100,
120,
),
);
expect(
drawing.finishDrawingMock,
).toHaveBeenCalledTimes(1);
});
it('должен передавать ready pointerDown concrete drawing', () => {
const {
drawing,
container,
} = createDrawing();
drawing.setMode('ready');
const event =
createPointerEvent(
container,
40,
50,
);
drawing.pointerDown(event);
expect(
drawing.readyPointerDownMock,
).toHaveBeenCalledWith(
event,
{
x: 40,
y: 50,
},
);
});
});
describe('dragging', () => {
it('должен сохранять state snapshot при начале drag', () => {
const {
drawing,
chart,
} = createDrawing();
setAnchors(drawing);
drawing.startDrag(
'dragging-body',
{
x: 50,
y: 60,
},
7,
);
expect(
drawing.getDragPointerId(),
).toBe(7);
expect(
drawing.getDragStartPoint(),
).toEqual({
x: 50,
y: 60,
});
expect(
drawing.getDragSnapshot(),
).toEqual({
mode: 'dragging-body',
startAnchor: {
time: 10,
price: 20,
},
endAnchor: {
time: 110,
price: 120,
},
});
expect(
chart.applyOptions,
).toHaveBeenCalledWith({
crosshair: {
mode: expect.any(Number),
},
});
});
it('должен очищать drag state после finishDragging', () => {
const {
drawing,
} = createDrawing();
setAnchors(drawing);
drawing.startDrag(
'dragging-body',
{
x: 50,
y: 60,
},
7,
);
drawing.finishDrag();
expect(
drawing.getState().mode,
).toBe('ready');
expect(
drawing.getDragPointerId(),
).toBeNull();
expect(
drawing.getDragStartPoint(),
).toBeNull();
expect(
drawing.getDragSnapshot(),
).toBeNull();
});
});
describe('finishLinearDrawing', () => {
it('должен удалять слишком короткий drawing', () => {
const { drawing } =
createDrawing();
const removeSelf =
jest.fn();
drawing.finishLinear(
{
startPoint: {
x: 10,
y: 10,
},
endPoint: {
x: 12,
y: 12,
},
left: 10,
right: 12,
top: 10,
bottom: 12,
},
4,
removeSelf,
);
expect(
removeSelf,
).toHaveBeenCalledTimes(1);
});
it('должен переводить корректный drawing в ready', () => {
const { drawing } =
createDrawing();
drawing.setMode('drawing');
const removeSelf =
jest.fn();
drawing.finishLinear(
{
startPoint: {
x: 10,
y: 10,
},
endPoint: {
x: 30,
y: 10,
},
left: 10,
right: 30,
top: 10,
bottom: 10,
},
4,
removeSelf,
);
expect(
removeSelf,
).not.toHaveBeenCalled();
expect(
drawing.getState().mode,
).toBe('ready');
});
});
});
import {
AreaDrawingBase,
} from '@src/core/Drawings/base';
import {
createDrawingTestEnvironment,
createPointerEvent,
} from '../../drawingTestUtils';
import type {
Anchor,
AxisLabel,
Bounds,
Point,
} from '@core/Drawings/types';
import type {
PrimitiveHoveredItem,
} from 'lightweight-charts';
import type {
SettingsTab,
SettingsValues,
} from '@src/types';
type HandleId =
| 'start'
| 'end';
type TestMode =
| 'idle'
| 'drawing'
| 'ready'
| 'dragging';
class TestAreaDrawingBase extends AreaDrawingBase<
SettingsValues,
HandleId
> {
protected mode: TestMode = 'idle';
protected settings: SettingsValues = {};
protected dragPointerId: number | null = null;
protected activeDragTarget: HandleId | 'body' | null = null;
public readonly finishDrawingMock = jest.fn();
public readonly startDraggingMock = jest.fn();
public readonly moveWholeMock = jest.fn();
public readonly resizeMock = jest.fn();
private geometry: Bounds | null = null;
private containsPointValue = false;
private dragTarget: HandleId | 'body' | null = null;
public getRenderData(): Bounds | null {
return this.geometry;
}
public getState() {
return {
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
};
}
public setState(): void {}
public getSettingsTabs(): SettingsTab[] {
return [];
}
public isCreationPending(): boolean {
return (
this.mode === 'idle' ||
this.mode === 'drawing'
);
}
public setMode(mode: TestMode): void {
this.mode = mode;
}
public setGeometry(
geometry: Bounds | null,
): void {
this.geometry = geometry;
}
public setContainsPoint(
value: boolean,
): void {
this.containsPointValue = value;
}
public setTestDragTarget(
target: HandleId | 'body' | null,
): void {
this.dragTarget = target;
}
public setDragState(
target: HandleId | 'body' | null,
pointerId: number | null,
): void {
this.activeDragTarget = target;
this.dragPointerId = pointerId;
}
public start(point: Point): void {
this.startDrawing(point);
}
public update(point: Point): void {
this.updateDrawing(point);
}
public pointerDown(
event: PointerEvent,
): void {
this.handlePointerDown(event);
}
public pointerMove(
event: PointerEvent,
): void {
this.handlePointerMove(event);
}
public getTestAnchors(): {
startAnchor: Anchor | null;
endAnchor: Anchor | null;
} {
return {
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
};
}
public getTimeSegments() {
return this.getTimeAxisSegments();
}
public getPriceSegments() {
return this.getPriceAxisSegments();
}
public getBoundsLabel(
kind: string,
kinds: readonly ('left' | 'right' | 'top' | 'bottom')[],
): AxisLabel | null {
return this.getBoundsAxisLabel(
kind,
kinds,
(labelKind) =>
String(labelKind),
);
}
protected finishDrawing(): void {
this.finishDrawingMock();
}
protected containsPoint(): boolean {
return this.containsPointValue;
}
protected getDragTarget(): HandleId | 'body' | null {
return this.dragTarget;
}
protected startDragging(
point: Point,
pointerId: number,
target: HandleId | 'body',
): void {
this.startDraggingMock(
point,
pointerId,
target,
);
this.activeDragTarget =
target;
this.dragPointerId =
pointerId;
this.mode =
'dragging';
}
protected moveWhole(
point: Point,
): void {
this.moveWholeMock(
point,
);
}
protected resize(
point: Point,
): void {
this.resizeMock(
point,
);
}
protected getGeometry(): Bounds | null {
return this.geometry;
}
protected getHoveredItem(): PrimitiveHoveredItem | null {
return null;
}
protected getTimeAxisLabel(): AxisLabel | null {
return null;
}
protected getPriceAxisLabel(): AxisLabel | null {
return null;
}
}
function createDrawing(
selected = false,
) {
const env =
createDrawingTestEnvironment({
selected,
});
const drawing =
new TestAreaDrawingBase(
env,
);
return {
...env,
drawing,
};
}
describe('AreaDrawingBase', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('drawing lifecycle', () => {
it('должен начинать drawing с одинаковых anchors', () => {
const { drawing } =
createDrawing();
drawing.start({
x: 20,
y: 30,
});
expect(
drawing.getState(),
).toEqual({
mode: 'drawing',
startAnchor: {
time: 20,
price: 30,
},
endAnchor: {
time: 20,
price: 30,
},
});
});
it('должен обновлять end anchor с clamp по container', () => {
const { drawing } =
createDrawing();
drawing.start({
x: 20,
y: 30,
});
drawing.update({
x: 500,
y: -50,
});
expect(
drawing.getTestAnchors()
.endAnchor,
).toEqual({
time: 400,
price: 0,
});
});
it('должен начинать drawing через первый pointerDown', () => {
const {
drawing,
container,
} = createDrawing();
const event =
createPointerEvent(
container,
20,
30,
);
drawing.pointerDown(
event,
);
expect(
drawing.getState().mode,
).toBe('drawing');
expect(
event.preventDefault,
).toHaveBeenCalledTimes(1);
expect(
event.stopPropagation,
).toHaveBeenCalledTimes(1);
});
it('должен завершать drawing через второй pointerDown', () => {
const {
drawing,
container,
} = createDrawing();
drawing.start({
x: 20,
y: 30,
});
drawing.pointerDown(
createPointerEvent(
container,
100,
120,
),
);
expect(
drawing.finishDrawingMock,
).toHaveBeenCalledTimes(1);
expect(
drawing.getTestAnchors()
.endAnchor,
).toEqual({
time: 100,
price: 120,
});
});
});
describe('selection and dragging', () => {
it('должен только выбрать ready drawing при первом pointerDown', () => {
const {
drawing,
container,
interaction,
} = createDrawing();
drawing.setMode('ready');
drawing.setContainsPoint(true);
drawing.pointerDown(
createPointerEvent(
container,
50,
60,
),
);
expect(
interaction.select,
).toHaveBeenCalledTimes(1);
expect(
drawing.startDraggingMock,
).not.toHaveBeenCalled();
});
it('должен deselect selected drawing при pointerDown вне drag target', () => {
const {
drawing,
container,
interaction,
} = createDrawing(true);
drawing.setMode('ready');
drawing.setTestDragTarget(null);
drawing.pointerDown(
createPointerEvent(
container,
50,
60,
),
);
expect(
interaction.deselect,
).toHaveBeenCalledTimes(1);
});
it('должен начинать drag выбранного target', () => {
const {
drawing,
container,
} = createDrawing(true);
drawing.setMode('ready');
drawing.setTestDragTarget(
'start',
);
const event =
createPointerEvent(
container,
50,
60,
7,
);
drawing.pointerDown(event);
expect(
drawing.startDraggingMock,
).toHaveBeenCalledWith(
{
x: 50,
y: 60,
},
7,
'start',
);
expect(
event.preventDefault,
).toHaveBeenCalledTimes(1);
expect(
event.stopPropagation,
).toHaveBeenCalledTimes(1);
});
it('должен двигать body во время body drag', () => {
const {
drawing,
container,
} = createDrawing(true);
drawing.setMode('dragging');
drawing.setDragState(
'body',
5,
);
const event =
createPointerEvent(
container,
80,
90,
5,
);
drawing.pointerMove(event);
expect(
drawing.moveWholeMock,
).toHaveBeenCalledWith({
x: 80,
y: 90,
});
expect(
drawing.resizeMock,
).not.toHaveBeenCalled();
expect(
event.preventDefault,
).toHaveBeenCalledTimes(1);
});
it('должен resize handle во время handle drag', () => {
const {
drawing,
container,
} = createDrawing(true);
drawing.setMode('dragging');
drawing.setDragState(
'end',
5,
);
drawing.pointerMove(
createPointerEvent(
container,
80,
90,
5,
),
);
expect(
drawing.resizeMock,
).toHaveBeenCalledWith({
x: 80,
y: 90,
});
expect(
drawing.moveWholeMock,
).not.toHaveBeenCalled();
});
it('не должен обрабатывать drag другого pointerId', () => {
const {
drawing,
container,
} = createDrawing(true);
drawing.setMode('dragging');
drawing.setDragState(
'body',
5,
);
drawing.pointerMove(
createPointerEvent(
container,
80,
90,
6,
),
);
expect(
drawing.moveWholeMock,
).not.toHaveBeenCalled();
});
});
describe('axis', () => {
it('должен строить time и price segments по geometry', () => {
const {
drawing,
setSelected,
} = createDrawing();
drawing.setGeometry({
left: 10,
right: 100,
top: 20,
bottom: 80,
});
setSelected(true);
expect(
drawing.getTimeSegments()[0],
).toMatchObject({
from: 10,
to: 100,
});
expect(
drawing.getPriceSegments()[0],
).toMatchObject({
from: 20,
to: 80,
});
});
it('должен создавать label из соответствующего geometry bound', () => {
const {
drawing,
setSelected,
} = createDrawing();
drawing.setGeometry({
left: 10,
right: 100,
top: 20,
bottom: 80,
});
setSelected(true);
expect(
drawing.getBoundsLabel(
'left',
['left', 'right'],
),
).toMatchObject({
coordinate: 10,
text: 'left',
});
expect(
drawing.getBoundsLabel(
'bottom',
['top', 'bottom'],
),
).toMatchObject({
coordinate: 80,
text: 'bottom',
});
});
it('не должен создавать label для неизвестного kind', () => {
const {
drawing,
setSelected,
} = createDrawing();
drawing.setGeometry({
left: 10,
right: 100,
top: 20,
bottom: 80,
});
setSelected(true);
expect(
drawing.getBoundsLabel(
'unknown',
['left', 'right'],
),
).toBeNull();
});
});
});