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


'import/no-extraneous-dependencies': [
  'error',
  {
    devDependencies: [
      '**/*.{test,spec}.{js,jsx,ts,tsx}',
      '**/*.config.{js,ts}',
    ],
  },
],


import { fireEvent, render, screen } from '@testing-library/react';

import { BehaviorSubject } from 'rxjs';

import { LegendComponent } from '@components/Legend';
import { LegendModel } from '@core/Legend';

import type { ReactNode } from 'react';

jest.mock('exchange-elements/v2', () => ({
  Button: ({ onClick, label }: { onClick?: () => void; label: ReactNode }) => (
    <button
      type="button"
      onClick={onClick}
    >
      {label}
    </button>
  ),
}));

jest.mock('@components/Icon', () => ({
  GearIcon: () => <span>settings</span>,
  TrashIcon: () => <span>remove</span>,
}));

jest.mock('@src/utils', () => ({
  formatDisplayText: (value: unknown) => (value === null || value === undefined ? '' : String(value)),
  useObservable: (observable: { getValue: () => unknown }) => observable.getValue(),
}));

function createMainSeriesModel(): LegendModel {
  return [
    {
      id: 'main-series',
      name: 'AAPL',
      isIndicator: false,
      values: {
        open: { value: 100, color: 'white', name: 'Open' },
        high: { value: 110, color: 'white', name: 'High' },
        absoluteChange: { value: 10, color: 'green', name: 'Absolute' },
        percentageChange: { value: '10%', color: 'green', name: 'Percentage' },
      },
    },
  ];
}

describe('LegendComponent', () => {
  it('должен отображать OHLC и относительное изменение по умолчанию', () => {
    const viewModel = new BehaviorSubject(createMainSeriesModel());

    render(
      <LegendComponent
        viewModel={viewModel}
        ohlcConfig={{ show: true }}
      />,
    );

    expect(screen.getByText('Open')).toBeTruthy();
    expect(screen.getByText('100')).toBeTruthy();
    expect(screen.getByText('High')).toBeTruthy();
    expect(screen.getByText('110')).toBeTruthy();
    expect(screen.getByText('Percentage')).toBeTruthy();
    expect(screen.getByText('10%')).toBeTruthy();

    expect(screen.queryByText('Absolute')).toBeNull();
  });

  it('должен скрывать относительное изменение при выключенном showPercentageChange', () => {
    const viewModel = new BehaviorSubject(createMainSeriesModel());

    render(
      <LegendComponent
        viewModel={viewModel}
        ohlcConfig={{
          show: true,
          showPercentageChange: false,
        }}
      />,
    );

    expect(screen.getByText('Open')).toBeTruthy();
    expect(screen.queryByText('Percentage')).toBeNull();
    expect(screen.queryByText('10%')).toBeNull();
  });

  it('должен отображать абсолютное изменение при включенном showAbsoluteChange', () => {
    const viewModel = new BehaviorSubject(createMainSeriesModel());

    render(
      <LegendComponent
        viewModel={viewModel}
        ohlcConfig={{
          show: true,
          showAbsoluteChange: true,
        }}
      />,
    );

    expect(screen.getByText('Absolute')).toBeTruthy();
    expect(screen.getByText('10')).toBeTruthy();
  });

  it('должен отображать изменения независимо от OHLC', () => {
    const viewModel = new BehaviorSubject(createMainSeriesModel());

    render(
      <LegendComponent
        viewModel={viewModel}
        ohlcConfig={{
          show: false,
          showAbsoluteChange: true,
          showPercentageChange: true,
        }}
      />,
    );

    expect(screen.queryByText('Open')).toBeNull();
    expect(screen.queryByText('High')).toBeNull();

    expect(screen.getByText('Absolute')).toBeTruthy();
    expect(screen.getByText('10')).toBeTruthy();
    expect(screen.getByText('Percentage')).toBeTruthy();
    expect(screen.getByText('10%')).toBeTruthy();
  });

  it('должен скрывать оба изменения при выключенных флагах', () => {
    const viewModel = new BehaviorSubject(createMainSeriesModel());

    render(
      <LegendComponent
        viewModel={viewModel}
        ohlcConfig={{
          show: true,
          showAbsoluteChange: false,
          showPercentageChange: false,
        }}
      />,
    );

    expect(screen.queryByText('Absolute')).toBeNull();
    expect(screen.queryByText('Percentage')).toBeNull();
  });

  it('должен отображать значения индикатора независимо от OHLC', () => {
    const model: LegendModel = [
      {
        id: 'rsi',
        name: 'RSI',
        isIndicator: true,
        values: {
          value: {
            value: 55,
            color: 'white',
          },
        },
      },
    ];

    render(
      <LegendComponent
        viewModel={new BehaviorSubject(model)}
        ohlcConfig={{
          show: false,
          showAbsoluteChange: false,
          showPercentageChange: false,
        }}
      />,
    );

    expect(screen.getByText('RSI')).toBeTruthy();
    expect(screen.getByText('55')).toBeTruthy();
  });

  it('должен вызывать обработчики настроек и удаления', () => {
    const settings = jest.fn();
    const remove = jest.fn();

    const model: LegendModel = [
      {
        id: 'ema',
        name: 'EMA',
        isIndicator: true,
        values: {
          value: {
            value: 100,
            color: 'white',
          },
        },
        settings,
        remove,
      },
    ];

    render(
      <LegendComponent
        viewModel={new BehaviorSubject(model)}
        ohlcConfig={{ show: true }}
      />,
    );

    fireEvent.click(screen.getByText('settings'));
    fireEvent.click(screen.getByText('remove'));

    expect(settings).toHaveBeenCalledTimes(1);
    expect(remove).toHaveBeenCalledTimes(1);
  });
});


