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


import { BehaviorSubject } from 'rxjs';

import type { BaseDrawingParams } from '@src/core/Drawings/DrawingBase';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type { Coordinate, Logical, Time } from 'lightweight-charts';

interface EnvironmentOptions {
  selected?: boolean;
  locked?: boolean;
  seriesData?: readonly unknown[];
  width?: number;
  height?: number;
}

interface CanvasTargetOptions {
  horizontalPixelRatio?: number;
  verticalPixelRatio?: number;
  bitmapWidth?: number;
  bitmapHeight?: number;
}

export function createDrawingTestEnvironment({
  selected = false,
  locked = false,
  seriesData = [],
  width = 400,
  height = 300,
}: EnvironmentOptions = {}) {
  let selectedValue = selected;
  let lockedValue = locked;

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

  const interaction = {
    selected$,
    locked$,
    isSelected: jest.fn(() => selectedValue),
    isLocked: jest.fn(() => lockedValue),
    select: jest.fn(() => {
      selectedValue = true;
      selected$.next(true);
    }),
    deselect: jest.fn(() => {
      selectedValue = false;
      selected$.next(false);
    }),
  } as unknown as BaseDrawingParams['interaction'];

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

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

  const coordinateToTime = jest.fn((coordinate: Coordinate): Time | null => Number(coordinate) as Time);
  const timeToCoordinate = jest.fn((time: Time): Coordinate | null => Number(time) as Coordinate);
  const coordinateToLogical = jest.fn((coordinate: Coordinate): Logical | null => Number(coordinate) as Logical);
  const logicalToCoordinate = jest.fn((logical: Logical): Coordinate | null => Number(logical) as Coordinate);

  const timeScale = {
    coordinateToTime,
    timeToCoordinate,
    coordinateToLogical,
    logicalToCoordinate,
    getVisibleLogicalRange: jest.fn(() => ({
      from: 0 as Logical,
      to: Math.max(seriesData.length - 1, 0) as Logical,
    })),
    subscribeVisibleLogicalRangeChange: jest.fn(),
    unsubscribeVisibleLogicalRangeChange: jest.fn(),
  };

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

  const chart = {
    timeScale: jest.fn(() => timeScale),
    applyOptions: jest.fn(),
    panes: jest.fn(() => [pane]),
    subscribeClick: jest.fn(),
    unsubscribeClick: jest.fn(),
    subscribeCrosshairMove: jest.fn(),
    unsubscribeCrosshairMove: jest.fn(),
  } as unknown as BaseDrawingParams['chart'];

  const coordinateToPrice = jest.fn((coordinate: Coordinate): number | null => Number(coordinate));
  const priceToCoordinate = jest.fn((price: number): Coordinate | null => price as Coordinate);

  const series = {
    coordinateToPrice,
    priceToCoordinate,
    data: jest.fn(() => seriesData),
    attachPrimitive: jest.fn(),
    detachPrimitive: jest.fn(),
    getPane: jest.fn(() => pane),
  } as unknown as BaseDrawingParams['series'];

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

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

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

export function createCanvasTarget({
  horizontalPixelRatio = 2,
  verticalPixelRatio = 2,
  bitmapWidth = 800,
  bitmapHeight = 600,
}: CanvasTargetOptions = {}) {
  const context = {
    canvas: {
      width: bitmapWidth,
      height: bitmapHeight,
    },
    save: jest.fn(),
    restore: jest.fn(),
    beginPath: jest.fn(),
    closePath: jest.fn(),
    moveTo: jest.fn(),
    lineTo: jest.fn(),
    quadraticCurveTo: jest.fn(),
    arc: jest.fn(),
    arcTo: jest.fn(),
    rect: jest.fn(),
    clip: jest.fn(),
    fill: jest.fn(),
    stroke: jest.fn(),
    fillRect: jest.fn(),
    strokeRect: jest.fn(),
    fillText: jest.fn(),
    setLineDash: jest.fn(),
    translate: jest.fn(),
    rotate: jest.fn(),
    measureText: jest.fn((text: string) => ({
      width: text.length * 8,
    })),
    fillStyle: '',
    strokeStyle: '',
    lineWidth: 0,
    globalAlpha: 1,
    font: '',
    textAlign: 'start',
    textBaseline: 'alphabetic',
  };

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

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

















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

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

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

function getView(views: readonly IPrimitivePaneView[]): IPrimitivePaneView {
  const [view] = views;

  if (!view) {
    throw new Error('Primitive pane view is not defined');
  }

  return view;
}

function getRenderer(views: readonly IPrimitivePaneView[]): IPrimitivePaneRenderer {
  const renderer = getView(views).renderer();

  if (!renderer) {
    throw new Error('Primitive pane renderer is not defined');
  }

  return renderer;
}

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

    expect(getView(primitive.paneViews()).zOrder?.()).toBe('top');
  });

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

    expect(getView(primitive.timeAxisPaneViews()).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('не должен определять time axis hit для handle выше границы pane', () => {
    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,
      },
    ]);

    getRenderer(primitive.paneViews()).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',
      },
    ]);

    getRenderer(primitive.paneViews()).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,
      },
    ]);

    getRenderer(primitive.paneViews()).draw(target);

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

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

    getRenderer(primitive.paneViews()).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);

    getRenderer(primitive.timeAxisPaneViews()).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);

    getRenderer(primitive.timeAxisPaneViews()).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 { Coordinate, IChartApi, Logical, 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 logicalToCoordinate = jest.fn((logical: Logical): Coordinate | null => {
    return (Number(logical) * 10) as Coordinate;
  });

  const timeScale = {
    coordinateToTime,
    timeToCoordinate,
    logicalToCoordinate,
  };

  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,
  };
}

describe('Drawing helpers', () => {
  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);
  });

  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, если координата времени отсутствует', () => {
    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 при отсутствии данных series', () => {
    const { chart, timeScale } = createChart();

    timeScale.timeToCoordinate.mockReturnValue(null);

    const { series } = createSeries();

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

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

    timeScale.timeToCoordinate.mockReturnValue(null);
    timeScale.logicalToCoordinate.mockReturnValue(null);

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

    expect(getXCoordinateFromTime(chart, 10 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);

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

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

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

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

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

















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 { 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 { AxisLine } from '@core/Drawings/axisLine/axisLine';
import { AxisLinePaneRenderer } from '@core/Drawings/axisLine/paneRenderer';
import { AxisLinePaneView } from '@core/Drawings/axisLine/paneView';
import { createDefaultSettings, getAxisLineSettingsTabs } from '@core/Drawings/axisLine/settings';

import {
  createCanvasTarget,
  createDrawingTestEnvironment,
  createMouseEvent,
  createPointerEvent,
} from '../drawingTestUtils';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartBackground: '#ffffff',
      chartLineColor: '#3366ff',
      chartPriceLineText: '#ffffff',
      axisMarkerLabelFill: '#112233',
    },
  }),
}));

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

jest.mock('@src/utils', () => ({
  formatPrice: (value: number) => String(value),
}));

jest.mock('@src/utils/formatter', () => ({
  formatDate: () => 'formatted-date',
}));

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

class TestAxisLine extends AxisLine {
  public handles(): readonly DrawingHandle<'main'>[] {
    return this.getDrawingHandles();
  }

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

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

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

  public pointerDown(event: PointerEvent): void {
    this.handlePointerDown(event);
  }

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

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

  public doubleClick(event: MouseEvent): void {
    this.handleDoubleClick(event);
  }
}

function createAxisLine(direction: 'vertical' | 'horizontal', selected = false) {
  const env = createDrawingTestEnvironment({ selected });
  const openSettings = jest.fn();

  const drawing = new TestAxisLine({
    ...env,
    direction,
    openSettings,
  });

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

describe('AxisLine', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

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

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

    expect(drawing.getState()).toMatchObject({
      mode: 'idle',
      time: null,
      price: null,
    });
  });

  it('должен создавать vertical line', () => {
    const { drawing, container, timeScale } = createAxisLine('vertical');

    drawing.pointerDown(createPointerEvent(container, 80, 120));

    expect(timeScale.coordinateToTime).toHaveBeenCalledWith(80);

    expect(drawing.getState()).toMatchObject({
      mode: 'ready',
      time: 80,
      price: null,
    });
  });

  it('должен создавать horizontal line', () => {
    const { drawing, container, coordinateToPrice } = createAxisLine('horizontal');

    drawing.pointerDown(createPointerEvent(container, 80, 120));

    expect(coordinateToPrice).toHaveBeenCalledWith(120);

    expect(drawing.getState()).toMatchObject({
      mode: 'ready',
      time: null,
      price: 120,
    });
  });

  it('не должен начинать drawing правой кнопкой', () => {
    const { drawing, container } = createAxisLine('vertical');

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

    expect(drawing.getState()).toMatchObject({
      mode: 'idle',
      time: null,
    });
  });

  it('должен восстанавливать state', () => {
    const { drawing } = createAxisLine('horizontal');

    drawing.setState({
      hidden: true,
      mode: 'ready',
      price: 150,
      settings: {
        lineColor: '#ff0000',
        text: 'Test',
      },
    });

    expect(drawing.getState()).toMatchObject({
      hidden: true,
      mode: 'ready',
      price: 150,
      settings: {
        lineColor: '#ff0000',
        text: 'Test',
      },
    });
  });

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

    drawing.setState({
      hidden: true,
      mode: 'ready',
      price: 100,
    });

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

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

    drawing.setState({
      mode: 'ready',
      time: 100 as Time,
    });

    expect(drawing.getRenderData()).toEqual({
      direction: 'vertical',
      coordinate: 100,
      lineColor: '#3366ff',
      text: '',
      fontSize: 14,
      isBold: false,
      isItalic: false,
      textColor: '#3366ff',
    });
  });

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

    drawing.setState({
      mode: 'ready',
      price: 120,
    });

    expect(drawing.getRenderData()).toEqual({
      direction: 'horizontal',
      coordinate: 120,
      lineColor: '#3366ff',
      text: '',
      fontSize: 14,
      isBold: false,
      isItalic: false,
      textColor: '#3366ff',
    });
  });

  it('должен размещать vertical handle по центру высоты', () => {
    const { drawing } = createAxisLine('vertical');

    drawing.setState({
      mode: 'ready',
      time: 80 as Time,
    });

    expect(drawing.handles()).toEqual([
      {
        id: 'main',
        x: 80,
        y: 150,
        shape: 'rounded',
      },
    ]);
  });

  it('должен размещать horizontal handle по центру ширины', () => {
    const { drawing } = createAxisLine('horizontal');

    drawing.setState({
      mode: 'ready',
      price: 120,
    });

    expect(drawing.handles()).toEqual([
      {
        id: 'main',
        x: 200,
        y: 120,
        shape: 'rounded',
      },
    ]);
  });

  it('должен возвращать time axis view только для vertical line', () => {
    const { drawing } = createAxisLine('vertical');

    expect(drawing.timeAxisViews()).toHaveLength(1);
    expect(drawing.priceAxisViews()).toHaveLength(0);
  });

  it('должен возвращать price axis view только для horizontal line', () => {
    const { drawing } = createAxisLine('horizontal');

    expect(drawing.timeAxisViews()).toHaveLength(0);
    expect(drawing.priceAxisViews()).toHaveLength(1);
  });

  it('должен возвращать time label для selected vertical line', () => {
    const { drawing } = createAxisLine('vertical', true);

    drawing.setState({
      mode: 'ready',
      time: 80 as Time,
    });

    expect(drawing.timeLabel('main')).toEqual({
      coordinate: 80,
      text: 'formatted-date',
      textColor: '#ffffff',
      backgroundColor: '#112233',
    });
  });

  it('должен возвращать price label для selected horizontal line', () => {
    const { drawing } = createAxisLine('horizontal', true);

    drawing.setState({
      mode: 'ready',
      price: 120,
    });

    expect(drawing.priceLabel('main')).toEqual({
      coordinate: 120,
      text: '120',
      textColor: '#ffffff',
      backgroundColor: '#112233',
    });
  });

  it('должен сохранять time label для unselected vertical line', () => {
    const { drawing } = createAxisLine('vertical');

    drawing.setState({
      mode: 'ready',
      time: 80 as Time,
    });

    expect(drawing.timeLabel('main')).toEqual({
      coordinate: 80,
      text: 'formatted-date',
      textColor: '#ffffff',
      backgroundColor: '#112233',
    });
  });

  it('должен сохранять price label для unselected horizontal line', () => {
    const { drawing } = createAxisLine('horizontal');

    drawing.setState({
      mode: 'ready',
      price: 120,
    });

    expect(drawing.priceLabel('main')).toEqual({
      coordinate: 120,
      text: '120',
      textColor: '#ffffff',
      backgroundColor: '#112233',
    });
  });

  it('не должен возвращать time label для hidden vertical line', () => {
    const { drawing } = createAxisLine('vertical');

    drawing.setState({
      hidden: true,
      mode: 'ready',
      time: 80 as Time,
    });

    expect(drawing.timeLabel('main')).toBeNull();
  });

  it('не должен возвращать price label для hidden horizontal line', () => {
    const { drawing } = createAxisLine('horizontal');

    drawing.setState({
      hidden: true,
      mode: 'ready',
      price: 120,
    });

    expect(drawing.priceLabel('main')).toBeNull();
  });

  it('не должен возвращать time label до завершения создания vertical line', () => {
    const { drawing } = createAxisLine('vertical');

    drawing.setState({
      mode: 'idle',
      time: 80 as Time,
    });

    expect(drawing.timeLabel('main')).toBeNull();
  });

  it('не должен возвращать price label до завершения создания horizontal line', () => {
    const { drawing } = createAxisLine('horizontal');

    drawing.setState({
      mode: 'idle',
      price: 120,
    });

    expect(drawing.priceLabel('main')).toBeNull();
  });

  it('не должен возвращать time label для horizontal line', () => {
    const { drawing } = createAxisLine('horizontal');

    drawing.setState({
      mode: 'ready',
      price: 120,
    });

    expect(drawing.timeLabel('main')).toBeNull();
  });

  it('не должен возвращать price label для vertical line', () => {
    const { drawing } = createAxisLine('vertical');

    drawing.setState({
      mode: 'ready',
      time: 80 as Time,
    });

    expect(drawing.priceLabel('main')).toBeNull();
  });

  it('не должен возвращать label для неизвестного kind', () => {
    const { drawing } = createAxisLine('vertical');

    drawing.setState({
      mode: 'ready',
      time: 80 as Time,
    });

    expect(drawing.timeLabel('unknown')).toBeNull();
    expect(drawing.priceLabel('unknown')).toBeNull();
  });

  it('должен определять hover vertical line', () => {
    const { drawing } = createAxisLine('vertical');

    drawing.setState({
      mode: 'ready',
      time: 50 as Time,
    });

    expect(drawing.hovered(54, 20)).toEqual({
      cursorStyle: 'ew-resize',
      externalId: 'axis-line',
      zOrder: 'top',
    });
  });

  it('должен определять hover horizontal line', () => {
    const { drawing } = createAxisLine('horizontal');

    drawing.setState({
      mode: 'ready',
      price: 50,
    });

    expect(drawing.hovered(20, 54)).toEqual({
      cursorStyle: 'ns-resize',
      externalId: 'axis-line',
      zOrder: 'top',
    });
  });

  it('не должен определять hover вдали от line', () => {
    const { drawing } = createAxisLine('vertical');

    drawing.setState({
      mode: 'ready',
      time: 50 as Time,
    });

    expect(drawing.hovered(70, 20)).toBeNull();
  });

  it('должен выбирать unselected line', () => {
    const { drawing, interaction, container } = createAxisLine('vertical');

    drawing.setState({
      mode: 'ready',
      time: 50 as Time,
    });

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

    expect(interaction.select).toHaveBeenCalledTimes(1);
  });

  it('должен перемещать vertical line', () => {
    const { drawing, container } = createAxisLine('vertical', true);

    drawing.setState({
      mode: 'ready',
      time: 50 as Time,
    });

    drawing.pointerDown(createPointerEvent(container, 50, 20, 1));
    drawing.pointerMove(createPointerEvent(container, 90, 20, 1));

    expect(drawing.getState()).toMatchObject({
      mode: 'dragging',
      time: 90,
    });

    drawing.pointerUp(createPointerEvent(container, 90, 20, 1));

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

  it('должен перемещать horizontal line', () => {
    const { drawing, container } = createAxisLine('horizontal', true);

    drawing.setState({
      mode: 'ready',
      price: 50,
    });

    drawing.pointerDown(createPointerEvent(container, 20, 50, 1));
    drawing.pointerMove(createPointerEvent(container, 20, 90, 1));

    expect(drawing.getState()).toMatchObject({
      mode: 'dragging',
      price: 90,
    });
  });

  it('не должен реагировать на другой pointerId', () => {
    const { drawing, container } = createAxisLine('vertical', true);

    drawing.setState({
      mode: 'ready',
      time: 50 as Time,
    });

    drawing.pointerDown(createPointerEvent(container, 50, 20, 1));
    drawing.pointerMove(createPointerEvent(container, 90, 20, 2));

    expect(drawing.getState()).toMatchObject({
      time: 50,
    });
  });

  it('должен открывать settings по double click', () => {
    const { drawing, container, openSettings } = createAxisLine('vertical');

    drawing.setState({
      mode: 'ready',
      time: 50 as Time,
    });

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

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

  it('должен обновлять settings через Base', () => {
    const { drawing } = createAxisLine('vertical');

    drawing.updateSettings({
      lineColor: '#ff0000',
      text: 'Updated',
    });

    expect(drawing.getSettings()).toMatchObject({
      lineColor: '#ff0000',
      text: 'Updated',
      fontSize: 14,
    });
  });
});

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

    const renderer = new AxisLinePaneRenderer({
      getRenderData: () => null,
    } as unknown as AxisLine);

    renderer.draw(target);

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

  it('должен рисовать vertical line', () => {
    const { context, target } = createCanvasTarget({
      bitmapWidth: 300,
      bitmapHeight: 200,
    });

    const renderer = new AxisLinePaneRenderer({
      getRenderData: () => ({
        direction: 'vertical',
        coordinate: 20,
        lineColor: '#ff0000',
        text: '',
        fontSize: 14,
        isBold: false,
        isItalic: false,
        textColor: '#ffffff',
      }),
    } as unknown as AxisLine);

    renderer.draw(target);

    expect(context.fillRect).toHaveBeenCalledWith(38, 0, 4, 200);
  });

  it('должен рисовать horizontal line', () => {
    const { context, target } = createCanvasTarget({
      bitmapWidth: 300,
      bitmapHeight: 200,
    });

    const renderer = new AxisLinePaneRenderer({
      getRenderData: () => ({
        direction: 'horizontal',
        coordinate: 30,
        lineColor: '#ff0000',
        text: '',
        fontSize: 14,
        isBold: false,
        isItalic: false,
        textColor: '#ffffff',
      }),
    } as unknown as AxisLine);

    renderer.draw(target);

    expect(context.fillRect).toHaveBeenCalledWith(0, 58, 300, 4);
  });

  it('должен рисовать vertical text с поворотом', () => {
    const { context, target } = createCanvasTarget({
      bitmapWidth: 300,
      bitmapHeight: 200,
    });

    const renderer = new AxisLinePaneRenderer({
      getRenderData: () => ({
        direction: 'vertical',
        coordinate: 20,
        lineColor: '#ff0000',
        text: 'Test',
        fontSize: 14,
        isBold: true,
        isItalic: true,
        textColor: '#ffffff',
      }),
    } as unknown as AxisLine);

    renderer.draw(target);

    expect(context.translate).toHaveBeenCalledWith(40, 100);
    expect(context.rotate).toHaveBeenCalledWith(-Math.PI / 2);
    expect(context.font).toContain('italic');
    expect(context.font).toContain('700');
    expect(context.fillText).toHaveBeenCalledWith('Test', 0, expect.any(Number));
  });
});

