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


import { BehaviorSubject } from 'rxjs';

import type { BaseDrawingParams } from '@core/Drawings/SeriesDrawingBase';
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;
}

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 timeScale = {
    coordinateToTime: jest.fn((coordinate: Coordinate) => Number(coordinate) as Time),
    timeToCoordinate: jest.fn((time: Time) => Number(time) as Coordinate),
    coordinateToLogical: jest.fn((coordinate: Coordinate) => Number(coordinate) as Logical),
    logicalToCoordinate: jest.fn((logical: Logical) => Number(logical) as Coordinate),
    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 series = {
    coordinateToPrice: jest.fn((coordinate: Coordinate) => Number(coordinate)),
    priceToCoordinate: jest.fn((price: number) => price as Coordinate),
    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,
    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() {
  const context = {
    canvas: { width: 800, height: 600 },
    save: jest.fn(),
    restore: jest.fn(),
    beginPath: jest.fn(),
    closePath: jest.fn(),
    moveTo: jest.fn(),
    lineTo: 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: 2,
        verticalPixelRatio: 2,
        bitmapSize: { width: 800, height: 600 },
      }),
  );

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


import { Rectangle, type RectangleRenderData } from '@core/Drawings/rectangle/rectangle';
import { RectanglePaneRenderer } from '@core/Drawings/rectangle/paneRenderer';
import { RectanglePaneView } from '@core/Drawings/rectangle/paneView';
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/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 {
  RegressionTrend,
  type RegressionTrendRenderData,
} from '@core/Drawings/regressionTrend/regressionTrend';
import { RegressionTrendPaneRenderer } from '@core/Drawings/regressionTrend/paneRenderer';
import { RegressionTrendPaneView } from '@core/Drawings/regressionTrend/paneView';
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/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',
        borderWidth: 2,
      },
      {
        id: 'end',
        x: 40,
        y: 27,
        shape: 'circle',
        borderWidth: 2,
      },
    ]);
  });

  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 { Ruler, type RulerRenderData } from '@core/Drawings/ruler/ruler';
import { RulerPaneRenderer } from '@core/Drawings/ruler/paneRenderer';
import { RulerPaneView } from '@core/Drawings/ruler/paneView';

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

    new TestRuler({
      ...env,
      removeSelf,
      resetTriggers: [reset$],
    });

    expect(removeSelf).not.toHaveBeenCalled();

    reset$.next(true);

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

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 { SliderPosition, type SliderRenderData } from '@core/Drawings/sliderPosition/sliderPosition';
import { SliderPaneRenderer } from '@core/Drawings/sliderPosition/paneRenderer';
import { SliderPaneView } from '@core/Drawings/sliderPosition/paneView';
import { createDefaultSettings, getSliderPositionSettingsTabs } from '@core/Drawings/sliderPosition/settings';

import { createCanvasTarget, createDrawingTestEnvironment, 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: {
      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/utils', () => ({
  formatPrice: (value: number) => String(value),
  formatPercent: (value: number) => `${value}%`,
  formatSignedNumber: (value: number) => String(value),
}));

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