import { render, screen } from '@testing-library/react';

import { BehaviorSubject } from 'rxjs';

import { ChartTooltip } from '@components/ChartTooltip';
import { TooltipVM } from '@core/Tooltip';

import { Defaults } from '@src/types/defaults';

import type { Timeframes, TooltipConfig } from '@src/types';

jest.mock('exchange-elements/v2', () => ({
  Divider: () => <hr data-testid="divider" />,
}));

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartTooltipBackground: '#000000',
    },
  }),
}));

jest.mock('@src/utils', () => ({
  calcTooltipPosition: () => ({
    left: 10,
    top: 20,
  }),
  shouldShowTime: () => true,
  useObservable: (observable: { getValue: () => unknown }) => observable.getValue(),
}));

jest.mock('@src/utils/formatter', () => ({
  formatDate: () => '01.01.2026',
  formatDisplayText: (value: unknown) => (value === null || value === undefined ? '' : String(value)),
}));

const formatObs = new BehaviorSubject({
  dateFormat: Defaults.dateFormat,
  timeFormat: Defaults.timeFormat,
});

const timeframeObs = new BehaviorSubject('1m' as Timeframes);

function createTooltipConfig(): TooltipConfig {
  return {
    high: {
      visible: false,
      label: 'High',
    },
    low: {
      visible: false,
      label: 'Low',
    },
    absoluteChange: {
      visible: false,
      label: 'Абсолютное изменение',
    },
    percentageChange: {
      visible: false,
      label: 'Относительное изменение',
    },
  };
}

function createViewModel(): BehaviorSubject<TooltipVM> {
  return new BehaviorSubject({
    visible: true,
    position: {
      x: 100,
      y: 100,
    },
    viewport: {
      width: 500,
      height: 300,
    },
    vm: [
      {
        id: 'main-series',
        name: 'AAPL',
        isIndicator: false,
        values: {
          high: {
            value: 110,
            color: 'white',
            name: 'High',
          },
          low: {
            value: 90,
            color: 'white',
            name: 'Low',
          },
          absoluteChange: {
            value: 10,
            color: 'green',
            name: 'Absolute',
          },
          percentageChange: {
            value: '10%',
            color: 'green',
            name: 'Percentage',
          },
        },
      },
    ],
  } as TooltipVM);
}