describe('AxisLinePaneView', () => {
  it('должен возвращать renderer и top zOrder', () => {
    const view = new AxisLinePaneView({
      getRenderData: jest.fn(),
    } as unknown as AxisLine);

    expect(view.renderer()).toBeInstanceOf(AxisLinePaneRenderer);
    expect(view.zOrder()).toBe('top');
  });
});

describe('AxisLine settings', () => {
  it('должен создавать defaults', () => {
    expect(createDefaultSettings()).toEqual({
      lineColor: '#3366ff',
      text: '',
      fontSize: 14,
      isBold: false,
      isItalic: false,
      textColor: '#3366ff',
    });
  });

  it('должен создавать style и text tabs', () => {
    expect(getAxisLineSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
  });
});
















import { CustomPriceAxisPaneView, CustomPriceAxisView } from '@core/Drawings/axis';

import type { CanvasRenderingTarget2D } from 'fancy-canvas';

function createTarget(verticalPixelRatio = 2) {
  const context = {
    save: jest.fn(),
    restore: jest.fn(),
    fillRect: jest.fn(),
    fillStyle: '',
  } as unknown as CanvasRenderingContext2D;

  const useBitmapCoordinateSpace = jest.fn(
    (
      callback: (scope: {
        context: CanvasRenderingContext2D;
        horizontalPixelRatio: number;
        verticalPixelRatio: number;
        bitmapSize: { width: number; height: number };
      }) => void,
    ) => {
      callback({
        context,
        horizontalPixelRatio: 2,
        verticalPixelRatio,
        bitmapSize: { width: 300, height: 200 },
      });
    },
  );

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

function createViewport(width = 400, height = 300): HTMLElement {
  const viewport = document.createElement('div');

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

  return viewport;
}

describe('CustomPriceAxisPaneView', () => {
  it('должен использовать bottom zOrder по умолчанию', () => {
    const view = new CustomPriceAxisPaneView({
      getAxisSegments: () => [],
    });

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

  it('должен использовать переданный zOrder', () => {
    const view = new CustomPriceAxisPaneView({
      getAxisSegments: () => [],
      zOrder: 'top',
    });

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

  it('должен возвращать один экземпляр renderer', () => {
    const view = new CustomPriceAxisPaneView({
      getAxisSegments: () => [],
    });

    expect(view.renderer()).toBe(view.renderer());
  });

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

    const view = new CustomPriceAxisPaneView({
      getAxisSegments: () => [],
    });

    view.renderer().draw(target);

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

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

    const view = new CustomPriceAxisPaneView({
      getAxisSegments: () => [
        {
          from: 10,
          to: 30,
          color: '#ff0000',
        },
      ],
    });

    view.renderer().draw(target);

    expect(context.fillStyle).toBe('#ff0000');
    expect(context.fillRect).toHaveBeenCalledWith(0, 20, 300, 40);
    expect(context.save).toHaveBeenCalledTimes(1);
    expect(context.restore).toHaveBeenCalledTimes(1);
  });

  it('должен корректно рисовать сегмент при обратном порядке координат', () => {
    const { context, target } = createTarget();

    const view = new CustomPriceAxisPaneView({
      getAxisSegments: () => [
        {
          from: 30,
          to: 10,
          color: '#00ff00',
        },
      ],
    });

    view.renderer().draw(target);

    expect(context.fillRect).toHaveBeenCalledWith(0, 20, 300, 40);
  });

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

    const view = new CustomPriceAxisPaneView({
      getAxisSegments: () => [
        {
          from: 10,
          to: 20,
          color: '#ff0000',
        },
        {
          from: 30,
          to: 50,
          color: '#00ff00',
        },
      ],
    });

    view.renderer().draw(target);

    expect(context.fillRect).toHaveBeenNthCalledWith(1, 0, 20, 300, 20);
    expect(context.fillRect).toHaveBeenNthCalledWith(2, 0, 60, 300, 40);
  });
});

describe('CustomPriceAxisView', () => {
  it('должен возвращать данные label внутри viewport', () => {
    const viewport = createViewport(50, 300);
    const getAxisLabel = jest.fn(() => ({
      coordinate: 125,
      text: '100.50',
      textColor: '#ffffff',
      backgroundColor: '#000000',
    }));

    const view = new CustomPriceAxisView({
      getAxisLabel,
      labelKind: 'entry',
      viewport,
    });

    expect(view.visible()).toBe(true);
    expect(view.tickVisible()).toBe(false);
    expect(view.coordinate()).toBe(125);
    expect(view.text()).toBe('100.50');
    expect(view.textColor()).toBe('#ffffff');
    expect(view.backColor()).toBe('#000000');
    expect(getAxisLabel).toHaveBeenCalledWith('entry');
  });

  it('должен возвращать значения по умолчанию при отсутствии label', () => {
    const view = new CustomPriceAxisView({
      getAxisLabel: () => null,
      labelKind: 'entry',
      viewport: createViewport(),
    });

    expect(view.visible()).toBe(false);
    expect(view.tickVisible()).toBe(false);
    expect(view.coordinate()).toBe(0);
    expect(view.text()).toBe('');
    expect(view.textColor()).toBe('');
    expect(view.backColor()).toBe('');
  });

  it('не должен быть видимым при пустом тексте', () => {
    const view = new CustomPriceAxisView({
      getAxisLabel: () => ({
        coordinate: 100,
        text: '',
        textColor: '#ffffff',
        backgroundColor: '#000000',
      }),
      labelKind: 'entry',
      viewport: createViewport(),
    });

    expect(view.visible()).toBe(false);
  });

  it.each([-1, 301])('не должен быть видимым вне viewport по вертикали: %s', (coordinate) => {
    const view = new CustomPriceAxisView({
      getAxisLabel: () => ({
        coordinate,
        text: '100.50',
        textColor: '#ffffff',
        backgroundColor: '#000000',
      }),
      labelKind: 'entry',
      viewport: createViewport(400, 300),
    });

    expect(view.visible()).toBe(false);
  });

  it.each([0, 300])('должен быть видимым на границе viewport по вертикали: %s', (coordinate) => {
    const view = new CustomPriceAxisView({
      getAxisLabel: () => ({
        coordinate,
        text: '100.50',
        textColor: '#ffffff',
        backgroundColor: '#000000',
      }),
      labelKind: 'entry',
      viewport: createViewport(400, 300),
    });

    expect(view.visible()).toBe(true);
  });

  it('не должен быть видимым при некорректной координате', () => {
    const view = new CustomPriceAxisView({
      getAxisLabel: () => ({
        coordinate: Number.NaN,
        text: '100.50',
        textColor: '#ffffff',
        backgroundColor: '#000000',
      }),
      labelKind: 'entry',
      viewport: createViewport(),
    });

    expect(view.visible()).toBe(false);
  });
});

















import { CustomTimeAxisPaneView, CustomTimeAxisView } from '@core/Drawings/axis';

import type { CanvasRenderingTarget2D } from 'fancy-canvas';

function createTarget(horizontalPixelRatio = 2) {
  const context = {
    save: jest.fn(),
    restore: jest.fn(),
    fillRect: jest.fn(),
    fillStyle: '',
  } as unknown as CanvasRenderingContext2D;

  const useBitmapCoordinateSpace = jest.fn(
    (
      callback: (scope: {
        context: CanvasRenderingContext2D;
        horizontalPixelRatio: number;
        verticalPixelRatio: number;
        bitmapSize: { width: number; height: number };
      }) => void,
    ) => {
      callback({
        context,
        horizontalPixelRatio,
        verticalPixelRatio: 2,
        bitmapSize: { width: 300, height: 200 },
      });
    },
  );

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

function createViewport(width = 400, height = 300): HTMLElement {
  const viewport = document.createElement('div');

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

  return viewport;
}

describe('CustomTimeAxisPaneView', () => {
  it('должен использовать bottom zOrder по умолчанию', () => {
    const view = new CustomTimeAxisPaneView({
      getAxisSegments: () => [],
    });

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

  it('должен использовать переданный zOrder', () => {
    const view = new CustomTimeAxisPaneView({
      getAxisSegments: () => [],
      zOrder: 'normal',
    });

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

  it('должен возвращать один экземпляр renderer', () => {
    const view = new CustomTimeAxisPaneView({
      getAxisSegments: () => [],
    });

    expect(view.renderer()).toBe(view.renderer());
  });

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

    const view = new CustomTimeAxisPaneView({
      getAxisSegments: () => [],
    });

    view.renderer().draw(target);

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

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

    const view = new CustomTimeAxisPaneView({
      getAxisSegments: () => [
        {
          from: 10,
          to: 30,
          color: '#ff0000',
        },
      ],
    });

    view.renderer().draw(target);

    expect(context.fillStyle).toBe('#ff0000');
    expect(context.fillRect).toHaveBeenCalledWith(20, 0, 40, 200);
    expect(context.save).toHaveBeenCalledTimes(1);
    expect(context.restore).toHaveBeenCalledTimes(1);
  });

  it('должен корректно рисовать сегмент при обратном порядке координат', () => {
    const { context, target } = createTarget();

    const view = new CustomTimeAxisPaneView({
      getAxisSegments: () => [
        {
          from: 30,
          to: 10,
          color: '#00ff00',
        },
      ],
    });

    view.renderer().draw(target);

    expect(context.fillRect).toHaveBeenCalledWith(20, 0, 40, 200);
  });

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

    const view = new CustomTimeAxisPaneView({
      getAxisSegments: () => [
        {
          from: 10,
          to: 20,
          color: '#ff0000',
        },
        {
          from: 30,
          to: 50,
          color: '#00ff00',
        },
      ],
    });

    view.renderer().draw(target);

    expect(context.fillRect).toHaveBeenNthCalledWith(1, 20, 0, 20, 200);
    expect(context.fillRect).toHaveBeenNthCalledWith(2, 60, 0, 40, 200);
  });
});

describe('CustomTimeAxisView', () => {
  it('должен возвращать данные label внутри viewport', () => {
    const viewport = createViewport(300, 50);
    const getAxisLabel = jest.fn(() => ({
      coordinate: 150,
      text: '09:30',
      textColor: '#ffffff',
      backgroundColor: '#000000',
    }));

    const view = new CustomTimeAxisView({
      getAxisLabel,
      labelKind: 'start',
      viewport,
    });

    expect(view.visible()).toBe(true);
    expect(view.tickVisible()).toBe(false);
    expect(view.coordinate()).toBe(150);
    expect(view.text()).toBe('09:30');
    expect(view.textColor()).toBe('#ffffff');
    expect(view.backColor()).toBe('#000000');
    expect(getAxisLabel).toHaveBeenCalledWith('start');
  });

  it('должен возвращать значения по умолчанию при отсутствии label', () => {
    const view = new CustomTimeAxisView({
      getAxisLabel: () => null,
      labelKind: 'start',
      viewport: createViewport(),
    });

    expect(view.visible()).toBe(false);
    expect(view.tickVisible()).toBe(false);
    expect(view.coordinate()).toBe(0);
    expect(view.text()).toBe('');
    expect(view.textColor()).toBe('');
    expect(view.backColor()).toBe('');
  });

  it('не должен быть видимым при пустом тексте', () => {
    const view = new CustomTimeAxisView({
      getAxisLabel: () => ({
        coordinate: 150,
        text: '',
        textColor: '#ffffff',
        backgroundColor: '#000000',
      }),
      labelKind: 'start',
      viewport: createViewport(),
    });

    expect(view.visible()).toBe(false);
  });

  it.each([-1, 401])('не должен быть видимым вне viewport по горизонтали: %s', (coordinate) => {
    const view = new CustomTimeAxisView({
      getAxisLabel: () => ({
        coordinate,
        text: '09:30',
        textColor: '#ffffff',
        backgroundColor: '#000000',
      }),
      labelKind: 'start',
      viewport: createViewport(400, 300),
    });

    expect(view.visible()).toBe(false);
  });

  it.each([0, 400])('должен быть видимым на границе viewport по горизонтали: %s', (coordinate) => {
    const view = new CustomTimeAxisView({
      getAxisLabel: () => ({
        coordinate,
        text: '09:30',
        textColor: '#ffffff',
        backgroundColor: '#000000',
      }),
      labelKind: 'start',
      viewport: createViewport(400, 300),
    });

    expect(view.visible()).toBe(true);
  });

  it('не должен быть видимым при некорректной координате', () => {
    const view = new CustomTimeAxisView({
      getAxisLabel: () => ({
        coordinate: Number.NaN,
        text: '09:30',
        textColor: '#ffffff',
        backgroundColor: '#000000',
      }),
      labelKind: 'start',
      viewport: createViewport(),
    });

    expect(view.visible()).toBe(false);
  });
});

















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

import {
  createCanvasTarget,
  createDrawingTestEnvironment,
  createMouseEvent,
  createPointerEvent,
} from '../drawingTestUtils';

import type { DiapsonRangeMode, DiapsonRenderData } from '@core/Drawings/diapson/diapson';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartBackground: '#101010',
      chartLineColor: '#2962ff',
      diapsonStrokeFill: '#ff9800',
      diapsonAreaFill: '#ff980033',
      axisRangeTooltipText: '#ffffff',
      axisRangeTooltipFill: '#333333',
      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
      chartPriceLineText: '#ffffff',
    },
  }),
}));

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

function createRendererDiapson(data: DiapsonRenderData | null): Diapson {
  return {
    getRenderData: () => data,
  } as unknown as Diapson;
}

