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


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

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

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartBackground: '#101010',
      chartLineColor: '#2962ff',
    },
  }),
}));

function createEnvironment(paneIndex = 0, paneCount = 1, paneHeight = 100) {
  const pane = {
    paneIndex: jest.fn(() => paneIndex),
    getHeight: jest.fn(() => paneHeight),
  };

  const panes = Array.from({ length: paneCount }, (_, index) => {
    return index === paneIndex ? pane : {};
  });

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

  const series = {
    getPane: jest.fn(() => pane),
  } as unknown as SeriesApi;

  return { chart, series, pane };
}

function attach<TId extends string>(
  primitive: DrawingHandlesPrimitive<TId>,
  chart: IChartApi,
  series: SeriesApi,
) {
  primitive.attached({
    chart,
    series,
    requestUpdate: jest.fn(),
  } as unknown as SeriesAttachedParameter<Time>);
}

function createTarget(horizontalPixelRatio = 2, verticalPixelRatio = 2) {
  const context = {
    save: jest.fn(),
    restore: jest.fn(),
    beginPath: jest.fn(),
    arc: jest.fn(),
    moveTo: jest.fn(),
    arcTo: jest.fn(),
    closePath: jest.fn(),
    fill: jest.fn(),
    stroke: jest.fn(),
    fillStyle: '',
    strokeStyle: '',
    lineWidth: 0,
  };

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

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

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

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

  return view;
}

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

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

  return renderer;
}

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

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

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

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

  it('должен находить handle в зоне hit tolerance', () => {
    const handle: DrawingHandle<'main'> = {
      id: 'main',
      x: 100,
      y: 50,
    };

    const primitive = new DrawingHandlesPrimitive(() => [handle]);

    expect(primitive.findHandle({ x: 108, y: 58 })).toBe(handle);
  });

  it('не должен находить handle за пределами hit tolerance', () => {
    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 100,
        y: 50,
      },
    ]);

    expect(primitive.findHandle({ x: 109, y: 50 })).toBeNull();
  });

  it('должен учитывать размер handle при вычислении hit area', () => {
    const handle: DrawingHandle<'main'> = {
      id: 'main',
      x: 100,
      y: 50,
      size: 24,
    };

    const primitive = new DrawingHandlesPrimitive(() => [handle]);

    expect(primitive.findHandle({ x: 112, y: 50 })).toBe(handle);
    expect(primitive.findHandle({ x: 113, y: 50 })).toBeNull();
  });

  it('должен отдавать приоритет последнему handle при пересечении', () => {
    const handles: DrawingHandle<'first' | 'second'>[] = [
      {
        id: 'first',
        x: 100,
        y: 50,
      },
      {
        id: 'second',
        x: 100,
        y: 50,
      },
    ];

    const primitive = new DrawingHandlesPrimitive(() => handles);

    expect(primitive.findHandle({ x: 100, y: 50 })?.id).toBe('second');
  });

  it('должен определять handle, пересекающий time axis', () => {
    const { chart, series } = createEnvironment(0, 1, 100);

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 50,
        y: 100,
      },
    ]);

    attach(primitive, chart, series);

    expect(primitive.isTimeAxisHit({ x: 50, y: 104 })).toBe(true);
  });

  it('не должен считать точку внутри pane попаданием по time axis', () => {
    const { chart, series } = createEnvironment(0, 1, 100);

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 50,
        y: 100,
      },
    ]);

    attach(primitive, chart, series);

    expect(primitive.isTimeAxisHit({ x: 50, y: 99 })).toBe(false);
  });

  it('не должен работать с time axis для pane, который не является последним', () => {
    const { chart, series } = createEnvironment(0, 2, 100);

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 50,
        y: 100,
      },
    ]);

    attach(primitive, chart, series);

    expect(primitive.isTimeAxisHit({ x: 50, y: 104 })).toBe(false);
  });

  it('не должен определять time axis hit для handle выше границы pane', () => {
    const { chart, series } = createEnvironment(0, 1, 100);

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 50,
        y: 80,
      },
    ]);

    attach(primitive, chart, series);

    expect(primitive.isTimeAxisHit({ x: 50, y: 101 })).toBe(false);
  });

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

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 100,
        y: 50,
      },
    ]);

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

    expect(context.arc).toHaveBeenCalledWith(200, 100, 10, 0, Math.PI * 2);
    expect(context.fill).toHaveBeenCalledTimes(1);
    expect(context.stroke).toHaveBeenCalledTimes(1);
    expect(context.fillStyle).toBe('#101010');
    expect(context.strokeStyle).toBe('#2962ff');
    expect(context.lineWidth).toBe(4);
  });

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

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 100,
        y: 50,
        shape: 'rounded',
      },
    ]);

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

    expect(context.arc).not.toHaveBeenCalled();
    expect(context.arcTo).toHaveBeenCalled();
    expect(context.closePath).toHaveBeenCalled();
    expect(context.fill).toHaveBeenCalledTimes(1);
    expect(context.stroke).toHaveBeenCalledTimes(1);
  });

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

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 100,
        y: 50,
        strokeColor: '#ff0000',
        borderWidth: 3,
      },
    ]);

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

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

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

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

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

  it('должен рисовать пересекающую границу часть handle на time axis', () => {
    const { chart, series } = createEnvironment(0, 1, 100);
    const { context, target } = createTarget();

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 50,
        y: 100,
      },
    ]);

    attach(primitive, chart, series);

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

    expect(context.arc).toHaveBeenCalled();
  });

  it('не должен рисовать handle на time axis для непоследнего pane', () => {
    const { chart, series } = createEnvironment(0, 2, 100);
    const { target, useBitmapCoordinateSpace } = createTarget();

    const primitive = new DrawingHandlesPrimitive(() => [
      {
        id: 'main',
        x: 50,
        y: 100,
      },
    ]);

    attach(primitive, chart, series);

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

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

  it('должен очищать chart и series после detached', () => {
    const { chart, series } = createEnvironment();
    const primitive = new DrawingHandlesPrimitive(() => []);

    attach(primitive, chart, series);
    primitive.detached();

    expect(primitive.isTimeAxisHit({ x: 50, y: 101 })).toBe(false);
  });
});




