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


import { DrawingHandlesPrimitive } from '@core/Drawings/handles';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type { IChartApi, SeriesAttachedParameter, Time } from 'lightweight-charts';
import type { SeriesApi } from '@core/Drawings/types';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartBackground: '#101010',
      chartLineColor: '#2962ff',
    },
  }),
}));

function createEnvironment(paneIndex = 0, paneCount = 1, paneHeight = 100) {
  const pane = {
    paneIndex: jest.fn(() => paneIndex),
    getHeight: jest.fn(() => paneHeight),
  };

  const panes = Array.from({ length: paneCount }, (_, index) => {
    return index === paneIndex ? pane : {};
  });

  const chart = {
    panes: jest.fn(() => panes),
  } as unknown as IChartApi;

  const series = {
    getPane: jest.fn(() => pane),
  } as unknown as SeriesApi;

  return { chart, series, pane };
}

function attach<TId extends string>(
  primitive: DrawingHandlesPrimitive<TId>,
  chart: IChartApi,
  series: SeriesApi,
) {
  primitive.attached({
    chart,
    series,
    requestUpdate: jest.fn(),
  } as unknown as SeriesAttachedParameter<Time>);
}

function createTarget(horizontalPixelRatio = 2, verticalPixelRatio = 2) {
  const context = {
    save: jest.fn(),
    restore: jest.fn(),
    beginPath: jest.fn(),
    arc: jest.fn(),
    rect: jest.fn(),
    moveTo: jest.fn(),
    arcTo: jest.fn(),
    closePath: jest.fn(),
    fill: jest.fn(),
    stroke: jest.fn(),
    fillStyle: '',
    strokeStyle: '',
    lineWidth: 0,
  };

  const useBitmapCoordinateSpace = jest.fn(
    (
      callback: (scope: {
        context: CanvasRenderingContext2D;
        horizontalPixelRatio: number;
        verticalPixelRatio: number;
        bitmapSize: { width: number; height: number };
      }) => void,
    ) => {
      callback({
        context: context as unknown as CanvasRenderingContext2D,
        horizontalPixelRatio,
        verticalPixelRatio,
        bitmapSize: {
          width: 400,
          height: 100,
        },
      });
    },
  );

  return {
    context,
    target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
    useBitmapCoordinateSpace,
  };
}

describe('DrawingHandlesPrimitive', () => {
  it('должен возвращать top zOrder для pane', () => {
    const primitive = new DrawingHandlesPrimitive(() => []);

    expect(primitive.paneViews()).toHaveLength(1);
    expect(primitive.paneViews()[0].zOrder?.()).toBe('top');
  });

  it('должен возвращать top zOrder для time axis', () => {
    const primitive = new DrawingHandlesPrimitive(() => []);

    expect(primitive.timeAxisPaneViews()).toHaveLength(1);
    expect(primitive.timeAxisPaneViews()[0].zOrder?.()).toBe('top');
  });

  it('должен находить handle в зоне hit tolerance', () => {
    const handle: DrawingHandle<'main'> = {
      id: 'main',
      x: 100,
      y: 50,
    };

    const primitive = new DrawingHandlesPrimitive(() => [handle]);

    expect(primitive.findHandle({ x: 108, y: 58 })).toBe(handle);
  });

  it('не должен находить handle за пределами hit tolerance', () => {
    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 100,
        y: 50,
      },
    ]);

    expect(primitive.findHandle({ x: 109, y: 50 })).toBeNull();
  });

  it('должен учитывать размер handle при вычислении hit area', () => {
    const handle: DrawingHandle<'main'> = {
      id: 'main',
      x: 100,
      y: 50,
      size: 24,
    };

    const primitive = new DrawingHandlesPrimitive(() => [handle]);

    expect(primitive.findHandle({ x: 112, y: 50 })).toBe(handle);
    expect(primitive.findHandle({ x: 113, y: 50 })).toBeNull();
  });

  it('должен отдавать приоритет последнему handle при пересечении', () => {
    const handles: DrawingHandle<'first' | 'second'>[] = [
      {
        id: 'first',
        x: 100,
        y: 50,
      },
      {
        id: 'second',
        x: 100,
        y: 50,
      },
    ];

    const primitive = new DrawingHandlesPrimitive(() => handles);

    expect(primitive.findHandle({ x: 100, y: 50 })?.id).toBe('second');
  });

  it('должен определять handle, пересекающий time axis', () => {
    const { chart, series } = createEnvironment(0, 1, 100);

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 50,
        y: 100,
      },
    ]);

    attach(primitive, chart, series);

    expect(primitive.isTimeAxisHit({ x: 50, y: 104 })).toBe(true);
  });

  it('не должен считать точку внутри pane попаданием по time axis', () => {
    const { chart, series } = createEnvironment(0, 1, 100);

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 50,
        y: 100,
      },
    ]);

    attach(primitive, chart, series);

    expect(primitive.isTimeAxisHit({ x: 50, y: 99 })).toBe(false);
  });

  it('не должен работать с time axis для pane, который не является последним', () => {
    const { chart, series } = createEnvironment(0, 2, 100);

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 50,
        y: 100,
      },
    ]);

    attach(primitive, chart, series);

    expect(primitive.isTimeAxisHit({ x: 50, y: 104 })).toBe(false);
  });

  it('не должен считать handle пересекающим time axis, если он находится выше границы', () => {
    const { chart, series } = createEnvironment(0, 1, 100);

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 50,
        y: 80,
      },
    ]);

    attach(primitive, chart, series);

    expect(primitive.isTimeAxisHit({ x: 50, y: 101 })).toBe(false);
  });

  it('должен рисовать circle по умолчанию', () => {
    const { context, target } = createTarget();

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 100,
        y: 50,
      },
    ]);

    primitive.paneViews()[0].renderer().draw(target);

    expect(context.arc).toHaveBeenCalledWith(200, 100, 10, 0, Math.PI * 2);
    expect(context.fill).toHaveBeenCalledTimes(1);
    expect(context.stroke).toHaveBeenCalledTimes(1);
    expect(context.fillStyle).toBe('#101010');
    expect(context.strokeStyle).toBe('#2962ff');
    expect(context.lineWidth).toBe(4);
  });

  it('должен рисовать rounded handle', () => {
    const { context, target } = createTarget();

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 100,
        y: 50,
        shape: 'rounded',
      },
    ]);

    primitive.paneViews()[0].renderer().draw(target);

    expect(context.arc).not.toHaveBeenCalled();
    expect(context.arcTo).toHaveBeenCalled();
    expect(context.closePath).toHaveBeenCalled();
    expect(context.fill).toHaveBeenCalledTimes(1);
    expect(context.stroke).toHaveBeenCalledTimes(1);
  });

  it('должен использовать custom strokeColor и borderWidth', () => {
    const { context, target } = createTarget();

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 100,
        y: 50,
        strokeColor: '#ff0000',
        borderWidth: 3,
      },
    ]);

    primitive.paneViews()[0].renderer().draw(target);

    expect(context.strokeStyle).toBe('#ff0000');
    expect(context.lineWidth).toBe(6);
  });

  it('не должен вызывать canvas при отсутствии handles', () => {
    const { target, useBitmapCoordinateSpace } = createTarget();
    const primitive = new DrawingHandlesPrimitive(() => []);

    primitive.paneViews()[0].renderer().draw(target);

    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
  });

  it('должен рисовать пересекающую границу часть handle на time axis', () => {
    const { chart, series } = createEnvironment(0, 1, 100);
    const { context, target } = createTarget();

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 50,
        y: 100,
      },
    ]);

    attach(primitive, chart, series);

    primitive.timeAxisPaneViews()[0].renderer().draw(target);

    expect(context.arc).toHaveBeenCalled();
  });

  it('не должен рисовать handle на time axis для непоследнего pane', () => {
    const { chart, series } = createEnvironment(0, 2, 100);
    const { target, useBitmapCoordinateSpace } = createTarget();

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 50,
        y: 100,
      },
    ]);

    attach(primitive, chart, series);

    primitive.timeAxisPaneViews()[0].renderer().draw(target);

    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
  });

  it('должен очищать chart и series после detached', () => {
    const { chart, series } = createEnvironment();
    const primitive = new DrawingHandlesPrimitive(() => []);

    attach(primitive, chart, series);
    primitive.detached();

    expect(primitive.isTimeAxisHit({ x: 50, y: 101 })).toBe(false);
  });
});


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 { 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 timeScale = {
    coordinateToTime: jest.fn((coordinate: number) => coordinate),
    timeToCoordinate: jest.fn((time: Time) => Number(time)),
    logicalToCoordinate: jest.fn((logical: number) => logical * 10),
  };

  const chart = {
    timeScale: jest.fn(() => timeScale),
  } as unknown as IChartApi;

  return { chart, timeScale };
}