function createRenderData(rangeMode: DiapsonRangeMode, overrides: Partial<DiapsonRenderData> = {}): DiapsonRenderData {
  return {
    left: 10,
    right: 90,
    top: 20,
    bottom: 80,
    width: 80,
    height: 60,
    startPoint: { x: 10, y: 20 },
    endPoint: { x: 90, y: 80 },
    rangeMode,
    showFill: true,
    labelLines: [],
    borderColor: '#ff0000',
    fillColor: '#ff000033',
    labelTextColor: '#ffffff',
    labelBackgroundColor: '#000000',
    fontSize: 10,
    isBold: false,
    isItalic: false,
    ...overrides,
  };
}

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

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

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

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

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

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

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

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

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

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

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

  it('должен менять rangeMode со сбросом drawing', () => {
    const { drawing, interaction } = createDiapson();

    setReady(drawing);

    drawing.setRangeMode('price');

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

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

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

    setReady(drawing);
    drawing.setRangeMode('date');

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

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

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

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

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

    setReady(drawing);

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

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

    setReady(drawing);

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

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

    setReady(drawing);

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

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

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

    setReady(drawing);

    expect(drawing.timeSegments()).toEqual([]);
    expect(drawing.priceSegments()).toEqual([]);
  });

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

    setReady(drawing);

    expect(drawing.timeLabel('left')).toMatchObject({
      coordinate: 50,
      text: 'date:50',
    });

    expect(drawing.timeLabel('right')).toMatchObject({
      coordinate: 150,
      text: 'date:150',
    });

    expect(drawing.priceLabel('top')).toMatchObject({
      coordinate: 50,
      text: 'price:50',
    });

    expect(drawing.priceLabel('bottom')).toMatchObject({
      coordinate: 150,
      text: 'price:150',
    });
  });

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

    setReady(drawing);

    expect(drawing.hovered(100, 100)?.cursorStyle).toBe('move');
  });

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

    setReady(drawing);

    expect(drawing.hovered(50, 50)?.cursorStyle).toBe('nwse-resize');
  });

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

    setReady(drawing);

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

    expect(interaction.select).toHaveBeenCalledTimes(1);
  });

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

    setReady(drawing);

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

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

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

    expect(drawing.getState().mode).toBe('ready');
  });

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

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 50, 50, 1));
    drawing.pointerMove(createPointerEvent(container, 70, 80, 1));
    drawing.pointerUp(createPointerEvent(container, 70, 80, 1));

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

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

    setReady(drawing);

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

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

  it('должен открывать settings', () => {
    const { drawing, container, openSettings } = createDiapson();

    setReady(drawing);

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

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

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

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

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

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

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

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

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

    setReady(drawing);
    drawing.hide();

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

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

    new DiapsonPaneRenderer(createRendererDiapson(null)).draw(target);

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

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

    new DiapsonPaneRenderer(createRendererDiapson(data)).draw(target);

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

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

    new DiapsonPaneRenderer(createRendererDiapson(data)).draw(target);

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

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

    const data = createRenderData('date', {
      labelLines: ['First', 'Second'],
      isBold: true,
      isItalic: true,
    });

    new DiapsonPaneRenderer(createRendererDiapson(data)).draw(target);

    expect(context.fillText).toHaveBeenCalledTimes(2);
    expect(context.font).toContain('italic');
    expect(context.font).toContain('700');
  });
});

describe('DiapsonPaneView', () => {
  it('должен возвращать renderer и top', () => {
    const view = new DiapsonPaneView(createRendererDiapson(null));

    expect(view.renderer()).toBeInstanceOf(DiapsonPaneRenderer);
    expect(view.zOrder()).toBe('top');
  });
});

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

  it('должен создавать style и text tabs', () => {
    expect(getDiapsonSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
  });
});
















import { FibonacciRetracement } from '@core/Drawings/fibonacciRetracement/fibonacciRetracement';
import { FibonacciRetracementPaneRenderer } from '@core/Drawings/fibonacciRetracement/paneRenderer';
import { FibonacciRetracementPaneView } from '@core/Drawings/fibonacciRetracement/paneView';
import {
  cloneFibonacciRetracementSettings,
  createDefaultSettings,
  formatLevelLabel,
  getFibonacciRetracementSettingsTabs,
  getFibonacciRetracementSettingsValues,
  getVisibleFibonacciLevels,
  mergeFibonacciRetracementSettings,
} from '@core/Drawings/fibonacciRetracement/settings';

import {
  createCanvasTarget,
  createDrawingTestEnvironment,
  createMouseEvent,
  createPointerEvent,
} from '../drawingTestUtils';

import type { FibonacciRetracementRenderData } from '@core/Drawings/fibonacciRetracement/fibonacciRetracement';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartBackground: '#101010',
      chartLineColor: '#2962ff',
      chartCrosshairLine: '#999999',
      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
      chartPriceLineText: '#ffffff',
    },
  }),
}));

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

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

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

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

class TestFibonacciRetracement extends FibonacciRetracement {
  public handles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

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

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

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

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

function createDrawing(selected = false) {
  const env = createDrawingTestEnvironment({ selected });
  const removeSelf = jest.fn();
  const openSettings = jest.fn();

  const drawing = new TestFibonacciRetracement({
    ...env,
    removeSelf,
    openSettings,
  });

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

function setReady(drawing: TestFibonacciRetracement): void {
  drawing.setState({
    mode: 'ready',
    startTime: 50 as Time,
    endTime: 150 as Time,
    startPrice: 50,
    endPrice: 150,
  });
}

function createRendererDrawing(data: FibonacciRetracementRenderData | null): FibonacciRetracement {
  return {
    getRenderData: () => data,
  } as unknown as FibonacciRetracement;
}

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

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

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

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

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

  it('должен создаваться двумя pointerDown', () => {
    const { drawing, container } = createDrawing();

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

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

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

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

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

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

    setReady(drawing);

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

  it('должен рассчитывать levels', () => {
    const { drawing } = createDrawing();

    setReady(drawing);

    const data = drawing.getRenderData();

    expect(data?.levels).toHaveLength(11);
    expect(data?.levels.find(({ id }) => id === '0')?.price).toBe(150);
    expect(data?.levels.find(({ id }) => id === '1')?.price).toBe(50);
    expect(data?.backgroundOpacity).toBe(0.18);
  });

  it('должен учитывать reverse', () => {
    const { drawing } = createDrawing();

    setReady(drawing);
    drawing.updateSettings({ reverse: true });

    const data = drawing.getRenderData();

    expect(data?.levels.find(({ id }) => id === '0')?.price).toBe(50);
    expect(data?.levels.find(({ id }) => id === '1')?.price).toBe(150);
  });

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

    setReady(drawing);

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

    expect(drawing.priceSegments()).toHaveLength(1);
  });

  it('не должен возвращать axis segments unselected drawing', () => {
    const { drawing } = createDrawing();

    setReady(drawing);

    expect(drawing.timeSegments()).toEqual([]);
    expect(drawing.priceSegments()).toEqual([]);
  });

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

    setReady(drawing);

    expect(drawing.timeLabel('start')).toMatchObject({
      coordinate: 50,
      text: 'date:50',
    });

    expect(drawing.timeLabel('end')).toMatchObject({
      coordinate: 150,
      text: 'date:150',
    });
  });

  it('должен возвращать pointer для selected handle', () => {
    const { drawing } = createDrawing(true);

    setReady(drawing);

    expect(drawing.hovered(50, 50)).toEqual({
      cursorStyle: 'pointer',
      externalId: 'fibonacci-retracement-position',
      zOrder: 'top',
    });
  });

  it('должен выбирать unselected retracement', () => {
    const { drawing, interaction, container } = createDrawing();

    setReady(drawing);

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

    expect(interaction.select).toHaveBeenCalledTimes(1);
  });

  it('должен перемещать start handle', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 50, 50, 1));
    drawing.pointerMove(createPointerEvent(container, 70, 80, 1));
    drawing.pointerUp(createPointerEvent(container, 70, 80, 1));

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

  it('должен перемещать end handle', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 150, 150, 1));
    drawing.pointerMove(createPointerEvent(container, 180, 170, 1));

    expect(drawing.getState()).toMatchObject({
      endTime: 180,
      endPrice: 170,
    });
  });

  it('должен открывать settings', () => {
    const { drawing, container, openSettings } = createDrawing();

    setReady(drawing);

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

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

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

    setReady(drawing);
    drawing.hide();

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

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

    new FibonacciRetracementPaneRenderer(createRendererDrawing(null)).draw(target);

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

  it('должен рисовать levels и background', () => {
    const data: FibonacciRetracementRenderData = {
      startPoint: { x: 10, y: 20 },
      endPoint: { x: 90, y: 80 },
      left: 10,
      right: 90,
      top: 20,
      bottom: 80,
      width: 80,
      height: 60,
      levels: [
        {
          id: '0',
          value: 0,
          price: 100,
          y: 20,
          color: '#ff0000',
          text: '0 (100)',
        },
        {
          id: '1',
          value: 1,
          price: 50,
          y: 80,
          color: '#00ff00',
          text: '1 (50)',
        },
      ],
      areas: [
        {
          top: 20,
          bottom: 80,
          color: '#00ff00',
        },
      ],
      showBackground: true,
      backgroundOpacity: 0.18,
      reverse: false,
      labelsPosition: 'right',
      showPrices: true,
      showLevelValues: true,
      fontSize: 12,
      isBold: false,
      isItalic: false,
    };

    const { target, context } = createCanvasTarget();

    new FibonacciRetracementPaneRenderer(createRendererDrawing(data)).draw(target);

    expect(context.fillRect).toHaveBeenCalled();
    expect(context.stroke).toHaveBeenCalled();
    expect(context.fillText).toHaveBeenCalledTimes(2);
  });
});

describe('FibonacciRetracementPaneView', () => {
  it('должен возвращать renderer и top', () => {
    const view = new FibonacciRetracementPaneView(createRendererDrawing(null));

    expect(view.renderer()).toBeInstanceOf(FibonacciRetracementPaneRenderer);
    expect(view.zOrder()).toBe('top');
  });
});

describe('FibonacciRetracement settings', () => {
  it('должен создавать независимые defaults', () => {
    const first = createDefaultSettings();
    const second = createDefaultSettings();

    first.levels[0].visible = false;

    expect(second.levels[0].visible).toBe(true);
  });

  it('должен deep clone settings', () => {
    const settings = createDefaultSettings();
    const clone = cloneFibonacciRetracementSettings(settings);

    clone.levels[0].color = '#000000';

    expect(settings.levels[0].color).not.toBe('#000000');
  });

  it('должен merge level settings', () => {
    const result = mergeFibonacciRetracementSettings(createDefaultSettings(), {
      reverse: true,
      level_0_236_visible: false,
      level_0_236_color: '#123456',
    });

    const level = result.levels.find(({ id }) => id === '0.236');

    expect(result.reverse).toBe(true);
    expect(level?.visible).toBe(false);
    expect(level?.color).toBe('#123456');
  });

  it('должен возвращать только visible levels', () => {
    const settings = createDefaultSettings();

    settings.levels[0].visible = false;

    expect(getVisibleFibonacciLevels(settings)).toHaveLength(settings.levels.length - 1);
  });

  it('должен форматировать level label', () => {
    expect(formatLevelLabel(0.618)).toBe('0,618');
    expect(formatLevelLabel(1)).toBe('1');
  });

  it('должен создавать style, text и levels tabs', () => {
    expect(getFibonacciRetracementSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual([
      'style',
      'text',
      'levels',
    ]);
  });

  it('должен преобразовывать settings values', () => {
    expect(getFibonacciRetracementSettingsValues(createDefaultSettings())).toMatchObject({
      showBackground: true,
      backgroundOpacity: 18,
      reverse: false,
    });
  });
});
















import { LineDrawing } from '@core/Drawings/line/lineDrawing';
import { LineDrawingPaneRenderer } from '@core/Drawings/line/paneRenderer';
import { LineDrawingPaneView } from '@core/Drawings/line/paneView';
import { createDefaultSettings, getLineDrawingSettingsTabs } from '@core/Drawings/line/settings';

import { LineMarker } from '@src/types';

import {
  createCanvasTarget,
  createDrawingTestEnvironment,
  createMouseEvent,
  createPointerEvent,
} from '../drawingTestUtils';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { LineDrawingRenderData } from '@core/Drawings/line/lineDrawing';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartLineColor: '#2962ff',
      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
      chartPriceLineText: '#ffffff',
    },
  }),
}));

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

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

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

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

class TestLineDrawing extends LineDrawing {
  public handles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

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

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

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

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

function createDrawing(selected = false) {
  const env = createDrawingTestEnvironment({ selected });
  const removeSelf = jest.fn();
  const openSettings = jest.fn();

  const drawing = new TestLineDrawing({
    ...env,
    removeSelf,
    openSettings,
  });

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

function setReady(drawing: TestLineDrawing): void {
  drawing.setState({
    mode: 'ready',
    startAnchor: {
      time: 50 as Time,
      price: 50,
    },
    endAnchor: {
      time: 150 as Time,
      price: 150,
    },
  });
}

function createRendererDrawing(data: LineDrawingRenderData | null): LineDrawing {
  return {
    getRenderData: () => data,
  } as unknown as LineDrawing;
}

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

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

  it('должен создаваться двумя pointerDown', () => {
    const { drawing, container } = createDrawing();

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

    expect(drawing.getState()).toMatchObject({
      mode: 'ready',
      startAnchor: { time: 50, price: 50 },
      endAnchor: { time: 150, price: 150 },
    });
  });

  it('должен удалять слишком короткую line', () => {
    const { drawing, container, removeSelf } = createDrawing();

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

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

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

    setReady(drawing);

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

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

    setReady(drawing);

    expect(drawing.getRenderData()).toMatchObject({
      startPoint: { x: 50, y: 50 },
      endPoint: { x: 150, y: 150 },
      lineColor: '#2962ff',
      fontSize: 14,
    });
  });

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

    setReady(drawing);

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

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

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

    setReady(drawing);

    expect(drawing.timeLabel('start')).toMatchObject({
      coordinate: 50,
      text: 'date:50',
    });

    expect(drawing.priceLabel('end')).toMatchObject({
      coordinate: 150,
      text: 'price:150',
    });
  });

  it('должен определять handles и body', () => {
    const { drawing } = createDrawing(true);

    setReady(drawing);

    expect(drawing.hovered(50, 50)?.cursorStyle).toBe('move');
    expect(drawing.hovered(100, 100)?.cursorStyle).toBe('grab');
    expect(drawing.hovered(300, 100)).toBeNull();
  });

  it('должен выбирать unselected line', () => {
    const { drawing, container, interaction } = createDrawing();

    setReady(drawing);

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

    expect(interaction.select).toHaveBeenCalledTimes(1);
  });

  it('должен перемещать start handle', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 50, 50));
    drawing.pointerMove(createPointerEvent(container, 70, 80));
    drawing.pointerUp(createPointerEvent(container, 70, 80));

    expect(drawing.getState()).toMatchObject({
      startAnchor: {
        time: 70,
        price: 80,
      },
      endAnchor: {
        time: 150,
        price: 150,
      },
    });
  });

  it('должен перемещать end handle', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 150, 150));
    drawing.pointerMove(createPointerEvent(container, 170, 180));

    expect(drawing.getState()).toMatchObject({
      endAnchor: {
        time: 170,
        price: 180,
      },
    });
  });

  it('должен перемещать всю line', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

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

    expect(drawing.getState()).toMatchObject({
      startAnchor: {
        time: 70,
        price: 80,
      },
      endAnchor: {
        time: 170,
        price: 180,
      },
    });
  });

  it('должен открывать settings', () => {
    const { drawing, container, openSettings } = createDrawing();

    setReady(drawing);

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

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

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

    setReady(drawing);
    drawing.hide();

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

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

    new LineDrawingPaneRenderer(createRendererDrawing(null)).draw(target);

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

  it('должен рисовать line и text', () => {
    const data: LineDrawingRenderData = {
      startPoint: { x: 10, y: 20 },
      endPoint: { x: 90, y: 80 },
      left: 10,
      right: 90,
      top: 20,
      bottom: 80,
      lineColor: '#ff0000',
      startMarker: LineMarker.arrow,
      endMarker: LineMarker.arrow,
      fontSize: 14,
      text: 'Test',
      isBold: true,
      isItalic: true,
      textColor: '#ffffff',
    };

    const { target, context } = createCanvasTarget();

    new LineDrawingPaneRenderer(createRendererDrawing(data)).draw(target);

    expect(context.stroke).toHaveBeenCalled();
    expect(context.fillText).toHaveBeenCalled();
    expect(context.translate).toHaveBeenCalled();
  });
});

describe('LineDrawingPaneView', () => {
  it('должен возвращать renderer и top', () => {
    const view = new LineDrawingPaneView(createRendererDrawing(null));

    expect(view.renderer()).toBeInstanceOf(LineDrawingPaneRenderer);
    expect(view.zOrder()).toBe('top');
  });
});

