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


import { BehaviorSubject } from 'rxjs';

import {
  FibonacciRetracement,
  FibonacciRetracementRenderData,
} 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 type { DrawingHandle } from '@core/Drawings/handles';
import type { DrawingInteraction } from '@core/Drawings/SeriesDrawingBase';
import type { AxisLabel, AxisSegment, SeriesApi } from '@core/Drawings/types';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type { Coordinate, IChartApi, Logical, 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 getHandles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

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

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

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

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

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

function createEnvironment(selected = false) {
  let selectedValue = selected;
  let lockedValue = false;

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

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

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

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

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

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

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

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

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

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

  const drawing = new TestFibonacciRetracement({
    chart: env.chart,
    series: env.series,
    container: env.container,
    interaction: env.interaction,
    removeSelf,
    openSettings,
  });

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

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

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

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

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

function createRenderData(): FibonacciRetracementRenderData {
  return {
    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,
  };
}

function createCanvasTarget() {
  const context = {
    save: jest.fn(),
    restore: jest.fn(),
    beginPath: jest.fn(),
    moveTo: jest.fn(),
    lineTo: jest.fn(),
    stroke: jest.fn(),
    fillRect: jest.fn(),
    fillText: jest.fn(),
    setLineDash: jest.fn(),
    fillStyle: '',
    strokeStyle: '',
    globalAlpha: 1,
    lineWidth: 0,
    font: '',
    textAlign: '',
    textBaseline: '',
  };

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

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

describe('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({
      hidden: false,
      mode: 'idle',
      startTime: null,
      endTime: null,
      startPrice: null,
      endPrice: null,
    });
  });

  it('должен создавать retracement двумя нажатиями', () => {
    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('должен возвращать start и end handles', () => {
    const { drawing } = createDrawing();

    setReady(drawing);

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

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

    setReady(drawing);

    const data = drawing.getRenderData();

    expect(data?.levels).toHaveLength(11);
    expect(data?.levels.find((level) => level.id === '0')?.price).toBe(150);
    expect(data?.levels.find((level) => level.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((level) => level.id === '0')?.price).toBe(50);
    expect(data?.levels.find((level) => level.id === '1')?.price).toBe(150);
  });

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

    setReady(drawing);

    const data = drawing.getRenderData();

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

    expect(drawing.getPriceSegments()).toEqual([
      {
        from: data?.top,
        to: data?.bottom,
        color: '#2962ff44',
      },
    ]);
  });

  it('не должен показывать axis segments у невыбранного ready retracement', () => {
    const { drawing } = createDrawing();

    setReady(drawing);

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

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

    setReady(drawing);

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

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

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

    setReady(drawing);

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

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

    setReady(drawing);

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

  it('должен выбирать retracement при нажатии на него', () => {
    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, 1));
    drawing.move(createPointerEvent(container, 70, 80, 1));
    drawing.up(createPointerEvent(container, 70, 80, 1));

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

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

    setReady(drawing);

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

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

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

    setReady(drawing);

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

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

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

    setReady(drawing);
    drawing.hide();

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

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

    renderer.draw(target);

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

  it('должен рисовать уровни, background и control line', () => {
    const { context, target } = createCanvasTarget();
    const renderer = new FibonacciRetracementPaneRenderer(createRendererDrawing(createRenderData()));

    renderer.draw(target);

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

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

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

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

    first.levels[0].visible = false;

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

  it('должен глубоко клонировать settings', () => {
    const settings = createDefaultSettings();
    const clone = cloneFibonacciRetracementSettings(settings);

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

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

  it('должен объединять обычные и level settings', () => {
    const settings = createDefaultSettings();

    const result = mergeFibonacciRetracementSettings(settings, {
      reverse: true,
      level_0_236_visible: false,
      level_0_236_color: '#123456',
    });

    const level = result.levels.find((item) => item.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('должен преобразовывать settings в SettingsValues', () => {
    const values = getFibonacciRetracementSettingsValues(createDefaultSettings());

    expect(values).toMatchObject({
      showBackground: true,
      backgroundOpacity: 18,
      reverse: false,
      level_0_visible: true,
      level_0_color: '#787B86',
    });
  });

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



import { BehaviorSubject } from 'rxjs';

import { LineDrawing, LineDrawingRenderData } 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 type { DrawingHandle } from '@core/Drawings/handles';
import type { DrawingInteraction } from '@core/Drawings/SeriesDrawingBase';
import type { AxisLabel, AxisSegment, SeriesApi } from '@core/Drawings/types';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type { Coordinate, IChartApi, Logical, 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 getHandles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

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

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

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

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

function createEnvironment(selected = false) {
  let selectedValue = selected;

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

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

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

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

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

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

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

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

  return { chart, series, container, interaction };
}

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

  const drawing = new TestLineDrawing({
    chart: env.chart,
    series: env.series,
    container: env.container,
    interaction: env.interaction,
    removeSelf,
    openSettings,
  });

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

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

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

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

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

function renderData(): LineDrawingRenderData {
  return {
    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',
  };
}

function canvasTarget() {
  const context = {
    save: jest.fn(),
    restore: jest.fn(),
    beginPath: jest.fn(),
    moveTo: jest.fn(),
    lineTo: jest.fn(),
    stroke: jest.fn(),
    translate: jest.fn(),
    rotate: jest.fn(),
    fillText: jest.fn(),
    lineWidth: 0,
    strokeStyle: '',
    fillStyle: '',
    font: '',
    textAlign: '',
    textBaseline: '',
  };

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

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

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

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

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

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

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

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

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

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

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

    setReady(drawing);

    expect(drawing.getHandles()).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('должен возвращать axis segments для выбранной линии', () => {
    const { drawing } = createDrawing(true);

    setReady(drawing);

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

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

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

    setReady(drawing);

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

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

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

    setReady(drawing);

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

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

    setReady(drawing);

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

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

  it('должен двигать start handle', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

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

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

  it('должен двигать end handle', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

    drawing.pointerDown(pointer(container, 150, 150));
    drawing.move(pointer(container, 170, 180));
    drawing.up(pointer(container, 170, 180));

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

  it('должен двигать всю линию', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

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

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

    drawing.up(pointer(container, 120, 130));

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

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

    setReady(drawing);

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

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

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

    setReady(drawing);
    drawing.hide();

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

describe('LineDrawingPaneRenderer', () => {
  it('не должен рисовать без данных', () => {
    const { target, useBitmapCoordinateSpace } = canvasTarget();
    const renderer = new LineDrawingPaneRenderer(rendererDrawing(null));

    renderer.draw(target);

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

  it('должен рисовать line, markers и text', () => {
    const { context, target } = canvasTarget();
    const renderer = new LineDrawingPaneRenderer(rendererDrawing(renderData()));

    renderer.draw(target);

    expect(context.stroke).toHaveBeenCalled();
    expect(context.fillText).toHaveBeenCalledWith('Test', 0, expect.any(Number));
    expect(context.translate).toHaveBeenCalled();
    expect(context.rotate).toHaveBeenCalled();
  });
});

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

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

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

  it('должен принимать default markers', () => {
    const settings = createDefaultSettings({
      startMarker: LineMarker.arrow,
    });

    expect(settings.startMarker).toBe(LineMarker.arrow);
    expect(settings.endMarker).toBe(LineMarker.normal);
  });

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




import { BehaviorSubject } from 'rxjs';

import { ParallelChannel, ParallelChannelRenderData } 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 type { DrawingHandle } from '@core/Drawings/handles';
import type { DrawingInteraction } from '@core/Drawings/SeriesDrawingBase';
import type { AxisLabel, AxisSegment, SeriesApi } from '@core/Drawings/types';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type { Coordinate, IChartApi, Logical, 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 getHandles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

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

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

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

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

function createEnvironment(selected = false) {
  let selectedValue = selected;

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

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

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

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

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

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

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

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

  return { chart, series, container, interaction };
}

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

  const drawing = new TestParallelChannel({
    chart: env.chart,
    series: env.series,
    container: env.container,
    interaction: env.interaction,
    openSettings,
  });

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

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

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

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

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

function renderData(): ParallelChannelRenderData {
  return {
    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',
  };
}

function canvasTarget() {
  const context = {
    save: jest.fn(),
    restore: jest.fn(),
    beginPath: jest.fn(),
    moveTo: jest.fn(),
    lineTo: jest.fn(),
    closePath: jest.fn(),
    fill: jest.fn(),
    stroke: jest.fn(),
    setLineDash: jest.fn(),
    translate: jest.fn(),
    rotate: jest.fn(),
    fillText: jest.fn(),
    measureText: jest.fn((text: string) => ({ width: text.length * 5 })),
    fillStyle: '',
    strokeStyle: '',
    lineWidth: 0,
    font: '',
    textAlign: '',
    textBaseline: '',
  };

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

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

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

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

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

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

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

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

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

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

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

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

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

    setReady(drawing);

    expect(drawing.getHandles()).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('должен возвращать axis segments', () => {
    const { drawing } = createDrawing(true);

    setReady(drawing);

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

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

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

    setReady(drawing);

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

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

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

    setReady(drawing);

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

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

    setReady(drawing);

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

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

    setReady(drawing);

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

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

    setReady(drawing);

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

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

    setReady(drawing);

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

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

  it('должен двигать main-start handle', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

    drawing.pointerDown(pointer(container, 50, 50));
    drawing.move(pointer(container, 60, 60));
    drawing.up(pointer(container, 60, 60));

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

  it('должен двигать parallel-middle handle', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

    drawing.pointerDown(pointer(container, 100, 125));
    drawing.move(pointer(container, 100, 145));

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

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

    setReady(drawing);

    drawing.pointerDown(pointer(container, 100, 90));
    drawing.move(pointer(container, 120, 100));

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

  it('должен открывать настройки только у выбранного channel', () => {
    const { drawing, container, openSettings } = createDrawing(true);

    setReady(drawing);

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

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

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

    setReady(drawing);
    drawing.hide();

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

describe('ParallelChannelPaneRenderer', () => {
  it('не должен рисовать без данных', () => {
    const { target, useBitmapCoordinateSpace } = canvasTarget();
    const renderer = new ParallelChannelPaneRenderer(rendererDrawing(null));

    renderer.draw(target);

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

  it('должен рисовать channel, middle line и text', () => {
    const { context, target } = canvasTarget();
    const renderer = new ParallelChannelPaneRenderer(rendererDrawing(renderData()));

    renderer.draw(target);

    expect(context.fill).toHaveBeenCalled();
    expect(context.stroke).toHaveBeenCalled();
    expect(context.setLineDash).toHaveBeenCalled();
    expect(context.fillText).toHaveBeenCalledWith('Channel', 0, expect.any(Number));
  });
});

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

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

describe('ParallelChannel settings', () => {
  it('должен создавать настройки по умолчанию', () => {
    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((tab) => tab.key)).toEqual(['style', 'text']);
  });
});




import { BehaviorSubject } from 'rxjs';

import { Ray, RayRenderData } 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 type { DrawingHandle } from '@core/Drawings/handles';
import type { DrawingInteraction } from '@core/Drawings/SeriesDrawingBase';
import type { AxisLabel, AxisSegment, SeriesApi } from '@core/Drawings/types';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type { Coordinate, IChartApi, Logical, 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 getHandles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

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

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

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

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

function createEnvironment(selected = false) {
  let selectedValue = selected;

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

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

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

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

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

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

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

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

  return { chart, series, container, interaction };
}

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

  const drawing = new TestRay({
    chart: env.chart,
    series: env.series,
    container: env.container,
    interaction: env.interaction,
    removeSelf,
    openSettings,
  });

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

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

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

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

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

function renderData(): RayRenderData {
  return {
    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',
  };
}

function canvasTarget() {
  const context = {
    save: jest.fn(),
    restore: jest.fn(),
    beginPath: jest.fn(),
    moveTo: jest.fn(),
    lineTo: jest.fn(),
    stroke: jest.fn(),
    translate: jest.fn(),
    rotate: jest.fn(),
    fillText: jest.fn(),
    lineWidth: 0,
    strokeStyle: '',
    fillStyle: '',
    font: '',
    textAlign: '',
    textBaseline: '',
  };

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

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

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

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

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

    drawing.pointerDown(pointer(container, 50, 50));
    drawing.pointerDown(pointer(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(pointer(container, 50, 50));
    drawing.pointerDown(pointer(container, 52, 52));

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

  it('должен продолжать ray до границы контейнера', () => {
    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('должен возвращать handles только для start и direction', () => {
    const { drawing } = createDrawing();

    setReady(drawing);

    expect(drawing.getHandles()).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('должен возвращать axis segments по управляющим точкам', () => {
    const { drawing } = createDrawing(true);

    setReady(drawing);

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

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

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

    setReady(drawing);

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

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

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

    setReady(drawing);

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

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

    setReady(drawing);

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

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

  it('должен двигать start handle', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

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

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

  it('должен двигать direction handle', () => {
    const { drawing, container } = createDrawing(true);

    setReady(drawing);

    drawing.pointerDown(pointer(container, 150, 100));
    drawing.move(pointer(container, 180, 130));
    drawing.up(pointer(container, 180, 130));

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

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

    setReady(drawing);

    drawing.pointerDown(pointer(container, 200, 125));
    drawing.move(pointer(container, 220, 155));

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

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

    setReady(drawing);

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

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

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

    setReady(drawing);
    drawing.hide();

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

describe('RayPaneRenderer', () => {
  it('не должен рисовать без данных', () => {
    const { target, useBitmapCoordinateSpace } = canvasTarget();
    const renderer = new RayPaneRenderer(rendererDrawing(null));

    renderer.draw(target);

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

  it('должен рисовать ray и text', () => {
    const { context, target } = canvasTarget();
    const renderer = new RayPaneRenderer(rendererDrawing(renderData()));

    renderer.draw(target);

    expect(context.moveTo).toHaveBeenCalledWith(20, 40);
    expect(context.lineTo).toHaveBeenCalledWith(200, 130);
    expect(context.fillText).toHaveBeenCalledWith('Ray', 0, expect.any(Number));
    expect(context.translate).toHaveBeenCalled();
    expect(context.rotate).toHaveBeenCalled();
  });
});

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

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

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

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