function createSeries(data: readonly { time: Time }[] = []) {
  const series = {
    coordinateToPrice: jest.fn((coordinate: number) => coordinate),
    priceToCoordinate: jest.fn((price: number) => price),
    data: jest.fn(() => data),
  } as unknown as SeriesApi;

  return series;
}

describe('Drawing helpers', () => {
  it('должен получать цену из y coordinate', () => {
    const series = createSeries();

    expect(getPriceFromYCoordinate(series, 50)).toBe(50);
    expect(series.coordinateToPrice).toHaveBeenCalledWith(50);
  });

  it('должен получать y coordinate из цены', () => {
    const series = createSeries();

    expect(getYCoordinateFromPrice(series, 100)).toBe(100);
    expect(series.priceToCoordinate).toHaveBeenCalledWith(100);
  });

  it('должен получать время из x coordinate', () => {
    const { chart, timeScale } = createChart();

    expect(getTimeFromXCoordinate(chart, 120)).toBe(120);
    expect(timeScale.coordinateToTime).toHaveBeenCalledWith(120);
  });

  it('должен возвращать прямую координату времени', () => {
    const { chart } = createChart();

    expect(getXCoordinateFromTime(chart, 100 as Time)).toBe(100);
  });

  it('должен возвращать null без series, если timeToCoordinate не дал координату', () => {
    const { chart, timeScale } = createChart();

    timeScale.timeToCoordinate.mockReturnValue(null);

    expect(getXCoordinateFromTime(chart, 100 as Time)).toBeNull();
  });

  it('должен проецировать отсутствующее время через ближайшую свечу', () => {
    const { chart, timeScale } = createChart();

    timeScale.timeToCoordinate.mockReturnValue(null);

    const series = createSeries([
      { time: 10 as Time },
      { time: 20 as Time },
      { time: 30 as Time },
    ]);

    expect(getXCoordinateFromTime(chart, 24 as Time, series)).toBe(10);
    expect(timeScale.logicalToCoordinate).toHaveBeenCalledWith(1);
  });

  it('должен выбирать предыдущую свечу при одинаковом расстоянии', () => {
    const { chart, timeScale } = createChart();

    timeScale.timeToCoordinate.mockReturnValue(null);

    const series = createSeries([
      { time: 10 as Time },
      { time: 20 as Time },
      { time: 30 as Time },
    ]);

    expect(getXCoordinateFromTime(chart, 25 as Time, series)).toBe(10);
  });

  it('должен использовать первую свечу для времени до диапазона', () => {
    const { chart, timeScale } = createChart();

    timeScale.timeToCoordinate.mockReturnValue(null);

    const series = createSeries([
      { time: 10 as Time },
      { time: 20 as Time },
    ]);

    expect(getXCoordinateFromTime(chart, 5 as Time, series)).toBe(0);
  });

  it('должен использовать последнюю свечу для времени после диапазона', () => {
    const { chart, timeScale } = createChart();

    timeScale.timeToCoordinate.mockReturnValue(null);

    const series = createSeries([
      { time: 10 as Time },
      { time: 20 as Time },
    ]);

    expect(getXCoordinateFromTime(chart, 30 as Time, series)).toBe(10);
  });

  it('должен возвращать null при отсутствии валидных свечей', () => {
    const { chart, timeScale } = createChart();

    timeScale.timeToCoordinate.mockReturnValue(null);

    const series = createSeries([]);

    expect(getXCoordinateFromTime(chart, 30 as Time, series)).toBeNull();
  });

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

    expect(
      getRawPointerPoint(container, {
        clientX: 110,
        clientY: 70,
      } as MouseEvent),
    ).toEqual({
      x: 100,
      y: 50,
    });
  });

  it('должен ограничивать pointer point границами container', () => {
    const container = createContainer(400, 300, 10, 20);

    expect(
      getPointerPoint(container, {
        clientX: 500,
        clientY: -10,
      } as MouseEvent),
    ).toEqual({
      x: 400,
      y: 0,
    });
  });

  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('должен определять 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,
    });
  });

  it('должен смещать время на количество пикселей', () => {
    const { chart } = createChart();

    expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBe(120);
  });

  it('должен возвращать null при невозможности получить исходную координату времени', () => {
    const { chart, timeScale } = createChart();

    timeScale.timeToCoordinate.mockReturnValue(null);

    expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBeNull();
  });

  it('должен вычислять price delta', () => {
    const series = createSeries();

    expect(getPriceDelta(series, 20, 70)).toBe(50);
  });

  it('должен возвращать 0 price delta при отсутствии цены', () => {
    const series = createSeries();

    (series.coordinateToPrice as jest.Mock).mockReturnValueOnce(null);

    expect(getPriceDelta(series, 20, 70)).toBe(0);
  });

  it('должен вычислять диапазон цены в container', () => {
    const container = createContainer(400, 300);
    const series = createSeries();

    (series.coordinateToPrice as jest.Mock)
      .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('должен создавать 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 = createSeries();

    (series.coordinateToPrice as jest.Mock).mockReturnValue(null);

    expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toBeNull();
  });
});



import { getOrderedSideValue, updateViews } from '@core/Drawings/utils';