describe('LineDrawing settings', () => {
  it('должен создавать defaults', () => {
    expect(createDefaultSettings()).toEqual({
      startMarker: LineMarker.normal,
      endMarker: LineMarker.normal,
      lineColor: '#2962ff',
      fontSize: 14,
      text: '',
      isBold: false,
      isItalic: false,
      textColor: '#2962ff',
    });
  });

  it('должен применять default marker override', () => {
    expect(
      createDefaultSettings({
        startMarker: LineMarker.arrow,
      }),
    ).toMatchObject({
      startMarker: LineMarker.arrow,
      endMarker: LineMarker.normal,
    });
  });

  it('должен создавать style и text tabs', () => {
    expect(getLineDrawingSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
  });
});
















import { ParallelChannelPaneRenderer } from '@core/Drawings/parallelChannel/paneRenderer';
import { ParallelChannelPaneView } from '@core/Drawings/parallelChannel/paneView';
import { ParallelChannel } from '@core/Drawings/parallelChannel/parallelChannel';
import { createDefaultSettings, getParallelChannelSettingsTabs } from '@core/Drawings/parallelChannel/settings';

import {
  createCanvasTarget,
  createDrawingTestEnvironment,
  createMouseEvent,
  createPointerEvent,
} from '../drawingTestUtils';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { ParallelChannelRenderData } from '@core/Drawings/parallelChannel/parallelChannel';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartLineColor: '#2962ff',
      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
      chartPriceLineText: '#ffffff',
    },
  }),
}));

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

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

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

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

class TestParallelChannel extends ParallelChannel {
  public handles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

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

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

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

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

function createDrawing(selected = false) {
  const env = createDrawingTestEnvironment({ selected });
  const openSettings = jest.fn();

  const drawing = new TestParallelChannel({
    ...env,
    openSettings,
  });

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

function setReady(drawing: TestParallelChannel): void {
  drawing.setState({
    mode: 'ready',
    startAnchor: {
      time: 50 as Time,
      price: 50,
    },
    endAnchor: {
      time: 150 as Time,
      price: 80,
    },
    priceOffset: 60,
  });
}

function createRendererDrawing(data: ParallelChannelRenderData | null): ParallelChannel {
  return {
    getRenderData: () => data,
  } as unknown as ParallelChannel;
}

describe('ParallelChannel', () => {
  it('должен создаваться за три этапа', () => {
    const { drawing, container } = createDrawing();

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

    expect(drawing.getState().mode).toBe('drawing-line');

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

    expect(drawing.getState().mode).toBe('drawing-channel');

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

    expect(drawing.getState().mode).toBe('ready');
  });

  it('должен восстанавливать dragging как ready', () => {
    const { drawing } = createDrawing();

    drawing.setState({
      mode: 'dragging',
    });

    expect(drawing.getState().mode).toBe('ready');
  });

  it('должен возвращать шесть handles', () => {
    const { drawing } = createDrawing();

    setReady(drawing);

    expect(drawing.handles()).toEqual([
      {
        id: 'parallel-end',
        x: 150,
        y: 140,
        shape: 'circle',
      },
      {
        id: 'parallel-middle',
        x: 100,
        y: 125,
        shape: 'circle',
      },
      {
        id: 'parallel-start',
        x: 50,
        y: 110,
        shape: 'circle',
      },
      {
        id: 'main-end',
        x: 150,
        y: 80,
        shape: 'circle',
      },
      {
        id: 'main-middle',
        x: 100,
        y: 65,
        shape: 'circle',
      },
      {
        id: 'main-start',
        x: 50,
        y: 50,
        shape: 'circle',
      },
    ]);
  });

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

    setReady(drawing);

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

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

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

    setReady(drawing);

    expect(drawing.timeLabel('start')).toMatchObject({
      coordinate: 50,
      text: 'date:50',
    });

    expect(drawing.timeLabel('end')).toMatchObject({
      coordinate: 150,
      text: 'date:150',
    });
  });

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

    setReady(drawing);

    expect(drawing.priceLabel('main-start')?.text).toBe('price:50');
    expect(drawing.priceLabel('main-end')?.text).toBe('price:80');
    expect(drawing.priceLabel('parallel-start')?.text).toBe('price:110');
    expect(drawing.priceLabel('parallel-end')?.text).toBe('price:140');
  });

  it('должен возвращать pointer unselected channel', () => {
    const { drawing } = createDrawing();

    setReady(drawing);

    expect(drawing.hovered(100, 90)?.cursorStyle).toBe('pointer');
  });

  it('должен возвращать move selected handle', () => {
    const { drawing } = createDrawing(true);

    setReady(drawing);

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

  it('должен возвращать grab внутри selected channel', () => {
    const { drawing } = createDrawing(true);

    setReady(drawing);

    expect(drawing.hovered(100, 90)?.cursorStyle).toBe('grab');
  });

  it('должен выбирать unselected channel', () => {
    const { drawing, container, interaction } = createDrawing();

    setReady(drawing);

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

    expect(interaction.select).toHaveBeenCalledTimes(1);
  });

  it('должен перемещать main-start', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

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

    expect(drawing.getState()).toMatchObject({
      startAnchor: {
        time: 60,
        price: 60,
      },
      endAnchor: {
        time: 150,
        price: 80,
      },
      priceOffset: 60,
    });
  });

  it('должен перемещать parallel-middle', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 100, 125));
    drawing.pointerMove(createPointerEvent(container, 100, 145));

    expect(drawing.getState().priceOffset).toBe(80);
  });

  it('должен перемещать channel целиком', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 100, 90));
    drawing.pointerMove(createPointerEvent(container, 120, 100));

    expect(drawing.getState()).toMatchObject({
      startAnchor: {
        time: 70,
        price: 60,
      },
      endAnchor: {
        time: 170,
        price: 90,
      },
      priceOffset: 60,
    });
  });

  it('должен открывать settings', () => {
    const { drawing, container, openSettings } = createDrawing(true);

    setReady(drawing);

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

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

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

    setReady(drawing);
    drawing.hide();

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

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

    new ParallelChannelPaneRenderer(createRendererDrawing(null)).draw(target);

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

  it('должен рисовать channel и text', () => {
    const data: ParallelChannelRenderData = {
      startPoint: { x: 10, y: 20 },
      mainMiddlePoint: { x: 50, y: 30 },
      endPoint: { x: 90, y: 40 },
      parallelStartPoint: { x: 10, y: 80 },
      parallelMiddlePoint: { x: 50, y: 90 },
      parallelEndPoint: { x: 90, y: 100 },
      middleStartPoint: { x: 10, y: 50 },
      middleEndPoint: { x: 90, y: 70 },
      left: 10,
      right: 90,
      top: 20,
      bottom: 100,
      lineColor: '#ff0000',
      backgroundColor: '#00ff0033',
      showMiddleLine: true,
      fontSize: 14,
      text: 'Channel',
      isBold: true,
      isItalic: false,
      textColor: '#ffffff',
    };

    const { target, context } = createCanvasTarget();

    new ParallelChannelPaneRenderer(createRendererDrawing(data)).draw(target);

    expect(context.fill).toHaveBeenCalled();
    expect(context.stroke).toHaveBeenCalled();
    expect(context.setLineDash).toHaveBeenCalled();
    expect(context.fillText).toHaveBeenCalled();
  });
});

describe('ParallelChannelPaneView', () => {
  it('должен возвращать renderer и top', () => {
    const view = new ParallelChannelPaneView(createRendererDrawing(null));

    expect(view.renderer()).toBeInstanceOf(ParallelChannelPaneRenderer);
    expect(view.zOrder()).toBe('top');
  });
});

describe('ParallelChannel settings', () => {
  it('должен создавать defaults', () => {
    expect(createDefaultSettings()).toEqual({
      lineColor: '#2962ff',
      backgroundColor: '#2962ff44',
      showMiddleLine: true,
      fontSize: 14,
      text: '',
      isBold: false,
      isItalic: false,
      textColor: '#2962ff',
    });
  });

  it('должен создавать style и text tabs', () => {
    expect(getParallelChannelSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
  });
});
















import { RayPaneRenderer } from '@core/Drawings/ray/paneRenderer';
import { RayPaneView } from '@core/Drawings/ray/paneView';
import { Ray } from '@core/Drawings/ray/ray';
import { createDefaultSettings, getRaySettingTabs } from '@core/Drawings/ray/settings';

import {
  createCanvasTarget,
  createDrawingTestEnvironment,
  createMouseEvent,
  createPointerEvent,
} from '../drawingTestUtils';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { RayRenderData } from '@core/Drawings/ray/ray';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartLineColor: '#2962ff',
      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
      chartPriceLineText: '#ffffff',
    },
  }),
}));

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

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

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

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

class TestRay extends Ray {
  public handles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

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

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

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

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

function createDrawing(selected = false) {
  const env = createDrawingTestEnvironment({ selected });
  const removeSelf = jest.fn();
  const openSettings = jest.fn();

  const drawing = new TestRay({
    ...env,
    removeSelf,
    openSettings,
  });

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

function setReady(drawing: TestRay): void {
  drawing.setState({
    mode: 'ready',
    startAnchor: {
      time: 50 as Time,
      price: 50,
    },
    directionAnchor: {
      time: 150 as Time,
      price: 100,
    },
  });
}

function createRendererDrawing(data: RayRenderData | null): Ray {
  return {
    getRenderData: () => data,
  } as unknown as Ray;
}

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

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

  it('должен создаваться двумя pointerDown', () => {
    const { drawing, container } = createDrawing();

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

    expect(drawing.getState()).toMatchObject({
      mode: 'ready',
      startAnchor: {
        time: 50,
        price: 50,
      },
      directionAnchor: {
        time: 150,
        price: 100,
      },
    });
  });

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

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

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

  it('должен продолжаться до границы container', () => {
    const { drawing } = createDrawing();

    setReady(drawing);

    expect(drawing.getRenderData()).toMatchObject({
      startPoint: {
        x: 50,
        y: 50,
      },
      directionPoint: {
        x: 150,
        y: 100,
      },
      rayEndPoint: {
        x: 400,
        y: 225,
      },
    });
  });

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

    setReady(drawing);

    expect(drawing.handles()).toEqual([
      {
        id: 'start',
        x: 50,
        y: 50,
        shape: 'circle',
        strokeColor: '#2962ff',
      },
      {
        id: 'direction',
        x: 150,
        y: 100,
        shape: 'circle',
        strokeColor: '#2962ff',
      },
    ]);
  });

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

    setReady(drawing);

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

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

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

    setReady(drawing);

    expect(drawing.timeLabel('direction')).toMatchObject({
      coordinate: 150,
      text: 'date:150',
    });

    expect(drawing.priceLabel('start')).toMatchObject({
      coordinate: 50,
      text: 'price:50',
    });
  });

  it('должен определять handles и body', () => {
    const { drawing } = createDrawing(true);

    setReady(drawing);

    expect(drawing.hovered(50, 50)?.cursorStyle).toBe('move');
    expect(drawing.hovered(200, 125)?.cursorStyle).toBe('grab');
    expect(drawing.hovered(200, 200)).toBeNull();
  });

  it('должен выбирать unselected ray', () => {
    const { drawing, interaction, container } = createDrawing();

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 200, 125));

    expect(interaction.select).toHaveBeenCalledTimes(1);
  });

  it('должен перемещать start handle', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 50, 50));
    drawing.pointerMove(createPointerEvent(container, 70, 80));
    drawing.pointerUp(createPointerEvent(container, 70, 80));

    expect(drawing.getState()).toMatchObject({
      startAnchor: {
        time: 70,
        price: 80,
      },
      directionAnchor: {
        time: 150,
        price: 100,
      },
    });
  });

  it('должен перемещать direction handle', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 150, 100));
    drawing.pointerMove(createPointerEvent(container, 180, 130));

    expect(drawing.getState()).toMatchObject({
      directionAnchor: {
        time: 180,
        price: 130,
      },
    });
  });

  it('должен перемещать ray целиком', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

    drawing.pointerDown(createPointerEvent(container, 200, 125));
    drawing.pointerMove(createPointerEvent(container, 220, 155));

    expect(drawing.getState()).toMatchObject({
      startAnchor: {
        time: 70,
        price: 80,
      },
      directionAnchor: {
        time: 170,
        price: 130,
      },
    });
  });

  it('должен открывать settings', () => {
    const { drawing, container, openSettings } = createDrawing();

    setReady(drawing);

    drawing.doubleClick(createMouseEvent(container, 200, 125));

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

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

    setReady(drawing);
    drawing.hide();

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

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

    new RayPaneRenderer(createRendererDrawing(null)).draw(target);

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

  it('должен рисовать ray и text', () => {
    const data: RayRenderData = {
      startPoint: { x: 10, y: 20 },
      directionPoint: { x: 50, y: 40 },
      rayEndPoint: { x: 100, y: 65 },
      left: 10,
      right: 100,
      top: 20,
      bottom: 65,
      lineColor: '#ff0000',
      text: 'Ray',
      fontSize: 14,
      isBold: true,
      isItalic: true,
      textColor: '#ffffff',
    };

    const { target, context } = createCanvasTarget();

    new RayPaneRenderer(createRendererDrawing(data)).draw(target);

    expect(context.moveTo).toHaveBeenCalledWith(20, 40);
    expect(context.lineTo).toHaveBeenCalledWith(200, 130);
    expect(context.fillText).toHaveBeenCalled();
    expect(context.translate).toHaveBeenCalled();
    expect(context.rotate).toHaveBeenCalled();
  });
});

describe('RayPaneView', () => {
  it('должен возвращать renderer и top', () => {
    const view = new RayPaneView(createRendererDrawing(null));

    expect(view.renderer()).toBeInstanceOf(RayPaneRenderer);
    expect(view.zOrder()).toBe('top');
  });
});

describe('Ray settings', () => {
  it('должен создавать defaults', () => {
    expect(createDefaultSettings()).toEqual({
      lineColor: '#2962ff',
      text: '',
      fontSize: 14,
      isBold: false,
      isItalic: false,
      textColor: '#2962ff',
    });
  });

  it('должен создавать style и text tabs', () => {
    expect(getRaySettingTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
  });
});
















import { RectanglePaneRenderer } from '@core/Drawings/rectangle/paneRenderer';
import { RectanglePaneView } from '@core/Drawings/rectangle/paneView';
import { Rectangle, type RectangleRenderData } from '@core/Drawings/rectangle/rectangle';
import { createDefaultSettings, getRectangleSettingsTabs } from '@core/Drawings/rectangle/settings';

import {
  createCanvasTarget,
  createDrawingTestEnvironment,
  createMouseEvent,
  createPointerEvent,
} from '../drawingTestUtils';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      rectangleBorderFill: '#2962ff',
      rectangleAreaFill: '#2962ff22',
      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
      chartPriceLineText: '#ffffff',
    },
  }),
}));

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

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

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

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

class TestRectangle extends Rectangle {
  public handles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

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

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

  public pointerDown(event: PointerEvent): void {
    this.handlePointerDown(event);
  }

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

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

  public doubleClick(event: MouseEvent): void {
    this.handleDoubleClick(event);
  }
}

