Загрузка данных


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();
    });
  });
});