describe('Drawing utils', () => {
  describe('updateViews', () => {
    it('должен обновлять все views', () => {
      const first = {
        update: jest.fn(),
      };

      const second = {
        update: jest.fn(),
      };

      updateViews([first, second]);

      expect(first.update).toHaveBeenCalledTimes(1);
      expect(second.update).toHaveBeenCalledTimes(1);
    });

    it('должен корректно работать с пустым массивом', () => {
      expect(() => updateViews([])).not.toThrow();
    });
  });

  describe('getOrderedSideValue', () => {
    it('должен возвращать null без startValue', () => {
      expect(getOrderedSideValue(null, 'end', 10, 20, 'start')).toBeNull();
    });

    it('должен возвращать null без endValue', () => {
      expect(getOrderedSideValue('start', null, 10, 20, 'end')).toBeNull();
    });

    it('должен использовать исходный порядок без координат', () => {
      expect(getOrderedSideValue('A', 'B', null, 20, 'start')).toBe('A');
      expect(getOrderedSideValue('A', 'B', 10, null, 'end')).toBe('B');
    });

    it('должен возвращать startValue для левой координаты', () => {
      expect(getOrderedSideValue('A', 'B', 10, 20, 'start')).toBe('A');
      expect(getOrderedSideValue('A', 'B', 10, 20, 'end')).toBe('B');
    });

    it('должен менять значения местами при обратном порядке координат', () => {
      expect(getOrderedSideValue('A', 'B', 20, 10, 'start')).toBe('B');
      expect(getOrderedSideValue('A', 'B', 20, 10, 'end')).toBe('A');
    });

    it('должен считать одинаковые координаты прямым порядком', () => {
      expect(getOrderedSideValue('A', 'B', 10, 10, 'start')).toBe('A');
      expect(getOrderedSideValue('A', 'B', 10, 10, 'end')).toBe('B');
    });
  });
});



import {
  DRAWING_KEYBOARD_SHORTCUTS,
  DRAWING_POINTER_SHORTCUTS,
  findDrawingPointerShortcut,
} from '@core/Drawings/shortcuts';

import { DrawingsNames } from '@src/constants';
import { Keys } from '@src/core/Hotkeys';

import type { MouseEventParams } from 'lightweight-charts';

function createEvent({
  shiftKey = false,
  altKey = false,
  ctrlKey = false,
  metaKey = false,
}: {
  shiftKey?: boolean;
  altKey?: boolean;
  ctrlKey?: boolean;
  metaKey?: boolean;
}): MouseEventParams {
  return {
    sourceEvent: {
      shiftKey,
      altKey,
      ctrlKey,
      metaKey,
    },
  } as unknown as MouseEventParams;
}

describe('Drawing shortcuts', () => {
  it('должен содержать keyboard shortcut для trend line', () => {
    expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
      drawingName: DrawingsNames.trendLine,
      keys: [Keys.alt, Keys.t],
    });
  });

  it('должен содержать keyboard shortcut для horizontal line', () => {
    expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
      drawingName: DrawingsNames.horizontalLine,
      keys: [Keys.alt, Keys.h],
    });
  });

  it('должен содержать keyboard shortcut для vertical line', () => {
    expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
      drawingName: DrawingsNames.verticalLine,
      keys: [Keys.alt, Keys.v],
    });
  });

  it('должен содержать keyboard shortcut для fibonacci', () => {
    expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
      drawingName: DrawingsNames.fibonacciRetracement,
      keys: [Keys.alt, Keys.f],
    });
  });

  it('должен содержать keyboard shortcut для rectangle', () => {
    expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
      drawingName: DrawingsNames.rectangle,
      keys: [Keys.alt, Keys.shift, Keys.r],
    });
  });

  it('должен содержать pointer shortcut для ruler', () => {
    expect(DRAWING_POINTER_SHORTCUTS).toEqual([
      {
        drawingName: DrawingsNames.ruler,
        modifiers: [Keys.shift],
      },
    ]);
  });

  it('должен находить ruler при нажатом Shift', () => {
    expect(
      findDrawingPointerShortcut(
        createEvent({
          shiftKey: true,
        }),
      ),
    ).toEqual({
      drawingName: DrawingsNames.ruler,
      modifiers: [Keys.shift],
    });
  });

  it('не должен находить shortcut без Shift', () => {
    expect(findDrawingPointerShortcut(createEvent({}))).toBeUndefined();
  });

  it('не должен находить Shift shortcut при дополнительном Alt', () => {
    expect(
      findDrawingPointerShortcut(
        createEvent({
          shiftKey: true,
          altKey: true,
        }),
      ),
    ).toBeUndefined();
  });

  it('не должен находить shortcut без sourceEvent', () => {
    expect(findDrawingPointerShortcut({} as MouseEventParams)).toBeUndefined();
  });
});



import { BehaviorSubject } from 'rxjs';

import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
import { SeriesDrawingBase } from '@core/Drawings/SeriesDrawingBase';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { DrawingInteraction } from '@core/Drawings/SeriesDrawingBase';
import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { SettingsTab, SettingsValues } from '@src/types';
import type {
  IChartApi,
  IPrimitivePaneView,
  ISeriesPrimitiveAxisView,
  PrimitiveHoveredItem,
  SeriesAttachedParameter,
  Time,
} from 'lightweight-charts';

class TestDrawing extends SeriesDrawingBase<SettingsValues, 'main'> {
  protected mode: 'idle' | 'ready' = 'ready';

  protected settings: SettingsValues = {
    color: '#ffffff',
  };

  public handlePoint: Point = {
    x: 50,
    y: 50,
  };

  public updateAllViews = jest.fn();

  public pointerDownCalls = 0;
  public clickCalls = 0;
  public doubleClickCalls = 0;
  public contextMenuCalls = 0;

  public isCreationPending(): boolean {
    return this.mode === 'idle';
  }

  public getState() {
    return {
      mode: this.mode,
    };
  }

  public setState(state: unknown): void {
    if (!state || typeof state !== 'object') {
      return;
    }

    const nextState = state as { mode?: 'idle' | 'ready' };

    this.mode = nextState.mode ?? this.mode;
  }

  public getSettingsTabs(): SettingsTab[] {
    return [];
  }

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

  public setMode(mode: 'idle' | 'ready'): void {
    this.mode = mode;
  }

  public markReady(): void {
    this.mode = 'ready';
    this.resolveReady?.();
  }

  public findHandle(point: Point): DrawingHandle<'main'> | null {
    return this.getDrawingHandleAtPoint(point);
  }

  protected getDrawingHandles(): readonly DrawingHandle<'main'>[] {
    return [
      {
        id: 'main',
        ...this.handlePoint,
      },
    ];
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    if (Math.abs(x - this.handlePoint.x) > 8 || Math.abs(y - this.handlePoint.y) > 8) {
      return null;
    }

    return {
      cursorStyle: 'move',
      externalId: 'test',
      zOrder: 'top',
    };
  }

  protected getGeometry() {
    return null;
  }

  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(): void {
    this.pointerDownCalls += 1;
  }

  protected handleClick(): void {
    this.clickCalls += 1;
  }

  protected handleDoubleClick(): void {
    this.doubleClickCalls += 1;
  }

  protected handleContextMenu(): void {
    this.contextMenuCalls += 1;
  }
}