function createRectangle(selected = false) {
  const env = createDrawingTestEnvironment({ selected });
  const removeSelf = jest.fn();
  const openSettings = jest.fn();

  const rectangle = new TestRectangle({
    ...env,
    removeSelf,
    openSettings,
  });

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

function setReady(rectangle: TestRectangle): void {
  rectangle.setState({
    mode: 'ready',
    startTime: 50 as Time,
    endTime: 150 as Time,
    startPrice: 50,
    endPrice: 150,
  });
}

describe('Rectangle', () => {
  it('прикрепляет primitive', () => {
    const { rectangle, series } = createRectangle();

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

  it('создаётся двумя нажатиями', () => {
    const { rectangle, container } = createRectangle();

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

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

  it('удаляет слишком маленький прямоугольник', () => {
    const { rectangle, container, removeSelf } = createRectangle();

    rectangle.pointerDown(createPointerEvent(container, 50, 50));
    rectangle.pointerDown(createPointerEvent(container, 54, 54));

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

  it('возвращает геометрию', () => {
    const { rectangle } = createRectangle();

    setReady(rectangle);

    expect(rectangle.getRenderData()).toMatchObject({
      left: 50,
      right: 150,
      top: 50,
      bottom: 150,
      width: 100,
      height: 100,
    });
  });

  it('возвращает восемь handles правильного типа', () => {
    const { rectangle } = createRectangle();

    setReady(rectangle);

    expect(rectangle.handles()).toEqual([
      { id: 'w', x: 50, y: 100, shape: 'rounded' },
      { id: 'sw', x: 50, y: 150, shape: 'circle' },
      { id: 's', x: 100, y: 150, shape: 'rounded' },
      { id: 'se', x: 150, y: 150, shape: 'circle' },
      { id: 'e', x: 150, y: 100, shape: 'rounded' },
      { id: 'ne', x: 150, y: 50, shape: 'circle' },
      { id: 'n', x: 100, y: 50, shape: 'rounded' },
      { id: 'nw', x: 50, y: 50, shape: 'circle' },
    ]);
  });

  it.each([
    ['nw', 50, 50, 'nwse-resize'],
    ['se', 150, 150, 'nwse-resize'],
    ['ne', 150, 50, 'nesw-resize'],
    ['sw', 50, 150, 'nesw-resize'],
    ['n', 100, 50, 'ns-resize'],
    ['s', 100, 150, 'ns-resize'],
    ['e', 150, 100, 'ew-resize'],
    ['w', 50, 100, 'ew-resize'],
  ])('возвращает корректный cursor для %s', (_, x, y, cursor) => {
    const { rectangle } = createRectangle(true);

    setReady(rectangle);

    expect(rectangle.hovered(x as number, y as number)?.cursorStyle).toBe(cursor);
  });

  it('возвращает pointer для невыбранного rectangle', () => {
    const { rectangle } = createRectangle();

    setReady(rectangle);

    expect(rectangle.hovered(100, 100)?.cursorStyle).toBe('pointer');
  });

  it('возвращает grab внутри выбранного rectangle', () => {
    const { rectangle } = createRectangle(true);

    setReady(rectangle);

    expect(rectangle.hovered(100, 100)?.cursorStyle).toBe('grab');
  });

  it('возвращает axis segments для selected rectangle', () => {
    const { rectangle } = createRectangle(true);

    setReady(rectangle);

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

  it('не возвращает axis segments для ready unselected rectangle', () => {
    const { rectangle } = createRectangle();

    setReady(rectangle);

    expect(rectangle.timeSegments()).toEqual([]);
    expect(rectangle.priceSegments()).toEqual([]);
  });

  it('возвращает axis labels', () => {
    const { rectangle } = createRectangle(true);

    setReady(rectangle);

    expect(rectangle.timeLabel('left')).toMatchObject({
      coordinate: 50,
      text: 'date:50',
    });

    expect(rectangle.timeLabel('right')).toMatchObject({
      coordinate: 150,
      text: 'date:150',
    });

    expect(rectangle.priceLabel('top')).toMatchObject({
      coordinate: 50,
      text: 'price:50',
    });

    expect(rectangle.priceLabel('bottom')).toMatchObject({
      coordinate: 150,
      text: 'price:150',
    });
  });

  it('выбирает rectangle первым нажатием', () => {
    const { rectangle, interaction, container } = createRectangle();

    setReady(rectangle);
    rectangle.pointerDown(createPointerEvent(container, 100, 100));

    expect(interaction.select).toHaveBeenCalledTimes(1);
  });

  it('изменяет левую границу через w handle', () => {
    const { rectangle, container } = createRectangle(true);

    setReady(rectangle);

    rectangle.pointerDown(createPointerEvent(container, 50, 100));
    rectangle.pointerMove(createPointerEvent(container, 30, 100));
    rectangle.pointerUp(createPointerEvent(container, 30, 100));

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

  it('изменяет верхний левый угол', () => {
    const { rectangle, container } = createRectangle(true);

    setReady(rectangle);

    rectangle.pointerDown(createPointerEvent(container, 50, 50));
    rectangle.pointerMove(createPointerEvent(container, 30, 20));

    expect(rectangle.getRenderData()).toMatchObject({
      left: 30,
      top: 20,
      right: 150,
      bottom: 150,
    });
  });

  it('перемещает rectangle целиком', () => {
    const { rectangle, container } = createRectangle(true);

    setReady(rectangle);

    rectangle.pointerDown(createPointerEvent(container, 100, 100));
    rectangle.pointerMove(createPointerEvent(container, 120, 130));

    expect(rectangle.getState()).toMatchObject({
      startTime: 70,
      endTime: 170,
      startPrice: 80,
      endPrice: 180,
    });
  });

  it('ограничивает перемещение границами контейнера', () => {
    const { rectangle, container } = createRectangle(true);

    setReady(rectangle);

    rectangle.pointerDown(createPointerEvent(container, 100, 100));
    rectangle.pointerMove(createPointerEvent(container, -500, -500));

    expect(rectangle.getRenderData()).toMatchObject({
      left: 0,
      top: 0,
      right: 100,
      bottom: 100,
    });
  });

  it('открывает settings по double click', () => {
    const { rectangle, container, openSettings } = createRectangle();

    setReady(rectangle);
    rectangle.doubleClick(createMouseEvent(container, 100, 100));

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

  it('не возвращает render data когда hidden', () => {
    const { rectangle } = createRectangle();

    setReady(rectangle);
    rectangle.hide();

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

describe('RectanglePaneRenderer', () => {
  it('ничего не рисует без render data', () => {
    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
    const renderer = new RectanglePaneRenderer({
      getRenderData: () => null,
    } as unknown as Rectangle);

    renderer.draw(target);

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

  it('рисует fill, border и text', () => {
    const data: RectangleRenderData = {
      left: 10,
      right: 100,
      top: 20,
      bottom: 80,
      width: 90,
      height: 60,
      borderColor: '#fff',
      fillColor: '#111',
      text: 'Rectangle',
      fontSize: 14,
      isBold: false,
      isItalic: false,
      textColor: '#fff',
    };

    const { target, context } = createCanvasTarget();
    const renderer = new RectanglePaneRenderer({
      getRenderData: () => data,
    } as unknown as Rectangle);

    renderer.draw(target);

    expect(context.fillRect).toHaveBeenCalled();
    expect(context.strokeRect).toHaveBeenCalled();
    expect(context.fillText).toHaveBeenCalledWith('Rectangle', 20, expect.any(Number));
  });
});

describe('RectanglePaneView', () => {
  it('возвращает renderer и top zOrder', () => {
    const view = new RectanglePaneView({} as Rectangle);

    expect(view.renderer()).toBeInstanceOf(RectanglePaneRenderer);
    expect(view.zOrder()).toBe('top');
  });
});

describe('Rectangle settings', () => {
  it('создаёт default settings', () => {
    expect(createDefaultSettings()).toEqual({
      borderColor: '#2962ff',
      fillColor: '#2962ff22',
      text: '',
      fontSize: 14,
      isBold: false,
      isItalic: false,
      textColor: '#2962ff',
    });
  });

  it('создаёт style и text tabs', () => {
    expect(getRectangleSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
  });
});

















import { RegressionTrendPaneRenderer } from '@core/Drawings/regressionTrend/paneRenderer';
import { RegressionTrendPaneView } from '@core/Drawings/regressionTrend/paneView';
import { RegressionTrend, type RegressionTrendRenderData } from '@core/Drawings/regressionTrend/regressionTrend';
import { createDefaultSettings, getRegressionTrendSettingsTabs } from '@core/Drawings/regressionTrend/settings';

import {
  createCanvasTarget,
  createDrawingTestEnvironment,
  createMouseEvent,
  createPointerEvent,
} from '../drawingTestUtils';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartLineColor: '#2962ff',
      chartCrosshairLine: '#999999',
      sliderPositiveFill: '#00aa0044',
      sliderNegativeFill: '#aa000044',
      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
      chartPriceLineText: '#ffffff',
    },
  }),
}));

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

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

const seriesData = [
  { time: 10 as Time, close: 10 },
  { time: 20 as Time, close: 20 },
  { time: 30 as Time, close: 15 },
  { time: 40 as Time, close: 30 },
];

class TestRegressionTrend extends RegressionTrend {
  public handles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

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

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

  public pointerDown(event: PointerEvent): void {
    this.handlePointerDown(event);
  }

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

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

  public doubleClick(event: MouseEvent): void {
    this.handleDoubleClick(event);
  }
}

function createRegression(selected = false) {
  const env = createDrawingTestEnvironment({ selected, seriesData });
  const openSettings = jest.fn();

  const regression = new TestRegressionTrend({
    ...env,
    openSettings,
  });

  regression.setState({
    mode: 'ready',
    startTime: 10 as Time,
    endTime: 40 as Time,
  });

  return { ...env, regression, openSettings };
}

describe('RegressionTrend', () => {
  it('рассчитывает linear regression', () => {
    const { regression } = createRegression();
    const data = regression.getRenderData();

    expect(data?.baseStartPrice).toBeCloseTo(10.5);
    expect(data?.baseEndPrice).toBeCloseTo(27);
    expect(data?.correlation).toBeCloseTo(0.8315218406);
  });

  it('строит upper и lower channel на двух отклонениях', () => {
    const { regression } = createRegression();
    const data = regression.getRenderData();

    const deviation = 4.1079191813;

    expect(data?.upperStartPoint.y).toBeCloseTo(10.5 + deviation * 2);
    expect(data?.lowerStartPoint.y).toBeCloseTo(10.5 - deviation * 2);
    expect(data?.upperEndPoint.y).toBeCloseTo(27 + deviation * 2);
    expect(data?.lowerEndPoint.y).toBeCloseTo(27 - deviation * 2);
  });

  it('возвращает два handles на base line', () => {
    const { regression } = createRegression();

    expect(regression.handles()).toEqual([
      {
        id: 'start',
        x: 10,
        y: 10.5,
        shape: 'circle',
      },
      {
        id: 'end',
        x: 40,
        y: 27,
        shape: 'circle',
      },
    ]);
  });

  it('показывает channel в ready', () => {
    const { regression } = createRegression();

    expect(regression.getRenderData()?.showChannel).toBe(true);
  });

  it('не показывает channel во время drawing', () => {
    const { regression } = createRegression();

    regression.setState({ mode: 'drawing' });

    expect(regression.getRenderData()?.showChannel).toBe(false);
  });

  it('нормализует dragging state в ready при setState', () => {
    const { regression } = createRegression();

    regression.setState({ mode: 'dragging' });

    expect(regression.getState().mode).toBe('ready');
  });

  it('возвращает axis segments', () => {
    const { regression } = createRegression(true);

    expect(regression.timeSegments()).toEqual([{ from: 10, to: 40, color: '#2962ff44' }]);
    expect(regression.priceSegments()).toEqual([{ from: 10.5, to: 27, color: '#2962ff44' }]);
  });

  it('возвращает axis labels', () => {
    const { regression } = createRegression(true);

    expect(regression.timeLabel('start')).toMatchObject({
      coordinate: 10,
      text: 'date:10',
    });

    expect(regression.priceLabel('end')).toMatchObject({
      coordinate: 27,
      text: 'price:27',
    });
  });

  it('возвращает pointer для unselected channel', () => {
    const { regression } = createRegression();

    expect(regression.hovered(25, 18.75)?.cursorStyle).toBe('pointer');
  });

  it('возвращает ew-resize для selected handle', () => {
    const { regression } = createRegression(true);

    expect(regression.hovered(10, 10.5)?.cursorStyle).toBe('ew-resize');
  });

  it('возвращает grab для selected body', () => {
    const { regression } = createRegression(true);

    expect(regression.hovered(25, 18.75)?.cursorStyle).toBe('grab');
  });

  it('перемещает start edge на ближайший bar', () => {
    const { regression, container } = createRegression(true);

    regression.pointerDown(createPointerEvent(container, 10, 10.5));
    regression.pointerMove(createPointerEvent(container, 20, 20));
    regression.pointerUp(createPointerEvent(container, 20, 20));

    expect(regression.getState()).toMatchObject({
      mode: 'ready',
      startTime: 20,
      endTime: 40,
    });
  });

  it('не позволяет схлопнуть range до одного bar', () => {
    const { regression, container } = createRegression(true);

    regression.pointerDown(createPointerEvent(container, 10, 10.5));
    regression.pointerMove(createPointerEvent(container, 40, 30));

    expect(regression.getState().startTime).toBe(10);
  });

  it('открывает настройки только для selected trend', () => {
    const { regression, container, openSettings } = createRegression(true);

    regression.doubleClick(createMouseEvent(container, 25, 18.75));

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

  it('возвращает null когда hidden', () => {
    const { regression } = createRegression();

    regression.hide();

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

describe('RegressionTrendPaneRenderer', () => {
  it('ничего не рисует без данных', () => {
    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
    const renderer = new RegressionTrendPaneRenderer({
      getRenderData: () => null,
    } as unknown as RegressionTrend);

    renderer.draw(target);

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

  it('рисует channel, middle line и correlation', () => {
    const data: RegressionTrendRenderData = {
      baseStartPoint: { x: 10, y: 20 },
      baseEndPoint: { x: 100, y: 40 },
      upperStartPoint: { x: 10, y: 10 },
      upperEndPoint: { x: 100, y: 30 },
      lowerStartPoint: { x: 10, y: 30 },
      lowerEndPoint: { x: 100, y: 50 },
      baseStartPrice: 20,
      baseEndPrice: 40,
      correlation: 0.9,
      left: 10,
      right: 100,
      top: 10,
      bottom: 50,
      lineColor: '#fff',
      middleLineColor: '#aaa',
      upperFillColor: '#0f03',
      lowerFillColor: '#f003',
      showMiddleLine: true,
      showCorrelation: true,
      showChannel: true,
    };

    const { target, context } = createCanvasTarget();
    const renderer = new RegressionTrendPaneRenderer({
      getRenderData: () => data,
    } as unknown as RegressionTrend);

    renderer.draw(target);

    expect(context.fill).toHaveBeenCalledTimes(2);
    expect(context.stroke).toHaveBeenCalled();
    expect(context.setLineDash).toHaveBeenCalled();
    expect(context.fillText).toHaveBeenCalledWith('0.9', expect.any(Number), expect.any(Number));
  });
});

describe('RegressionTrendPaneView', () => {
  it('возвращает renderer и top', () => {
    const view = new RegressionTrendPaneView({} as RegressionTrend);

    expect(view.renderer()).toBeInstanceOf(RegressionTrendPaneRenderer);
    expect(view.zOrder()).toBe('top');
  });
});

describe('RegressionTrend settings', () => {
  it('создаёт defaults', () => {
    expect(createDefaultSettings()).toEqual({
      lineColor: '#2962ff',
      middleLineColor: '#999999',
      upperFillColor: '#00aa0044',
      lowerFillColor: '#aa000044',
      showMiddleLine: true,
      showCorrelation: true,
    });
  });

  it('имеет только style tab', () => {
    expect(getRegressionTrendSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style']);
  });
});
















import { BehaviorSubject } from 'rxjs';

import { RulerPaneRenderer } from '@core/Drawings/ruler/paneRenderer';
import { RulerPaneView } from '@core/Drawings/ruler/paneView';
import { Ruler, type RulerRenderData } from '@core/Drawings/ruler/ruler';

import { Direction } from '@src/types';

import { createCanvasTarget, createDrawingTestEnvironment } from '../drawingTestUtils';

import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { Time } from 'lightweight-charts';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartLineColor: '#00aa00',
      chartLineColorAlternative: '#aa0000',
      rulerPositiveFill: '#00aa0044',
      rulerNegativeFill: '#aa000044',
      chartPriceLineText: '#ffffff',
      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
    },
  }),
}));

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

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

jest.mock('@src/utils', () => ({
  formatPrice: (value: number) => `price:${value}`,
  formatVolume: (value: number) => `volume:${value}`,
}));

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

const seriesData = [
  { time: 10 as Time, close: 10, volume: 100 },
  { time: 20 as Time, close: 20, customValues: { volume: 200 } },
  { time: 30 as Time, close: 30, volume: 300 },
];

class TestRuler extends Ruler {
  public timeSegments(): AxisSegment[] {
    return this.getTimeAxisSegments();
  }

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

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

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

function createRuler() {
  const env = createDrawingTestEnvironment({ seriesData });
  const removeSelf = jest.fn();

  const ruler = new TestRuler({
    ...env,
    removeSelf,
  });

  return { ...env, ruler, removeSelf };
}

function setReady(ruler: TestRuler, startPrice = 10, endPrice = 30): void {
  ruler.setState({
    mode: 'ready',
    startAnchor: { time: 10 as Time, price: startPrice },
    endAnchor: { time: 30 as Time, price: endPrice },
  });
}

describe('Ruler', () => {
  it('не отображается в Object Tree', () => {
    const { ruler } = createRuler();

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

  it('не имеет settings tabs', () => {
    const { ruler } = createRuler();

    expect(ruler.getSettingsTabs()).toEqual([]);
  });

  it('считает изменение цены, процент, bars и volume', () => {
    const { ruler } = createRuler();

    setReady(ruler);

    expect(ruler.getRenderData().infoLines).toEqual(['price:20 (price:200%)', '2 bars,', 'Vol volume:600']);
  });

  it('берёт volume из customValues', () => {
    const { ruler } = createRuler();

    setReady(ruler);

    expect(ruler.getRenderData().infoLines[2]).toBe('Vol volume:600');
  });

  it('выбирает positive цвета для роста', () => {
    const { ruler } = createRuler();

    setReady(ruler, 10, 30);

    expect(ruler.getRenderData()).toMatchObject({
      lineColor: '#00aa00',
      fillColor: '#00aa0044',
    });
  });

  it('выбирает negative цвета для падения', () => {
    const { ruler } = createRuler();

    setReady(ruler, 30, 10);

    expect(ruler.getRenderData()).toMatchObject({
      lineColor: '#aa0000',
      fillColor: '#aa000044',
    });
  });

  it('определяет направление стрелок', () => {
    const { ruler } = createRuler();

    setReady(ruler);

    expect(ruler.getRenderData()).toMatchObject({
      horizontalArrowSide: Direction.Right,
      verticalArrowSide: Direction.Bottom,
    });
  });

  it('возвращает axis bounds', () => {
    const { ruler } = createRuler();

    setReady(ruler);

    expect(ruler.getTimeBounds()).toEqual({ left: 10, right: 30 });
    expect(ruler.getPriceBounds()).toEqual({ top: 10, bottom: 30 });
  });

  it('возвращает axis segments', () => {
    const { ruler } = createRuler();

    setReady(ruler);

    expect(ruler.timeSegments()).toEqual([{ from: 10, to: 30, color: '#2962ff44' }]);
    expect(ruler.priceSegments()).toEqual([{ from: 10, to: 30, color: '#2962ff44' }]);
  });

  it('возвращает axis labels', () => {
    const { ruler } = createRuler();

    setReady(ruler);

    expect(ruler.timeLabel('start')).toMatchObject({
      coordinate: 10,
      text: 'date:10',
    });

    expect(ruler.priceLabel('end')).toMatchObject({
      coordinate: 30,
      text: 'price:30',
    });
  });

  it('возвращает autoscaleInfo когда ruler входит в logical range', () => {
    const { ruler } = createRuler();

    setReady(ruler);

    expect(ruler.autoscaleInfo(0 as never, 50 as never)).toEqual({
      priceRange: {
        minValue: 10,
        maxValue: 30,
      },
    });
  });

  it('не влияет на autoscale вне logical range', () => {
    const { ruler } = createRuler();

    setReady(ruler);

    expect(ruler.autoscaleInfo(100 as never, 200 as never)).toBeNull();
  });

  it('не влияет на autoscale во время placingEnd', () => {
    const { ruler } = createRuler();

    ruler.setState({
      mode: 'placingEnd',
      startAnchor: { time: 10 as Time, price: 10 },
      endAnchor: { time: 30 as Time, price: 30 },
    });

    expect(ruler.autoscaleInfo(0 as never, 50 as never)).toBeNull();
  });

  it('вызывает removeSelf после reset trigger', () => {
    const reset$ = new BehaviorSubject(false);
    const env = createDrawingTestEnvironment({ seriesData });
    const removeSelf = jest.fn();

    const ruler = new TestRuler({
      ...env,
      removeSelf,
      resetTriggers: [reset$],
    });

    expect(removeSelf).not.toHaveBeenCalled();

    reset$.next(true);

    expect(removeSelf).toHaveBeenCalledTimes(1);

    ruler.destroy();
  });
});

describe('RulerPaneRenderer', () => {
  it('не рисует hidden ruler', () => {
    const { target, useBitmapCoordinateSpace } = createCanvasTarget();
    const renderer = new RulerPaneRenderer({
      getRenderData: () => ({
        hidden: true,
        startPoint: null,
        endPoint: null,
      }),
    } as unknown as Ruler);

    renderer.draw(target);

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

  it('рисует область, стрелки и info lines', () => {
    const data: RulerRenderData = {
      hidden: false,
      startPoint: { x: 10, y: 20 },
      endPoint: { x: 100, y: 80 },
      lineColor: '#fff',
      fillColor: '#111',
      textColor: '#fff',
      infoLines: ['one', 'two', 'three'],
      horizontalArrowSide: Direction.Right,
      verticalArrowSide: Direction.Bottom,
    };

    const { target, context } = createCanvasTarget();
    const renderer = new RulerPaneRenderer({
      getRenderData: () => data,
    } as unknown as Ruler);

    renderer.draw(target);

    expect(context.fillRect).toHaveBeenCalled();
    expect(context.stroke).toHaveBeenCalled();
    expect(context.fillText).toHaveBeenCalledTimes(3);
  });
});

describe('RulerPaneView', () => {
  it('возвращает renderer и top', () => {
    const view = new RulerPaneView({} as Ruler);

    expect(view.renderer()).toBeInstanceOf(RulerPaneRenderer);
    expect(view.zOrder()).toBe('top');
  });
});

















import { SliderPaneRenderer } from '@core/Drawings/sliderPosition/paneRenderer';
import { SliderPaneView } from '@core/Drawings/sliderPosition/paneView';
import { createDefaultSettings, getSliderPositionSettingsTabs } from '@core/Drawings/sliderPosition/settings';
import { SliderPosition, type SliderRenderData } from '@core/Drawings/sliderPosition/sliderPosition';

import { createCanvasTarget, createDrawingTestEnvironment, createPointerEvent } from '../drawingTestUtils';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { Coordinate, MouseEventParams, PrimitiveHoveredItem, Time } from 'lightweight-charts';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartCrosshairLine: '#999999',
      sliderPositiveFill: '#00aa0044',
      sliderNegativeFill: '#aa000044',
      chartPriceLineText: '#ffffff',
      chartCandleUp: '#00aa00',
      chartCandleDown: '#aa0000',
      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
      axisMarkerLabelDefaultFill: '#777777',
      axisMarkerLabelPositiveFill: '#00aa00',
      axisMarkerLabelNegativeFill: '#aa0000',
    },
  }),
}));

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

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

jest.mock('@src/utils', () => ({
  formatPrice: (value: number, precision = 4) => {
    const factor = 10 ** precision;

    return String(Math.round(value * factor) / factor);
  },
  formatPercent: (value: number) => {
    const factor = 100;

    return `${Math.round(value * factor) / factor}%`;
  },
  formatSignedNumber: (value: number) => String(value),
  getPercentPrecision: () => 2,
  getPricePrecisionStep: () => 0.01,
}));

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

class TestSliderPosition extends SliderPosition {
  public handles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

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

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

  public pointerDown(event: PointerEvent): void {
    this.handlePointerDown(event);
  }

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

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

function createSlider(side: 'long' | 'short' = 'long', selected = false) {
  const seriesData = [
    {
      time: 10 as Time,
      close: 100,
    },
    {
      time: 100 as Time,
      close: 110,
    },
  ];

  const env = createDrawingTestEnvironment({
    selected,
    seriesData,
  });

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

  const slider = new TestSliderPosition({
    ...env,
    side,
    removeSelf,
    openSettings,
  });

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

function setReady(slider: TestSliderPosition): void {
  slider.setState({
    mode: 'ready',
    startTime: 10 as Time,
    endTime: 100 as Time,
    entryPrice: 100,
    stopPrice: 90,
    targetPrice: 120,
    amount: 1000,
    settings: {
      ...createDefaultSettings(),
      accountSize: 1000,
      risk: 25,
      lotSize: 1,
      leverage: 5,
      quantityPrecision: 3,
    },
  });
}

function createInitialEvent(x: number, y: number, time: Time): MouseEventParams<Time> {
  return {
    point: { x, y },
    time,
  } as unknown as MouseEventParams<Time>;
}

describe('SliderPosition', () => {
  it('считает Risk/Reward Ratio', () => {
    const { slider } = createSlider();

    setReady(slider);

    expect(slider.getState().riskRewardRatio).toBe(2);
  });

  it('при риске 25% и RR 1 считает 1000 -> 1250/750', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 110,
      settings: {
        ...createDefaultSettings(),
        accountSize: 1000,
        risk: 25,
        lotSize: 1,
        leverage: 5,
        quantityPrecision: 3,
      },
    });

    expect(slider.getRenderData()).toMatchObject({
      targetText: 'Target: 10 (10%), Amount: 1250',
      centerText: 'Open P&L: 10, Qty: 25\nRisk/Reward Ratio: 1',
      stopText: 'Stop: 10 (10%), Amount: 750',
    });
  });

  it('ограничивает Qty через leverage', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 110,
      settings: {
        ...createDefaultSettings(),
        accountSize: 1000,
        risk: 25,
        lotSize: 1,
        leverage: 1,
        quantityPrecision: 3,
      },
    });

    expect(slider.getRenderData()).toMatchObject({
      targetText: 'Target: 10 (10%), Amount: 1100',
      centerText: 'Open P&L: 10, Qty: 10\nRisk/Reward Ratio: 1',
      stopText: 'Stop: 10 (10%), Amount: 900',
    });
  });

  it('всегда трактует Risk как процент от размера счёта', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 120,
      settings: {
        ...createDefaultSettings(),
        accountSize: 1000,
        risk: 2,
        lotSize: 1,
        leverage: 10,
        quantityPrecision: 3,
      },
    });

    expect(slider.getRenderData()).toMatchObject({
      targetText: 'Target: 20 (20%), Amount: 1040',
      centerText: 'Open P&L: 10, Qty: 2\nRisk/Reward Ratio: 2',
      stopText: 'Stop: 10 (10%), Amount: 980',
    });
  });

  it('учитывает Lot size', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 110,
      settings: {
        ...createDefaultSettings(),
        accountSize: 1000,
        risk: 25,
        lotSize: 5,
        leverage: 5,
        quantityPrecision: 3,
      },
    });

    expect(slider.getRenderData()).toMatchObject({
      targetText: 'Target: 10 (10%), Amount: 1250',
      centerText: 'Open P&L: 10, Qty: 5\nRisk/Reward Ratio: 1',
      stopText: 'Stop: 10 (10%), Amount: 750',
    });
  });

  it('QTY precision округляет Qty до расчёта Amount', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 93,
      targetPrice: 114,
      settings: {
        ...createDefaultSettings(),
        accountSize: 1000,
        risk: 10,
        lotSize: 1,
        leverage: 10,
        quantityPrecision: 2,
      },
    });

    expect(slider.getRenderData()).toMatchObject({
      targetText: 'Target: 14 (14%), Amount: 1200.06',
      centerText: 'Open P&L: 10, Qty: 14.29\nRisk/Reward Ratio: 2',
      stopText: 'Stop: 7 (7%), Amount: 899.97',
    });
  });

  it('изменение Target не меняет Qty', () => {
    const { slider } = createSlider();

    setReady(slider);
    expect(slider.getRenderData()?.centerText).toContain('Qty: 25');

    slider.setState({
      targetPrice: 140,
    });

    expect(slider.getRenderData()).toMatchObject({
      targetText: 'Target: 40 (40%), Amount: 2000',
      centerText: 'Open P&L: 10, Qty: 25\nRisk/Reward Ratio: 4',
      stopText: 'Stop: 10 (10%), Amount: 750',
    });
  });

  it('изменение Stop пересчитывает Qty', () => {
    const { slider } = createSlider();

    setReady(slider);

    slider.setState({
      stopPrice: 95,
    });

    expect(slider.getRenderData()).toMatchObject({
      targetText: 'Target: 20 (20%), Amount: 2000',
      centerText: 'Open P&L: 10, Qty: 50\nRisk/Reward Ratio: 4',
      stopText: 'Stop: 5 (5%), Amount: 750',
    });
  });

  it('симметрично считает short позицию', () => {
    const { slider } = createSlider('short');

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 110,
      targetPrice: 80,
      settings: {
        ...createDefaultSettings(),
        accountSize: 1000,
        risk: 25,
        lotSize: 1,
        leverage: 5,
        quantityPrecision: 3,
      },
    });

    expect(slider.getRenderData()).toMatchObject({
      targetText: 'Target: 20 (20%), Amount: 1500',
      centerText: 'Open P&L: -10, Qty: 25\nRisk/Reward Ratio: 2',
      stopText: 'Stop: 10 (10%), Amount: 750',
      targetLabelDirection: 'down',
      stopLabelDirection: 'up',
    });
  });

  it('разрешает Risk/Reward Ratio = 0', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      riskRewardRatio: 0,
    });

    expect(slider.getState()).toMatchObject({
      targetPrice: 100,
      riskRewardRatio: 0,
    });

    expect(slider.getRenderData()?.centerText).toContain('Risk/Reward Ratio: 0');
  });

  it('использует минимальный шаг цены только для Stop', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 100,
      targetPrice: 100,
    });

    expect(slider.getState()).toMatchObject({
      entryPrice: 100,
      stopPrice: 99.99,
      targetPrice: 100,
      riskRewardRatio: 0,
    });
  });

  it('для short использует минимальный шаг цены только для Stop', () => {
    const { slider } = createSlider('short');

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 100,
      targetPrice: 100,
    });

    expect(slider.getState()).toMatchObject({
      entryPrice: 100,
      stopPrice: 100.01,
      targetPrice: 100,
      riskRewardRatio: 0,
    });
  });

  it('возвращает четыре handles в правильном порядке', () => {
    const { slider } = createSlider();

    setReady(slider);

    expect(slider.handles()).toEqual([
      {
        id: 'target',
        x: 10,
        y: 120,
      },
      {
        id: 'stop',
        x: 10,
        y: 90,
      },
      {
        id: 'end',
        x: 100,
        y: 100,
      },
      {
        id: 'entry',
        x: 10,
        y: 100,
        shape: 'circle',
      },
    ]);
  });

  it('Entry имеет приоритет при точном совпадении с Target', () => {
    const { slider, container, priceToCoordinate } = createSlider('long', true);

    priceToCoordinate.mockImplementation((price: number) => {
      return (price === 101 ? 100 : price) as Coordinate;
    });

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 101,
    });

    slider.pointerDown(createPointerEvent(container, 10, 100));
    slider.pointerMove(createPointerEvent(container, 10, 95));

    expect(slider.getState()).toMatchObject({
      entryPrice: 95,
      targetPrice: 101,
    });
  });

  it('Entry имеет приоритет при точном совпадении со Stop', () => {
    const { slider, container, priceToCoordinate } = createSlider('long', true);

    priceToCoordinate.mockImplementation((price: number) => {
      return (price === 99 ? 100 : price) as Coordinate;
    });

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 99,
      targetPrice: 120,
    });

    slider.pointerDown(createPointerEvent(container, 10, 100));
    slider.pointerMove(createPointerEvent(container, 10, 110));

    expect(slider.getState()).toMatchObject({
      entryPrice: 110,
      stopPrice: 99,
    });
  });

  it('позволяет вытянуть Target из overlap', () => {
    const { slider, container, priceToCoordinate } = createSlider('long', true);

    priceToCoordinate.mockImplementation((price: number) => {
      return (price === 101 ? 100 : price) as Coordinate;
    });

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 101,
    });

    slider.pointerDown(createPointerEvent(container, 10, 101));
    slider.pointerMove(createPointerEvent(container, 10, 110));

    expect(slider.getState().targetPrice).toBe(110);
  });

  it('позволяет вытянуть Stop из overlap', () => {
    const { slider, container, priceToCoordinate } = createSlider('long', true);

    priceToCoordinate.mockImplementation((price: number) => {
      return (price === 99 ? 100 : price) as Coordinate;
    });

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 99,
      targetPrice: 120,
    });

    slider.pointerDown(createPointerEvent(container, 10, 99));
    slider.pointerMove(createPointerEvent(container, 10, 90));

    expect(slider.getState().stopPrice).toBe(90);
  });

  it('Entry не пересекает Stop и Target', () => {
    const { slider, container } = createSlider('long', true);

    setReady(slider);

    slider.pointerDown(createPointerEvent(container, 10, 100));
    slider.pointerMove(createPointerEvent(container, 10, 200));

    expect(slider.getState().entryPrice).toBe(120);
  });

  it('оставляет минимальный шаг между Stop и Entry', () => {
    const { slider, container } = createSlider('long', true);

    setReady(slider);

    slider.pointerDown(createPointerEvent(container, 10, 90));
    slider.pointerMove(createPointerEvent(container, 10, 100));

    expect(slider.getState().stopPrice).toBe(99.99);
  });

  it('Target может совпасть с Entry', () => {
    const { slider, container } = createSlider('long', true);

    setReady(slider);

    slider.pointerDown(createPointerEvent(container, 10, 120));
    slider.pointerMove(createPointerEvent(container, 10, 100));

    expect(slider.getState().targetPrice).toBe(100);
  });

  it('для short оставляет минимальный шаг между Stop и Entry', () => {
    const { slider, container } = createSlider('short', true);

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 110,
      targetPrice: 80,
    });

    slider.pointerDown(createPointerEvent(container, 10, 110));
    slider.pointerMove(createPointerEvent(container, 10, 100));

    expect(slider.getState().stopPrice).toBe(100.01);
  });

  it('для short Target может совпасть с Entry', () => {
    const { slider, container } = createSlider('short', true);

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 110,
      targetPrice: 80,
    });

    slider.pointerDown(createPointerEvent(container, 10, 80));
    slider.pointerMove(createPointerEvent(container, 10, 100));

    expect(slider.getState().targetPrice).toBe(100);
  });

  it.each(['long', 'short'] as const)('создаёт %s позицию с ограниченной начальной высотой', (side) => {
    const seriesData = [
      {
        time: 150 as Time,
        close: 150,
      },
    ];

    const env = createDrawingTestEnvironment({
      seriesData,
      height: 300,
    });

    const slider = new TestSliderPosition({
      ...env,
      side,
      initialEvent: createInitialEvent(100, 150, 150 as Time),
    });

    const priceHandles = slider.handles().filter(({ id }) => {
      return id === 'target' || id === 'stop';
    });

    expect(slider.getState().mode).toBe('ready');
    expect(priceHandles).toHaveLength(2);
    expect(priceHandles.every(({ y }) => Math.abs(y - 150) <= 60)).toBe(true);
  });

  it.each([
    ['long', 20],
    ['long', 280],
    ['short', 20],
    ['short', 280],
  ] as const)('не выводит handles %s позиции за pane при y=%s', (side, entryY) => {
    const seriesData = [
      {
        time: entryY as Time,
        close: entryY,
      },
    ];

    const env = createDrawingTestEnvironment({
      seriesData,
      height: 300,
    });

    const slider = new TestSliderPosition({
      ...env,
      side,
      initialEvent: createInitialEvent(100, entryY, entryY as Time),
    });

    const priceHandles = slider.handles().filter(({ id }) => {
      return id === 'target' || id === 'stop';
    });

    expect(slider.getState().mode).toBe('ready');
    expect(priceHandles.every(({ y }) => y >= 8 && y <= 292)).toBe(true);
  });

  it('возвращает selected axis areas', () => {
    const { slider } = createSlider('long', true);

    setReady(slider);

    expect(slider.timeSegments()).toEqual([
      {
        from: 10,
        to: 100,
        color: '#2962ff44',
      },
    ]);

    expect(slider.priceSegments()).toEqual([
      {
        from: 100,
        to: 120,
        color: '#2962ff44',
      },
      {
        from: 90,
        to: 100,
        color: '#2962ff44',
      },
    ]);
  });

  it('time labels показываются только у selected drawing', () => {
    const { slider } = createSlider('long', true);

    setReady(slider);

    expect(slider.timeLabel('start')).toMatchObject({
      coordinate: 10,
      text: 'date:10',
    });

    expect(slider.timeLabel('end')).toMatchObject({
      coordinate: 100,
      text: 'date:100',
    });
  });

  it('price labels доступны независимо от selection', () => {
    const { slider } = createSlider();

    setReady(slider);

    expect(slider.priceLabel('target')).toMatchObject({
      coordinate: 120,
      text: '120',
      backgroundColor: '#00aa00',
    });

    expect(slider.priceLabel('entry')).toMatchObject({
      coordinate: 100,
      backgroundColor: '#777777',
    });

    expect(slider.priceLabel('stop')).toMatchObject({
      coordinate: 90,
      backgroundColor: '#aa0000',
    });
  });

  it('возвращает правильные cursors', () => {
    const { slider } = createSlider('long', true);

    setReady(slider);

    expect(slider.hovered(10, 120)?.cursorStyle).toBe('ns-resize');
    expect(slider.hovered(10, 90)?.cursorStyle).toBe('ns-resize');
    expect(slider.hovered(100, 100)?.cursorStyle).toBe('ew-resize');
    expect(slider.hovered(10, 100)?.cursorStyle).toBe('grab');
  });

  it('выбирает unselected slider по body', () => {
    const { slider, interaction, container } = createSlider();

    setReady(slider);

    slider.pointerDown(createPointerEvent(container, 50, 100));

    expect(interaction.select).toHaveBeenCalledTimes(1);
  });

  it('изменяет end time', () => {
    const { slider, container } = createSlider('long', true);

    setReady(slider);

    slider.pointerDown(createPointerEvent(container, 100, 100));
    slider.pointerMove(createPointerEvent(container, 150, 100));

    expect(slider.getState().endTime).toBe(150);
  });

  it('поддерживает legacy amount как accountSize', () => {
    const { slider } = createSlider();

    slider.setState({
      amount: 2500,
    });

    expect(slider.getState().settings.accountSize).toBe(2500);
  });
});