describe('SliderPosition', () => {
  it('считает Risk/Reward Ratio', () => {
    const { slider } = createSlider();

    setReady(slider);

    expect(slider.getState().riskRewardRatio).toBe(2);
  });

  it('поддерживает RR = 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,
    });
  });

  it('возвращает RR = 0 когда Target совпадает с Entry', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 100,
    });

    expect(slider.getState().riskRewardRatio).toBe(0);
  });

  it('возвращает RR = 0 когда Stop совпадает с Entry', () => {
    const { slider } = createSlider();

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 100,
      targetPrice: 120,
    });

    expect(slider.getState().riskRewardRatio).toBe(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 handle имеет приоритет над Target при совпадении', () => {
    const { slider, container } = createSlider('long', true);

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 100,
    });

    slider.pointerDown(createPointerEvent(container, 10, 100));
    slider.pointerMove(createPointerEvent(container, 10, 95));

    expect(slider.getState()).toMatchObject({
      entryPrice: 95,
      targetPrice: 100,
    });
  });

  it('Entry handle имеет приоритет над Stop при совпадении', () => {
    const { slider, container } = createSlider('long', true);

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 100,
      targetPrice: 120,
    });

    slider.pointerDown(createPointerEvent(container, 10, 100));
    slider.pointerMove(createPointerEvent(container, 10, 110));

    expect(slider.getState()).toMatchObject({
      entryPrice: 110,
      stopPrice: 100,
    });
  });

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

  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 позиции', () => {
    const { slider } = createSlider('short');

    slider.setState({
      mode: 'ready',
      startTime: 10 as Time,
      endTime: 100 as Time,
      entryPrice: 100,
      stopPrice: 110,
      targetPrice: 80,
    });

    expect(slider.getState().riskRewardRatio).toBe(2);
    expect(slider.getRenderData()).toMatchObject({
      targetLabelDirection: 'down',
      stopLabelDirection: 'up',
    });
  });

  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('использует normal zOrder для selected axis pane areas', () => {
    const { slider } = createSlider();

    expect(slider.timeAxisPaneViews()[0].zOrder?.()).toBe('normal');
    expect(slider.priceAxisPaneViews()[0].zOrder?.()).toBe('normal');
  });

  it('time labels показываются только selected drawing', () => {
    const { slider } = createSlider();

    setReady(slider);

    expect(slider.timeLabel('start')).toBeNull();
  });

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

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: '#0a0',
      targetLabelDirection: 'up',
      stopLabelDirection: 'down',
      showLabels: true,
      lineColor: '#fff',
      positiveFillColor: '#0f03',
      negativeFillColor: '#f003',
      textColor: '#fff',
      fontSize: 10,
      isBold: false,
      isItalic: false,
    };

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

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', () => {
    expect(createDefaultSettings()).toEqual({
      lineColor: '#999999',
      positiveFillColor: '#00aa0044',
      negativeFillColor: '#aa000044',
      textColor: '#ffffff',
      fontSize: 10,
      isBold: false,
      isItalic: false,
    });
  });

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




import { Text, type TextRenderData } from '@core/Drawings/text/text';
import { TextPaneRenderer } from '@core/Drawings/text/paneRenderer';
import { TextPaneView } from '@core/Drawings/text/paneView';
import { createDefaultSettings, getTextSettingsTabs } from '@core/Drawings/text/settings';

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/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 { Traectory } from '@core/Drawings/traectory/traectory';
import { TraectoryPaneRenderer } from '@core/Drawings/traectory/paneRenderer';
import { TraectoryPaneView } from '@core/Drawings/traectory/paneView';
import { createDefaultSettings, getTraectorySettingsTabs } from '@core/Drawings/traectory/settings';

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', size: 10, borderWidth: 2 },
      { id: '1', x: 50, y: 60, shape: 'circle', size: 10, borderWidth: 2 },
      { id: '0', x: 10, y: 20, shape: 'circle', size: 10, borderWidth: 2 },
    ]);
  });

  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 {
  VolumeProfile,
  type VolumeProfileRenderData,
} from '@core/Drawings/volumeProfile/volumeProfile';
import { VolumeProfilePaneRenderer } from '@core/Drawings/volumeProfile/paneRenderer';
import { VolumeProfilePaneView } from '@core/Drawings/volumeProfile/paneView';
import {
  createDefaultSettings,
  getVolumeProfileSettingsTabs,
} from '@core/Drawings/volumeProfile/settings';

import { createCanvasTarget, createDrawingTestEnvironment, 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: {
      volumeProfileAreaFill: '#11111122',
      volumeProfileBuyFill: '#00aa00',
      volumeProfileSellFill: '#aa0000',
      volumeProfilePocLine: '#ffaa00',
      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
      chartPriceLineText: '#ffffff',
    },
  }),
}));

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 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', size: 10, borderWidth: 2 },
      { id: 'start', x: 10, y: 9, shape: 'circle', size: 10, borderWidth: 2 },
    ]);
  });

  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('отображается в Object Tree', () => {
    const { profile } = createProfile('visibleRange');

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

  it('не имеет axis views', () => {
    const { profile } = createProfile('visibleRange');

    expect(profile.timeAxisPaneViews()).toEqual([]);
    expect(profile.priceAxisPaneViews()).toEqual([]);
    expect(profile.timeAxisViews()).toEqual([]);
    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',
      size: 10,
      borderWidth: 2,
    });
  });

  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('подписывается и отписывается от 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']);
  });
});