function createEnvironment({
  selected = false,
  locked = false,
  width = 400,
  height = 300,
}: {
  selected?: boolean;
  locked?: boolean;
  width?: number;
  height?: number;
} = {}) {
  let selectedValue = selected;
  let lockedValue = locked;

  const selected$ = new BehaviorSubject(selected);
  const locked$ = new BehaviorSubject(locked);

  const interaction: DrawingInteraction = {
    selected$,
    locked$,
    isSelected: jest.fn(() => selectedValue),
    isLocked: jest.fn(() => lockedValue),
    select: jest.fn(() => {
      selectedValue = true;
      selected$.next(true);
    }),
    deselect: jest.fn(() => {
      selectedValue = false;
      selected$.next(false);
    }),
  };

  const container = document.createElement('div');

  jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({
    x: 0,
    y: 0,
    left: 0,
    top: 0,
    right: width,
    bottom: height,
    width,
    height,
    toJSON: jest.fn(),
  });

  const pane = {
    paneIndex: jest.fn(() => 0),
    getHeight: jest.fn(() => height),
  };

  const chart = {
    panes: jest.fn(() => [pane]),
    applyOptions: jest.fn(),
  } as unknown as IChartApi;

  const series = {
    attachPrimitive: jest.fn(),
    detachPrimitive: jest.fn(),
    getPane: jest.fn(() => pane),
  } as unknown as SeriesApi;

  return {
    container,
    interaction,
    chart,
    series,
    setSelected(value: boolean) {
      selectedValue = value;
      selected$.next(value);
    },
    setLocked(value: boolean) {
      lockedValue = value;
      locked$.next(value);
    },
  };
}

function createPointerEvent(
  target: Node,
  x: number,
  y: number,
  button = 0,
): PointerEvent {
  return {
    target,
    clientX: x,
    clientY: y,
    button,
    pointerId: 1,
    preventDefault: jest.fn(),
    stopPropagation: jest.fn(),
  } as unknown as PointerEvent;
}

function createMouseEvent(target: Node, x: number, y: number): MouseEvent {
  return {
    target,
    clientX: x,
    clientY: y,
    preventDefault: jest.fn(),
    stopPropagation: jest.fn(),
  } as unknown as MouseEvent;
}

describe('SeriesDrawingBase', () => {
  it('должен возвращать копию settings', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    const first = drawing.getSettings();

    first.color = '#000000';

    expect(drawing.getSettings()).toEqual({
      color: '#ffffff',
    });
  });

  it('должен объединять новые settings с текущими', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    drawing.updateSettings({
      lineWidth: 2,
    });

    expect(drawing.getSettings()).toEqual({
      color: '#ffffff',
      lineWidth: 2,
    });
  });

  it('должен уведомлять подписчика settings', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);
    const callback = jest.fn();

    const subscription = drawing.subscribeSettings(callback);

    expect(callback).toHaveBeenCalledWith({
      color: '#ffffff',
    });

    drawing.updateSettings({
      color: '#ff0000',
    });

    expect(callback).toHaveBeenLastCalledWith({
      color: '#ff0000',
    });

    subscription.unsubscribe();
  });

  it('должен скрывать drawing', () => {
    const env = createEnvironment({
      selected: true,
    });

    const drawing = new TestDrawing(env);

    drawing.hide();

    expect(drawing.findHandle({ x: 50, y: 50 })).toBeNull();
    expect(drawing.updateAllViews).toHaveBeenCalled();
    expect(env.chart.applyOptions).toHaveBeenCalled();
  });

  it('должен снова показывать drawing', () => {
    const env = createEnvironment({
      selected: true,
    });

    const drawing = new TestDrawing(env);

    drawing.hide();
    drawing.show();

    expect(drawing.findHandle({ x: 50, y: 50 })?.id).toBe('main');
  });

  it('должен показывать handle для selected drawing', () => {
    const env = createEnvironment({
      selected: true,
    });

    const drawing = new TestDrawing(env);

    expect(drawing.findHandle({ x: 50, y: 50 })?.id).toBe('main');
  });

  it('не должен показывать handle для невыбранного ready drawing', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    expect(drawing.findHandle({ x: 50, y: 50 })).toBeNull();
  });

  it('должен показывать handle во время создания', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    drawing.setMode('idle');

    expect(drawing.findHandle({ x: 50, y: 50 })?.id).toBe('main');
  });

  it('не должен показывать handle для locked drawing', () => {
    const env = createEnvironment({
      selected: true,
      locked: true,
    });

    const drawing = new TestDrawing(env);

    expect(drawing.findHandle({ x: 50, y: 50 })).toBeNull();
  });

  it('должен прикреплять DrawingHandlesPrimitive при attached', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    drawing.attached({
      chart: env.chart,
      series: env.series,
      requestUpdate: jest.fn(),
    } as unknown as SeriesAttachedParameter<Time>);

    expect(env.series.attachPrimitive).toHaveBeenCalledTimes(1);
    expect(env.series.attachPrimitive).toHaveBeenCalledWith(expect.any(DrawingHandlesPrimitive));

    drawing.detached();
  });

  it('должен отсоединять DrawingHandlesPrimitive при detached', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    drawing.attached({
      chart: env.chart,
      series: env.series,
      requestUpdate: jest.fn(),
    } as unknown as SeriesAttachedParameter<Time>);

    const handlesPrimitive = (env.series.attachPrimitive as jest.Mock).mock.calls[0][0];

    drawing.detached();

    expect(env.series.detachPrimitive).toHaveBeenCalledWith(handlesPrimitive);
  });

  it('должен возвращать false для object tree в idle', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    drawing.setMode('idle');

    expect(drawing.shouldShowInObjectTree()).toBe(false);
  });

  it('должен возвращать true для object tree в ready', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    expect(drawing.shouldShowInObjectTree()).toBe(true);
  });

  it('waitTillReady должен сразу завершаться для ready drawing', async () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    await expect(drawing.waitTillReady()).resolves.toBeUndefined();
  });

  it('waitTillReady должен ожидать перехода в ready', async () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    drawing.setMode('idle');

    const promise = drawing.waitTillReady();

    drawing.markReady();

    await expect(promise).resolves.toBeUndefined();
  });

  it('должен возвращать исходный cursor для unlocked drawing', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    expect(drawing.hitTest(50, 50)?.cursorStyle).toBe('move');
  });

  it('должен заменять cursor на pointer для locked drawing', () => {
    const env = createEnvironment({
      locked: true,
    });

    const drawing = new TestDrawing(env);

    expect(drawing.hitTest(50, 50)?.cursorStyle).toBe('pointer');
  });

  it('должен передавать pointerDown в drawing для unlocked состояния', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    drawing.pointerDown(createPointerEvent(env.container, 50, 50));

    expect(drawing.pointerDownCalls).toBe(1);
  });

  it('не должен обрабатывать событие вне container', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);
    const outside = document.createElement('div');

    drawing.pointerDown(createPointerEvent(outside, 50, 50));

    expect(drawing.pointerDownCalls).toBe(0);
  });

  it('должен выбирать locked drawing при pointerDown по нему', () => {
    const env = createEnvironment({
      locked: true,
    });

    const drawing = new TestDrawing(env);

    drawing.pointerDown(createPointerEvent(env.container, 50, 50));

    expect(env.interaction.select).toHaveBeenCalledTimes(1);
    expect(drawing.pointerDownCalls).toBe(0);
  });

  it('должен вызывать click handler', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    drawing.click(createMouseEvent(env.container, 50, 50));

    expect(drawing.clickCalls).toBe(1);
  });

  it('должен вызывать doubleClick handler', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    drawing.doubleClick(createMouseEvent(env.container, 50, 50));

    expect(drawing.doubleClickCalls).toBe(1);
  });

  it('должен вызывать contextMenu handler', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    drawing.contextMenu(createMouseEvent(env.container, 50, 50));

    expect(drawing.contextMenuCalls).toBe(1);
  });

  it('должен принимать событие на выступающей части handle поверх time axis', () => {
    const env = createEnvironment({
      selected: true,
      height: 100,
    });

    const drawing = new TestDrawing(env);

    drawing.handlePoint = {
      x: 50,
      y: 100,
    };

    drawing.attached({
      chart: env.chart,
      series: env.series,
      requestUpdate: jest.fn(),
    } as unknown as SeriesAttachedParameter<Time>);

    const handlesPrimitive = (env.series.attachPrimitive as jest.Mock).mock.calls[0][0] as DrawingHandlesPrimitive;

    handlesPrimitive.attached({
      chart: env.chart,
      series: env.series,
      requestUpdate: jest.fn(),
    } as unknown as SeriesAttachedParameter<Time>);

    const outside = document.createElement('div');

    expect(drawing.isHit(createMouseEvent(outside, 50, 104))).toBe(true);

    drawing.detached();
  });

  it('должен rebind drawing на новую series', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    const newSeries = {
      attachPrimitive: jest.fn(),
      detachPrimitive: jest.fn(),
    } as unknown as SeriesApi;

    drawing.rebind(newSeries);

    expect(env.series.detachPrimitive).toHaveBeenCalledWith(drawing);
    expect(newSeries.attachPrimitive).toHaveBeenCalledWith(drawing);
  });

  it('должен detach drawing при destroy', () => {
    const env = createEnvironment();
    const drawing = new TestDrawing(env);

    drawing.destroy();

    expect(env.series.detachPrimitive).toHaveBeenCalledWith(drawing);
  });
});