describe('SliderPaneRenderer', () => {
  it('не рисует без data', () => {
    const { target, useBitmapCoordinateSpace } = createCanvasTarget();

    new SliderPaneRenderer({
      getRenderData: () => null,
    } as unknown as SliderPosition).draw(target);

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

  it('рисует profit/loss, labels и три границы', () => {
    const data: SliderRenderData = {
      startX: 10,
      endX: 100,
      leftX: 10,
      rightX: 100,
      entryY: 100,
      stopY: 120,
      targetY: 80,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 120,
      profitTop: 80,
      profitBottom: 100,
      lossTop: 100,
      lossBottom: 120,
      targetText: 'target',
      centerText: 'center',
      stopText: 'stop',
      centerBoxColor: '#00aa00',
      targetLabelDirection: 'up',
      stopLabelDirection: 'down',
      showLabels: true,
      lineColor: '#ffffff',
      lineWidth: 1,
      positiveFillColor: '#00ff0033',
      negativeFillColor: '#ff000033',
      textColor: '#ffffff',
      fontSize: 10,
    };

    const { target, context } = createCanvasTarget();

    new SliderPaneRenderer({
      getRenderData: () => data,
    } as unknown as SliderPosition).draw(target);

    expect(context.fillRect).toHaveBeenCalledTimes(2);
    expect(context.fillText).toHaveBeenCalled();
    expect(context.stroke).toHaveBeenCalledTimes(3);
  });
});

describe('SliderPaneView', () => {
  it('возвращает renderer и top', () => {
    const view = new SliderPaneView({} as SliderPosition);

    expect(view.renderer()).toBeInstanceOf(SliderPaneRenderer);
    expect(view.zOrder()).toBe('top');
  });
});

describe('SliderPosition settings', () => {
  it('создаёт defaults без boolean-настроек', () => {
    expect(createDefaultSettings()).toEqual({
      accountSize: 1000,
      risk: 25,
      lotSize: 1,
      leverage: 10000,
      quantityPrecision: 3,
      lineColor: '#999999',
      lineWidth: 1,
      positiveFillColor: '#00aa0044',
      negativeFillColor: '#aa000044',
      textColor: '#ffffff',
      fontSize: 10,
    });
  });

  it('создаёт только Arguments и Style tabs', () => {
    const tabs = getSliderPositionSettingsTabs(createDefaultSettings(), {
      entryPrice: 100,
      targetPrice: 120,
      stopPrice: 90,
    });

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

  it('обновляет Entry, Target и Stop через настройки', () => {
    const { slider } = createSlider();

    setReady(slider);

    slider.updateSettings({
      entryPrice: 105,
      targetPrice: 105,
      stopPrice: 105,
    });

    expect(slider.getState()).toMatchObject({
      entryPrice: 105,
      targetPrice: 105,
      stopPrice: 104.99,
    });
  });
});
















import { TextPaneRenderer } from '@core/Drawings/text/paneRenderer';
import { TextPaneView } from '@core/Drawings/text/paneView';
import { createDefaultSettings, getTextSettingsTabs } from '@core/Drawings/text/settings';
import { Text, type TextRenderData } from '@core/Drawings/text/text';

import {
  createCanvasTarget,
  createDrawingTestEnvironment,
  createMouseEvent,
  createPointerEvent,
} from '../drawingTestUtils';

import type { AxisLabel } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';

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

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

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

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

class TestText extends Text {
  public hovered(x: number, y: number): PrimitiveHoveredItem | null {
    return this.getHoveredItem(x, y);
  }

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

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

  public pointerDown(event: PointerEvent): void {
    this.handlePointerDown(event);
  }

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

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

  public doubleClick(event: MouseEvent): void {
    this.handleDoubleClick(event);
  }
}

let getContextSpy: jest.SpyInstance;

beforeAll(() => {
  getContextSpy = jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
    () =>
      ({
        measureText: (value: string) => ({ width: value.length * 8 }),
      }) as unknown as CanvasRenderingContext2D,
  );
});

afterAll(() => {
  getContextSpy.mockRestore();
});

function createText(selected = false) {
  const env = createDrawingTestEnvironment({ selected });
  const removeSelf = jest.fn();
  const openSettings = jest.fn();

  const text = new TestText({
    ...env,
    removeSelf,
    openSettings,
  });

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

function setReady(text: TestText): void {
  text.setState({
    mode: 'ready',
    point: { time: 50 as Time, price: 60 },
    settings: {
      ...createDefaultSettings(),
      text: 'Test',
    },
  });
}

describe('Text', () => {
  it('создаётся одним pointer down', () => {
    const { text, container } = createText();

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

    expect(text.getState()).toMatchObject({
      mode: 'ready',
      point: {
        time: 50,
        price: 60,
      },
    });
  });

  it('правильно измеряет text block', () => {
    const { text } = createText();

    setReady(text);

    expect(text.getRenderData()).toMatchObject({
      point: { x: 50, y: 60 },
      width: 44,
      height: 26,
      lines: ['Test'],
      lineHeight: 14,
    });
  });

  it('поддерживает multiline text', () => {
    const { text } = createText();

    text.setState({
      mode: 'ready',
      point: { time: 50 as Time, price: 60 },
      settings: {
        ...createDefaultSettings(),
        text: 'One\nTwo',
      },
    });

    expect(text.getRenderData()).toMatchObject({
      lines: ['One', 'Two'],
      height: 40,
    });
  });

  it('показывает selection border только selected', () => {
    const { text, setSelected } = createText();

    setReady(text);

    expect(text.getRenderData()?.showSelectionBorder).toBe(false);

    setSelected(true);

    expect(text.getRenderData()?.showSelectionBorder).toBe(true);
  });

  it('возвращает move cursor внутри текста', () => {
    const { text } = createText();

    setReady(text);

    expect(text.hovered(60, 70)?.cursorStyle).toBe('move');
    expect(text.hovered(200, 200)).toBeNull();
  });

  it('выбирает text первым нажатием', () => {
    const { text, container, interaction } = createText();

    setReady(text);

    text.pointerDown(createPointerEvent(container, 60, 70));

    expect(interaction.select).toHaveBeenCalledTimes(1);
  });

  it('перемещает selected text', () => {
    const { text, container } = createText(true);

    setReady(text);

    text.pointerDown(createPointerEvent(container, 60, 70));
    text.pointerMove(createPointerEvent(container, 80, 90));
    text.pointerUp(createPointerEvent(container, 80, 90));

    expect(text.getState()).toMatchObject({
      mode: 'ready',
      point: {
        time: 70,
        price: 80,
      },
    });
  });

  it('не позволяет text выйти за пределы контейнера', () => {
    const { text, container } = createText(true);

    setReady(text);

    text.pointerDown(createPointerEvent(container, 60, 70));
    text.pointerMove(createPointerEvent(container, 1000, 1000));

    const data = text.getRenderData();

    expect(data?.right).toBeLessThanOrEqual(400);
    expect(data?.bottom).toBeLessThanOrEqual(300);
  });

  it('показывает axis labels только selected', () => {
    const { text } = createText(true);

    setReady(text);

    expect(text.timeLabel('main')).toMatchObject({
      coordinate: 50,
      text: 'date:50',
    });

    expect(text.priceLabel('main')).toMatchObject({
      coordinate: 60,
      text: 'price:60',
    });
  });

  it('не имеет axis pane segments', () => {
    const { text } = createText(true);

    setReady(text);

    expect(text.timeAxisPaneViews()).toEqual([]);
    expect(text.priceAxisPaneViews()).toEqual([]);
  });

  it('открывает settings по double click selected text', () => {
    const { text, container, openSettings } = createText(true);

    setReady(text);
    text.doubleClick(createMouseEvent(container, 60, 70));

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

  it('не возвращает render data когда hidden', () => {
    const { text } = createText();

    setReady(text);
    text.hide();

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

describe('TextPaneRenderer', () => {
  it('ничего не рисует без данных', () => {
    const { target, useBitmapCoordinateSpace } = createCanvasTarget();

    new TextPaneRenderer({
      getRenderData: () => null,
    } as unknown as Text).draw(target);

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

  it('рисует background, border, text и selection', () => {
    const data: TextRenderData = {
      point: { x: 10, y: 20 },
      left: 10,
      right: 80,
      top: 20,
      bottom: 60,
      width: 70,
      height: 40,
      lines: ['Test'],
      font: '14px Inter',
      lineHeight: 14,
      text: 'Test',
      fontSize: 14,
      isBold: false,
      isItalic: false,
      textColor: '#fff',
      backgroundColor: '#111',
      borderColor: '#222',
      showSelectionBorder: true,
    };

    const { target, context } = createCanvasTarget();

    new TextPaneRenderer({
      getRenderData: () => data,
    } as unknown as Text).draw(target);

    expect(context.fill).toHaveBeenCalled();
    expect(context.stroke).toHaveBeenCalled();
    expect(context.fillText).toHaveBeenCalledWith('Test', expect.any(Number), expect.any(Number));
  });
});

describe('TextPaneView', () => {
  it('переиспользует renderer и возвращает top', () => {
    const view = new TextPaneView({} as Text);
    const renderer = view.renderer();

    expect(renderer).toBeInstanceOf(TextPaneRenderer);
    expect(view.renderer()).toBe(renderer);
    expect(view.zOrder()).toBe('top');
  });
});

describe('Text settings', () => {
  it('создаёт defaults', () => {
    expect(createDefaultSettings()).toEqual({
      text: 'Text',
      fontSize: 14,
      isBold: false,
      isItalic: false,
      textColor: '#ffffff',
      backgroundColor: '#00000000',
      borderColor: '#00000000',
    });
  });

  it('создаёт style и text tabs', () => {
    expect(getTextSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
  });
});
















import { TraectoryPaneRenderer } from '@core/Drawings/traectory/paneRenderer';
import { TraectoryPaneView } from '@core/Drawings/traectory/paneView';
import { createDefaultSettings, getTraectorySettingsTabs } from '@core/Drawings/traectory/settings';
import { Traectory } from '@core/Drawings/traectory/traectory';

import {
  createCanvasTarget,
  createDrawingTestEnvironment,
  createMouseEvent,
  createPointerEvent,
} from '../drawingTestUtils';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';

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

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

class TestTraectory extends Traectory {
  public handles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

  public pointerDown(event: PointerEvent): void {
    this.handlePointerDown(event);
  }

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

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

  public click(event: MouseEvent): void {
    this.handleClick(event);
  }

  public doubleClick(event: MouseEvent): void {
    this.handleDoubleClick(event);
  }

  public contextMenu(event: MouseEvent): void {
    this.handleContextMenu(event);
  }
}

function createTraectory(selected = false) {
  const env = createDrawingTestEnvironment({ selected });
  const removeSelf = jest.fn();
  const openSettings = jest.fn();

  const traectory = new TestTraectory({
    ...env,
    removeSelf,
    openSettings,
  });

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

function setReady(traectory: TestTraectory): void {
  traectory.setState({
    mode: 'ready',
    points: [
      { time: 10 as Time, price: 20 },
      { time: 50 as Time, price: 60 },
      { time: 100 as Time, price: 40 },
    ],
  });
}

describe('Traectory', () => {
  it('начинает drawing первым pointer down', () => {
    const { traectory, container } = createTraectory();

    traectory.pointerDown(createPointerEvent(container, 10, 20));

    expect(traectory.getState()).toMatchObject({
      mode: 'drawing',
      points: [{ time: 10, price: 20 }],
    });
  });

  it('добавляет точки обычным click', () => {
    const { traectory, container } = createTraectory();

    traectory.pointerDown(createPointerEvent(container, 10, 20));
    traectory.click(createMouseEvent(container, 50, 60));

    expect(traectory.getState().points).toEqual([
      { time: 10, price: 20 },
      { time: 50, price: 60 },
    ]);
  });

  it('не добавляет одну и ту же точку дважды', () => {
    const { traectory, container } = createTraectory();

    traectory.pointerDown(createPointerEvent(container, 10, 20));
    traectory.click(createMouseEvent(container, 10, 20));

    expect(traectory.getState().points).toHaveLength(1);
  });

  it('заканчивает drawing double click', () => {
    const { traectory, container } = createTraectory();

    traectory.pointerDown(createPointerEvent(container, 10, 20));
    traectory.click(createMouseEvent(container, 50, 60));
    traectory.doubleClick(createMouseEvent(container, 50, 60, 2));

    expect(traectory.getState().mode).toBe('ready');
    expect(traectory.getRenderData()?.showArrow).toBe(true);
  });

  it('заканчивает drawing context menu', () => {
    const { traectory, container } = createTraectory();

    traectory.pointerDown(createPointerEvent(container, 10, 20));
    traectory.click(createMouseEvent(container, 50, 60));
    traectory.contextMenu(createMouseEvent(container, 50, 60));

    expect(traectory.getState().mode).toBe('ready');
  });

  it('удаляется при завершении с одной точкой', () => {
    const { traectory, container, removeSelf } = createTraectory();

    traectory.pointerDown(createPointerEvent(container, 10, 20));
    traectory.doubleClick(createMouseEvent(container, 10, 20, 2));

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

  it('возвращает handles в обратном порядке', () => {
    const { traectory } = createTraectory();

    setReady(traectory);

    expect(traectory.handles()).toEqual([
      { id: '2', x: 100, y: 40, shape: 'circle' },
      { id: '1', x: 50, y: 60, shape: 'circle' },
      { id: '0', x: 10, y: 20, shape: 'circle' },
    ]);
  });

  it('возвращает axis segments', () => {
    const { traectory } = createTraectory(true);

    setReady(traectory);

    expect(traectory.timeSegments()).toEqual([{ from: 10, to: 100, color: '#2962ff44' }]);
    expect(traectory.priceSegments()).toEqual([{ from: 20, to: 60, color: '#2962ff44' }]);
  });

  it('не имеет axis labels', () => {
    const { traectory } = createTraectory(true);

    setReady(traectory);

    expect(traectory.timeAxisViews()).toEqual([]);
    expect(traectory.priceAxisViews()).toEqual([]);
  });

  it('определяет segment как drawing hit', () => {
    const { traectory } = createTraectory();

    setReady(traectory);

    expect(traectory.hovered(30, 40)?.cursorStyle).toBe('move');
    expect(traectory.hovered(300, 200)).toBeNull();
  });

  it('перемещает отдельную точку', () => {
    const { traectory, container } = createTraectory(true);

    setReady(traectory);

    traectory.pointerDown(createPointerEvent(container, 50, 60));
    traectory.pointerMove(createPointerEvent(container, 70, 80));
    traectory.pointerUp(createPointerEvent(container, 70, 80));

    expect(traectory.getState().points[1]).toEqual({
      time: 70,
      price: 80,
    });
  });

  it('перемещает всю trajectory', () => {
    const { traectory, container } = createTraectory(true);

    setReady(traectory);

    traectory.pointerDown(createPointerEvent(container, 30, 40));
    traectory.pointerMove(createPointerEvent(container, 40, 50));

    expect(traectory.getState().points).toEqual([
      { time: 20, price: 30 },
      { time: 60, price: 70 },
      { time: 110, price: 50 },
    ]);
  });

  it('открывает settings по double click', () => {
    const { traectory, container, openSettings } = createTraectory(true);

    setReady(traectory);
    traectory.doubleClick(createMouseEvent(container, 30, 40, 2));

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

  it('не возвращает render data когда hidden', () => {
    const { traectory } = createTraectory();

    setReady(traectory);
    traectory.hide();

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

describe('TraectoryPaneRenderer', () => {
  it('не рисует без data', () => {
    const { target, useBitmapCoordinateSpace } = createCanvasTarget();

    new TraectoryPaneRenderer({
      getRenderData: () => null,
    } as unknown as Traectory).draw(target);

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

  it('рисует trajectory', () => {
    const { traectory } = createTraectory();

    setReady(traectory);

    const { target, context } = createCanvasTarget();

    new TraectoryPaneRenderer(traectory).draw(target);

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

describe('TraectoryPaneView', () => {
  it('возвращает renderer и top', () => {
    const view = new TraectoryPaneView({} as Traectory);

    expect(view.renderer()).toBeInstanceOf(TraectoryPaneRenderer);
    expect(view.zOrder()).toBe('top');
  });
});

describe('Traectory settings', () => {
  it('создаёт default lineColor', () => {
    expect(createDefaultSettings()).toEqual({
      lineColor: '#2962ff',
    });
  });

  it('имеет один style tab', () => {
    expect(getTraectorySettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style']);
  });
});

















import { VolumeProfilePaneRenderer } from '@core/Drawings/volumeProfile/paneRenderer';
import { VolumeProfilePaneView } from '@core/Drawings/volumeProfile/paneView';
import { createDefaultSettings, getVolumeProfileSettingsTabs } from '@core/Drawings/volumeProfile/settings';
import { VolumeProfile, type VolumeProfileRenderData } from '@core/Drawings/volumeProfile/volumeProfile';

import { createCanvasTarget, createDrawingTestEnvironment, createPointerEvent } from '../drawingTestUtils';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { MouseEventParams, PrimitiveHoveredItem, Time } from 'lightweight-charts';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      volumeProfileAreaFill: '#11111122',
      volumeProfileBuyFill: '#00aa00',
      volumeProfileSellFill: '#aa0000',
      volumeProfilePocLine: '#ffaa00',
      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
      chartPriceLineText: '#ffffff',
    },
  }),
}));

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

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

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

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

const candles = [
  { time: 10 as Time, open: 10, high: 14, low: 9, close: 12, volume: 100 },
  { time: 20 as Time, open: 12, high: 16, low: 10, close: 11, volume: 50 },
  { time: 30 as Time, open: 11, high: 25, low: 10, close: 20, customValues: { volume: 200 } },
  { time: 40 as Time, open: 20, high: 31, low: 18, close: 30, volume: 150 },
];

class TestVolumeProfile extends VolumeProfile {
  public handles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

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

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

  public pointerDown(event: PointerEvent): void {
    this.handlePointerDown(event);
  }

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

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

function createProfile(profileKind: 'fixedRange' | 'visibleRange' = 'fixedRange', selected = false) {
  const env = createDrawingTestEnvironment({ selected, seriesData: candles });
  const removeSelf = jest.fn();
  const openSettings = jest.fn();

  const profile = new TestVolumeProfile({
    ...env,
    profileKind,
    removeSelf,
    openSettings,
  });

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

function createInitialEvent(sourceEvent: PointerEvent): MouseEventParams {
  return { sourceEvent } as unknown as MouseEventParams;
}

function setFixedReady(profile: TestVolumeProfile): void {
  profile.setState({
    mode: 'ready',
    startAnchor: { time: 10 as Time, price: 10 },
    endAnchor: { time: 40 as Time, price: 30 },
  });
}

describe('VolumeProfile fixedRange', () => {
  it('является creation pending до завершения', () => {
    const { profile } = createProfile();

    expect(profile.isCreationPending()).toBe(true);
  });

  it('рассчитывает 24 profile rows', () => {
    const { profile } = createProfile();

    setFixedReady(profile);

    expect(profile.getRenderData()?.rows).toHaveLength(24);
  });

  it('рассчитывает min/max по high и low candles', () => {
    const { profile } = createProfile();

    setFixedReady(profile);

    expect(profile.getRenderData()).toMatchObject({
      left: 10,
      right: 40,
      top: 9,
      bottom: 31,
    });
  });

  it('использует customValues.volume раньше обычного volume', () => {
    const { profile } = createProfile();

    setFixedReady(profile);

    const rows = profile.getRenderData()?.rows ?? [];
    const hasVolume = rows.some(({ buyWidth, sellWidth }) => buyWidth > 0 || sellWidth > 0);

    expect(hasVolume).toBe(true);
  });

  it('рассчитывает POC', () => {
    const { profile } = createProfile();

    setFixedReady(profile);

    expect(profile.getRenderData()?.pocY).not.toBeNull();
  });

  it('возвращает два edge handles', () => {
    const { profile } = createProfile();

    setFixedReady(profile);

    expect(profile.handles()).toEqual([
      { id: 'end', x: 40, y: 31, shape: 'circle' },
      { id: 'start', x: 10, y: 9, shape: 'circle' },
    ]);
  });

  it('возвращает selected axis segments', () => {
    const { profile } = createProfile('fixedRange', true);

    setFixedReady(profile);

    expect(profile.timeSegments()).toEqual([{ from: 10, to: 40, color: '#2962ff44' }]);
    expect(profile.priceSegments()).toEqual([{ from: 9, to: 31, color: '#2962ff44' }]);
  });

  it('возвращает axis labels', () => {
    const { profile } = createProfile('fixedRange', true);

    setFixedReady(profile);

    expect(profile.timeLabel('start')).toMatchObject({
      coordinate: 10,
      text: 'date:10',
    });

    expect(profile.priceLabel('start')).toMatchObject({
      coordinate: 9,
      text: 'price:9',
    });

    expect(profile.priceLabel('end')).toMatchObject({
      coordinate: 31,
      text: 'price:31',
    });
  });

  it('создаётся двумя pointer down', () => {
    const { profile, container } = createProfile();

    profile.pointerDown(createPointerEvent(container, 10, 10));
    profile.pointerDown(createPointerEvent(container, 40, 30));

    expect(profile.getState().mode).toBe('ready');
  });

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

    profile.pointerDown(createPointerEvent(container, 10, 10));
    profile.pointerDown(createPointerEvent(container, 14, 14));

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

  it('перемещает start handle', () => {
    const { profile, container } = createProfile('fixedRange', true);

    setFixedReady(profile);

    profile.pointerDown(createPointerEvent(container, 10, 9));
    profile.pointerMove(createPointerEvent(container, 20, 15));
    profile.pointerUp(createPointerEvent(container, 20, 15));

    expect(profile.getState().startAnchor).toEqual({
      time: 20,
      price: 15,
    });
  });

  it('выбирает unselected profile', () => {
    const { profile, container, interaction } = createProfile();

    setFixedReady(profile);

    profile.pointerDown(createPointerEvent(container, 25, 20));

    expect(interaction.select).toHaveBeenCalledTimes(1);
  });

  it('возвращает pointer/grab в зависимости от selection', () => {
    const unselected = createProfile();
    setFixedReady(unselected.profile);

    expect(unselected.profile.hovered(25, 20)?.cursorStyle).toBe('pointer');

    const selected = createProfile('fixedRange', true);
    setFixedReady(selected.profile);

    expect(selected.profile.hovered(25, 20)?.cursorStyle).toBe('grab');
  });
});

describe('VolumeProfile visibleRange', () => {
  it('сразу находится в ready', () => {
    const { profile } = createProfile('visibleRange');

    expect(profile.getState().mode).toBe('ready');
    expect(profile.isCreationPending()).toBe(false);
  });

  it('использует initialEvent как начало profile и остаётся в ready', () => {
    const env = createDrawingTestEnvironment({
      selected: false,
      seriesData: candles,
    });
    const sourceEvent = createPointerEvent(env.container, 200, 20);

    const profile = new TestVolumeProfile({
      ...env,
      profileKind: 'visibleRange',
      initialEvent: createInitialEvent(sourceEvent),
    });

    expect(profile.getState()).toMatchObject({
      mode: 'ready',
      visibleRangeStartRatio: 0.5,
    });
    expect(profile.isCreationPending()).toBe(false);
    expect(profile.getRenderData()).toMatchObject({
      left: 200,
      right: 400,
      width: 200,
      profileKind: 'visibleRange',
    });
  });

  it('показывает время левой точки на time axis', () => {
    const { profile } = createProfile('visibleRange', true);

    profile.setState({ visibleRangeStartRatio: 0.5 });

    expect(profile.timeLabel('start')).toMatchObject({
      coordinate: 200,
      text: 'date:200',
    });
    expect(profile.timeLabel('end')).toBeNull();
  });

  it('отображается в Object Tree', () => {
    const { profile } = createProfile('visibleRange');

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

  it('имеет только start time axis view', () => {
    const { profile } = createProfile('visibleRange');

    expect(profile.timeAxisPaneViews()).toEqual([]);
    expect(profile.priceAxisPaneViews()).toEqual([]);
    expect(profile.timeAxisViews()).toHaveLength(1);
    expect(profile.priceAxisViews()).toEqual([]);
  });

  it('clamp visibleRangeStartRatio до 0.95', () => {
    const { profile } = createProfile('visibleRange');

    profile.setState({ visibleRangeStartRatio: 2 });

    expect(profile.getState().visibleRangeStartRatio).toBe(0.95);
  });

  it('меняет левую границу через ratio', () => {
    const { profile } = createProfile('visibleRange');

    profile.setState({ visibleRangeStartRatio: 0.5 });

    expect(profile.getRenderData()).toMatchObject({
      left: 200,
      right: 400,
      width: 200,
      profileKind: 'visibleRange',
    });
  });

  it('имеет только POC handle', () => {
    const { profile, setSelected } = createProfile('visibleRange');

    setSelected(true);

    const handles = profile.handles();

    expect(handles).toHaveLength(1);
    expect(handles[0]).toMatchObject({
      id: 'poc',
      x: 0,
      shape: 'circle',
    });
  });

  it('POC handle имеет ew-resize cursor', () => {
    const { profile, setSelected } = createProfile('visibleRange');

    setSelected(true);

    const [handle] = profile.handles();

    expect(profile.hovered(handle.x, handle.y)?.cursorStyle).toBe('ew-resize');
  });

  it('перемещает начало visible profile через POC', () => {
    const { profile, container, setSelected } = createProfile('visibleRange');

    setSelected(true);

    const [handle] = profile.handles();

    profile.pointerDown(createPointerEvent(container, handle.x, handle.y));
    profile.pointerMove(createPointerEvent(container, 300, handle.y));

    expect(profile.getState().visibleRangeStartRatio).toBe(0.75);
  });

  it('обновляет time label после перемещения POC handle', () => {
    const { profile, container, setSelected } = createProfile('visibleRange');

    setSelected(true);

    const [handle] = profile.handles();

    profile.pointerDown(createPointerEvent(container, handle.x, handle.y));
    profile.pointerMove(createPointerEvent(container, 300, handle.y));

    expect(profile.timeLabel('start')).toMatchObject({
      coordinate: 300,
      text: 'date:300',
    });
  });

  it('подписывается и отписывается от visible range', () => {
    const { profile, timeScale } = createProfile('visibleRange');

    expect(timeScale.subscribeVisibleLogicalRangeChange).toHaveBeenCalledTimes(1);

    profile.destroy();

    expect(timeScale.unsubscribeVisibleLogicalRangeChange).toHaveBeenCalledTimes(1);
  });
});

describe('VolumeProfilePaneRenderer', () => {
  it('не рисует без render data', () => {
    const { target, useBitmapCoordinateSpace } = createCanvasTarget();

    new VolumeProfilePaneRenderer({
      getRenderData: () => null,
    } as unknown as VolumeProfile).draw(target);

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

  it('рисует background, rows и POC', () => {
    const data: VolumeProfileRenderData = {
      startPoint: { x: 10, y: 10 },
      endPoint: { x: 100, y: 100 },
      left: 10,
      right: 100,
      top: 10,
      bottom: 100,
      width: 90,
      height: 90,
      profileKind: 'fixedRange',
      rows: [
        {
          top: 10,
          height: 10,
          buyWidth: 30,
          sellWidth: 20,
        },
      ],
      pocY: 50,
      areaFillColor: '#111',
      buyFillColor: '#0a0',
      sellFillColor: '#a00',
      pocLineColor: '#fa0',
    };

    const { target, context } = createCanvasTarget();

    new VolumeProfilePaneRenderer({
      getRenderData: () => data,
    } as unknown as VolumeProfile).draw(target);

    expect(context.fillRect).toHaveBeenCalledTimes(3);
    expect(context.stroke).toHaveBeenCalledTimes(1);
  });

  it('рисует visibleRange справа налево и POC на всю ширину canvas', () => {
    const data: VolumeProfileRenderData = {
      startPoint: { x: 200, y: 10 },
      endPoint: { x: 400, y: 100 },
      left: 200,
      right: 400,
      top: 10,
      bottom: 100,
      width: 200,
      height: 90,
      profileKind: 'visibleRange',
      rows: [{ top: 10, height: 10, buyWidth: 30, sellWidth: 20 }],
      pocY: 50,
      areaFillColor: '#111',
      buyFillColor: '#0a0',
      sellFillColor: '#a00',
      pocLineColor: '#fa0',
    };

    const { target, context } = createCanvasTarget();

    new VolumeProfilePaneRenderer({
      getRenderData: () => data,
    } as unknown as VolumeProfile).draw(target);

    expect(context.moveTo).toHaveBeenCalledWith(0, 100);
    expect(context.lineTo).toHaveBeenCalledWith(800, 100);
  });
});

describe('VolumeProfilePaneView', () => {
  it('возвращает renderer и top', () => {
    const view = new VolumeProfilePaneView({} as VolumeProfile);

    expect(view.renderer()).toBeInstanceOf(VolumeProfilePaneRenderer);
    expect(view.zOrder()).toBe('top');
  });
});

describe('VolumeProfile settings', () => {
  it('создаёт defaults', () => {
    expect(createDefaultSettings()).toEqual({
      areaFillColor: '#11111122',
      buyFillColor: '#00aa00',
      sellFillColor: '#aa0000',
      pocLineColor: '#ffaa00',
    });
  });

  it('имеет один style tab', () => {
    expect(getVolumeProfileSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style']);
  });
});