describe('ChartTooltip', () => {
  it('не должен отображаться при выключенной видимости', () => {
    const viewModel = createViewModel();

    viewModel.next({
      ...viewModel.getValue(),
      visible: false,
    });

    const { container } = render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={createTooltipConfig()}
        viewModel={viewModel}
        ohlcConfig={{ show: true }}
      />,
    );

    expect(container.firstChild).toBeNull();
  });

  it('должен отдельно отображать абсолютное изменение', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.absoluteChange = {
      visible: true,
      label: 'Абсолютное изменение',
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
        ohlcConfig={{ show: true }}
      />,
    );

    expect(screen.getByText('Абсолютное изменение')).toBeTruthy();
    expect(screen.getByText('10')).toBeTruthy();

    expect(screen.queryByText('Относительное изменение')).toBeNull();
    expect(screen.queryByText('10%')).toBeNull();
  });

  it('должен отдельно отображать относительное изменение', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.percentageChange = {
      visible: true,
      label: 'Относительное изменение',
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
        ohlcConfig={{ show: true }}
      />,
    );

    expect(screen.getByText('Относительное изменение')).toBeTruthy();
    expect(screen.getByText('10%')).toBeTruthy();

    expect(screen.queryByText('Абсолютное изменение')).toBeNull();
    expect(screen.queryByText('10')).toBeNull();
  });

  it('должен одновременно отображать абсолютное и относительное изменение', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.absoluteChange = {
      visible: true,
      label: 'Absolute',
    };

    tooltipConfig.percentageChange = {
      visible: true,
      label: 'Percentage',
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
        ohlcConfig={{ show: true }}
      />,
    );

    expect(screen.getByText('Absolute')).toBeTruthy();
    expect(screen.getByText('Percentage')).toBeTruthy();
    expect(screen.getByText('10')).toBeTruthy();
    expect(screen.getByText('10%')).toBeTruthy();
  });

  it('должен использовать подписи из конфигурации', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.absoluteChange = {
      visible: true,
      label: 'Custom absolute',
    };

    tooltipConfig.percentageChange = {
      visible: true,
      label: 'Custom percentage',
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
        ohlcConfig={{ show: true }}
      />,
    );

    expect(screen.getByText('Custom absolute')).toBeTruthy();
    expect(screen.getByText('Custom percentage')).toBeTruthy();
  });

  it('не должен отображать изменение без label', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.absoluteChange = {
      visible: true,
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
        ohlcConfig={{ show: true }}
      />,
    );

    expect(screen.queryByText('10')).toBeNull();
  });

  it('должен отображать значение индикатора', () => {
    const viewModel = createViewModel();

    viewModel.next({
      ...viewModel.getValue(),
      vm: [
        {
          id: 'rsi',
          name: 'RSI',
          isIndicator: true,
          values: {
            value: {
              value: 55,
              color: 'white',
              name: 'RSI',
            },
          },
        },
      ],
    } as TooltipVM);

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={createTooltipConfig()}
        viewModel={viewModel}
        ohlcConfig={{ show: true }}
      />,
    );

    expect(screen.getByText('RSI')).toBeTruthy();
    expect(screen.getByText('55')).toBeTruthy();
  });
});


import { MouseEventParams, Time } from 'lightweight-charts';

import { BehaviorSubject } from 'rxjs';

import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { Legend, LegendModel, LegendParams } from '@core/Legend';

import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';

import type { OHLCConfig } from '@src/types';