import { BehaviorSubject } from 'rxjs';

import { Diapson } from '@core/Drawings/diapson/diapson';
import { DiapsonPaneRenderer } from '@core/Drawings/diapson/paneRenderer';
import { DiapsonPaneView } from '@core/Drawings/diapson/paneView';
import { createDefaultSettings, getDiapsonSettingsTabs } from '@core/Drawings/diapson/settings';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { DrawingInteraction } from '@core/Drawings/SeriesDrawingBase';
import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type { IChartApi, PrimitiveHoveredItem, Time } from 'lightweight-charts';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartBackground: '#101010',
      chartLineColor: '#2962ff',

      diapsonStrokeFill: '#ff9800',
      diapsonAreaFill: '#ff980033',
      axisRangeTooltipText: '#ffffff',
      axisRangeTooltipFill: '#333333',

      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
      chartPriceLineText: '#ffffff',
    },
  }),
}));

jest.mock('@src/translations', () => ({
  t: (key: string) => key,
}));

jest.mock('@src/types/defaults', () => ({
  Defaults: {
    dateFormat: 'date',
    timeFormat: '24h',
    showTime: true,
  },
}));

jest.mock('@src/utils', () => ({
  formatPercent: (value: number) => `${value}%`,
  formatPrice: (value: number) => `price:${value}`,
  formatSignedNumber: (value: number) => (value > 0 ? `+${value}` : String(value)),
  formatVolume: (value: number) => `volume:${value}`,
}));

jest.mock('@src/utils/formatter', () => ({
  formatDate: (value: number) => `date:${value}`,
}));

class TestDiapson extends Diapson {
  public getHandles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

  public getTimeSegments(): AxisSegment[] {
    return this.getTimeAxisSegments();
  }

  public getPriceSegments(): AxisSegment[] {
    return this.getPriceAxisSegments();
  }

  public getTimeLabel(kind: string): AxisLabel | null {
    return this.getTimeAxisLabel(kind);
  }

  public getPriceLabel(kind: string): AxisLabel | null {
    return this.getPriceAxisLabel(kind);
  }

  public getHovered(x: number, y: number): PrimitiveHoveredItem | null {
    return this.getHoveredItem(x, y);
  }

  public getGeometryForTest() {
    return this.getGeometry();
  }

  public move(event: PointerEvent): void {
    this.handlePointerMove(event);
  }

  public up(event: PointerEvent): void {
    this.handlePointerUp(event);
  }
}

function createEnvironment({
  selected = false,
  locked = false,
  width = 400,
  height = 300,
  data = [],
}: {
  selected?: boolean;
  locked?: boolean;
  width?: number;
  height?: number;
  data?: readonly Record<string, unknown>[];
} = {}) {
  let selectedValue = selected;
  let lockedValue = locked;

  const selected$ = new BehaviorSubject(selected);
  const locked$ = new BehaviorSubject(locked);

  const interaction: DrawingInteraction = {
    selected$,
    locked$,
    isSelected: jest.fn(() => selectedValue),
    isLocked: jest.fn(() => lockedValue),
    select: jest.fn(() => {
      selectedValue = true;
      selected$.next(true);
    }),
    deselect: jest.fn(() => {
      selectedValue = false;
      selected$.next(false);
    }),
  };

  const container = document.createElement('div');

  jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({
    x: 0,
    y: 0,
    left: 0,
    top: 0,
    right: width,
    bottom: height,
    width,
    height,
    toJSON: jest.fn(),
  });

  const timeScale = {
    coordinateToTime: jest.fn((coordinate: number) => coordinate),
    timeToCoordinate: jest.fn((time: Time) => Number(time)),
    logicalToCoordinate: jest.fn((logical: number) => logical),
  };

  const pane = {
    paneIndex: jest.fn(() => 0),
    getHeight: jest.fn(() => height),
  };

  const chart = {
    timeScale: jest.fn(() => timeScale),
    applyOptions: jest.fn(),
    panes: jest.fn(() => [pane]),
  } as unknown as IChartApi;

  const series = {
    coordinateToPrice: jest.fn((coordinate: number) => coordinate),
    priceToCoordinate: jest.fn((price: number) => price),
    data: jest.fn(() => data),
    attachPrimitive: jest.fn(),
    detachPrimitive: jest.fn(),
    getPane: jest.fn(() => pane),
  } as unknown as SeriesApi;

  return {
    chart,
    series,
    container,
    interaction,
    timeScale,
    setSelected(value: boolean) {
      selectedValue = value;
      selected$.next(value);
    },
    setLocked(value: boolean) {
      lockedValue = value;
      locked$.next(value);
    },
  };
}

function createPointerEvent(
  target: Node,
  x: number,
  y: number,
  pointerId = 1,
  button = 0,
): PointerEvent {
  return {
    target,
    clientX: x,
    clientY: y,
    pointerId,
    button,
    preventDefault: jest.fn(),
    stopPropagation: jest.fn(),
  } as unknown as PointerEvent;
}

