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


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

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 { 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('не должен возвращать label для unselected line', () => {
    const { drawing } = createAxisLine('vertical');

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

    expect(drawing.timeLabel('main')).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 { 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',
        size: 12,
        borderWidth: 2,
      },
      {
        id: 'start',
        x: 50,
        y: 50,
        shape: 'circle',
        size: 12,
        borderWidth: 2,
      },
    ]);
  });

  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 { LineDrawingRenderData } from '@core/Drawings/line/lineDrawing';
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',
      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',
        borderWidth: 2,
      },
      {
        id: 'end',
        x: 150,
        y: 150,
        shape: 'circle',
        borderWidth: 2,
      },
    ]);
  });

  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 { ParallelChannel } from '@core/Drawings/parallelChannel/parallelChannel';
import { ParallelChannelPaneRenderer } from '@core/Drawings/parallelChannel/paneRenderer';
import { ParallelChannelPaneView } from '@core/Drawings/parallelChannel/paneView';
import {
  createDefaultSettings,
  getParallelChannelSettingsTabs,
} from '@core/Drawings/parallelChannel/settings';

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

import type { ParallelChannelRenderData } from '@core/Drawings/parallelChannel/parallelChannel';
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',
      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',
        borderWidth: 2,
      },
      {
        id: 'parallel-middle',
        x: 100,
        y: 125,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'parallel-start',
        x: 50,
        y: 110,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'main-end',
        x: 150,
        y: 80,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'main-middle',
        x: 100,
        y: 65,
        shape: 'circle',
        borderWidth: 2,
      },
      {
        id: 'main-start',
        x: 50,
        y: 50,
        shape: 'circle',
        borderWidth: 2,
      },
    ]);
  });

  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 { Ray } from '@core/Drawings/ray/ray';
import { RayPaneRenderer } from '@core/Drawings/ray/paneRenderer';
import { RayPaneView } from '@core/Drawings/ray/paneView';
import { createDefaultSettings, getRaySettingTabs } from '@core/Drawings/ray/settings';

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

import type { RayRenderData } from '@core/Drawings/ray/ray';
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',
      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',
        borderWidth: 2,
        strokeColor: '#2962ff',
      },
      {
        id: 'direction',
        x: 150,
        y: 100,
        shape: 'circle',
        borderWidth: 2,
        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']);
  });
});