import {
  clampPointToContainer,
  getAnchorFromPoint,
  getContainerSize,
  getPointerPoint,
  getPriceDelta,
  getPriceFromYCoordinate,
  getPriceRangeInContainer,
  getRawPointerPoint,
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
  isPointInBounds,
  normalizeBounds,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';

import type { SeriesApi } from '@core/Drawings/types';
import type { Coordinate, IChartApi, Logical, Time } from 'lightweight-charts';

function createContainer(width = 400, height = 300, left = 10, top = 20) {
  const container = document.createElement('div');

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

  return container;
}

function createChart() {
  const coordinateToTime = jest.fn((coordinate: Coordinate): Time | null => Number(coordinate) as Time);

  const timeToCoordinate = jest.fn((time: Time): Coordinate | null => Number(time) as Coordinate);

  const logicalToCoordinate = jest.fn((logical: Logical): Coordinate | null => {
    return (Number(logical) * 10) as Coordinate;
  });

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

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

  return {
    chart,
    timeScale,
  };
}

function createSeries(data: readonly { time: Time }[] = []) {
  const coordinateToPrice = jest.fn((coordinate: Coordinate): number | null => Number(coordinate));

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

  const dataMock = jest.fn(() => data);

  const series = {
    coordinateToPrice,
    priceToCoordinate,
    data: dataMock,
  } as unknown as SeriesApi;

  return {
    series,
    coordinateToPrice,
    priceToCoordinate,
    dataMock,
  };
}

describe('Drawing helpers', () => {
  it('должен получать цену из y coordinate', () => {
    const { series, coordinateToPrice } = createSeries();

    expect(getPriceFromYCoordinate(series, 50)).toBe(50);
    expect(coordinateToPrice).toHaveBeenCalledWith(50);
  });

  it('должен получать y coordinate из цены', () => {
    const { series, priceToCoordinate } = createSeries();

    expect(getYCoordinateFromPrice(series, 100)).toBe(100);
    expect(priceToCoordinate).toHaveBeenCalledWith(100);
  });

  it('должен получать время из x coordinate', () => {
    const { chart, timeScale } = createChart();

    expect(getTimeFromXCoordinate(chart, 120)).toBe(120);
    expect(timeScale.coordinateToTime).toHaveBeenCalledWith(120);
  });

  it('должен возвращать прямую координату времени', () => {
    const { chart } = createChart();

    expect(getXCoordinateFromTime(chart, 100 as Time)).toBe(100);
  });

  it('должен возвращать null без series, если координата времени отсутствует', () => {
    const { chart, timeScale } = createChart();

    timeScale.timeToCoordinate.mockReturnValue(null);

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

  it('должен проецировать отсутствующее время через ближайшую свечу', () => {
    const { chart, timeScale } = createChart();

    timeScale.timeToCoordinate.mockReturnValue(null);

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

    expect(getXCoordinateFromTime(chart, 24 as Time, series)).toBe(10);
    expect(timeScale.logicalToCoordinate).toHaveBeenCalledWith(1);
  });

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

    timeScale.timeToCoordinate.mockReturnValue(null);

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

    expect(getXCoordinateFromTime(chart, 25 as Time, series)).toBe(10);
  });

  it('должен использовать первую свечу для времени до диапазона', () => {
    const { chart, timeScale } = createChart();

    timeScale.timeToCoordinate.mockReturnValue(null);

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

    expect(getXCoordinateFromTime(chart, 5 as Time, series)).toBe(0);
  });

  it('должен использовать последнюю свечу для времени после диапазона', () => {
    const { chart, timeScale } = createChart();

    timeScale.timeToCoordinate.mockReturnValue(null);

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

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

  it('должен возвращать null при отсутствии данных series', () => {
    const { chart, timeScale } = createChart();

    timeScale.timeToCoordinate.mockReturnValue(null);

    const { series } = createSeries();

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

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

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

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

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

  it('должен получать размеры container', () => {
    const container = createContainer(500, 250);

    expect(getContainerSize(container)).toEqual({
      width: 500,
      height: 250,
    });
  });

  it('должен ограничивать point границами container', () => {
    const container = createContainer(400, 300);

    expect(clampPointToContainer({ x: -20, y: 350 }, container)).toEqual({
      x: 0,
      y: 300,
    });
  });

  it('должен получать raw pointer point относительно container', () => {
    const container = createContainer(400, 300, 10, 20);

    const event = {
      clientX: 110,
      clientY: 70,
    } as unknown as MouseEvent;

    expect(getRawPointerPoint(container, event)).toEqual({
      x: 100,
      y: 50,
    });
  });

  it('должен ограничивать pointer point границами container', () => {
    const container = createContainer(400, 300, 10, 20);

    const event = {
      clientX: 500,
      clientY: -10,
    } as unknown as MouseEvent;

    expect(getPointerPoint(container, event)).toEqual({
      x: 400,
      y: 0,
    });
  });

  it('должен определять близость point', () => {
    expect(isNearPoint({ x: 108, y: 92 }, 100, 100, 8)).toBe(true);
    expect(isNearPoint({ x: 109, y: 100 }, 100, 100, 8)).toBe(false);
  });

  it('должен учитывать границу tolerance при проверке близости', () => {
    expect(isNearPoint({ x: 108, y: 108 }, 100, 100, 8)).toBe(true);
  });

  it('должен определять point внутри bounds', () => {
    const bounds = {
      left: 10,
      right: 100,
      top: 20,
      bottom: 80,
    };

    expect(isPointInBounds({ x: 50, y: 50 }, bounds)).toBe(true);
    expect(isPointInBounds({ x: 5, y: 50 }, bounds)).toBe(false);
    expect(isPointInBounds({ x: 5, y: 50 }, bounds, 5)).toBe(true);
  });

  it('должен нормализовать и ограничивать bounds', () => {
    const container = createContainer(400, 300);

    expect(normalizeBounds(450, -20, 350, -50, container)).toEqual({
      left: 0,
      right: 400,
      top: 0,
      bottom: 300,
    });
  });

  it('должен смещать время на количество пикселей', () => {
    const { chart } = createChart();

    expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBe(120);
  });

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

    timeScale.timeToCoordinate.mockReturnValue(null);

    expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBeNull();
  });

  it('должен вычислять price delta', () => {
    const { series } = createSeries();

    expect(getPriceDelta(series, 20, 70)).toBe(50);
  });

  it('должен возвращать 0 price delta при отсутствии начальной цены', () => {
    const { series, coordinateToPrice } = createSeries();

    coordinateToPrice.mockReturnValueOnce(null);

    expect(getPriceDelta(series, 20, 70)).toBe(0);
  });

  it('должен возвращать 0 price delta при отсутствии конечной цены', () => {
    const { series, coordinateToPrice } = createSeries();

    coordinateToPrice.mockReturnValueOnce(20).mockReturnValueOnce(null);

    expect(getPriceDelta(series, 20, 70)).toBe(0);
  });

  it('должен вычислять диапазон цены в container', () => {
    const container = createContainer(400, 300);
    const { series, coordinateToPrice } = createSeries();

    coordinateToPrice.mockReturnValueOnce(150).mockReturnValueOnce(50);

    expect(getPriceRangeInContainer(series, container)).toEqual({
      min: 50,
      max: 150,
    });
  });

  it('должен возвращать null диапазона при нулевой высоте', () => {
    const container = createContainer(400, 0);
    const { series } = createSeries();

    expect(getPriceRangeInContainer(series, container)).toBeNull();
  });

  it('должен возвращать null диапазона при отсутствии цены', () => {
    const container = createContainer(400, 300);
    const { series, coordinateToPrice } = createSeries();

    coordinateToPrice.mockReturnValueOnce(null);

    expect(getPriceRangeInContainer(series, container)).toBeNull();
  });

  it('должен создавать anchor из point', () => {
    const { chart } = createChart();
    const { series } = createSeries();

    expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toEqual({
      time: 100,
      price: 200,
    });
  });

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

    timeScale.coordinateToTime.mockReturnValue(null);

    expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toBeNull();
  });

  it('должен возвращать null anchor при отсутствии цены', () => {
    const { chart } = createChart();
    const { series, coordinateToPrice } = createSeries();

    coordinateToPrice.mockReturnValue(null);

    expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toBeNull();
  });
});




import { BehaviorSubject } from 'rxjs';

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

import type { DiapsonRenderData, DiapsonRangeMode } from '@core/Drawings/diapson/diapson';
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',
      diapsonStrokeFill: '#ff9800',
      diapsonAreaFill: '#ff980033',
      axisRangeTooltipText: '#ffffff',
      axisRangeTooltipFill: '#333333',
      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
      chartPriceLineText: '#ffffff',
    },
  }),
}));

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

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

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

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