function createMouseEvent(target: Node, x: number, y: number): MouseEvent {
  return {
    target,
    clientX: x,
    clientY: y,
    preventDefault: jest.fn(),
    stopPropagation: jest.fn(),
  } as unknown as MouseEvent;
}

function createDiapson(
  rangeMode: 'date' | 'price' = 'date',
  options: {
    selected?: boolean;
    data?: readonly Record<string, unknown>[];
    stepSize?: number;
    stepLabel?: string;
  } = {},
) {
  const env = createEnvironment({
    selected: options.selected,
    data: options.data,
  });

  const removeSelf = jest.fn();
  const openSettings = jest.fn();

  const drawing = new TestDiapson({
    chart: env.chart,
    series: env.series,
    container: env.container,
    interaction: env.interaction,
    rangeMode,
    removeSelf,
    openSettings,
    stepSize: options.stepSize,
    stepLabel: options.stepLabel,
  });

  return {
    ...env,
    drawing,
    removeSelf,
    openSettings,
  };
}

function setReady(
  drawing: TestDiapson,
  {
    rangeMode = 'date',
    startTime = 50,
    endTime = 150,
    startPrice = 50,
    endPrice = 150,
  }: {
    rangeMode?: 'date' | 'price';
    startTime?: number;
    endTime?: number;
    startPrice?: number;
    endPrice?: number;
  } = {},
) {
  drawing.setState({
    mode: 'ready',
    rangeMode,
    startTime: startTime as Time,
    endTime: endTime as Time,
    startPrice,
    endPrice,
  });
}

function createCanvasTarget() {
  const context = {
    save: jest.fn(),
    restore: jest.fn(),
    beginPath: jest.fn(),
    moveTo: jest.fn(),
    lineTo: jest.fn(),
    stroke: jest.fn(),
    fill: jest.fn(),
    fillRect: jest.fn(),
    quadraticCurveTo: jest.fn(),
    closePath: jest.fn(),
    fillText: jest.fn(),
    measureText: jest.fn((text: string) => ({
      width: text.length * 5,
    })),
    fillStyle: '',
    strokeStyle: '',
    lineWidth: 0,
    font: '',
    textAlign: '',
    textBaseline: '',
    canvas: {
      width: 800,
      height: 600,
    },
  };

  const useBitmapCoordinateSpace = jest.fn(
    (
      callback: (scope: {
        context: CanvasRenderingContext2D;
        horizontalPixelRatio: number;
        verticalPixelRatio: number;
      }) => void,
    ) => {
      callback({
        context: context as unknown as CanvasRenderingContext2D,
        horizontalPixelRatio: 2,
        verticalPixelRatio: 2,
      });
    },
  );

  return {
    context,
    target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
    useBitmapCoordinateSpace,
  };
}

