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


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

import { BehaviorSubject } from 'rxjs';

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

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

import { LegendComponent } from './index';

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

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

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

const createMainSeriesModel = (): LegendModel => [
  {
    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')).toBeInTheDocument();
    expect(screen.getByText('100')).toBeInTheDocument();
    expect(screen.getByText('High')).toBeInTheDocument();
    expect(screen.getByText('110')).toBeInTheDocument();

    expect(screen.getByText('Percentage')).toBeInTheDocument();
    expect(screen.getByText('10%')).toBeInTheDocument();

    expect(screen.queryByText('Absolute')).not.toBeInTheDocument();
  });

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

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

    expect(screen.getByText('Open')).toBeInTheDocument();
    expect(screen.queryByText('Percentage')).not.toBeInTheDocument();
    expect(screen.queryByText('10%')).not.toBeInTheDocument();
  });

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

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

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

  it('должен отображать изменения независимо от отображения OHLC', () => {
    const viewModel = new BehaviorSubject(createMainSeriesModel());
    const config: OHLCConfig = {
      show: false,
      showAbsoluteChange: true,
      showPercentageChange: true,
    };

    render(<LegendComponent viewModel={viewModel} ohlcConfig={config} />);

    expect(screen.queryByText('Open')).not.toBeInTheDocument();
    expect(screen.queryByText('High')).not.toBeInTheDocument();

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

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

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

    expect(screen.queryByText('Absolute')).not.toBeInTheDocument();
    expect(screen.queryByText('Percentage')).not.toBeInTheDocument();
  });

  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')).toBeInTheDocument();
    expect(screen.getByText('55')).toBeInTheDocument();
  });

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

    const model: LegendModel = [
      {
        id: 'indicator',
        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 type { TooltipVM } from '@core/Tooltip';
import type { TimeFormat, Timeframes, TooltipConfig } from '@src/types';
import type { DateFormat } from '@src/utils/formatter';

import { ChartTooltip } from './index';

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) => String(value),
}));

const formatObs = new BehaviorSubject({
  dateFormat: 'DD.MM.YYYY' as DateFormat,
  timeFormat: 'HH:mm:ss' as TimeFormat,
});

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

const createViewModel = () =>
  new BehaviorSubject<TooltipVM>({
    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);

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

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('Абсолютное изменение')).toBeInTheDocument();
    expect(screen.getByText('10')).toBeInTheDocument();

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

  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('Относительное изменение')).toBeInTheDocument();
    expect(screen.getByText('10%')).toBeInTheDocument();

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

  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')).toBeInTheDocument();
    expect(screen.getByText('Percentage')).toBeInTheDocument();
    expect(screen.getByText('10')).toBeInTheDocument();
    expect(screen.getByText('10%')).toBeInTheDocument();
  });

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

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

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

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

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

  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')).not.toBeInTheDocument();
  });

  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')).toBeInTheDocument();
    expect(screen.getByText('55')).toBeInTheDocument();
  });
});


import { BehaviorSubject } from 'rxjs';

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

import type { EventManager } from '@core/EventManager';
import type { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import type { OHLCConfig } from '@src/types';

describe('Legend', () => {
  let crosshairMoveHandler: ((param: any) => void) | undefined;
  let mainSeries: jest.Mocked<SeriesStrategies>;
  let eventManager: EventManager;
  let indicators: BehaviorSubject<Map<string, never>>;
  let mainSeriesSubject: BehaviorSubject<SeriesStrategies | null>;

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

  beforeEach(() => {
    crosshairMoveHandler = undefined;

    mainSeries = {
      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(),
    } as unknown as jest.Mocked<SeriesStrategies>;

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

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

  const createLegend = () =>
    new Legend({
      config,
      eventManager,
      indicators,
      mainSeries: mainSeriesSubject,
      paneId: 1,
      paneIndex: () => 0,
      subscribeChartEvent: jest.fn((event, handler) => {
        if (event === 'crosshairMove') {
          crosshairMoveHandler = handler;
        }

        return () => {};
      }),
      openIndicatorSettings: jest.fn(),
    });

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

    let model: LegendModel = [];

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

    const values = model[0].values as Map<string, { value: unknown }>;

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

    legend.destroy();
  });

  it('должен обновлять абсолютное и относительное изменение данными под курсором', () => {
    mainSeries.getLegendData.mockImplementation((param?: unknown) => {
      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 legend = createLegend();

    crosshairMoveHandler?.({
      point: { x: 10, y: 20 },
      time: 100,
      paneIndex: 0,
      seriesData: new Map([['main', {}]]),
    });

    let model: LegendModel = [];

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

    const values = model[0].values as Map<string, { value: unknown }>;

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

    legend.destroy();
  });

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

    let visible = false;
    let position = null;

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

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

    crosshairMoveHandler?.({
      point: { x: 10, y: 20 },
      time: 100,
      paneIndex: 0,
      seriesData: new Map([['main', {}]]),
    });

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

    legend.destroy();
  });

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

    let visible = true;

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

    crosshairMoveHandler?.({
      point: { x: 10, y: 20 },
      time: 100,
      paneIndex: 1,
      seriesData: new Map([['main', {}]]),
    });

    expect(visible).toBe(false);

    legend.destroy();
  });

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

    expect(legend.getConfig()).toBe(config);

    legend.destroy();
  });

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

    expect(mainSeries.subscribeDataChanged).toHaveBeenCalledTimes(1);

    legend.destroy();

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