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


import { SliderPaneRenderer } from '@core/Drawings/sliderPosition/paneRenderer';
import { SliderPaneView } from '@core/Drawings/sliderPosition/paneView';
import { createDefaultSettings, getSliderPositionSettingsTabs } from '@core/Drawings/sliderPosition/settings';
import { SliderPosition, type SliderRenderData } from '@core/Drawings/sliderPosition/sliderPosition';

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

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartCrosshairLine: '#999999',
      sliderPositiveFill: '#00aa0044',
      sliderNegativeFill: '#aa000044',
      chartPriceLineText: '#ffffff',
      chartCandleUp: '#00aa00',
      chartCandleDown: '#aa0000',
      axisMarkerAreaFill: '#2962ff44',
      axisMarkerLabelFill: '#2962ff',
      axisMarkerLabelDefaultFill: '#777777',
      axisMarkerLabelPositiveFill: '#00aa00',
      axisMarkerLabelNegativeFill: '#aa0000',
    },
  }),
}));

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

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

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

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

class TestSliderPosition extends SliderPosition {
  public handles(): readonly DrawingHandle[] {
    return this.getDrawingHandles();
  }

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

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

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

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

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

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

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

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

function createSlider(side: 'long' | 'short' = 'long', selected = false) {
  const seriesData = [
    { time: 10 as Time, close: 100 },
    { time: 100 as Time, close: 110 },
  ];

  const env = createDrawingTestEnvironment({ selected, seriesData });
  const removeSelf = jest.fn();
  const openSettings = jest.fn();

  const slider = new TestSliderPosition({
    ...env,
    side,
    removeSelf,
    openSettings,
  });

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

function setReady(slider: TestSliderPosition): void {
  slider.setState({
    mode: 'ready',
    startTime: 10 as Time,
    endTime: 100 as Time,
    entryPrice: 100,
    stopPrice: 90,
    targetPrice: 120,
    amount: 1000,
    tickSize: 1,
  });
}

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

    setReady(slider);

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

  it('поддерживает RR = 0', () => {
    const { slider } = createSlider();

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

    expect(slider.getState()).toMatchObject({
      targetPrice: 100,
      riskRewardRatio: 0,
    });
  });

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

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

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

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

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

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

  it('возвращает четыре handles в правильном приоритетном порядке', () => {
    const { slider } = createSlider();

    setReady(slider);

    expect(slider.handles()).toEqual([
      { id: 'target', x: 10, y: 120 },
      { id: 'stop', x: 10, y: 90 },
      { id: 'end', x: 100, y: 100 },
      { id: 'entry', x: 10, y: 100, shape: 'circle' },
    ]);
  });

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

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

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

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

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

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

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

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

  it('Entry нельзя вывести за Stop и Target', () => {
    const { slider, container } = createSlider('long', true);

    setReady(slider);

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

    expect(slider.getState().entryPrice).toBe(120);
  });

  it('Stop можно схлопнуть с Entry', () => {
    const { slider, container } = createSlider('long', true);

    setReady(slider);

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

    expect(slider.getState().stopPrice).toBe(100);
  });

  it('Target можно схлопнуть с Entry', () => {
    const { slider, container } = createSlider('long', true);

    setReady(slider);

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

    expect(slider.getState().targetPrice).toBe(100);
  });

  it('работает для short позиции', () => {
    const { slider } = createSlider('short');

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

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

  it('возвращает selected axis areas', () => {
    const { slider } = createSlider('long', true);

    setReady(slider);

    expect(slider.timeSegments()).toEqual([{ from: 10, to: 100, color: '#2962ff44' }]);
    expect(slider.priceSegments()).toEqual([
      { from: 100, to: 120, color: '#2962ff44' },
      { from: 90, to: 100, color: '#2962ff44' },
    ]);
  });

  it('использует normal zOrder для selected axis pane areas', () => {
    const { slider } = createSlider();

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

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

    setReady(slider);

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

  it('price labels доступны независимо от selection', () => {
    const { slider } = createSlider();

    setReady(slider);

    expect(slider.priceLabel('target')).toMatchObject({
      coordinate: 120,
      text: '120',
      backgroundColor: '#00aa00',
    });

    expect(slider.priceLabel('entry')).toMatchObject({
      coordinate: 100,
      backgroundColor: '#777777',
    });

    expect(slider.priceLabel('stop')).toMatchObject({
      coordinate: 90,
      backgroundColor: '#aa0000',
    });
  });

  it('возвращает правильные cursors', () => {
    const { slider } = createSlider('long', true);

    setReady(slider);

    expect(slider.hovered(10, 120)?.cursorStyle).toBe('ns-resize');
    expect(slider.hovered(10, 90)?.cursorStyle).toBe('ns-resize');
    expect(slider.hovered(100, 100)?.cursorStyle).toBe('ew-resize');
    expect(slider.hovered(10, 100)?.cursorStyle).toBe('grab');
  });

  it('выбирает unselected slider по body', () => {
    const { slider, interaction, container } = createSlider();

    setReady(slider);
    slider.pointerDown(createPointerEvent(container, 50, 100));

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

  it('изменяет end time', () => {
    const { slider, container } = createSlider('long', true);

    setReady(slider);

    slider.pointerDown(createPointerEvent(container, 100, 100));
    slider.pointerMove(createPointerEvent(container, 150, 100));

    expect(slider.getState().endTime).toBe(150);
  });
});

describe('SliderPaneRenderer', () => {
  it('не рисует без data', () => {
    const { target, useBitmapCoordinateSpace } = createCanvasTarget();

    new SliderPaneRenderer({
      getRenderData: () => null,
    } as unknown as SliderPosition).draw(target);

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

  it('рисует profit/loss и labels', () => {
    const data: SliderRenderData = {
      startX: 10,
      endX: 100,
      leftX: 10,
      rightX: 100,
      entryY: 100,
      stopY: 120,
      targetY: 80,
      entryPrice: 100,
      stopPrice: 90,
      targetPrice: 120,
      profitTop: 80,
      profitBottom: 100,
      lossTop: 100,
      lossBottom: 120,
      targetText: 'target',
      centerText: 'center',
      stopText: 'stop',
      centerBoxColor: '#0a0',
      targetLabelDirection: 'up',
      stopLabelDirection: 'down',
      showLabels: true,
      lineColor: '#fff',
      positiveFillColor: '#0f03',
      negativeFillColor: '#f003',
      textColor: '#fff',
      fontSize: 10,
      isBold: false,
      isItalic: false,
    };

    const { target, context } = createCanvasTarget();

    new SliderPaneRenderer({
      getRenderData: () => data,
    } as unknown as SliderPosition).draw(target);

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

describe('SliderPaneView', () => {
  it('возвращает renderer и top', () => {
    const view = new SliderPaneView({} as SliderPosition);

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

describe('SliderPosition settings', () => {
  it('создаёт defaults', () => {
    expect(createDefaultSettings()).toEqual({
      lineColor: '#999999',
      positiveFillColor: '#00aa0044',
      negativeFillColor: '#aa000044',
      textColor: '#ffffff',
      fontSize: 10,
      isBold: false,
      isItalic: false,
    });
  });

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