describe('Diapson', () => {
  it('должен прикреплять primitive при создании', () => {
    const { drawing, series } = createDiapson();

    expect(series.attachPrimitive).toHaveBeenCalledWith(drawing);
  });

  it('должен начинаться в idle', () => {
    const { drawing } = createDiapson();

    expect(drawing.isCreationPending()).toBe(true);

    expect(drawing.getState()).toMatchObject({
      hidden: false,
      mode: 'idle',
      rangeMode: 'date',
      startTime: null,
      endTime: null,
      startPrice: null,
      endPrice: null,
    });
  });

  it('должен начинать drawing первым pointerDown', () => {
    const { drawing, container } = createDiapson();

    drawing.pointerDown(createPointerEvent(container, 50, 60));

    expect(drawing.getState()).toMatchObject({
      mode: 'drawing',
      startTime: 50,
      endTime: 50,
      startPrice: 60,
      endPrice: 60,
    });
  });

  it('должен завершать drawing вторым pointerDown', () => {
    const { drawing, container } = createDiapson();

    drawing.pointerDown(createPointerEvent(container, 50, 60));
    drawing.pointerDown(createPointerEvent(container, 150, 160));

    expect(drawing.getState()).toMatchObject({
      mode: 'ready',
      startTime: 50,
      endTime: 150,
      startPrice: 60,
      endPrice: 160,
    });

    expect(drawing.isCreationPending()).toBe(false);
  });

  it('должен удалять слишком маленький диапазон', () => {
    const { drawing, container, removeSelf } = createDiapson();

    drawing.pointerDown(createPointerEvent(container, 50, 50));
    drawing.pointerDown(createPointerEvent(container, 53, 53));

    expect(removeSelf).toHaveBeenCalledTimes(1);
  });

  it('waitTillReady должен завершаться после создания', async () => {
    const { drawing, container } = createDiapson();

    const readyPromise = drawing.waitTillReady();

    drawing.pointerDown(createPointerEvent(container, 50, 50));
    drawing.pointerDown(createPointerEvent(container, 150, 150));

    await expect(readyPromise).resolves.toBeUndefined();
  });

  it('должен сбрасывать drawing при изменении range mode', () => {
    const { drawing, interaction } = createDiapson();

    setReady(drawing);

    drawing.setRangeMode('price');

    expect(drawing.getState()).toMatchObject({
      mode: 'idle',
      rangeMode: 'price',
      startTime: null,
      endTime: null,
      startPrice: null,
      endPrice: null,
    });

    expect(interaction.deselect).toHaveBeenCalled();
  });

  it('не должен сбрасываться при установке текущего range mode', () => {
    const { drawing } = createDiapson();

    setReady(drawing);

    drawing.setRangeMode('date');

    expect(drawing.getState()).toMatchObject({
      mode: 'ready',
      startTime: 50,
      endTime: 150,
    });
  });

  it('должен возвращать нормализованную geometry', () => {
    const { drawing } = createDiapson();

    setReady(drawing, {
      startTime: 150,
      endTime: 50,
      startPrice: 160,
      endPrice: 60,
    });

    expect(drawing.getGeometryForTest()).toEqual({
      left: 50,
      right: 150,
      top: 60,
      bottom: 160,
      width: 100,
      height: 100,
      startPoint: {
        x: 150,
        y: 160,
      },
      endPoint: {
        x: 50,
        y: 60,
      },
    });
  });

  it('должен возвращать start и end handles', () => {
    const { drawing } = createDiapson({
      } as never);

    setReady(drawing);

    expect(drawing.getHandles()).toEqual([
      expect.objectContaining({
        id: 'end',
        x: 150,
        y: 150,
        shape: 'circle',
      }),
      expect.objectContaining({
        id: 'start',
        x: 50,
        y: 50,
        shape: 'circle',
      }),
    ]);
  });

  it('должен возвращать пустые handles без geometry', () => {
    const { drawing } = createDiapson();

    expect(drawing.getHandles()).toEqual([]);
  });

  it('должен возвращать render data', () => {
    const { drawing } = createDiapson();

    setReady(drawing);

    expect(drawing.getRenderData()).toMatchObject({
      left: 50,
      right: 150,
      top: 50,
      bottom: 150,
      width: 100,
      height: 100,
      rangeMode: 'date',
      showFill: true,
      borderColor: '#ff9800',
      fillColor: '#ff980033',
    });
  });

  it('не должен возвращать render data для hidden drawing', () => {
    const { drawing } = createDiapson();

    drawing.setState({
      hidden: true,
      mode: 'ready',
      startTime: 50,
      endTime: 150,
      startPrice: 50,
      endPrice: 150,
    });

    expect(drawing.getRenderData()).toBeNull();
  });

  it('должен возвращать time axis segment для selected drawing', () => {
    const { drawing } = createDiapson('date', {
      selected: true,
    });

    setReady(drawing);

    expect(drawing.getTimeSegments()).toEqual([
      {
        from: 50,
        to: 150,
        color: '#2962ff44',
      },
    ]);
  });

  it('должен возвращать price axis segment для selected drawing', () => {
    const { drawing } = createDiapson('date', {
      selected: true,
    });

    setReady(drawing);

    expect(drawing.getPriceSegments()).toEqual([
      {
        from: 50,
        to: 150,
        color: '#2962ff44',
      },
    ]);
  });

  it('не должен возвращать axis segments для невыбранного ready drawing', () => {
    const { drawing } = createDiapson();

    setReady(drawing);

    expect(drawing.getTimeSegments()).toEqual([]);
    expect(drawing.getPriceSegments()).toEqual([]);
  });

  it('должен возвращать time axis labels', () => {
    const { drawing } = createDiapson('date', {
      selected: true,
    });

    setReady(drawing);

    expect(drawing.getTimeLabel('left')).toEqual({
      coordinate: 50,
      text: 'date:50',
      textColor: '#ffffff',
      backgroundColor: '#2962ff',
    });

    expect(drawing.getTimeLabel('right')).toEqual({
      coordinate: 150,
      text: 'date:150',
      textColor: '#ffffff',
      backgroundColor: '#2962ff',
    });
  });

  it('должен возвращать price axis labels', () => {
    const { drawing } = createDiapson('date', {
      selected: true,
    });

    setReady(drawing);

    expect(drawing.getPriceLabel('top')).toEqual({
      coordinate: 50,
      text: 'price:50',
      textColor: '#ffffff',
      backgroundColor: '#2962ff',
    });

    expect(drawing.getPriceLabel('bottom')).toEqual({
      coordinate: 150,
      text: 'price:150',
      textColor: '#ffffff',
      backgroundColor: '#2962ff',
    });
  });

  it('не должен возвращать label неизвестного kind', () => {
    const { drawing } = createDiapson('date', {
      selected: true,
    });

    setReady(drawing);

    expect(drawing.getTimeLabel('unknown')).toBeNull();
    expect(drawing.getPriceLabel('unknown')).toBeNull();
  });

  it('должен показывать move cursor для невыбранного диапазона', () => {
    const { drawing } = createDiapson();

    setReady(drawing);

    expect(drawing.getHovered(100, 100)).toEqual({
      cursorStyle: 'move',
      externalId: 'diapson-date',
      zOrder: 'top',
    });
  });

  it('должен показывать resize cursor для выбранного handle', () => {
    const { drawing } = createDiapson('date', {
      selected: true,
    });

    setReady(drawing);

    expect(drawing.getHovered(50, 50)).toEqual({
      cursorStyle: 'nwse-resize',
      externalId: 'diapson-date',
      zOrder: 'top',
    });
  });

  it('должен выбирать диапазон первым кликом', () => {
    const { drawing, container, interaction } = createDiapson();

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 100, 100));

    expect(interaction.select).toHaveBeenCalledTimes(1);
    expect(drawing.getState()).toMatchObject({
      mode: 'ready',
    });
  });

  it('должен перемещать весь выбранный диапазон', () => {
    const { drawing, container } = createDiapson('date', {
      selected: true,
    });

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 100, 100, 1));
    drawing.move(createPointerEvent(container, 120, 130, 1));

    expect(drawing.getState()).toMatchObject({
      mode: 'dragging',
      startTime: 70,
      endTime: 170,
      startPrice: 80,
      endPrice: 180,
    });

    drawing.up(createPointerEvent(container, 120, 130, 1));

    expect(drawing.getState()).toMatchObject({
      mode: 'ready',
    });
  });

  it('должен resize start handle', () => {
    const { drawing, container } = createDiapson('date', {
      selected: true,
    });

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 50, 50, 1));
    drawing.move(createPointerEvent(container, 70, 80, 1));
    drawing.up(createPointerEvent(container, 70, 80, 1));

    expect(drawing.getState()).toMatchObject({
      mode: 'ready',
      startTime: 70,
      startPrice: 80,
      endTime: 150,
      endPrice: 150,
    });
  });

  it('должен resize end handle', () => {
    const { drawing, container } = createDiapson('date', {
      selected: true,
    });

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 150, 150, 1));
    drawing.move(createPointerEvent(container, 180, 190, 1));
    drawing.up(createPointerEvent(container, 180, 190, 1));

    expect(drawing.getState()).toMatchObject({
      startTime: 50,
      startPrice: 50,
      endTime: 180,
      endPrice: 190,
    });
  });

  it('не должен двигаться при другом pointerId', () => {
    const { drawing, container } = createDiapson('date', {
      selected: true,
    });

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 100, 100, 1));
    drawing.move(createPointerEvent(container, 120, 120, 2));

    expect(drawing.getState()).toMatchObject({
      startTime: 50,
      endTime: 150,
      startPrice: 50,
      endPrice: 150,
    });
  });

  it('должен открывать настройки по double click внутри диапазона', () => {
    const { drawing, container, openSettings } = createDiapson();

    setReady(drawing);

    drawing.doubleClick(createMouseEvent(container, 100, 100));

    expect(openSettings).toHaveBeenCalledTimes(1);
  });

  it('не должен открывать настройки по double click вне диапазона', () => {
    const { drawing, container, openSettings } = createDiapson();

    setReady(drawing);

    drawing.doubleClick(createMouseEvent(container, 300, 250));

    expect(openSettings).not.toHaveBeenCalled();
  });

  it('должен формировать price metrics', () => {
    const { drawing } = createDiapson('price', {
      stepSize: 5,
      stepLabel: 'pts',
    });

    setReady(drawing, {
      rangeMode: 'price',
      startPrice: 100,
      endPrice: 120,
    });

    expect(drawing.getRenderData()?.labelLines).toEqual([
      '+20 (20%) 4 pts',
    ]);
  });

  it('должен корректно формировать отрицательные price metrics', () => {
    const { drawing } = createDiapson('price', {
      stepSize: 5,
      stepLabel: 'pts',
    });

    setReady(drawing, {
      rangeMode: 'price',
      startPrice: 100,
      endPrice: 80,
    });

    expect(drawing.getRenderData()?.labelLines).toEqual([
      '-20 (-20%) -4 pts',
    ]);
  });

  it('должен формировать date metrics и volume', () => {
    const data = [
      {
        time: 1000,
        volume: 10,
      },
      {
        time: 1060,
        volume: 20,
      },
      {
        time: 1120,
        volume: 30,
      },
    ];

    const { drawing } = createDiapson('date', {
      data,
    });

    setReady(drawing, {
      rangeMode: 'date',
      startTime: 1000,
      endTime: 1120,
    });

    expect(drawing.getRenderData()?.labelLines).toEqual([
      '2 bars, 2m',
      'Vol volume:60',
    ]);
  });

  it('должен учитывать volume из customValues', () => {
    const data = [
      {
        time: 1000,
        customValues: {
          volume: 10,
        },
      },
      {
        time: 1060,
        customValues: {
          volume: 20,
        },
      },
    ];

    const { drawing } = createDiapson('date', {
      data,
    });

    setReady(drawing, {
      startTime: 1000,
      endTime: 1060,
    });

    expect(drawing.getRenderData()?.labelLines).toEqual([
      '1 bars, 1m',
      'Vol volume:30',
    ]);
  });
});