describe('Legend', () => {
  let crosshairMoveHandler: ((param: MouseEventParams) => void) | null;
  let indicators: BehaviorSubject<Map<string, Indicator>>;
  let mainSeriesSubject: BehaviorSubject<SeriesStrategies | null>;
  let mainSeries: SeriesStrategies;
  let eventManager: EventManager;
  let getLegendData: jest.Mock;
  let subscribeDataChanged: jest.Mock;
  let unsubscribeDataChanged: jest.Mock;
  let legend: Legend | null;

  const config: OHLCConfig = {
    show: true,
    showAbsoluteChange: false,
    showPercentageChange: true,
  };

  beforeEach(() => {
    crosshairMoveHandler = null;
    legend = null;

    getLegendData = jest.fn().mockReturnValue({
      open: {
        value: 100,
        color: 'white',
        name: 'Open',
      },
      absoluteChange: {
        value: 10,
        color: 'green',
        name: 'Absolute',
      },
      percentageChange: {
        value: '10%',
        color: 'green',
        name: 'Percentage',
      },
    });

    subscribeDataChanged = jest.fn();
    unsubscribeDataChanged = jest.fn();

    mainSeries = {
      getLegendData,
      subscribeDataChanged,
      unsubscribeDataChanged,
    } as unknown as SeriesStrategies;

    eventManager = {
      symbolName: jest.fn().mockReturnValue(new BehaviorSubject('AAPL')),
    } as unknown as EventManager;

    indicators = new BehaviorSubject<Map<string, Indicator>>(new Map());
    mainSeriesSubject = new BehaviorSubject<SeriesStrategies | null>(mainSeries);
  });

  afterEach(() => {
    legend?.destroy();
  });

  function createLegend(): Legend {
    const subscribeChartEvent = jest.fn((event, handler) => {
      if (event === 'crosshairMove') {
        crosshairMoveHandler = handler as (param: MouseEventParams) => void;
      }

      return () => {};
    }) as unknown as LegendParams['subscribeChartEvent'];

    legend = new Legend({
      config,
      eventManager,
      indicators,
      subscribeChartEvent,
      mainSeries: mainSeriesSubject,
      paneId: 1,
      paneIndex: () => 0,
      openIndicatorSettings: jest.fn(),
    });

    return legend;
  }

  function createCrosshairParam(paneIndex = 0): MouseEventParams {
    return {
      point: {
        x: 10,
        y: 20,
      },
      time: 100 as Time,
      paneIndex,
      seriesData: new Map([['main-series', {}]]),
    } as unknown as MouseEventParams;
  }

  function getMainSeriesValues(model: LegendModel) {
    return model[0].values as unknown as Map<
      string,
      {
        value: unknown;
        color: string;
        name?: string;
      }
    >;
  }

  it('должен добавлять абсолютное и относительное изменение в модель основной серии', () => {
    const currentLegend = createLegend();

    let model: LegendModel = [];

    currentLegend.getLegendViewModel().subscribe((value) => {
      model = value;
    });

    const values = getMainSeriesValues(model);

    expect(values.get('absoluteChange')?.value).toBe(10);
    expect(values.get('percentageChange')?.value).toBe('10%');
  });

  it('должен обновлять абсолютное и относительное изменение данными под курсором', () => {
    getLegendData.mockImplementation((param?: MouseEventParams) => {
      if (param) {
        return {
          absoluteChange: {
            value: 20,
            color: 'green',
            name: 'Absolute',
          },
          percentageChange: {
            value: '20%',
            color: 'green',
            name: 'Percentage',
          },
        };
      }

      return {
        absoluteChange: {
          value: 10,
          color: 'green',
          name: 'Absolute',
        },
        percentageChange: {
          value: '10%',
          color: 'green',
          name: 'Percentage',
        },
      };
    });

    const currentLegend = createLegend();

    crosshairMoveHandler?.(createCrosshairParam());

    let model: LegendModel = [];

    currentLegend.getLegendViewModel().subscribe((value) => {
      model = value;
    });

    const values = getMainSeriesValues(model);

    expect(values.get('absoluteChange')?.value).toBe(20);
    expect(values.get('percentageChange')?.value).toBe('20%');
  });

  it('должен отображать тултип при наведении на текущий pane', () => {
    const currentLegend = createLegend();

    let visible = false;
    let position = null;

    currentLegend.subscribeCursorVisability((value) => {
      visible = value;
    });

    currentLegend.subscribeCursorPosition((value) => {
      position = value;
    });

    crosshairMoveHandler?.(createCrosshairParam());

    expect(visible).toBe(true);
    expect(position).toEqual({
      x: 10,
      y: 20,
    });
  });

  it('должен скрывать тултип при наведении на другой pane', () => {
    const currentLegend = createLegend();

    let visible = true;

    currentLegend.subscribeCursorVisability((value) => {
      visible = value;
    });

    crosshairMoveHandler?.(createCrosshairParam(1));

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

  it('должен подписываться и отписываться от изменений основной серии', () => {
    const currentLegend = createLegend();

    expect(subscribeDataChanged).toHaveBeenCalledTimes(1);

    currentLegend.destroy();
    legend = null;

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

  it('должен возвращать конфигурацию легенды', () => {
    const currentLegend = createLegend();

    expect(currentLegend.getConfig()).toBe(config);
  });
});