class TestDiapson extends Diapson {
  public 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,
  locked = false,
  width = 400,
  height = 300,
  data = [],
}: {
  selected?: boolean;
  locked?: boolean;
  width?: number;
  height?: number;
  data?: readonly Record<string, unknown>[];
} = {}) {
  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);
    }),
  };

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

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

  const coordinateToTime = jest.fn((coordinate: Coordinate): Time | null => Number(coordinate) as Time);

  const timeToCoordinate = jest.fn((time: Time): Coordinate | null => Number(time) as Coordinate);

  const logicalToCoordinate = jest.fn((logical: Logical): Coordinate | null => Number(logical) as Coordinate);

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

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

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

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

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

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

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

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 createDiapson(
  rangeMode: DiapsonRangeMode = 'date',
  options: {
    selected?: boolean;
    data?: readonly Record<string, unknown>[];
    stepSize?: number;
    stepLabel?: string;
  } = {},
) {
  const env = createEnvironment({
    selected: options.selected,
    data: options.data,
  });

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

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

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

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

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

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

function createCanvasTarget() {
  const context = {
    save: jest.fn(),
    restore: jest.fn(),
    beginPath: jest.fn(),
    moveTo: jest.fn(),
    lineTo: jest.fn(),
    stroke: jest.fn(),
    fill: jest.fn(),
    fillRect: jest.fn(),
    quadraticCurveTo: jest.fn(),
    closePath: jest.fn(),
    fillText: jest.fn(),
    measureText: jest.fn((text: string) => ({
      width: text.length * 5,
    })),
    fillStyle: '',
    strokeStyle: '',
    lineWidth: 0,
    font: '',
    textAlign: '',
    textBaseline: '',
    canvas: {
      width: 800,
      height: 600,
    },
  };

  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('Diapson', () => {
  it('должен прикреплять primitive при создании', () => {
    const { drawing, series } = createDiapson();

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    const readyPromise = drawing.waitTillReady();

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

    await expect(readyPromise).resolves.toBeUndefined();
  });

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

    setReady(drawing);

    drawing.setRangeMode('price');

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

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

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

    setReady(drawing);

    drawing.setRangeMode('date');

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

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

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

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

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

    setReady(drawing);

    expect(drawing.getHandles()).toEqual([
      {
        id: 'end',
        x: 150,
        y: 150,
        shape: 'circle',
        size: 12,
        borderWidth: 2,
      },
      {
        id: 'start',
        x: 50,
        y: 50,
        shape: 'circle',
        size: 12,
        borderWidth: 2,
      },
    ]);
  });

  it('должен возвращать пустые handles без geometry', () => {
    const { drawing } = createDiapson();

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

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

    setReady(drawing);

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

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

    drawing.setState({
      hidden: true,
      mode: 'ready',
      startTime: 50,
      endTime: 150,
      startPrice: 50,
      endPrice: 150,
    });

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

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

    setReady(drawing);

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

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

    setReady(drawing);

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

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

    setReady(drawing);

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

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

    setReady(drawing);

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

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

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

    setReady(drawing);

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

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

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

    setReady(drawing);

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

  it('должен показывать move cursor для невыбранного диапазона', () => {
    const { drawing } = createDiapson();

    setReady(drawing);

    expect(drawing.getHovered(100, 100)).toEqual({
      cursorStyle: 'move',
      externalId: 'diapson-date',
      zOrder: 'top',
    });
  });

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

    setReady(drawing);

    expect(drawing.getHovered(50, 50)).toEqual({
      cursorStyle: 'nwse-resize',
      externalId: 'diapson-date',
      zOrder: 'top',
    });
  });

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

    setReady(drawing);

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

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

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

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

    setReady(drawing);

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

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

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

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

  it('должен resize start handle', () => {
    const { drawing, container } = createDiapson('date', {
      selected: 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('должен resize end handle', () => {
    const { drawing, container } = createDiapson('date', {
      selected: true,
    });

    setReady(drawing);

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

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

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

    setReady(drawing);

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

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

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

    setReady(drawing);

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

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

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

    setReady(drawing);

    drawing.doubleClick(createMouseEvent(container, 300, 250));

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

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

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

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

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

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

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

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

    const { drawing } = createDiapson('date', {
      data,
    });

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

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

  it('должен учитывать volume из customValues', () => {
    const data = [
      {
        time: 1000,
        customValues: {
          volume: 10,
        },
      },
      {
        time: 1060,
        customValues: {
          volume: 20,
        },
      },
    ];

    const { drawing } = createDiapson('date', {
      data,
    });

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

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

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

    const renderer = new DiapsonPaneRenderer(createRendererDiapson(null));

    renderer.draw(target);

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

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

    const renderer = new DiapsonPaneRenderer(createRendererDiapson(data));

    renderer.draw(target);

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

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

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

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

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

    const renderer = new DiapsonPaneRenderer(createRendererDiapson(data));

    renderer.draw(target);

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

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

    expect(context.moveTo).toHaveBeenCalledWith(100, 40);
    expect(context.lineTo).toHaveBeenCalledWith(100, 160);
  });

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

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

    const renderer = new DiapsonPaneRenderer(createRendererDiapson(data));

    renderer.draw(target);

    expect(context.fillText).toHaveBeenCalledTimes(2);
    expect(context.fillText).toHaveBeenCalledWith('First', expect.any(Number), expect.any(Number));
    expect(context.fillText).toHaveBeenCalledWith('Second', expect.any(Number), expect.any(Number));
    expect(context.font).toContain('italic');
    expect(context.font).toContain('700');
  });

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

    const data = createRenderData('date', {
      showFill: false,
    });

    const renderer = new DiapsonPaneRenderer(createRendererDiapson(data));

    renderer.draw(target);

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

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

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

  it('должен иметь top zOrder', () => {
    const drawing = createRendererDiapson(null);
    const view = new DiapsonPaneView(drawing);

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

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

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

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

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

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

    expect(styleTab?.fields).toEqual([
      expect.objectContaining({
        key: 'borderColor',
        defaultValue: '#111111',
      }),
      expect.objectContaining({
        key: 'fillColor',
        defaultValue: '#222222',
      }),
    ]);
  });

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

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

    expect(textTab?.fields).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          key: 'fontSize',
          defaultValue: 18,
          min: 8,
          max: 24,
        }),
        expect.objectContaining({
          key: 'isBold',
          defaultValue: true,
        }),
        expect.objectContaining({
          key: 'isItalic',
          defaultValue: true,
        }),
        expect.objectContaining({
          key: 'labelTextColor',
          defaultValue: '#123456',
        }),
        expect.objectContaining({
          key: 'labelBackgroundColor',
          defaultValue: '#654321',
        }),
      ]),
    );
  });
});