describe('DiapsonPaneRenderer', () => {
  it('не должен рисовать без render data', () => {
    const { target, useBitmapCoordinateSpace } = createCanvasTarget();

    const renderer = new DiapsonPaneRenderer({
      getRenderData: () => null,
    } as Diapson);

    renderer.draw(target);

    expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
  });

  it('должен рисовать date range', () => {
    const { context, target } = createCanvasTarget();

    const renderer = new DiapsonPaneRenderer({
      getRenderData: () => ({
        left: 10,
        right: 90,
        top: 20,
        bottom: 80,
        width: 80,
        height: 60,
        startPoint: {
          x: 10,
          y: 20,
        },
        endPoint: {
          x: 90,
          y: 80,
        },
        rangeMode: 'date',
        showFill: true,
        labelLines: [],
        borderColor: '#ff0000',
        fillColor: '#ff000033',
        labelTextColor: '#ffffff',
        labelBackgroundColor: '#000000',
        fontSize: 10,
        isBold: false,
        isItalic: false,
      }),
    } as Diapson);

    renderer.draw(target);

    expect(context.fillRect).toHaveBeenCalledWith(20, 40, 160, 120);

    expect(context.moveTo).toHaveBeenCalledWith(20, 40);
    expect(context.lineTo).toHaveBeenCalledWith(20, 160);

    expect(context.moveTo).toHaveBeenCalledWith(180, 40);
    expect(context.lineTo).toHaveBeenCalledWith(180, 160);

    expect(context.moveTo).toHaveBeenCalledWith(20, 100);
    expect(context.lineTo).toHaveBeenCalledWith(180, 100);
  });

  it('должен рисовать price range', () => {
    const { context, target } = createCanvasTarget();

    const renderer = new DiapsonPaneRenderer({
      getRenderData: () => ({
        left: 10,
        right: 90,
        top: 20,
        bottom: 80,
        width: 80,
        height: 60,
        startPoint: {
          x: 10,
          y: 20,
        },
        endPoint: {
          x: 90,
          y: 80,
        },
        rangeMode: 'price',
        showFill: true,
        labelLines: [],
        borderColor: '#ff0000',
        fillColor: '#ff000033',
        labelTextColor: '#ffffff',
        labelBackgroundColor: '#000000',
        fontSize: 10,
        isBold: false,
        isItalic: false,
      }),
    } as Diapson);

    renderer.draw(target);

    expect(context.moveTo).toHaveBeenCalledWith(20, 40);
    expect(context.lineTo).toHaveBeenCalledWith(180, 40);

    expect(context.moveTo).toHaveBeenCalledWith(20, 160);
    expect(context.lineTo).toHaveBeenCalledWith(180, 160);

    expect(context.moveTo).toHaveBeenCalledWith(100, 40);
    expect(context.lineTo).toHaveBeenCalledWith(100, 160);
  });

  it('должен рисовать label lines', () => {
    const { context, target } = createCanvasTarget();

    const renderer = new DiapsonPaneRenderer({
      getRenderData: () => ({
        left: 10,
        right: 90,
        top: 20,
        bottom: 80,
        width: 80,
        height: 60,
        startPoint: {
          x: 10,
          y: 20,
        },
        endPoint: {
          x: 90,
          y: 80,
        },
        rangeMode: 'date',
        showFill: true,
        labelLines: ['First', 'Second'],
        borderColor: '#ff0000',
        fillColor: '#ff000033',
        labelTextColor: '#ffffff',
        labelBackgroundColor: '#000000',
        fontSize: 10,
        isBold: true,
        isItalic: true,
      }),
    } as Diapson);

    renderer.draw(target);

    expect(context.fillText).toHaveBeenCalledTimes(2);
    expect(context.fillText).toHaveBeenCalledWith('First', expect.any(Number), expect.any(Number));
    expect(context.fillText).toHaveBeenCalledWith('Second', expect.any(Number), expect.any(Number));
    expect(context.font).toContain('italic');
    expect(context.font).toContain('700');
  });
});

describe('DiapsonPaneView', () => {
  it('должен возвращать renderer', () => {
    const drawing = {
      getRenderData: jest.fn(),
    } as unknown as Diapson;

    const view = new DiapsonPaneView(drawing);

    expect(view.renderer()).toBeInstanceOf(DiapsonPaneRenderer);
  });

  it('должен иметь top zOrder', () => {
    const drawing = {
      getRenderData: jest.fn(),
    } as unknown as Diapson;

    const view = new DiapsonPaneView(drawing);

    expect(view.zOrder()).toBe('top');
  });
});

describe('Diapson settings', () => {
  it('должен создавать настройки по умолчанию', () => {
    expect(createDefaultSettings()).toEqual({
      borderColor: '#ff9800',
      fillColor: '#ff980033',
      labelTextColor: '#ffffff',
      labelBackgroundColor: '#333333',
      fontSize: 10,
      isBold: false,
      isItalic: false,
    });
  });

  it('должен создавать style и text tabs', () => {
    const tabs = getDiapsonSettingsTabs(createDefaultSettings());

    expect(tabs.map((tab) => tab.key)).toEqual([
      'style',
      'text',
    ]);
  });

  it('должен передавать текущие style значения', () => {
    const settings = {
      ...createDefaultSettings(),
      borderColor: '#111111',
      fillColor: '#222222',
    };

    const styleTab = getDiapsonSettingsTabs(settings).find((tab) => tab.key === 'style');

    expect(styleTab?.fields).toEqual([
      expect.objectContaining({
        key: 'borderColor',
        defaultValue: '#111111',
      }),
      expect.objectContaining({
        key: 'fillColor',
        defaultValue: '#222222',
      }),
    ]);
  });

  it('должен передавать текущие text значения', () => {
    const settings = {
      ...createDefaultSettings(),
      fontSize: 18,
      isBold: true,
      isItalic: true,
      labelTextColor: '#123456',
      labelBackgroundColor: '#654321',
    };

    const textTab = getDiapsonSettingsTabs(settings).find((tab) => tab.key === 'text');

    expect(textTab?.fields).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          key: 'fontSize',
          defaultValue: 18,
          min: 8,
          max: 24,
        }),
        expect.objectContaining({
          key: 'isBold',
          defaultValue: true,
        }),
        expect.objectContaining({
          key: 'isItalic',
          defaultValue: true,
        }),
        expect.objectContaining({
          key: 'labelTextColor',
          defaultValue: '#123456',
        }),
        expect.objectContaining({
          key: 'labelBackgroundColor',
          defaultValue: '#654321',
        }),
      ]),
    );
  });
});