Загрузка данных
import { DrawingBase } from '@core/Drawings/DrawingBase';
import { createDrawingTestEnvironment, createPointerEvent } from '../drawingTestUtils';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { SettingsTab, SettingsValues } from '@src/types';
import type {
IPrimitivePaneView,
ISeriesPrimitiveAxisView,
PrimitiveHoveredItem,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartBackground: '#101010',
chartLineColor: '#2962ff',
chartPriceLineText: '#ffffff',
axisMarkerLabelFill: '#112233',
axisMarkerAreaFill: '#2962ff44',
},
}),
}));
type TestMode = 'idle' | 'ready';
type TestState = {
hidden: boolean;
mode: TestMode;
settings: SettingsValues;
};
class TestDrawingBase extends DrawingBase<SettingsValues, 'main'> {
protected settings: SettingsValues = {
lineWidth: 1,
};
protected mode: TestMode = 'ready';
public readonly pointerDownMock = jest.fn();
public readonly updateAllViewsMock = jest.fn();
public hoveredItem: PrimitiveHoveredItem | null = {
cursorStyle: 'pointer',
externalId: 'test-drawing',
zOrder: 'top',
};
public setMode(mode: TestMode): void {
this.mode = mode;
}
public createAnchor(point: Point): Anchor | null {
return this.createAnchorFromPoint(point);
}
public projectAnchor(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 getState(): TestState {
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<TestState>;
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if (nextState.mode) {
this.mode = nextState.mode;
}
if (nextState.settings) {
this.settings = {
...this.settings,
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return [];
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public updateAllViews(): void {
this.updateAllViewsMock();
}
public paneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [];
}
public getRenderData(): unknown {
return this.hidden ? null : this.getGeometry();
}
protected getDrawingHandles(): readonly DrawingHandle<'main'>[] {
return [
{
id: 'main',
x: 50,
y: 50,
},
];
}
protected getHoveredItem(): PrimitiveHoveredItem | null {
return this.hoveredItem;
}
protected getGeometry(): Point {
return {
x: 10,
y: 20,
};
}
protected getTimeAxisSegments(): AxisSegment[] {
return [];
}
protected getPriceAxisSegments(): AxisSegment[] {
return [];
}
protected getTimeAxisLabel(_kind: string): AxisLabel | null {
return null;
}
protected getPriceAxisLabel(_kind: string): AxisLabel | null {
return null;
}
protected handlePointerDown = (event: PointerEvent): void => {
this.pointerDownMock(event);
};
}
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>);
requestUpdate.mockClear();
return requestUpdate;
}
describe('DrawingBase', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('должен создавать anchor из screen point', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
expect(drawing.createAnchor({ x: 120, y: 80 })).toEqual({
time: 120,
price: 80,
});
expect(env.timeScale.coordinateToTime).toHaveBeenCalledWith(120);
expect(env.coordinateToPrice).toHaveBeenCalledWith(80);
});
it('должен проецировать anchor в screen point без округления', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
expect(
drawing.projectAnchor({
time: 120.5 as Time,
price: 80.25,
}),
).toEqual({
x: 120.5,
y: 80.25,
});
});
it('должен возвращать null при невозможности спроецировать anchor', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
env.timeScale.timeToCoordinate.mockReturnValue(null);
env.timeScale.logicalToCoordinate.mockReturnValue(null);
expect(
drawing.projectAnchor({
time: 100 as Time,
price: 50,
}),
).toBeNull();
});
it('должен создавать axis label с theme colors', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
expect(drawing.createLabel(100, 'Test')).toEqual({
coordinate: 100,
text: 'Test',
textColor: '#ffffff',
backgroundColor: '#112233',
});
});
it('должен позволять переопределить цвета axis label', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
expect(
drawing.createLabel(100, 'Test', {
textColor: '#ff0000',
backgroundColor: '#00ff00',
}),
).toEqual({
coordinate: 100,
text: 'Test',
textColor: '#ff0000',
backgroundColor: '#00ff00',
});
});
it('не должен создавать axis label без coordinate или text', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
expect(drawing.createLabel(null, 'Test')).toBeNull();
expect(drawing.createLabel(100, '')).toBeNull();
});
it('должен создавать axis segment с theme color', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
expect(drawing.createSegment(10, 20)).toEqual({
from: 10,
to: 20,
color: '#2962ff44',
});
});
it('должен обновлять settings и уведомлять подписчика', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
const subscriber = jest.fn();
const subscription = drawing.subscribeSettings(subscriber);
expect(subscriber).toHaveBeenCalledWith({
lineWidth: 1,
});
drawing.updateSettings({
lineWidth: 3,
});
expect(drawing.getSettings()).toEqual({
lineWidth: 3,
});
expect(subscriber).toHaveBeenLastCalledWith({
lineWidth: 3,
});
subscription.unsubscribe();
});
it('должен вызывать requestUpdate после render', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
const requestUpdate = attachDrawing(drawing, env);
drawing.hide();
expect(drawing.getState().hidden).toBe(true);
expect(drawing.updateAllViewsMock).toHaveBeenCalledTimes(1);
expect(requestUpdate).toHaveBeenCalledTimes(1);
});
it('должен показывать drawing после show', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
drawing.hide();
drawing.show();
expect(drawing.getState().hidden).toBe(false);
});
it('должен выбирать locked drawing по hit вместо запуска interaction', () => {
const env = createDrawingTestEnvironment({ locked: true });
const drawing = new TestDrawingBase(env);
const event = createPointerEvent(env.container, 50, 50);
drawing.pointerDown(event);
expect(env.interaction.select).toHaveBeenCalledTimes(1);
expect(drawing.pointerDownMock).not.toHaveBeenCalled();
});
it('должен передавать pointerDown concrete drawing когда creation pending', () => {
const env = createDrawingTestEnvironment({ locked: true });
const drawing = new TestDrawingBase(env);
const event = createPointerEvent(env.container, 50, 50);
drawing.setMode('idle');
drawing.pointerDown(event);
expect(drawing.pointerDownMock).toHaveBeenCalledWith(event);
});
it('должен rebind drawing на новую series без изменения chart', () => {
const env = createDrawingTestEnvironment();
const nextEnv = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
drawing.rebind(nextEnv.series);
expect(env.series.detachPrimitive).toHaveBeenCalledWith(drawing);
expect(nextEnv.series.attachPrimitive).toHaveBeenCalledWith(drawing);
});
it('должен завершать waitTillReady сразу для ready drawing', async () => {
const env = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
await expect(drawing.waitTillReady()).resolves.toBeUndefined();
});
it('должен считать idle drawing creation pending', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestDrawingBase(env);
drawing.setMode('idle');
expect(drawing.isCreationPending()).toBe(true);
expect(drawing.shouldShowInObjectTree()).toBe(false);
});
});
import { TwoPointDrawingBase } from '@core/Drawings/TwoPointDrawingBase';
import { createDrawingTestEnvironment } from '../drawingTestUtils';
import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { SettingsTab, SettingsValues } from '@src/types';
import type {
IPrimitivePaneView,
ISeriesPrimitiveAxisView,
PrimitiveHoveredItem,
Time,
} from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartBackground: '#101010',
chartLineColor: '#2962ff',
chartPriceLineText: '#ffffff',
axisMarkerLabelFill: '#112233',
axisMarkerAreaFill: '#2962ff44',
},
}),
}));
type TestMode = 'idle' | 'ready';
class TestTwoPointDrawingBase extends TwoPointDrawingBase<SettingsValues> {
protected settings: SettingsValues = {};
protected mode: TestMode = 'ready';
public setMode(mode: TestMode): void {
this.mode = mode;
}
public setTestAnchors(
startTime: number,
startPrice: number,
endTime: number,
endPrice: number,
): void {
this.setAnchors(
{
time: startTime as Time,
price: startPrice,
},
{
time: endTime as Time,
price: endPrice,
},
);
}
public getTestAnchor(kind: 'start' | 'end') {
return this.getAnchor(kind);
}
public setTestAnchorFromPoint(kind: 'start' | 'end', point: Point): boolean {
return this.setAnchorFromPoint(kind, point);
}
public setTestAnchorsFromPoints(startPoint: Point, endPoint: Point): boolean {
return this.setAnchorsFromPoints(startPoint, endPoint);
}
public getTestTimeCoordinate(kind: 'start' | 'end'): number | null {
return this.getAnchorTimeCoordinate(kind);
}
public getTestPriceCoordinate(kind: 'start' | 'end'): number | null {
return this.getAnchorPriceCoordinate(kind);
}
public moveByPixels(dragStartPoint: Point | null, point: Point): boolean {
return this.moveAnchorsByPixels(
this.startAnchor,
this.endAnchor,
dragStartPoint,
point,
);
}
public moveWithinContainer(
dragStartPoint: Point,
point: Point,
bounds: {
left: number;
right: number;
top: number;
bottom: number;
},
): boolean {
return this.moveAnchorsWithinContainer(
this.startAnchor,
this.endAnchor,
dragStartPoint,
point,
bounds,
);
}
public geometry() {
return this.getTwoPointGeometry();
}
public getState(): unknown {
return {
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
};
}
public setState(_state: unknown): void {}
public getSettingsTabs(): SettingsTab[] {
return [];
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public updateAllViews(): void {}
public paneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [];
}
public getRenderData(): unknown {
return this.getTwoPointGeometry();
}
protected getHoveredItem(): PrimitiveHoveredItem | null {
return null;
}
protected getGeometry(): unknown {
return this.getTwoPointGeometry();
}
protected getTimeAxisSegments(): AxisSegment[] {
return [];
}
protected getPriceAxisSegments(): AxisSegment[] {
return [];
}
protected getTimeAxisLabel(_kind: string): AxisLabel | null {
return null;
}
protected getPriceAxisLabel(_kind: string): AxisLabel | null {
return null;
}
}
describe('TwoPointDrawingBase', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('должен хранить start и end anchors', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestTwoPointDrawingBase(env);
drawing.setTestAnchors(10, 20, 30, 40);
expect(drawing.getTestAnchor('start')).toEqual({
time: 10,
price: 20,
});
expect(drawing.getTestAnchor('end')).toEqual({
time: 30,
price: 40,
});
});
it('должен создавать отдельный anchor из point', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestTwoPointDrawingBase(env);
expect(
drawing.setTestAnchorFromPoint('start', {
x: 50,
y: 70,
}),
).toBe(true);
expect(drawing.getTestAnchor('start')).toEqual({
time: 50,
price: 70,
});
});
it('не должен менять anchor если point нельзя преобразовать', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestTwoPointDrawingBase(env);
drawing.setTestAnchors(10, 20, 30, 40);
env.timeScale.coordinateToTime.mockReturnValue(null);
expect(
drawing.setTestAnchorFromPoint('start', {
x: 50,
y: 70,
}),
).toBe(false);
expect(drawing.getTestAnchor('start')).toEqual({
time: 10,
price: 20,
});
});
it('должен атомарно создавать два anchors из points', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestTwoPointDrawingBase(env);
expect(
drawing.setTestAnchorsFromPoints(
{
x: 10,
y: 20,
},
{
x: 30,
y: 40,
},
),
).toBe(true);
expect(drawing.getState()).toMatchObject({
startAnchor: {
time: 10,
price: 20,
},
endAnchor: {
time: 30,
price: 40,
},
});
});
it('не должен частично менять anchors если одна точка не преобразуется', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestTwoPointDrawingBase(env);
drawing.setTestAnchors(10, 20, 30, 40);
env.timeScale.coordinateToTime
.mockReturnValueOnce(100 as Time)
.mockReturnValueOnce(null);
expect(
drawing.setTestAnchorsFromPoints(
{
x: 100,
y: 200,
},
{
x: 300,
y: 400,
},
),
).toBe(false);
expect(drawing.getState()).toMatchObject({
startAnchor: {
time: 10,
price: 20,
},
endAnchor: {
time: 30,
price: 40,
},
});
});
it('должен возвращать coordinates anchors', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestTwoPointDrawingBase(env);
drawing.setTestAnchors(10, 20, 30, 40);
expect(drawing.getTestTimeCoordinate('start')).toBe(10);
expect(drawing.getTestTimeCoordinate('end')).toBe(30);
expect(drawing.getTestPriceCoordinate('start')).toBe(20);
expect(drawing.getTestPriceCoordinate('end')).toBe(40);
});
it('должен строить нормализованную geometry без округления', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestTwoPointDrawingBase(env);
drawing.setTestAnchors(150.5, 100.25, 50.25, 20.75);
expect(drawing.geometry()).toEqual({
startPoint: {
x: 150.5,
y: 100.25,
},
endPoint: {
x: 50.25,
y: 20.75,
},
left: 50.25,
right: 150.5,
top: 20.75,
bottom: 100.25,
});
});
it('должен перемещать оба anchors на одинаковый pixel offset', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestTwoPointDrawingBase(env);
drawing.setTestAnchors(50, 60, 150, 100);
expect(
drawing.moveByPixels(
{
x: 100,
y: 80,
},
{
x: 120,
y: 110,
},
),
).toBe(true);
expect(drawing.getState()).toMatchObject({
startAnchor: {
time: 70,
price: 90,
},
endAnchor: {
time: 170,
price: 130,
},
});
});
it('должен ограничивать body move границами container', () => {
const env = createDrawingTestEnvironment({
width: 400,
height: 300,
});
const drawing = new TestTwoPointDrawingBase(env);
drawing.setTestAnchors(50, 50, 150, 100);
expect(
drawing.moveWithinContainer(
{
x: 100,
y: 75,
},
{
x: 500,
y: 400,
},
{
left: 50,
right: 150,
top: 50,
bottom: 100,
},
),
).toBe(true);
expect(drawing.getState()).toMatchObject({
startAnchor: {
time: 300,
price: 250,
},
endAnchor: {
time: 400,
price: 300,
},
});
});
it('не должен перемещать anchors без dragStartPoint', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestTwoPointDrawingBase(env);
drawing.setTestAnchors(50, 60, 150, 100);
expect(
drawing.moveByPixels(null, {
x: 120,
y: 110,
}),
).toBe(false);
});
});
import { LinearDrawingBase } from '@core/Drawings/LinearDrawingBase';
import { createDrawingTestEnvironment } from '../drawingTestUtils';
import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { SettingsTab, SettingsValues } from '@src/types';
import type {
IPrimitivePaneView,
ISeriesPrimitiveAxisView,
PrimitiveHoveredItem,
Time,
} from 'lightweight-charts';
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartBackground: '#101010',
chartLineColor: '#2962ff',
chartPriceLineText: '#ffffff',
axisMarkerLabelFill: '#112233',
axisMarkerAreaFill: '#2962ff44',
},
}),
}));
type TestMode = 'idle' | 'ready';
type TestHandleId = 'start' | 'end';
class TestLinearDrawingBase extends LinearDrawingBase<SettingsValues, TestHandleId> {
protected settings: SettingsValues = {};
protected mode: TestMode = 'ready';
public setMode(mode: TestMode): void {
this.mode = mode;
}
public setTestAnchors(
startTime: number,
startPrice: number,
endTime: number,
endPrice: number,
): void {
this.setAnchors(
{
time: startTime as Time,
price: startPrice,
},
{
time: endTime as Time,
price: endPrice,
},
);
}
public handles() {
return this.getDrawingHandles();
}
public timeSegments(): AxisSegment[] {
return this.getTimeAxisSegments();
}
public priceSegments(): AxisSegment[] {
return this.getPriceAxisSegments();
}
public nearLine(point: Point, tolerance: number): boolean {
return this.isPointNearLine(point, tolerance);
}
public getState(): unknown {
return {
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
};
}
public setState(_state: unknown): void {}
public getSettingsTabs(): SettingsTab[] {
return [];
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public updateAllViews(): void {}
public paneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [];
}
public getRenderData(): unknown {
return this.getTwoPointGeometry();
}
protected getStartHandleId(): TestHandleId {
return 'start';
}
protected getEndHandleId(): TestHandleId {
return 'end';
}
protected getLineHandleStrokeColor(): string | undefined {
return '#ff0000';
}
protected getHoveredItem(): PrimitiveHoveredItem | null {
return null;
}
protected getGeometry(): unknown {
return this.getTwoPointGeometry();
}
protected getTimeAxisLabel(_kind: string): AxisLabel | null {
return null;
}
protected getPriceAxisLabel(_kind: string): AxisLabel | null {
return null;
}
}
describe('LinearDrawingBase', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('должен создавать start и end handles', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestLinearDrawingBase(env);
drawing.setTestAnchors(50, 60, 150, 100);
expect(drawing.handles()).toEqual([
{
id: 'start',
x: 50,
y: 60,
shape: 'circle',
strokeColor: '#ff0000',
},
{
id: 'end',
x: 150,
y: 100,
shape: 'circle',
strokeColor: '#ff0000',
},
]);
});
it('должен возвращать axis segments для selected drawing', () => {
const env = createDrawingTestEnvironment({ selected: true });
const drawing = new TestLinearDrawingBase(env);
drawing.setTestAnchors(150, 100, 50, 20);
expect(drawing.timeSegments()).toEqual([
{
from: 50,
to: 150,
color: '#2962ff44',
},
]);
expect(drawing.priceSegments()).toEqual([
{
from: 20,
to: 100,
color: '#2962ff44',
},
]);
});
it('не должен возвращать axis segments для ready unselected drawing', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestLinearDrawingBase(env);
drawing.setTestAnchors(50, 60, 150, 100);
expect(drawing.timeSegments()).toEqual([]);
expect(drawing.priceSegments()).toEqual([]);
});
it('должен возвращать axis segments во время creation pending', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestLinearDrawingBase(env);
drawing.setMode('idle');
drawing.setTestAnchors(50, 60, 150, 100);
expect(drawing.timeSegments()).toEqual([
{
from: 50,
to: 150,
color: '#2962ff44',
},
]);
expect(drawing.priceSegments()).toEqual([
{
from: 60,
to: 100,
color: '#2962ff44',
},
]);
});
it('должен определять point рядом с line segment', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestLinearDrawingBase(env);
drawing.setTestAnchors(50, 50, 150, 100);
expect(
drawing.nearLine(
{
x: 100,
y: 75,
},
4,
),
).toBe(true);
expect(
drawing.nearLine(
{
x: 100,
y: 100,
},
4,
),
).toBe(false);
});
it('не должен возвращать handles без geometry', () => {
const env = createDrawingTestEnvironment();
const drawing = new TestLinearDrawingBase(env);
expect(drawing.handles()).toEqual([]);
});
});
import {
clampPointToContainer,
getAnchorFromPoint,
getContainerSize,
getPointerPoint,
getPriceDelta,
getPriceFromYCoordinate,
getPriceRangeInContainer,
getRawPointerPoint,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
normalizeBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import type { SeriesApi } from '@core/Drawings/types';
import type {
Coordinate,
IChartApi,
Time,
} from 'lightweight-charts';
function createContainer(
width = 400,
height = 300,
left = 10,
top = 20,
) {
const container = document.createElement('div');
jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({
x: left,
y: top,
left,
top,
right: left + width,
bottom: top + height,
width,
height,
toJSON: jest.fn(),
});
return container;
}
function createChart() {
const coordinateToTime = jest.fn(
(coordinate: Coordinate): Time | null =>
Number(coordinate) as Time,
);
const timeToCoordinate = jest.fn(
(time: Time): Coordinate | null =>
Number(time) as Coordinate,
);
const timeScale = {
coordinateToTime,
timeToCoordinate,
};
const chart = {
timeScale: jest.fn(() => timeScale),
} as unknown as IChartApi;
return {
chart,
timeScale,
};
}
function createSeries(
data: readonly { time: Time }[] = [],
) {
const coordinateToPrice = jest.fn(
(coordinate: Coordinate): number | null =>
Number(coordinate),
);
const priceToCoordinate = jest.fn(
(price: number): Coordinate | null =>
price as Coordinate,
);
const dataMock = jest.fn(() => data);
const series = {
coordinateToPrice,
priceToCoordinate,
data: dataMock,
} as unknown as SeriesApi;
return {
series,
coordinateToPrice,
priceToCoordinate,
dataMock,
};
}
function mockTimeCoordinates(
timeToCoordinate: jest.Mock,
coordinates: Record<number, number>,
missingTimes: number[] = [],
): void {
timeToCoordinate.mockImplementation(
(time: Time): Coordinate | null => {
if (typeof time !== 'number') {
return null;
}
if (missingTimes.includes(time)) {
return null;
}
const coordinate = coordinates[time];
return coordinate === undefined
? null
: (coordinate as Coordinate);
},
);
}
describe('Drawing helpers', () => {
describe('price coordinates', () => {
it('должен получать цену из y coordinate', () => {
const {
series,
coordinateToPrice,
} = createSeries();
expect(
getPriceFromYCoordinate(
series,
50,
),
).toBe(50);
expect(
coordinateToPrice,
).toHaveBeenCalledWith(50);
});
it('должен получать y coordinate из цены', () => {
const {
series,
priceToCoordinate,
} = createSeries();
expect(
getYCoordinateFromPrice(
series,
100,
),
).toBe(100);
expect(
priceToCoordinate,
).toHaveBeenCalledWith(100);
});
});
describe('time coordinates', () => {
it('должен получать время из x coordinate', () => {
const {
chart,
timeScale,
} = createChart();
expect(
getTimeFromXCoordinate(
chart,
120,
),
).toBe(120);
expect(
timeScale.coordinateToTime,
).toHaveBeenCalledWith(120);
});
it('должен возвращать прямую координату времени, если она существует', () => {
const {
chart,
timeScale,
} = createChart();
expect(
getXCoordinateFromTime(
chart,
100 as Time,
),
).toBe(100);
expect(
timeScale.timeToCoordinate,
).toHaveBeenCalledWith(
100,
);
});
it('должен возвращать null без series, если координата времени отсутствует', () => {
const {
chart,
timeScale,
} = createChart();
timeScale.timeToCoordinate.mockReturnValue(
null,
);
expect(
getXCoordinateFromTime(
chart,
100 as Time,
),
).toBeNull();
});
it('должен интерполировать координату между существующими свечами', () => {
const {
chart,
timeScale,
} = createChart();
mockTimeCoordinates(
timeScale.timeToCoordinate,
{
20: 100,
30: 200,
},
[24],
);
const { series } = createSeries([
{ time: 20 as Time },
{ time: 30 as Time },
]);
expect(
getXCoordinateFromTime(
chart,
24 as Time,
series,
),
).toBe(140);
});
it('должен сохранять разницу координат для разных времён между одними свечами', () => {
const {
chart,
timeScale,
} = createChart();
mockTimeCoordinates(
timeScale.timeToCoordinate,
{
20: 100,
30: 200,
},
[24, 25],
);
const { series } = createSeries([
{ time: 20 as Time },
{ time: 30 as Time },
]);
expect(
getXCoordinateFromTime(
chart,
24 as Time,
series,
),
).toBe(140);
expect(
getXCoordinateFromTime(
chart,
25 as Time,
series,
),
).toBe(150);
});
it('должен экстраполировать координату для времени до диапазона', () => {
const {
chart,
timeScale,
} = createChart();
mockTimeCoordinates(
timeScale.timeToCoordinate,
{
10: 100,
20: 200,
},
[5],
);
const { series } = createSeries([
{ time: 10 as Time },
{ time: 20 as Time },
]);
expect(
getXCoordinateFromTime(
chart,
5 as Time,
series,
),
).toBe(50);
});
it('должен экстраполировать координату для времени после диапазона', () => {
const {
chart,
timeScale,
} = createChart();
mockTimeCoordinates(
timeScale.timeToCoordinate,
{
10: 100,
20: 200,
},
[30],
);
const { series } = createSeries([
{ time: 10 as Time },
{ time: 20 as Time },
]);
expect(
getXCoordinateFromTime(
chart,
30 as Time,
series,
),
).toBe(300);
});
it('должен использовать координату единственной свечи, если в series одна точка', () => {
const {
chart,
timeScale,
} = createChart();
mockTimeCoordinates(
timeScale.timeToCoordinate,
{
10: 100,
},
[15],
);
const { series } = createSeries([
{ time: 10 as Time },
]);
expect(
getXCoordinateFromTime(
chart,
15 as Time,
series,
),
).toBe(100);
});
it('должен возвращать null при отсутствии данных series', () => {
const {
chart,
timeScale,
} = createChart();
timeScale.timeToCoordinate.mockReturnValue(
null,
);
const { series } =
createSeries();
expect(
getXCoordinateFromTime(
chart,
30 as Time,
series,
),
).toBeNull();
});
it('должен возвращать null, если координаты соседних свечей недоступны', () => {
const {
chart,
timeScale,
} = createChart();
timeScale.timeToCoordinate.mockReturnValue(
null,
);
const { series } = createSeries([
{ time: 10 as Time },
{ time: 20 as Time },
]);
expect(
getXCoordinateFromTime(
chart,
15 as Time,
series,
),
).toBeNull();
});
it('должен возвращать null для отсутствующего ненумерического времени', () => {
const {
chart,
timeScale,
} = createChart();
timeScale.timeToCoordinate.mockReturnValue(
null,
);
const { series } = createSeries([
{ time: 10 as Time },
{ time: 20 as Time },
]);
const time = {
year: 2026,
month: 9,
day: 21,
} as Time;
expect(
getXCoordinateFromTime(
chart,
time,
series,
),
).toBeNull();
});
it('должен возвращать null для невалидной прямой координаты без series', () => {
const {
chart,
timeScale,
} = createChart();
timeScale.timeToCoordinate.mockReturnValue(
Number.NaN as Coordinate,
);
expect(
getXCoordinateFromTime(
chart,
100 as Time,
),
).toBeNull();
});
});
describe('container', () => {
it('должен получать размеры container', () => {
const container =
createContainer(
500,
250,
);
expect(
getContainerSize(
container,
),
).toEqual({
width: 500,
height: 250,
});
});
it('должен ограничивать point границами container', () => {
const container =
createContainer(
400,
300,
);
expect(
clampPointToContainer(
{
x: -20,
y: 350,
},
container,
),
).toEqual({
x: 0,
y: 300,
});
});
it('должен получать raw pointer point относительно container', () => {
const container =
createContainer(
400,
300,
10,
20,
);
const event = {
clientX: 110,
clientY: 70,
} as unknown as MouseEvent;
expect(
getRawPointerPoint(
container,
event,
),
).toEqual({
x: 100,
y: 50,
});
});
it('должен ограничивать pointer point границами container', () => {
const container =
createContainer(
400,
300,
10,
20,
);
const event = {
clientX: 500,
clientY: -10,
} as unknown as MouseEvent;
expect(
getPointerPoint(
container,
event,
),
).toEqual({
x: 400,
y: 0,
});
});
});
describe('point helpers', () => {
it('должен определять близость point', () => {
expect(
isNearPoint(
{
x: 108,
y: 92,
},
100,
100,
8,
),
).toBe(true);
expect(
isNearPoint(
{
x: 109,
y: 100,
},
100,
100,
8,
),
).toBe(false);
});
it('должен учитывать границу tolerance при проверке близости', () => {
expect(
isNearPoint(
{
x: 108,
y: 108,
},
100,
100,
8,
),
).toBe(true);
});
it('должен определять point внутри bounds', () => {
const bounds = {
left: 10,
right: 100,
top: 20,
bottom: 80,
};
expect(
isPointInBounds(
{
x: 50,
y: 50,
},
bounds,
),
).toBe(true);
expect(
isPointInBounds(
{
x: 5,
y: 50,
},
bounds,
),
).toBe(false);
expect(
isPointInBounds(
{
x: 5,
y: 50,
},
bounds,
5,
),
).toBe(true);
});
it('должен нормализовать и ограничивать bounds', () => {
const container =
createContainer(
400,
300,
);
expect(
normalizeBounds(
450,
-20,
350,
-50,
container,
),
).toEqual({
left: 0,
right: 400,
top: 0,
bottom: 300,
});
});
});
describe('time shifting', () => {
it('должен смещать время на количество пикселей', () => {
const { chart } =
createChart();
expect(
shiftTimeByPixels(
chart,
100 as Time,
20,
),
).toBe(120);
});
it('должен использовать projected coordinate при смещении отсутствующего времени', () => {
const {
chart,
timeScale,
} = createChart();
mockTimeCoordinates(
timeScale.timeToCoordinate,
{
20: 100,
30: 200,
},
[24],
);
timeScale.coordinateToTime.mockImplementation(
(coordinate: Coordinate) =>
Number(coordinate) as Time,
);
const { series } = createSeries([
{ time: 20 as Time },
{ time: 30 as Time },
]);
expect(
shiftTimeByPixels(
chart,
24 as Time,
20,
series,
),
).toBe(160);
});
it('должен возвращать null при отсутствии исходной координаты времени', () => {
const {
chart,
timeScale,
} = createChart();
timeScale.timeToCoordinate.mockReturnValue(
null,
);
expect(
shiftTimeByPixels(
chart,
100 as Time,
20,
),
).toBeNull();
});
});
describe('price delta', () => {
it('должен вычислять price delta', () => {
const { series } =
createSeries();
expect(
getPriceDelta(
series,
20,
70,
),
).toBe(50);
});
it('должен возвращать 0 price delta при отсутствии начальной цены', () => {
const {
series,
coordinateToPrice,
} = createSeries();
coordinateToPrice.mockReturnValueOnce(
null,
);
expect(
getPriceDelta(
series,
20,
70,
),
).toBe(0);
});
it('должен возвращать 0 price delta при отсутствии конечной цены', () => {
const {
series,
coordinateToPrice,
} = createSeries();
coordinateToPrice
.mockReturnValueOnce(20)
.mockReturnValueOnce(null);
expect(
getPriceDelta(
series,
20,
70,
),
).toBe(0);
});
});
describe('price range', () => {
it('должен вычислять диапазон цены в container', () => {
const container =
createContainer(
400,
300,
);
const {
series,
coordinateToPrice,
} = createSeries();
coordinateToPrice
.mockReturnValueOnce(150)
.mockReturnValueOnce(50);
expect(
getPriceRangeInContainer(
series,
container,
),
).toEqual({
min: 50,
max: 150,
});
});
it('должен возвращать null диапазона при нулевой высоте', () => {
const container =
createContainer(
400,
0,
);
const { series } =
createSeries();
expect(
getPriceRangeInContainer(
series,
container,
),
).toBeNull();
});
it('должен возвращать null диапазона при отсутствии цены', () => {
const container =
createContainer(
400,
300,
);
const {
series,
coordinateToPrice,
} = createSeries();
coordinateToPrice.mockReturnValueOnce(
null,
);
expect(
getPriceRangeInContainer(
series,
container,
),
).toBeNull();
});
});
describe('anchors', () => {
it('должен создавать anchor из point', () => {
const { chart } =
createChart();
const { series } =
createSeries();
expect(
getAnchorFromPoint(
chart,
series,
{
x: 100,
y: 200,
},
),
).toEqual({
time: 100,
price: 200,
});
});
it('должен возвращать null anchor при отсутствии времени', () => {
const {
chart,
timeScale,
} = createChart();
const { series } =
createSeries();
timeScale.coordinateToTime.mockReturnValue(
null,
);
expect(
getAnchorFromPoint(
chart,
series,
{
x: 100,
y: 200,
},
),
).toBeNull();
});
it('должен возвращать null anchor при отсутствии цены', () => {
const { chart } =
createChart();
const {
series,
coordinateToPrice,
} = createSeries();
coordinateToPrice.mockReturnValue(
null,
);
expect(
getAnchorFromPoint(
chart,
series,
{
x: 100,
y: 200,
},
),
).toBeNull();
});
});
});