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


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

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

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

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

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

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

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

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

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

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

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

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

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

    view.renderer().draw(target);

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

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

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

    view.renderer().draw(target);

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

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

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

    view.renderer().draw(target);

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

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

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

    view.renderer().draw(target);

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

    view.renderer().draw(target);

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

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

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

    view.renderer().draw(target);

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

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

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

    view.renderer().draw(target);

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

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

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

    view.renderer().draw(target);

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

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

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

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

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

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

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

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


import { BehaviorSubject } from 'rxjs';

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

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

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

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

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

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

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

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

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

  return container;
}

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

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

  return {
    chart,
    timeScale,
  };
}

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

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

  return {
    series,
    pane,
  };
}

function createInteraction(selected = false, locked = false) {
  let selectedValue = selected;
  let lockedValue = locked;

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

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

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

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

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

function createAxisLine(direction: 'vertical' | 'horizontal', selected = false) {
  const container = createContainer();
  const { chart, timeScale } = createChart();
  const { series } = createSeries();
  const { interaction, setSelected, setLocked } = createInteraction(selected);
  const openSettings = jest.fn();

  const drawing = new TestAxisLine({
    chart,
    series,
    container,
    direction,
    interaction,
    openSettings,
  });

  return {
    drawing,
    chart,
    timeScale,
    series,
    interaction,
    setSelected,
    setLocked,
    openSettings,
  };
}

function createCanvasTarget() {
  const context = {
    save: jest.fn(),
    restore: jest.fn(),
    fillRect: jest.fn(),
    fillText: jest.fn(),
    translate: jest.fn(),
    rotate: jest.fn(),
    fillStyle: '',
    font: '',
    textAlign: '',
    textBaseline: '',
  } as unknown as CanvasRenderingContext2D;

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

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

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

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

      expect(drawing.isCreationPending()).toBe(true);
      expect(drawing.shouldShowInObjectTree()).toBe(false);
      expect(drawing.getState()).toMatchObject({
        mode: 'idle',
        time: null,
        price: null,
      });
    });

    it('должен создавать вертикальную линию по координате времени', () => {
      const { drawing, timeScale } = createAxisLine('vertical');

      drawing.handlePointerDownForTest(createPointerEvent(80, 120));

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

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

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

    it('должен создавать горизонтальную линию по координате цены', () => {
      const { drawing, series } = createAxisLine('horizontal');

      drawing.handlePointerDownForTest(createPointerEvent(80, 120));

      expect(series.coordinateToPrice).toHaveBeenCalledWith(120);

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

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

      drawing.handlePointerDownForTest(createPointerEvent(80, 120, 1, 2));

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

  describe('state', () => {
    it('должен восстанавливать состояние', () => {
      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('должен сохранять дефолтные настройки при частичном setState', () => {
      const { drawing } = createAxisLine('vertical');

      drawing.setState({
        settings: {
          text: 'Test',
        },
      });

      expect(drawing.getSettings()).toMatchObject({
        lineColor: '#3366ff',
        text: 'Test',
        fontSize: 14,
        isBold: false,
        isItalic: false,
        textColor: '#3366ff',
      });
    });

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

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

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

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

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

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

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

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

    it('должен возвращать render data горизонтальной линии', () => {
      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',
      });
    });
  });

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

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

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

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

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

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

    it('не должен возвращать handle без координаты', () => {
      const { drawing } = createAxisLine('vertical');

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

    it('должен определять hover по handle через реальную инфраструктуру Base', () => {
      const { drawing } = createAxisLine('vertical', true);

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

      expect(drawing.getHovered(80, 150)).toEqual({
        cursorStyle: 'ew-resize',
        externalId: 'axis-line',
        zOrder: 'top',
      });
    });
  });

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

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

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

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

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

      expect(drawing.timeAxisPaneViews()).toEqual([]);
      expect(drawing.priceAxisPaneViews()).toEqual([]);
    });
  });

  describe('axis labels', () => {
    it('должен возвращать time label для выбранной вертикальной линии', () => {
      const { drawing } = createAxisLine('vertical', true);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  describe('hover', () => {
    it('должен возвращать ew-resize для вертикальной линии', () => {
      const { drawing } = createAxisLine('vertical');

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

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

    it('должен возвращать ns-resize для горизонтальной линии', () => {
      const { drawing } = createAxisLine('horizontal');

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

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

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

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

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

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

      expect(drawing.getHovered(50, 20)).toBeNull();
    });

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

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

      expect(drawing.getHovered(50, 20)).toBeNull();
    });
  });

  describe('interaction', () => {
    it('должен выбирать невыбранную линию при нажатии на неё', () => {
      const { drawing, interaction } = createAxisLine('vertical');

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

      const event = createPointerEvent(50, 20);

      drawing.handlePointerDownForTest(event);

      expect(interaction.select).toHaveBeenCalledTimes(1);
      expect(event.preventDefault).toHaveBeenCalled();
      expect(event.stopPropagation).toHaveBeenCalled();

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

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

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

      drawing.handlePointerDownForTest(createPointerEvent(100, 20));

      expect(interaction.select).not.toHaveBeenCalled();
    });

    it('должен переходить в dragging при нажатии на выбранную линию', () => {
      const { drawing } = createAxisLine('vertical', true);

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

      drawing.handlePointerDownForTest(createPointerEvent(50, 20));

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

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

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

      drawing.handlePointerDownForTest(createPointerEvent(50, 20, 1));
      drawing.handlePointerMoveForTest(createPointerEvent(90, 20, 1));

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

      drawing.handlePointerUpForTest(createPointerEvent(90, 20, 1));

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

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

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

      drawing.handlePointerDownForTest(createPointerEvent(20, 50, 1));
      drawing.handlePointerMoveForTest(createPointerEvent(20, 90, 1));

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

      drawing.handlePointerUpForTest(createPointerEvent(20, 90, 1));

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

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

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

      drawing.handlePointerDownForTest(createPointerEvent(50, 20, 1));
      drawing.handlePointerMoveForTest(createPointerEvent(90, 20, 2));

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

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

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

      drawing.handlePointerDownForTest(createPointerEvent(50, 20, 1));
      drawing.handlePointerUpForTest(createPointerEvent(50, 20, 2));

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

    it('должен снимать выделение при нажатии вне выбранной линии', () => {
      const { drawing, interaction } = createAxisLine('vertical', true);

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

      drawing.handlePointerDownForTest(createPointerEvent(100, 20));

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

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

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

      const event = createMouseEvent(50, 20);

      drawing.handleDoubleClickForTest(event);

      expect(openSettings).toHaveBeenCalledTimes(1);
      expect(event.preventDefault).toHaveBeenCalled();
      expect(event.stopPropagation).toHaveBeenCalled();
    });

    it('должен открывать настройки при двойном клике по handle', () => {
      const { drawing, openSettings } = createAxisLine('vertical', true);

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

      drawing.handleDoubleClickForTest(createMouseEvent(50, 150));

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

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

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

      drawing.handleDoubleClickForTest(createMouseEvent(100, 20));

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

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

      drawing.handleDoubleClickForTest(createMouseEvent(50, 20));

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

  describe('settings', () => {
    it('должен обновлять настройки через SeriesDrawingBase', () => {
      const { drawing } = createAxisLine('vertical');

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

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

    it('должен уведомлять подписчика об изменении настроек', () => {
      const { drawing } = createAxisLine('vertical');
      const callback = jest.fn();

      const subscription = drawing.subscribeSettings(callback);

      expect(callback).toHaveBeenCalledWith(
        expect.objectContaining({
          lineColor: '#3366ff',
          fontSize: 14,
        }),
      );

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

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

      subscription.unsubscribe();
    });
  });
});

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

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

    renderer.draw(target);

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

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

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

    renderer.draw(target);

    expect(context.fillStyle).toBe('#ff0000');
    expect(context.fillRect).toHaveBeenCalledWith(38, 0, 4, 200);
  });

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

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

    renderer.draw(target);

    expect(context.fillStyle).toBe('#ff0000');
    expect(context.fillRect).toHaveBeenCalledWith(0, 58, 300, 4);
  });

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

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

    renderer.draw(target);

    expect(context.fillText).not.toHaveBeenCalled();
  });

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

    const renderer = new AxisLinePaneRenderer({
      getRenderData: () => ({
        direction: 'vertical',
        coordinate: 20,
        lineColor: '#ff0000',
        text: 'Test',
        fontSize: 14,
        isBold: true,
        isItalic: true,
        textColor: '#ffffff',
      }),
    } 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));
  });

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

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

    renderer.draw(target);

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

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

    const view = new AxisLinePaneView(axisLine);

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

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

    const view = new AxisLinePaneView(axisLine);

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

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

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

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

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

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

    expect(styleTab?.fields).toEqual([
      expect.objectContaining({
        key: 'lineColor',
        label: 'Line',
        type: 'color',
        defaultValue: '#ff0000',
      }),
    ]);
  });

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

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

    expect(textTab?.fields).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          key: 'fontSize',
          defaultValue: 18,
          min: 8,
          max: 24,
        }),
        expect.objectContaining({
          key: 'text',
          defaultValue: 'Test',
        }),
        expect.objectContaining({
          key: 'isBold',
          defaultValue: true,
        }),
        expect.objectContaining({
          key: 'isItalic',
          defaultValue: true,
        }),
        expect.objectContaining({
          key: 'textColor',
          defaultValue: '#00ff00',
        }),
      ]),
    );
  });
});