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


import { BehaviorSubject, Subscription } from 'rxjs';

import { Chart } from '@core/Chart';
import { Intervals } from '@src/types/intervals';

interface ChartInternals {
  eventManager: {
    symbolId: () => BehaviorSubject<string>;
    getInterval: () => BehaviorSubject<Intervals | null>;
    getTimeframe: jest.Mock;
  };
  compareManager: {
    itemsObs: () => BehaviorSubject<{ symbolId: string }[]>;
  };
  dataSource: {
    setSymbols: jest.Mock;
    loadTill: jest.Mock;
    loadAllHistory: jest.Mock;
    loadMoreHistory: jest.Mock;
    getOldestTime: jest.Mock;
    getLastCandle: jest.Mock;
  };
  lwcChart: {
    timeScale: () => {
      getVisibleRange: jest.Mock;
      setVisibleRange: jest.Mock;
      getVisibleLogicalRange: jest.Mock;
    };
  };
  mouseEvents: {
    subscribe: jest.Mock;
  };
  paneManager: {
    setVisibleLogicalRange: jest.Mock;
  };
  subscriptions: Subscription;
  activeSymbolIds: string[];
  active: boolean;
  historyBatchRunning: boolean;
  setupDataSourceSubs: () => void;
  setupHistoricalDataLoading: () => void;
  scheduleHistoryBatch: () => void;
}

interface CreateChartOptions {
  symbolId?: string;
  interval?: Intervals | null;
  compareSymbolIds?: string[];
}

interface ChartTestContext {
  chart: ChartInternals;
  symbolId$: BehaviorSubject<string>;
  interval$: BehaviorSubject<Intervals | null>;
  getTimeframe: jest.Mock;
  compareItems$: BehaviorSubject<{ symbolId: string }[]>;
  setSymbols: jest.Mock;
  loadTill: jest.Mock;
  loadAllHistory: jest.Mock;
  loadMoreHistory: jest.Mock;
  getOldestTime: jest.Mock;
  getLastCandle: jest.Mock;
  getVisibleRange: jest.Mock;
  getVisibleLogicalRange: jest.Mock;
  setVisibleRange: jest.Mock;
  mouseEventsSubscribe: jest.Mock;
  setVisibleLogicalRange: jest.Mock;
}

interface Deferred {
  promise: Promise<void>;
  resolve: () => void;
}

function createDeferred(): Deferred {
  let resolve!: () => void;

  const promise = new Promise<void>((resolver) => {
    resolve = resolver;
  });

  return { promise, resolve };
}

async function flushPromises(): Promise<void> {
  await Promise.resolve();
  await Promise.resolve();
  await Promise.resolve();
}

describe('Chart', () => {
  let animationFrameCallbacks: FrameRequestCallback[];
  let subscriptions: Subscription[];

  beforeEach(() => {
    animationFrameCallbacks = [];
    subscriptions = [];

    jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
      animationFrameCallbacks.push(callback);

      return animationFrameCallbacks.length;
    });
  });

  afterEach(() => {
    subscriptions.forEach((subscription) => subscription.unsubscribe());
    jest.restoreAllMocks();
  });

  function runNextAnimationFrame(): void {
    const callback = animationFrameCallbacks.shift();

    callback?.(0);
  }

  function createChartInternals({
    symbolId = '',
    interval = null,
    compareSymbolIds = ['SBER', 'SBER'],
  }: CreateChartOptions = {}): ChartTestContext {
    const symbolId$ = new BehaviorSubject(symbolId);
    const interval$ = new BehaviorSubject<Intervals | null>(interval);
    const compareItems$ = new BehaviorSubject(
      compareSymbolIds.map((value) => ({
        symbolId: value,
      })),
    );

    const setSymbols = jest.fn();
    const loadTill = jest.fn(() => Promise.resolve());
    const loadAllHistory = jest.fn(() => Promise.resolve());
    const loadMoreHistory = jest.fn(() => Promise.resolve());
    const getOldestTime = jest.fn(() => 100);
    const getLastCandle = jest.fn(() => ({ time: 200 }));
    const getVisibleRange = jest.fn(() => null);
    const getVisibleLogicalRange = jest.fn(() => ({ from: 0, to: 1000 }));
    const setVisibleRange = jest.fn();
    const getTimeframe = jest.fn(() => '1m');
    const mouseEventsSubscribe = jest.fn();
    const setVisibleLogicalRange = jest.fn();

    const chart = Object.assign(Object.create(Chart.prototype), {
      eventManager: {
        symbolId: () => symbolId$,
        getInterval: () => interval$,
        getTimeframe,
      },
      compareManager: {
        itemsObs: () => compareItems$,
      },
      dataSource: {
        setSymbols,
        loadTill,
        loadAllHistory,
        loadMoreHistory,
        getOldestTime,
        getLastCandle,
      },
      lwcChart: {
        timeScale: () => ({
          getVisibleRange,
          getVisibleLogicalRange,
          setVisibleRange,
        }),
      },
      mouseEvents: {
        subscribe: mouseEventsSubscribe,
      },
      paneManager: {
        setVisibleLogicalRange,
      },
      subscriptions: new Subscription(),
      activeSymbolIds: [],
      active: true,
      historyBatchRunning: false,
    }) as ChartInternals;

    subscriptions.push(chart.subscriptions);

    return {
      chart,
      symbolId$,
      interval$,
      getTimeframe,
      compareItems$,
      setSymbols,
      loadTill,
      loadAllHistory,
      loadMoreHistory,
      getOldestTime,
      getLastCandle,
      getVisibleRange,
      getVisibleLogicalRange,
      setVisibleRange,
      mouseEventsSubscribe,
      setVisibleLogicalRange,
    };
  }

  it('должен исключать пустой основной symbolId и удалять дубликаты', () => {
    const { chart, setSymbols } = createChartInternals();

    chart.setupDataSourceSubs();

    expect(setSymbols).toHaveBeenCalledTimes(1);
    expect(setSymbols).toHaveBeenCalledWith(['SBER']);
  });

  it('должен зарегистрировать символ до загрузки данных начального интервала', () => {
    const callOrder: string[] = [];

    const { chart, setSymbols, loadTill } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals['1Y'],
    });

    setSymbols.mockImplementation(() => {
      callOrder.push('setSymbols');
    });

    loadTill.mockImplementation(() => {
      callOrder.push('loadTill');

      return Promise.resolve();
    });

    chart.setupDataSourceSubs();

    expect(callOrder).toEqual(['setSymbols', 'loadTill']);
  });

  it('должен применить начальный интервал после загрузки данных', async () => {
    const { chart, loadTill, setVisibleRange } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals['1Y'],
    });

    chart.setupDataSourceSubs();

    expect(loadTill).toHaveBeenCalledTimes(1);
    expect(loadTill).toHaveBeenCalledWith('SBER', expect.any(Number));

    await flushPromises();

    expect(setVisibleRange).toHaveBeenCalledTimes(1);
    expect(setVisibleRange).toHaveBeenCalledWith({
      from: expect.any(Number),
      to: expect.any(Number),
    });
  });

  it('не должен применять интервал при отсутствии данных', async () => {
    const { chart, getOldestTime, setVisibleRange } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals['1Y'],
    });

    getOldestTime.mockReturnValue(null);

    chart.setupDataSourceSubs();

    await flushPromises();

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

  it('не должен применять результат устаревшего интервала', async () => {
    const firstLoad = createDeferred();
    const secondLoad = createDeferred();

    const { chart, interval$, loadTill, loadAllHistory, setVisibleRange } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals['1Y'],
    });

    loadTill.mockReturnValue(firstLoad.promise);
    loadAllHistory.mockReturnValue(secondLoad.promise);

    chart.setupDataSourceSubs();

    expect(loadTill).toHaveBeenCalledTimes(1);

    interval$.next(Intervals.All);

    expect(loadAllHistory).toHaveBeenCalledTimes(1);

    secondLoad.resolve();

    await flushPromises();

    expect(setVisibleRange).toHaveBeenCalledTimes(1);
    expect(setVisibleRange).toHaveBeenCalledWith({
      from: 100,
      to: 200,
    });

    firstLoad.resolve();

    await flushPromises();

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

  it('не должен повторно регистрировать символы при смене только интервала', () => {
    const { chart, interval$, setSymbols } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals['1Y'],
    });

    chart.setupDataSourceSubs();

    expect(setSymbols).toHaveBeenCalledTimes(1);

    interval$.next(Intervals.All);

    expect(setSymbols).toHaveBeenCalledTimes(1);
    expect(setSymbols).toHaveBeenCalledWith(['SBER']);
  });

  it('должен зарегистрировать новый основной символ и применить к нему активный интервал', async () => {
    const { chart, symbolId$, setSymbols, loadTill } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals['1Y'],
    });

    chart.setupDataSourceSubs();

    await flushPromises();

    setSymbols.mockClear();
    loadTill.mockClear();

    symbolId$.next('GAZP');

    expect(setSymbols).toHaveBeenCalledTimes(1);
    expect(setSymbols).toHaveBeenCalledWith(['GAZP']);

    expect(loadTill).toHaveBeenCalledTimes(1);
    expect(loadTill).toHaveBeenCalledWith('GAZP', expect.any(Number));
  });

  it('должен загрузить всю историю для интервала All и применить общий диапазон', async () => {
    const { chart, loadAllHistory, getOldestTime, getLastCandle, setVisibleRange } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: ['GAZP'],
      interval: Intervals.All,
    });

    getOldestTime.mockImplementation((symbolId: string) => (symbolId === 'SBER' ? 100 : 50));

    getLastCandle.mockImplementation((symbolId: string) => ({
      time: symbolId === 'SBER' ? 200 : 300,
    }));

    chart.setupDataSourceSubs();

    expect(loadAllHistory).toHaveBeenCalledTimes(2);
    expect(loadAllHistory).toHaveBeenCalledWith('SBER');
    expect(loadAllHistory).toHaveBeenCalledWith('GAZP');

    await flushPromises();

    expect(setVisibleRange).toHaveBeenCalledTimes(1);
    expect(setVisibleRange).toHaveBeenCalledWith({
      from: 50,
      to: 300,
    });
  });

  it('должен прогреть добавленный символ при отсутствии активного интервала', async () => {
    const { chart, getVisibleRange, loadTill } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: null,
    });

    getVisibleRange.mockReturnValue({
      from: 100,
      to: 200,
    });

    chart.setupDataSourceSubs();

    expect(loadTill).toHaveBeenCalledTimes(1);
    expect(loadTill).toHaveBeenCalledWith('SBER', 100);

    await flushPromises();
  });

  it('не должен применять интервал после отписки', async () => {
    const loading = createDeferred();

    const { chart, loadTill, setVisibleRange } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals['1Y'],
    });

    loadTill.mockReturnValue(loading.promise);

    chart.setupDataSourceSubs();

    expect(loadTill).toHaveBeenCalledTimes(1);

    chart.subscriptions.unsubscribe();

    loading.resolve();

    await flushPromises();

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

  it('не должен повторно догружать историю, если старейшая свеча не изменилась', async () => {
    const { chart, loadMoreHistory, getOldestTime, getVisibleLogicalRange } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
    });

    chart.activeSymbolIds = ['SBER'];
    getOldestTime.mockReturnValue(100);

    chart.scheduleHistoryBatch();
    runNextAnimationFrame();

    await flushPromises();

    expect(loadMoreHistory).toHaveBeenCalledTimes(1);
    expect(getVisibleLogicalRange).not.toHaveBeenCalled();
    expect(animationFrameCallbacks).toHaveLength(0);
  });

  it('должен продолжить догрузку истории, если старейшая свеча сдвинулась назад', async () => {
    const { chart, loadMoreHistory, getOldestTime, getVisibleLogicalRange } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
    });

    let oldestTime = 100;

    chart.activeSymbolIds = ['SBER'];

    getOldestTime.mockImplementation(() => oldestTime);

    loadMoreHistory.mockImplementation(() => {
      oldestTime = 90;

      return Promise.resolve();
    });

    chart.scheduleHistoryBatch();
    runNextAnimationFrame();

    await flushPromises();

    expect(loadMoreHistory).toHaveBeenCalledTimes(1);
    expect(getVisibleLogicalRange).toHaveBeenCalledTimes(1);
    expect(animationFrameCallbacks).toHaveLength(1);
  });

  it('должен считать появление первой старейшей свечи прогрессом', async () => {
    const { chart, loadMoreHistory, getOldestTime } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
    });

    let oldestTime: number | null = null;

    chart.activeSymbolIds = ['SBER'];

    getOldestTime.mockImplementation(() => oldestTime);

    loadMoreHistory.mockImplementation(() => {
      oldestTime = 100;

      return Promise.resolve();
    });

    chart.scheduleHistoryBatch();
    runNextAnimationFrame();

    await flushPromises();

    expect(animationFrameCallbacks).toHaveLength(1);
  });

  it('не должен останавливать новую цепочку загрузки при смене символа во время запроса', async () => {
    const loading = createDeferred();

    const { chart, loadMoreHistory, getOldestTime } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
    });

    chart.activeSymbolIds = ['SBER'];

    getOldestTime.mockImplementation((symbolId: string) => (symbolId === 'SBER' ? 100 : 200));

    loadMoreHistory.mockReturnValue(loading.promise);

    chart.scheduleHistoryBatch();
    runNextAnimationFrame();

    chart.activeSymbolIds = ['GAZP'];

    loading.resolve();

    await flushPromises();

    expect(animationFrameCallbacks).toHaveLength(1);
  });

  it('не должен останавливать новую цепочку загрузки при смене таймфрейма во время запроса', async () => {
    const loading = createDeferred();

    const { chart, getTimeframe, loadMoreHistory, getOldestTime } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
    });

    let timeframe = '1m';

    chart.activeSymbolIds = ['SBER'];

    getTimeframe.mockImplementation(() => timeframe);
    getOldestTime.mockReturnValue(100);
    loadMoreHistory.mockReturnValue(loading.promise);

    chart.scheduleHistoryBatch();
    runNextAnimationFrame();

    timeframe = '5m';

    loading.resolve();

    await flushPromises();

    expect(animationFrameCallbacks).toHaveLength(1);
  });

  it('должен запускать фоновую догрузку для интервала All', async () => {
    const { chart, loadMoreHistory, mouseEventsSubscribe, setVisibleLogicalRange } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals.All,
    });

    chart.setupDataSourceSubs();
    chart.setupHistoricalDataLoading();

    const visibleLogicalRangeChangeHandler = mouseEventsSubscribe.mock.calls[0][1] as (
      logicalRange: { from: number; to: number } | null,
    ) => void;

    visibleLogicalRangeChangeHandler({ from: 0, to: 1000 });

    expect(setVisibleLogicalRange).toHaveBeenCalledWith({
      from: 0,
      to: 1000,
    });
    expect(animationFrameCallbacks).toHaveLength(1);

    runNextAnimationFrame();

    await flushPromises();

    expect(loadMoreHistory).toHaveBeenCalledTimes(1);
    expect(animationFrameCallbacks).toHaveLength(0);
  });

  it('не должен применять интервал к неактивному графику', async () => {
    const { chart, loadTill, setVisibleRange } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals['1Y'],
    });

    chart.active = false;
    chart.setupDataSourceSubs();

    await flushPromises();

    expect(loadTill).toHaveBeenCalledTimes(1);
    expect(setVisibleRange).not.toHaveBeenCalled();
  });

  it('не должен планировать догрузку для неактивного графика', () => {
    const { chart, loadMoreHistory } = createChartInternals();

    chart.activeSymbolIds = ['SBER'];
    chart.active = false;

    chart.scheduleHistoryBatch();

    expect(animationFrameCallbacks).toHaveLength(0);
    expect(loadMoreHistory).not.toHaveBeenCalled();
  });

  it('не должен начинать запрос, если график выключен до выполнения кадра', () => {
    const { chart, loadMoreHistory } = createChartInternals();

    chart.activeSymbolIds = ['SBER'];
    chart.scheduleHistoryBatch();
    chart.active = false;

    runNextAnimationFrame();

    expect(loadMoreHistory).not.toHaveBeenCalled();
    expect(chart.historyBatchRunning).toBe(false);
  });
});

interface ChartViewportInternals {
  setActive: (active: boolean) => void;
  preserveInactiveTimeScale: () => void;
  active: boolean;
  isPointerDown: boolean;
  savedViewport: {
    logicalRange: { from: number; to: number } | null;
    timeRange: { from: number; to: number } | null;
    oldestTimes: (number | null)[];
    shiftVisibleRangeOnNewBar: boolean;
    allowShiftVisibleRangeOnWhitespaceReplacement: boolean;
  } | null;
}

function createViewportContext() {
  const applyOptions = jest.fn();
  const setVisibleRange = jest.fn();
  const setVisibleLogicalRange = jest.fn();
  const getOldestTime = jest.fn(() => 100);
  const getVisibleRange: jest.Mock = jest.fn(() => ({ from: 120, to: 200 }));
  const getVisibleLogicalRange: jest.Mock = jest.fn(() => ({ from: 10, to: 30 }));
  const options = jest.fn(() => ({
    shiftVisibleRangeOnNewBar: true,
    allowShiftVisibleRangeOnWhitespaceReplacement: false,
  }));

  const chart = Object.assign(Object.create(Chart.prototype), {
    active: true,
    isPointerDown: true,
    savedViewport: null,
    activeSymbolIds: ['SBER'],
    dataSource: { getOldestTime },
    lwcChart: {
      timeScale: () => ({
        options,
        applyOptions,
        getVisibleRange,
        getVisibleLogicalRange,
        setVisibleRange,
        setVisibleLogicalRange,
      }),
    },
  }) as ChartViewportInternals;

  return {
    chart,
    options,
    applyOptions,
    getOldestTime,
    getVisibleRange,
    getVisibleLogicalRange,
    setVisibleRange,
    setVisibleLogicalRange,
  };
}

describe('Chart: сохранение видимой области', () => {
  it('должен сохранять видимую область и отключать автосдвиг неактивного графика', () => {
    const { chart, applyOptions } = createViewportContext();

    chart.setActive(false);

    expect(chart.active).toBe(false);
    expect(chart.isPointerDown).toBe(false);
    expect(chart.savedViewport).toEqual({
      logicalRange: { from: 10, to: 30 },
      timeRange: { from: 120, to: 200 },
      oldestTimes: [100],
      shiftVisibleRangeOnNewBar: true,
      allowShiftVisibleRangeOnWhitespaceReplacement: false,
    });
    expect(applyOptions).toHaveBeenCalledWith({
      shiftVisibleRangeOnNewBar: false,
      allowShiftVisibleRangeOnWhitespaceReplacement: false,
    });
  });

  it('не должен повторно сохранять область при неизменном состоянии', () => {
    const { chart, options, applyOptions } = createViewportContext();

    chart.setActive(false);
    chart.setActive(false);

    expect(options).toHaveBeenCalledTimes(1);
    expect(applyOptions).toHaveBeenCalledTimes(1);
  });

  it('должен восстанавливать логический диапазон при неизменной истории', () => {
    const { chart, applyOptions, setVisibleRange, setVisibleLogicalRange } = createViewportContext();

    chart.setActive(false);
    chart.setActive(true);

    expect(chart.savedViewport).toBeNull();
    expect(applyOptions).toHaveBeenLastCalledWith({
      shiftVisibleRangeOnNewBar: true,
      allowShiftVisibleRangeOnWhitespaceReplacement: false,
    });
    expect(setVisibleLogicalRange).toHaveBeenCalledWith({ from: 10, to: 30 });
    expect(setVisibleRange).not.toHaveBeenCalled();
  });

  it('должен восстанавливать временной диапазон после догрузки старых свечей', () => {
    const { chart, getOldestTime, setVisibleRange, setVisibleLogicalRange } = createViewportContext();

    chart.setActive(false);
    getOldestTime.mockReturnValue(50);
    chart.setActive(true);

    expect(setVisibleRange).toHaveBeenCalledWith({ from: 120, to: 200 });
    expect(setVisibleLogicalRange).not.toHaveBeenCalled();
  });

  it('должен использовать логический диапазон при отсутствии сохранённого временного', () => {
    const { chart, getOldestTime, getVisibleRange, setVisibleLogicalRange } = createViewportContext();

    getVisibleRange.mockReturnValue(null);
    chart.setActive(false);
    getOldestTime.mockReturnValue(50);
    chart.setActive(true);

    expect(setVisibleLogicalRange).toHaveBeenCalledWith({ from: 10, to: 30 });
  });

  it('не должен применять диапазоны, если они не были сохранены', () => {
    const { chart, getVisibleRange, getVisibleLogicalRange, setVisibleRange, setVisibleLogicalRange } =
      createViewportContext();

    getVisibleRange.mockReturnValue(null);
    getVisibleLogicalRange.mockReturnValue(null);
    chart.setActive(false);
    chart.setActive(true);

    expect(setVisibleRange).not.toHaveBeenCalled();
    expect(setVisibleLogicalRange).not.toHaveBeenCalled();
  });

  it('не должен применять настройки неактивности к активному графику', () => {
    const { chart, applyOptions } = createViewportContext();

    chart.preserveInactiveTimeScale();

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

  it('должен поддерживать активацию без сохранённого состояния', () => {
    const { chart, applyOptions } = createViewportContext();

    chart.active = false;
    chart.setActive(true);

    expect(chart.active).toBe(true);
    expect(chart.savedViewport).toBeNull();
    expect(applyOptions).not.toHaveBeenCalled();
  });
});

















import { EMPTY } from 'rxjs';

import { Keys } from '@core/Hotkeys';
import { type ChartCollectionPreset, type IMoexChart, MoexChart } from '@core/MoexChart';
import { ReactRenderer } from '@core/ReactRenderer';

import type { ReactElement } from 'react';

interface HeaderTestProps {
  copyImage?: () => Promise<void>;
  setChartView?: (view: unknown) => void;
  seasonalityStateObs: typeof EMPTY;
}

interface HeaderTestOptions {
  showSnapshotButton?: boolean;
  showSeasonalityButton?: boolean;
  seasonalityActive?: boolean;
}

function createTestContext() {
  const drawingsCollectionManager = {
    selectedDrawing: jest.fn(),
    updateSelectedDrawingSettings: jest.fn(),
    toggleSelectedDrawingLock: jest.fn(),
    openSelectedDrawingSettings: jest.fn(),
    deleteSelectedDrawing: jest.fn(),
  };

  const mainChart = {
    copyImage: jest.fn().mockResolvedValue(undefined),
    getDrawingsCollectionManager: () => drawingsCollectionManager,
  };

  const seasonalityManager = {
    back: jest.fn(),
    getState: jest.fn(() => EMPTY),
    isActive: jest.fn(() => false),
    copyImage: jest.fn().mockResolvedValue(undefined),
  };

  const eventManager = {
    clearSymbol: jest.fn(),
    setSymbol: jest.fn(),
    getTimeframeObs: jest.fn(),
    setTimeframe: jest.fn(),
    getSelectedSeries: jest.fn(),
    setSeriesSelected: jest.fn(),
  };

  const headerRenderer = { renderComponent: jest.fn() };
  const setChartView = jest.fn();

  const chart = Object.assign(Object.create(MoexChart.prototype), {
    chart: mainChart,
    seasonalityManager,
    eventManager,
    headerRenderer,
    drawingToolbarRenderer: { renderComponent: jest.fn() },
    toolbarRenderer: undefined,
    controlBarRenderer: undefined,
    footerRenderer: undefined,
    modalRenderer: {},
    fullscreen: {},
    setChartView,
  }) as MoexChart;

  return {
    chart,
    mainChart,
    seasonalityManager,
    eventManager,
    headerRenderer,
    setChartView,
  };
}

function renderHeader({
  showSnapshotButton,
  showSeasonalityButton,
  seasonalityActive = false,
}: HeaderTestOptions = {}) {
  const context = createTestContext();

  context.seasonalityManager.isActive.mockReturnValue(seasonalityActive);

  const config = {
    chartCollectionPreset: {
      supportedTimeframes: [],
      supportedChartSeriesTypes: [],
      showSettingsButton: false,
      showMenuButton: false,
      showFullscreenButton: false,
      undoRedoEnabled: false,
      showCompareButton: false,
      showSnapshotButton,
      showSeasonalityButton,
      theme: 'tr',
    },
  } as unknown as IMoexChart;

  const internals = context.chart as unknown as {
    renderAttachments: (config: IMoexChart, toggleToolbar: () => boolean) => void;
  };

  internals.renderAttachments(
    config,
    jest.fn(() => false),
  );

  const header = context.headerRenderer.renderComponent.mock.calls[0][0] as ReactElement<HeaderTestProps>;

  return { ...context, header };
}

describe('MoexChart', () => {
  it('должен выйти из сезонности перед очисткой основного инструмента', () => {
    const { chart, eventManager, seasonalityManager } = createTestContext();

    chart.clearSymbol();

    expect(seasonalityManager.back).toHaveBeenCalledTimes(1);
    expect(eventManager.clearSymbol).toHaveBeenCalledTimes(1);

    expect(seasonalityManager.back.mock.invocationCallOrder[0]).toBeLessThan(
      eventManager.clearSymbol.mock.invocationCallOrder[0],
    );
  });

  it('должен выйти из сезонности перед установкой основного инструмента', () => {
    const { chart, eventManager, seasonalityManager } = createTestContext();

    const symbol = {
      symbolId: 'SBER',
      symbol: 'SBER',
      symbolName: 'Sberbank',
    };

    chart.setSymbol(symbol);

    expect(seasonalityManager.back).toHaveBeenCalledTimes(1);
    expect(eventManager.setSymbol).toHaveBeenCalledTimes(1);
    expect(eventManager.setSymbol).toHaveBeenCalledWith(symbol);

    expect(seasonalityManager.back.mock.invocationCallOrder[0]).toBeLessThan(
      eventManager.setSymbol.mock.invocationCallOrder[0],
    );
  });

  it('должен копировать основной график, если сезонность неактивна', async () => {
    const { header, mainChart, seasonalityManager } = renderHeader({
      showSnapshotButton: true,
    });

    expect(header.props.copyImage).toEqual(expect.any(Function));

    await header.props.copyImage?.();

    expect(mainChart.copyImage).toHaveBeenCalledTimes(1);
    expect(seasonalityManager.copyImage).not.toHaveBeenCalled();
  });

  it('должен копировать сезонность, если она активна', async () => {
    const { header, mainChart, seasonalityManager } = renderHeader({
      showSnapshotButton: true,
      seasonalityActive: true,
    });

    expect(header.props.copyImage).toEqual(expect.any(Function));

    await header.props.copyImage?.();

    expect(seasonalityManager.copyImage).toHaveBeenCalledTimes(1);
    expect(mainChart.copyImage).not.toHaveBeenCalled();
  });

  it.each([undefined, false])('не должен передавать copyImage при showSnapshotButton=%s', (showSnapshotButton) => {
    const { header, mainChart, seasonalityManager } = renderHeader({
      showSnapshotButton,
    });

    expect(header.props.copyImage).toBeUndefined();
    expect(mainChart.copyImage).not.toHaveBeenCalled();
    expect(seasonalityManager.copyImage).not.toHaveBeenCalled();
  });

  it('должен передавать переключение вида при включённой сезонности', () => {
    const { header, setChartView } = renderHeader({
      showSeasonalityButton: true,
    });

    expect(header.props.setChartView).toBe(setChartView);
  });

  it.each([undefined, false])(
    'не должен передавать переключение вида при showSeasonalityButton=%s',
    (showSeasonalityButton) => {
      const { header } = renderHeader({ showSeasonalityButton });

      expect(header.props.setChartView).toBeUndefined();
    },
  );

  it('должен передавать состояние сезонности в Header', () => {
    const { header, seasonalityManager } = renderHeader();

    expect(seasonalityManager.getState).toHaveBeenCalledTimes(1);
    expect(header.props.seasonalityStateObs).toBe(EMPTY);
  });
});

jest.mock('@core/ReactRenderer', () => ({
  ReactRenderer: jest.fn(() => ({
    renderComponent: jest.fn(),
    destroy: jest.fn(),
  })),
}));

interface RendererContainers {
  headerContainer: HTMLElement;
  toolBarContainer: HTMLElement;
  drawingToolbarContainer: HTMLElement;
  controlBarContainer: HTMLElement;
  footerContainer: HTMLElement;
}

interface ChartSetupInternals {
  registerUndoRedoHotkeys: (enabled?: boolean) => void;
  createRenderers: (preset: ChartCollectionPreset, containers: RendererContainers) => void;
  headerRenderer?: object;
  toolbarRenderer?: object;
  drawingToolbarRenderer?: object;
  controlBarRenderer?: object;
  footerRenderer?: object;
}

function createSetupContext() {
  const register = jest.fn();
  const undo = jest.fn();
  const redo = jest.fn();
  const getUndoRedo = jest.fn(() => ({ undo, redo }));
  const containers: RendererContainers = {
    headerContainer: document.createElement('div'),
    toolBarContainer: document.createElement('div'),
    drawingToolbarContainer: document.createElement('div'),
    controlBarContainer: document.createElement('div'),
    footerContainer: document.createElement('div'),
  };

  const chart = Object.assign(Object.create(MoexChart.prototype), {
    hotkeys: { register },
    eventManager: { getUndoRedo },
    headerRenderer: undefined,
    toolbarRenderer: undefined,
    drawingToolbarRenderer: undefined,
    controlBarRenderer: undefined,
    footerRenderer: undefined,
  }) as ChartSetupInternals;

  return { chart, containers, register, getUndoRedo, undo, redo };
}

describe('MoexChart: настройка интерфейса', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it.each([undefined, false])('не должен регистрировать историю действий при enabled=%s', (enabled) => {
    const { chart, register, getUndoRedo } = createSetupContext();

    chart.registerUndoRedoHotkeys(enabled);

    expect(getUndoRedo).not.toHaveBeenCalled();
    expect(register).not.toHaveBeenCalled();
  });

  it('должен регистрировать undo и оба варианта redo', () => {
    const { chart, register, undo, redo } = createSetupContext();

    chart.registerUndoRedoHotkeys(true);

    expect(register).toHaveBeenCalledTimes(3);
    expect(register).toHaveBeenNthCalledWith(1, { keys: [Keys.mod, Keys.z], callback: undo });
    expect(register).toHaveBeenNthCalledWith(2, { keys: [Keys.mod, Keys.y], callback: redo });
    expect(register).toHaveBeenNthCalledWith(3, { keys: [Keys.mod, Keys.shift, Keys.z], callback: redo });
  });

  it('должен создавать только обязательные рендереры при выключенных панелях', () => {
    const { chart, containers } = createSetupContext();

    chart.createRenderers({} as ChartCollectionPreset, containers);

    expect(ReactRenderer).toHaveBeenCalledTimes(3);
    expect(ReactRenderer).toHaveBeenNthCalledWith(1, containers.headerContainer);
    expect(ReactRenderer).toHaveBeenNthCalledWith(2, containers.toolBarContainer);
    expect(ReactRenderer).toHaveBeenNthCalledWith(3, containers.drawingToolbarContainer);
    expect(chart.headerRenderer).toBeDefined();
    expect(chart.toolbarRenderer).toBeDefined();
    expect(chart.drawingToolbarRenderer).toBeDefined();
    expect(chart.controlBarRenderer).toBeUndefined();
    expect(chart.footerRenderer).toBeUndefined();
  });

  it('должен создавать обе дополнительные панели при включённых флагах', () => {
    const { chart, containers } = createSetupContext();

    chart.createRenderers({ showControlBar: true, showBottomPanel: true } as ChartCollectionPreset, containers);

    expect(ReactRenderer).toHaveBeenCalledTimes(5);
    expect(ReactRenderer).toHaveBeenCalledWith(containers.controlBarContainer);
    expect(ReactRenderer).toHaveBeenCalledWith(containers.footerContainer);
    expect(chart.controlBarRenderer).toBeDefined();
    expect(chart.footerRenderer).toBeDefined();
  });

  it.each([
    { showControlBar: true, showBottomPanel: false },
    { showControlBar: false, showBottomPanel: true },
  ])('должен независимо учитывать флаги панелей %j', (preset) => {
    const { chart, containers } = createSetupContext();

    chart.createRenderers(preset as ChartCollectionPreset, containers);

    expect(ReactRenderer).toHaveBeenCalledTimes(4);
    expect(Boolean(chart.controlBarRenderer)).toBe(preset.showControlBar);
    expect(Boolean(chart.footerRenderer)).toBe(preset.showBottomPanel);
  });
});

















import { createChart, createSeriesMarkers, PriceScaleMode } from 'lightweight-charts';
import type { ChartOptions, DeepPartial, LineData, LineSeriesPartialOptions, WhitespaceData } from 'lightweight-charts';

import { createInitialSeasonalityState } from '@src/constants';
import { SeasonalityManager } from '@core/SeasonalityManager';
import { ChartViewType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { getThemeStore } from '@src/theme/store';
import { canvasToBlob, drawTextLayer } from '@src/utils';

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

interface SeriesTestContext {
  options: LineSeriesPartialOptions;
  applyOptions: jest.Mock;
  setData: jest.Mock;
  priceFormatter: () => { format: (value: number) => string };
}

interface ChartTestContext {
  options: DeepPartial<ChartOptions>;
  series: SeriesTestContext[];
  addSeries: jest.Mock;
  applyOptions: jest.Mock;
  removeSeries: jest.Mock;
  remove: jest.Mock;
  timeScale: () => { setVisibleRange: jest.Mock };
  subscribeCrosshairMove: jest.Mock;
  unsubscribeCrosshairMove: jest.Mock;
  takeScreenshot: jest.Mock;
  chartElement: () => HTMLElement;
}

interface MarkersTestContext {
  setMarkers: jest.Mock;
  detach: jest.Mock;
}

const mocks = {
  charts: [] as ChartTestContext[],
  markers: [] as MarkersTestContext[],
};

function createSeriesContext(options: LineSeriesPartialOptions): SeriesTestContext {
  return {
    options,
    applyOptions: jest.fn(),
    setData: jest.fn(),
    priceFormatter: () => ({ format: (value: number) => value.toFixed(2) }),
  };
}

function createChartContext(container: HTMLElement, options: DeepPartial<ChartOptions>): ChartTestContext {
  const timeScale = { setVisibleRange: jest.fn() };
  const series: SeriesTestContext[] = [];

  return {
    options,
    series,
    addSeries: jest.fn((_type: unknown, seriesOptions: LineSeriesPartialOptions) => {
      const item = createSeriesContext(seriesOptions);

      series.push(item);

      return item;
    }),
    applyOptions: jest.fn(),
    removeSeries: jest.fn(),
    remove: jest.fn(),
    timeScale: () => timeScale,
    subscribeCrosshairMove: jest.fn(),
    unsubscribeCrosshairMove: jest.fn(),
    takeScreenshot: jest.fn(() => document.createElement('canvas')),
    chartElement: () => container,
  };
}

jest.mock('lightweight-charts', () => ({
  ...jest.requireActual('lightweight-charts'),
  createChart: jest.fn(),
  createSeriesMarkers: jest.fn(),
}));

jest.mock('@src/theme/store', () => ({
  getThemeStore: jest.fn(),
}));

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

jest.mock('@src/utils', () => ({
  ...jest.requireActual('@src/utils/seasonality'),
  formatCompactNumber: (value: number) => String(value),
  canvasToBlob: jest.fn(async () => new Blob(['image'], { type: 'image/png' })),
  drawTextLayer: jest.fn(),
}));

function getLastCall(mock: jest.Mock) {
  return mock.mock.calls[mock.mock.calls.length - 1];
}

function createCandle(date: string, close = 100, milliseconds = false): Candle {
  return {
    time: Date.parse(`${date}T12:00:00Z`) / (milliseconds ? 1 : 1000),
    open: close,
    high: close,
    low: close,
    close,
    volume: 1,
  };
}

function createDeferred<T>() {
  let resolve!: (value: T) => void;
  let reject!: (reason: unknown) => void;

  const promise = new Promise<T>((yes, no) => {
    resolve = yes;
    reject = no;
  });

  return { promise, resolve, reject };
}

let container: HTMLDivElement;
let manager: SeasonalityManager;
let getData: jest.Mock;
let onOpen: jest.Mock;
let onBack: jest.Mock;
let state: ReturnType<typeof createInitialSeasonalityState>;
let frames: Map<number, FrameRequestCallback>;
let nextFrame: number;
let clipboardDescriptor: PropertyDescriptor | undefined;
let clipboardItemDescriptor: PropertyDescriptor | undefined;

const colors = {
  chartBackground: '#ffffff',
  chartTextPrimary: '#111111',
  chartPaneSeparator: '#222222',
  chartGridLine: '#333333',
  chartCrosshairLine: '#444444',
  chartCrosshairLabel: '#555555',
};

function createManager() {
  manager = new SeasonalityManager({
    container,
    getData,
    onOpen,
    onBack,
    precision: 3,
  });

  manager.getState().subscribe((value) => {
    state = value;
  });
}

function flushAnimationFrames() {
  const callbacks = [...frames.values()];

  frames.clear();

  callbacks.forEach((callback) => callback(0));
}

async function loadSampleHistory() {
  getData
    .mockResolvedValueOnce([
      createCandle('2020-01-01'),
      createCandle('2023-01-01'),
      createCandle('2025-01-01'),
      createCandle('2025-01-02', 125),
    ])
    .mockResolvedValueOnce(null);

  await manager.show('AAA', { fromYear: 2020, toYear: 2025 });
}

beforeEach(() => {
  clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
  clipboardItemDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'ClipboardItem');

  jest.useFakeTimers();
  jest.setSystemTime(new Date('2025-06-01T12:00:00Z'));

  jest.spyOn(console, 'error').mockImplementation(() => {});

  mocks.charts.length = 0;
  mocks.markers.length = 0;

  (createChart as jest.Mock).mockImplementation((element: HTMLElement, options: DeepPartial<ChartOptions>) => {
    const chart = createChartContext(element, options);

    mocks.charts.push(chart);

    return chart;
  });

  (createSeriesMarkers as jest.Mock).mockImplementation(() => {
    const markers = {
      setMarkers: jest.fn(),
      detach: jest.fn(),
    };

    mocks.markers.push(markers);

    return markers;
  });

  frames = new Map();
  nextFrame = 0;

  jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
    frames.set(++nextFrame, callback);

    return nextFrame;
  });

  jest.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => {
    frames.delete(id);
  });

  jest.mocked(getThemeStore).mockReturnValue({ colors } as ReturnType<typeof getThemeStore>);

  getData = jest.fn();
  onOpen = jest.fn();
  onBack = jest.fn();

  container = document.createElement('div');
  document.body.append(container);

  state = createInitialSeasonalityState();

  createManager();
});

afterEach(() => {
  manager.destroy();
  container.remove();

  if (clipboardDescriptor) {
    Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
  } else {
    Reflect.deleteProperty(navigator, 'clipboard');
  }

  if (clipboardItemDescriptor) {
    Object.defineProperty(globalThis, 'ClipboardItem', clipboardItemDescriptor);
  } else {
    Reflect.deleteProperty(globalThis, 'ClipboardItem');
  }

  jest.restoreAllMocks();
  jest.clearAllMocks();
  jest.useRealTimers();
});

describe('SeasonalityManager: загрузка истории', () => {
  it('должен загружать дневные страницы, нормализовать время и удалять граничные дубликаты', async () => {
    const boundary = createCandle('2025-01-02', 125);

    getData
      .mockResolvedValueOnce([boundary, createCandle('2025-01-03', 150, true)])
      .mockResolvedValueOnce([createCandle('2025-01-01', 100), boundary])
      .mockResolvedValueOnce(null);

    await manager.show('AAA');

    expect(state.status).toBe('ready');
    expect(getData.mock.calls.map((args) => args.slice(0, 2))).toEqual(
      Array(3).fill([Timeframes['1d'], 'AAA']),
    );
    expect(getData.mock.calls[1][2].time).toBe(boundary.time);

    manager.setScale(PriceScaleMode.Normal);

    const data = getLastCall(mocks.charts[0].series[0].setData)[0];

    expect(data.filter((point: LineData | WhitespaceData) => 'value' in point)).toEqual([
      { time: '2000-01-01', value: 100 },
      { time: '2000-01-02', value: 125 },
      { time: '2000-01-03', value: 150 },
    ]);
    expect(onOpen).toHaveBeenCalledTimes(1);
  });

  it('должен завершать загрузку при повторении только граничной свечи', async () => {
    const first = createCandle('2025-01-01');

    getData.mockResolvedValueOnce([first]).mockResolvedValueOnce([first]);

    await manager.show('AAA');

    expect(state.status).toBe('ready');
    expect(getData).toHaveBeenCalledTimes(2);
  });

  it('должен показывать ошибку при отсутствии прогресса пагинации', async () => {
    getData
      .mockResolvedValueOnce([createCandle('2025-01-01')])
      .mockResolvedValueOnce([createCandle('2025-01-02')]);

    await manager.show('AAA');

    expect(state.status).toBe('error');
    expect(state.error).toContain('made no progress');
  });

  it.each([{ page: null }, { page: [] }])(
    'должен показывать пустое состояние для страницы $page',
    async ({ page }) => {
      getData.mockResolvedValueOnce(page);

      await manager.show('AAA');

      expect(state.status).toBe('empty');
      expect(mocks.charts).toHaveLength(0);
      expect(container.textContent).toContain('No seasonality data');
    },
  );

  it('не должен запрашивать историю без выбранного инструмента', async () => {
    await manager.show('');

    expect(state.status).toBe('error');
    expect(getData).not.toHaveBeenCalled();
  });

  it.each(['time', 'close'] as const)('должен отклонять некорректное поле %s', async (field) => {
    getData.mockResolvedValueOnce([
      {
        ...createCandle('2025-01-01'),
        [field]: NaN,
      },
    ]);

    await manager.show('AAA');

    expect(state.status).toBe('error');
    expect(state.error).toContain('Invalid daily candle');
  });

  it('должен повторно загружать историю после ошибки', async () => {
    getData.mockRejectedValueOnce(new Error('network'));

    await manager.show('AAA');

    expect(state.status).toBe('error');

    await loadSampleHistory();

    expect(state.status).toBe('ready');
    expect(state.error).toBeNull();
  });

  it('не должен применять ответ после возврата к основному графику', async () => {
    const request = createDeferred<Candle[]>();

    getData.mockReturnValueOnce(request.promise);

    const loading = manager.show('AAA');

    expect(state.status).toBe('loading');

    manager.back();
    request.resolve([createCandle('2025-01-01')]);

    await loading;

    expect(state.view).toBe(ChartViewType.Chart);
    expect(state.status).toBe('idle');
    expect(mocks.charts).toHaveLength(0);
    expect(onBack).toHaveBeenCalledTimes(1);
  });

  it('не должен применять ошибку предыдущего инструмента', async () => {
    const old = createDeferred<Candle[]>();

    getData.mockReturnValueOnce(old.promise);

    const loading = manager.show('OLD');

    getData
      .mockResolvedValueOnce([createCandle('2025-01-01')])
      .mockResolvedValueOnce(null);

    await manager.show('NEW');

    old.reject(new Error('late failure'));

    await loading;

    expect(state.symbolId).toBe('NEW');
    expect(state.status).toBe('ready');
    expect(console.error).not.toHaveBeenCalled();
  });

  it('не должен применять данные предыдущего инструмента', async () => {
    const old = createDeferred<Candle[]>();

    getData.mockReturnValueOnce(old.promise);

    const loading = manager.show('OLD');

    getData
      .mockResolvedValueOnce([createCandle('2025-01-01')])
      .mockResolvedValueOnce(null);

    await manager.show('NEW');

    old.resolve([createCandle('2020-01-01')]);

    await loading;

    expect(state.symbolId).toBe('NEW');
    expect(state.minYear).toBe(2025);
    expect(mocks.charts).toHaveLength(1);
  });

  it('не должен повторно запрашивать загружаемый инструмент', async () => {
    const request = createDeferred<Candle[]>();

    getData.mockReturnValueOnce(request.promise);

    const loading = manager.show('AAA');

    await manager.show('AAA');

    expect(getData).toHaveBeenCalledTimes(1);

    request.resolve([]);

    await loading;
  });

  it('должен использовать загруженную историю при изменении диапазона', async () => {
    await loadSampleHistory();

    const count = getData.mock.calls.length;

    await manager.show('AAA', { fromYear: 2025, toYear: 2025 });

    flushAnimationFrames();

    expect(getData).toHaveBeenCalledTimes(count);
    expect(state.fromYear).toBe(2025);
    expect(mocks.charts[0].removeSeries).toHaveBeenCalledTimes(2);
  });

  it('должен выбирать последние пять календарных лет по умолчанию', async () => {
    getData
      .mockResolvedValueOnce([
        createCandle('2020-01-01'),
        createCandle('2025-01-01'),
      ])
      .mockResolvedValueOnce(null);

    await manager.show('AAA');

    expect(state.fromYear).toBe(2021);
    expect(state.toYear).toBe(2025);
  });
});

describe('SeasonalityManager: отображение и очистка', () => {
  it('должен объединять изменения диапазона в один кадр и применять последнее значение', async () => {
    await loadSampleHistory();

    manager.setRange({ fromYear: 2020, toYear: 2023 });
    manager.setRange({ fromYear: 2023, toYear: 2023 });

    expect(frames.size).toBe(1);

    flushAnimationFrames();

    expect(state.status).toBe('ready');
    expect(container.textContent).toContain('2023');
    expect(container.textContent).not.toContain('2025');
    expect(mocks.markers[0].detach).toHaveBeenCalledTimes(1);
  });

  it('не должен перерисовывать прежний диапазон и должен поддерживать диапазон без данных', async () => {
    await loadSampleHistory();

    manager.setRange({ fromYear: 2020, toYear: 2025 });

    expect(frames.size).toBe(0);

    manager.setRange({ fromYear: 2021, toYear: 2022 });

    flushAnimationFrames();

    expect(state.status).toBe('empty');
    expect(container.textContent).toContain('No seasonality data');
  });

  it('должен переключать формат значений без пересоздания графика', async () => {
    await loadSampleHistory();

    manager.setScale(PriceScaleMode.Normal);
    manager.setScale(PriceScaleMode.Percentage);

    const series = mocks.charts[0].series[mocks.charts[0].series.length - 1];
    const priceFormat = getLastCall(series.applyOptions)[0].priceFormat;

    expect(priceFormat.formatter(12.5)).toBe('12.50%');
    expect(priceFormat.minMove).toBe(0.01);

    manager.setScale(PriceScaleMode.Normal);

    expect(getLastCall(series.applyOptions)[0].priceFormat.minMove).toBe(0.001);
    expect(mocks.charts).toHaveLength(1);
    expect(mocks.charts[0].timeScale().setVisibleRange).toHaveBeenCalledWith({
      from: '2000-01-01',
      to: '2000-12-31',
    });
    expect(getLastCall(mocks.markers[0].setMarkers)[0][0].time).toBe('2000-01-02');
  });

  it('должен показывать предупреждение о базе только для процентной шкалы', async () => {
    getData
      .mockResolvedValueOnce([createCandle('2025-01-01', 0)])
      .mockResolvedValueOnce(null);

    await manager.show('AAA');

    manager.setScale(PriceScaleMode.Percentage);

    expect(container.textContent).toContain('Percentage requires a positive baseline');
    expect(getLastCall(mocks.markers[0].setMarkers)[0]).toEqual([]);

    manager.setScale(PriceScaleMode.Normal);

    expect((container.lastElementChild as HTMLElement).style.display).toBe('none');
  });

  it('должен применять актуальные цвета темы при создании и обновлении графика', async () => {
    await loadSampleHistory();

    expect(mocks.charts[0].options.crosshair).toMatchObject({
      vertLine: {
        color: colors.chartCrosshairLine,
        labelBackgroundColor: colors.chartCrosshairLabel,
      },
      horzLine: {
        color: colors.chartCrosshairLine,
        labelBackgroundColor: colors.chartCrosshairLabel,
      },
    });

    jest.mocked(getThemeStore).mockReturnValue({
      colors: {
        ...colors,
        chartTextPrimary: '#999999',
        chartCrosshairLine: '#aabbcc',
        chartCrosshairLabel: '#ddeeff',
      },
    } as ReturnType<typeof getThemeStore>);

    manager.updateTheme();

    expect(getLastCall(mocks.charts[0].applyOptions)[0]).toMatchObject({
      layout: {
        textColor: '#999999',
      },
      crosshair: {
        vertLine: {
          color: '#aabbcc',
          labelBackgroundColor: '#ddeeff',
        },
        horzLine: {
          color: '#aabbcc',
          labelBackgroundColor: '#ddeeff',
        },
      },
    });
  });

  it('должен применять цвета перекрестия из темы', async () => {
    await loadSampleHistory();

    expect(mocks.charts[0].options.crosshair?.vertLine).toMatchObject({
      color: colors.chartCrosshairLine,
      labelBackgroundColor: colors.chartCrosshairLabel,
    });
  });

  it('должен форматировать все варианты времени как день и месяц', async () => {
    await loadSampleHistory();

    const formatter = mocks.charts[0].options.localization?.timeFormatter;

    expect(formatter?.({ year: 2000, month: 2, day: 9 })).toBe('09.02');
    expect(formatter?.('2000-02-09')).toBe('09.02');
    expect(formatter?.(Date.UTC(2000, 1, 9) / 1000)).toBe('09.02');
  });

  it('должен обновлять легенду по перекрестию и обозначать отсутствующие значения', async () => {
    await loadSampleHistory();

    const chart = mocks.charts[0];
    const listener = chart.subscribeCrosshairMove.mock.calls[0][0];

    listener({
      time: '2000-01-01',
      seriesData: new Map([[chart.series[0], { value: 42 }]]),
    });

    expect(container.textContent).toContain('42.00');
    expect(container.textContent).toContain('—');

    listener({
      time: undefined,
      seriesData: new Map(),
    });

    expect(container.textContent).not.toContain('—');
  });

  it('должен копировать готовый график вместе с легендой', async () => {
    const write = jest.fn().mockResolvedValue(undefined);

    Object.defineProperty(navigator, 'clipboard', {
      configurable: true,
      value: { write },
    });

    Object.defineProperty(globalThis, 'ClipboardItem', {
      configurable: true,
      value: class {
        constructor(public data: Record<string, Blob>) {}
      },
    });

    await manager.copyImage();

    expect(write).not.toHaveBeenCalled();

    await loadSampleHistory();
    await manager.copyImage();

    expect(mocks.charts[0].takeScreenshot).toHaveBeenCalledWith(true, false);
    expect(drawTextLayer).toHaveBeenCalledTimes(1);
    expect(canvasToBlob).toHaveBeenCalledTimes(1);
    expect(write).toHaveBeenCalledTimes(1);

    const items = write.mock.calls[0][0];

    expect(items[0].data['image/png']).toBeInstanceOf(Blob);

    Reflect.deleteProperty(navigator, 'clipboard');
  });

  it('должен отменять кадры и освобождать ресурсы при возврате к графику', async () => {
    await loadSampleHistory();

    manager.setRange({ fromYear: 2023, toYear: 2025 });

    const chart = mocks.charts[0];

    manager.back();
    manager.back();

    expect(frames.size).toBe(0);
    expect(chart.remove).toHaveBeenCalledTimes(1);
    expect(chart.unsubscribeCrosshairMove).toHaveBeenCalledWith(
      chart.subscribeCrosshairMove.mock.calls[0][0],
    );
    expect(mocks.markers[0].detach).toHaveBeenCalledTimes(1);
    expect(onBack).toHaveBeenCalledTimes(1);
  });

  it('должен завершать состояние и игнорировать ответ после уничтожения', async () => {
    const complete = jest.fn();

    manager.getState().subscribe({ complete });

    const request = createDeferred<Candle[]>();

    getData.mockReturnValueOnce(request.promise);

    const loading = manager.show('AAA');

    manager.destroy();
    manager.destroy();

    request.resolve([createCandle('2025-01-01')]);

    await loading;
    await manager.show('BBB');

    manager.setRange({ fromYear: 2020, toYear: 2025 });
    manager.setScale(PriceScaleMode.Normal);

    expect(complete).toHaveBeenCalledTimes(1);
    expect(container.children).toHaveLength(0);
    expect(mocks.charts).toHaveLength(0);
    expect(getData).toHaveBeenCalledTimes(1);
  });

  it('не должен изменять диапазон и шкалу неактивной сезонности', () => {
    const initial = state;

    manager.setRange({ fromYear: 2020, toYear: 2025 });
    manager.setScale(PriceScaleMode.Normal);

    expect(state).toBe(initial);
    expect(frames.size).toBe(0);
  });
});

















import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';

import { SymbolSource } from '@core/SymbolSource';
import { Timeframes } from '@src/types/timeframes';

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

dayjs.extend(utc);

jest.mock('@src/utils', () => ({
  normalizeSeriesData: (candles: Candle[]) =>
    [...new Map(candles.map((candle) => [candle.time, candle])).values()].sort(
      (first, second) => first.time - second.time,
    ),
  parseTimeframe: () => ({
    candleWidth: 1,
    dayjsUnit: 'day',
  }),
}));

function createCandle(day: number, close = day): Candle {
  return {
    time: Date.UTC(2025, 0, day) / 1000,
    open: close,
    high: close,
    low: close,
    close,
    volume: 1,
  };
}

function createDeferred<T>() {
  let resolve!: (value: T) => void;
  let reject!: (reason: unknown) => void;

  const promise = new Promise<T>((yes, no) => {
    resolve = yes;
    reject = no;
  });

  return { promise, resolve, reject };
}

let source: SymbolSource;
let getData: jest.Mock;
let data: Candle[];

async function initialize(candles: Candle[] = [createCandle(2), createCandle(3)]) {
  getData.mockResolvedValueOnce(candles);

  await source.reload(Timeframes['1d']);
}

function realCandles() {
  return data.filter((item) => item.open !== undefined);
}

beforeEach(() => {
  getData = jest.fn().mockResolvedValue(null);

  source = new SymbolSource({
    symbol: 'AAA',
    getData,
    getTimeframe: () => Timeframes['1d'],
  });

  data = [];

  source.data$().subscribe((value) => {
    data = value;
  });

  jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
  source.destroy();
  jest.restoreAllMocks();
});

describe('SymbolSource.loadMoreHistory', () => {
  it('должен объединять историю и сохранять текущую граничную свечу', async () => {
    await initialize();

    getData.mockResolvedValueOnce([
      createCandle(2, 999),
      createCandle(1),
    ]);

    await source.loadMoreHistory();

    expect(getData).toHaveBeenLastCalledWith(
      Timeframes['1d'],
      'AAA',
      createCandle(2),
    );
    expect(realCandles()).toEqual([
      createCandle(1),
      createCandle(2),
      createCandle(3),
    ]);
    expect(source.getOldestTime()).toBe(createCandle(1).time);
    expect(source.getLastValue()).toEqual(createCandle(3));
    expect(source.isLoadingValue()).toBe(false);
    expect(data).toHaveLength(5003);
  });

  it('должен использовать один запрос для параллельных вызовов догрузки', async () => {
    await initialize();

    const request = createDeferred<Candle[] | null>();

    getData.mockReturnValueOnce(request.promise);

    const first = source.loadMoreHistory();
    const second = source.loadMoreHistory();

    await Promise.resolve();

    expect(getData).toHaveBeenCalledTimes(2);
    expect(source.isLoadingValue()).toBe(true);

    request.resolve([createCandle(1)]);

    await Promise.all([first, second]);

    expect(realCandles()).toEqual([
      createCandle(1),
      createCandle(2),
      createCandle(3),
    ]);
  });

  it('не должен догружать историю после получения null', async () => {
    await initialize();

    getData.mockResolvedValueOnce(null);

    await source.loadMoreHistory();
    await source.loadMoreHistory();

    expect(getData).toHaveBeenCalledTimes(2);
    expect(source.getOldestTime()).toBe(createCandle(2).time);
    expect(source.isLoadingValue()).toBe(false);
  });

  it('должен разрешать повторную попытку после пустой страницы', async () => {
    await initialize();

    getData
      .mockResolvedValueOnce([])
      .mockResolvedValueOnce([createCandle(1)]);

    await source.loadMoreHistory();

    expect(source.getOldestTime()).toBe(createCandle(2).time);

    await source.loadMoreHistory();

    expect(source.getOldestTime()).toBe(createCandle(1).time);
    expect(getData).toHaveBeenCalledTimes(3);
  });

  it('должен игнорировать страницу без более ранних свечей и разрешать повторную попытку', async () => {
    await initialize();

    getData
      .mockResolvedValueOnce([createCandle(2), createCandle(3)])
      .mockResolvedValueOnce([createCandle(1)]);

    await source.loadMoreHistory();

    expect(realCandles()).toEqual([
      createCandle(2),
      createCandle(3),
    ]);

    await source.loadMoreHistory();

    expect(source.getOldestTime()).toBe(createCandle(1).time);
  });

  it('не должен догружать историю без старейшей свечи', async () => {
    await initialize([]);

    await source.loadMoreHistory();

    expect(getData).toHaveBeenCalledTimes(1);
    expect(source.getOldestTime()).toBeNull();
  });

  it('должен сбрасывать состояние загрузки после ошибки и разрешать повторную попытку', async () => {
    await initialize();

    const error = new Error('network');

    getData
      .mockRejectedValueOnce(error)
      .mockResolvedValueOnce([createCandle(1)]);

    await expect(source.loadMoreHistory()).resolves.toBeUndefined();

    expect(source.isLoadingValue()).toBe(false);
    expect(console.error).toHaveBeenCalledWith(
      '[DataSource] Ошибка при догрузке истории:',
      error,
    );

    await source.loadMoreHistory();

    expect(source.getOldestTime()).toBe(createCandle(1).time);
  });

  it('должен игнорировать старую страницу и сохранять состояние новой загрузки', async () => {
    await initialize();

    const page = createDeferred<Candle[] | null>();
    const reload = createDeferred<Candle[] | null>();

    getData
      .mockReturnValueOnce(page.promise)
      .mockReturnValueOnce(reload.promise);

    const loading = source.loadMoreHistory();

    await Promise.resolve();

    const reloading = source.reload(Timeframes['1d']);

    page.resolve([createCandle(1)]);

    await loading;

    expect(realCandles()).toEqual([]);
    expect(source.isLoadingValue()).toBe(true);

    reload.resolve([createCandle(10)]);

    await reloading;

    expect(realCandles()).toEqual([createCandle(10)]);
    expect(source.isLoadingValue()).toBe(false);
  });

  it('не должен завершать новую историю из-за устаревшего ответа null', async () => {
    await initialize();

    const page = createDeferred<Candle[] | null>();

    getData.mockReturnValueOnce(page.promise);

    const loading = source.loadMoreHistory();

    await Promise.resolve();

    await initialize([createCandle(10)]);

    page.resolve(null);

    await loading;

    getData.mockResolvedValueOnce([createCandle(9)]);

    await source.loadMoreHistory();

    expect(source.getOldestTime()).toBe(createCandle(9).time);
  });

  it('не должен публиковать ответ после уничтожения источника', async () => {
    await initialize();

    const listener = jest.fn();

    source.data$().subscribe(listener);

    const page = createDeferred<Candle[] | null>();

    getData.mockReturnValueOnce(page.promise);

    const loading = source.loadMoreHistory();

    await Promise.resolve();

    source.destroy();

    page.resolve([createCandle(1)]);

    await loading;

    expect(listener).toHaveBeenCalledTimes(1);
    expect(source.getOldestTime()).toBeNull();
  });

  it('должен сохранять realtime-свечи, полученные во время догрузки истории', async () => {
    await initialize();

    const page = createDeferred<Candle[] | null>();

    getData.mockReturnValueOnce(page.promise);

    const loading = source.loadMoreHistory();

    await Promise.resolve();

    source.pushRealtime(createCandle(4));

    page.resolve([createCandle(1)]);

    await loading;

    expect(realCandles()).toEqual([
      createCandle(1),
      createCandle(2),
      createCandle(3),
      createCandle(4),
    ]);
    expect(source.getLastValue()).toEqual(createCandle(4));
  });

  it('должен преобразовывать время из миллисекунд в секунды', async () => {
    await initialize();

    getData.mockResolvedValueOnce([
      {
        ...createCandle(1),
        time: createCandle(1).time * 1000,
      },
    ]);

    await source.loadMoreHistory();

    expect(source.getOldestTime()).toBe(createCandle(1).time);
  });
});

describe('SymbolSource: завершение загрузки', () => {
  it('должен загружать все страницы и сбрасывать состояние загрузки', async () => {
    await initialize();

    getData
      .mockResolvedValueOnce([createCandle(1)])
      .mockResolvedValueOnce(null);

    await source.loadAllHistory();

    expect(realCandles()).toEqual([
      createCandle(1),
      createCandle(2),
      createCandle(3),
    ]);
    expect(source.isLoadingValue()).toBe(false);
  });

  it('должен сбрасывать состояние загрузки после ошибки полной истории', async () => {
    await initialize();

    getData.mockRejectedValueOnce(new Error('network'));

    await source.loadAllHistory();

    expect(source.isLoadingValue()).toBe(false);
    expect(source.getOldestTime()).toBe(createCandle(2).time);
  });

  it('должен сбрасывать состояние загрузки после ошибки reload', async () => {
    getData.mockRejectedValueOnce(new Error('network'));

    await source.reload(Timeframes['1d']);

    expect(source.isLoadingValue()).toBe(false);
  });
});

















import { Hotkeys, Keys } from '@core/Hotkeys';

let hotkeys: Hotkeys;
let scope: HTMLDivElement;

function pressKey(
  code: string,
  init: KeyboardEventInit = {},
  target: EventTarget = scope,
) {
  const event = new KeyboardEvent('keydown', {
    code,
    bubbles: true,
    cancelable: true,
    ...init,
  });

  target.dispatchEvent(event);

  return event;
}

beforeEach(() => {
  scope = document.createElement('div');
  scope.tabIndex = 0;

  document.body.append(scope);
  scope.focus();

  hotkeys = new Hotkeys(scope);
});

afterEach(() => {
  hotkeys.destroy();
  document.body.replaceChildren();
  jest.restoreAllMocks();
});

describe('Hotkeys', () => {
  it.each([
    { ctrlKey: true },
    { metaKey: true },
    { ctrlKey: true, metaKey: true },
  ])('должен обрабатывать mod с модификаторами %j', (modifiers) => {
    const callback = jest.fn();

    hotkeys.register({
      keys: [Keys.mod, Keys.z],
      callback,
    });

    expect(pressKey('KeyZ', modifiers).defaultPrevented).toBe(true);
    expect(callback).toHaveBeenCalledTimes(1);
  });

  it('должен учитывать точный набор модификаторов', () => {
    const callback = jest.fn();

    hotkeys.register({
      keys: [Keys.control, Keys.shift, Keys.alt, Keys.z],
      callback,
    });

    pressKey('KeyZ', {
      ctrlKey: true,
      shiftKey: true,
    });

    pressKey('KeyZ', {
      ctrlKey: true,
      shiftKey: true,
      altKey: true,
      metaKey: true,
    });

    expect(callback).not.toHaveBeenCalled();

    pressKey('KeyZ', {
      ctrlKey: true,
      shiftKey: true,
      altKey: true,
    });

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

  it('должен выбирать последний разрешённый обработчик и возвращаться к предыдущему после отписки', () => {
    const first = jest.fn();
    const second = jest.fn();
    const disabled = jest.fn();

    hotkeys.register({
      keys: [Keys.escape],
      callback: first,
    });

    const unregister = hotkeys.register({
      keys: [Keys.escape],
      callback: second,
    });

    hotkeys.register({
      keys: [Keys.escape],
      callback: disabled,
      enabled: () => false,
    });

    pressKey('Escape');

    expect(second).toHaveBeenCalledTimes(1);
    expect(first).not.toHaveBeenCalled();

    unregister();
    unregister();

    pressKey('Escape');

    expect(first).toHaveBeenCalledTimes(1);
    expect(disabled).not.toHaveBeenCalled();
  });

  it('должен разрешать Escape при отключённых основных хоткеях', () => {
    hotkeys.destroy();

    hotkeys = new Hotkeys(scope, () => false);

    const drawing = jest.fn();
    const back = jest.fn();

    hotkeys.register({
      keys: [Keys.t],
      callback: drawing,
    });

    hotkeys.register({
      keys: [Keys.escape],
      callback: back,
      enabled: () => true,
    });

    expect(pressKey('KeyT').defaultPrevented).toBe(false);

    pressKey('Escape');

    expect(drawing).not.toHaveBeenCalled();
    expect(back).toHaveBeenCalledTimes(1);
  });

  it('должен проверять доступность обработчика при каждом событии', () => {
    let enabled = false;

    const callback = jest.fn();

    hotkeys.register({
      keys: [Keys.escape],
      callback,
      enabled: () => enabled,
    });

    pressKey('Escape');

    enabled = true;

    pressKey('Escape');

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

  it.each(['input', 'textarea', 'select', 'contenteditable'])(
    'не должен обрабатывать хоткей в редактируемом элементе %s',
    (kind) => {
      const element = document.createElement(
        kind === 'contenteditable' ? 'div' : kind,
      );

      if (kind === 'contenteditable') {
        element.setAttribute('contenteditable', 'true');
      }

      scope.append(element);

      const callback = jest.fn();

      hotkeys.register({
        keys: [Keys.escape],
        callback,
      });

      pressKey('Escape', {}, element);

      expect(callback).not.toHaveBeenCalled();
    },
  );

  it.each([
    { repeat: true },
    { isComposing: true },
  ])('не должен обрабатывать повторное нажатие или композицию %j', (init) => {
    const callback = jest.fn();

    hotkeys.register({
      keys: [Keys.escape],
      callback,
    });

    pressKey('Escape', init);

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

  it('не должен обрабатывать событие с preventDefault', () => {
    const callback = jest.fn();

    hotkeys.register({
      keys: [Keys.escape],
      callback,
    });

    const event = new KeyboardEvent('keydown', {
      code: 'Escape',
      bubbles: true,
      cancelable: true,
    });

    event.preventDefault();
    scope.dispatchEvent(event);

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

  it('не должен обрабатывать хоткей при фокусе вне графика', () => {
    const callback = jest.fn();

    hotkeys.register({
      keys: [Keys.escape],
      callback,
    });

    const button = document.createElement('button');

    document.body.append(button);
    button.focus();

    pressKey('Escape', {}, button);

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

  it('должен удалять все варианты комбинации mod при отписке', () => {
    const callback = jest.fn();

    const unregister = hotkeys.register({
      keys: [Keys.mod, Keys.z],
      callback,
    });

    unregister();

    pressKey('KeyZ', { ctrlKey: true });
    pressKey('KeyZ', { metaKey: true });
    pressKey('KeyZ', { ctrlKey: true, metaKey: true });

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

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

    hotkeys.register({
      keys: [Keys.escape],
      callback,
    });

    hotkeys.destroy();

    pressKey('Escape');

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

  it.each([
    { keys: [] },
    { keys: [Keys.t, Keys.z] },
    { keys: [Keys.mod, Keys.control] },
    { keys: [Keys.mod, Keys.meta] },
  ])('должен отклонять некорректную комбинацию $keys', ({ keys }) => {
    expect(() =>
      hotkeys.register({
        keys,
        callback: jest.fn(),
      }),
    ).toThrow('[Hotkeys]');
  });
});

















import type { ComponentProps } from 'react';

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

import { RangeField } from '@src/components/FormFields/RangeField';

afterEach(() => {
  cleanup();
  jest.restoreAllMocks();
});

type IntervalRangeProps = Extract<
  ComponentProps<typeof RangeField>,
  { mode: 'range' }
>;

function renderRangeField(props: Partial<IntervalRangeProps> = {}) {
  const onRangeChange = jest.fn();

  const result = render(
    <RangeField
      mode="range"
      value={[20, 40]}
      min={0}
      max={100}
      step={5}
      onRangeChange={onRangeChange}
      {...props}
    />,
  );

  const handles = result.getAllByRole('slider') as HTMLInputElement[];
  const selection = result.container.querySelector<HTMLDivElement>(
    'div[style*="left:"]',
  )!;
  const track = selection.parentElement!;

  jest.spyOn(track, 'getBoundingClientRect').mockReturnValue({
    width: 200,
  } as DOMRect);

  Object.defineProperty(selection, 'setPointerCapture', {
    configurable: true,
    value: jest.fn(),
  });

  return {
    ...result,
    handles,
    selection,
    onRangeChange,
  };
}

function pointer(
  element: HTMLElement,
  type: string,
  clientX: number,
  button = 0,
) {
  const event = new MouseEvent(type, {
    bubbles: true,
    cancelable: true,
    clientX,
    button,
  });

  Object.defineProperty(event, 'pointerId', {
    value: 1,
  });

  fireEvent(element, event);
}

describe('RangeField', () => {
  it('должен сохранять поведение одиночного слайдера и связь с подписью', () => {
    const onChange = jest.fn();

    const result = render(
      <RangeField
        label="Opacity"
        value={50}
        onChange={onChange}
      />,
    );

    const input = result.getByLabelText('Opacity') as HTMLInputElement;

    expect(result.container.textContent).toContain('50%');

    fireEvent.change(input, {
      target: { value: '60' },
    });

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

  it('не должен допускать пересечения границ диапазона', () => {
    const { handles, onRangeChange } = renderRangeField();

    fireEvent.change(handles[0], {
      target: { value: '70' },
    });

    expect(onRangeChange).toHaveBeenLastCalledWith([40, 40]);

    fireEvent.change(handles[1], {
      target: { value: '10' },
    });

    expect(onRangeChange).toHaveBeenLastCalledWith([20, 20]);
  });

  it('должен вычислять положение диапазона относительно минимальной и максимальной границ', () => {
    const { selection } = renderRangeField({
      value: [2020, 2025],
      min: 2010,
      max: 2030,
      suffix: '',
    });

    expect(selection.style.left).toBe('50%');
    expect(selection.style.width).toBe('25%');
  });

  it('должен сортировать отметки и исключать повторяющиеся значения', () => {
    const { container } = renderRangeField({
      marks: [100, 50, 25, 50, -1, 200, 0],
    });

    const marks = [...container.querySelectorAll('span[style*="left:"]')].map(
      (node) => node.textContent,
    );

    expect(marks).toEqual(['0%', '25%', '50%', '100%']);
  });

  it('должен переносить диапазон с учётом шага и сохранять его ширину', () => {
    const { selection, onRangeChange } = renderRangeField();

    pointer(selection, 'pointerdown', 100);
    pointer(selection, 'pointermove', 121);

    expect(onRangeChange).toHaveBeenLastCalledWith([30, 50]);
    expect(selection.setPointerCapture).toHaveBeenCalledWith(1);
  });

  it.each([
    [1000, [80, 100]],
    [-1000, [0, 20]],
  ] as const)(
    'должен ограничивать перенос границами шкалы при x=%s',
    (clientX, expected) => {
      const { selection, onRangeChange } = renderRangeField();

      pointer(selection, 'pointerdown', 100);
      pointer(selection, 'pointermove', clientX);

      expect(onRangeChange).toHaveBeenLastCalledWith(expected);
    },
  );

  it.each([
    'pointerup',
    'pointercancel',
    'lostpointercapture',
  ])('должен завершать перенос после события %s', (endEvent) => {
    const { selection, onRangeChange } = renderRangeField();

    pointer(selection, 'pointerdown', 100);
    pointer(selection, endEvent, 100);
    pointer(selection, 'pointermove', 150);

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

  it('не должен начинать перенос без нажатия основной кнопки', () => {
    const { selection, onRangeChange } = renderRangeField();

    pointer(selection, 'pointermove', 150);
    pointer(selection, 'pointerdown', 100, 2);
    pointer(selection, 'pointermove', 150);

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

  it('не должен переносить диапазон при нулевой ширине шкалы', () => {
    const { selection, onRangeChange } = renderRangeField();

    jest.mocked(
      selection.parentElement!.getBoundingClientRect,
    ).mockReturnValue({
      width: 0,
    } as DOMRect);

    pointer(selection, 'pointerdown', 100);
    pointer(selection, 'pointermove', 150);

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

  it('должен отключать обе границы и перенос диапазона', () => {
    const { handles, selection, onRangeChange } = renderRangeField({
      disabled: true,
    });

    expect(handles.every((input) => input.disabled)).toBe(true);

    pointer(selection, 'pointerdown', 100);
    pointer(selection, 'pointermove', 150);

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

  it('должен отключать диапазон нулевой длины и сохранять корректные позиции', () => {
    const { handles, selection, container } = renderRangeField({
      min: 2025,
      max: 2025,
      value: [2025, 2025],
      suffix: '',
    });

    expect(handles.every((input) => input.disabled)).toBe(true);
    expect(selection.style.left).toBe('0%');
    expect(selection.style.width).toBe('0%');

    expect(
      [...container.querySelectorAll('span[style*="left:"]')].map(
        (node) => node.textContent,
      ),
    ).toEqual(['2025']);
  });
});

















import { PriceScaleMode } from 'lightweight-charts';

import {
  buildSeasonality,
  normalizeRange,
  toSeriesData,
} from '@src/utils/seasonality';

describe('normalizeRange', () => {
  it.each([
    [
      { fromYear: 2010, toYear: 2040 },
      { fromYear: 2020, toYear: 2025 },
    ],
    [
      { fromYear: 2022.6, toYear: 2024.4 },
      { fromYear: 2023, toYear: 2024 },
    ],
    [
      { fromYear: 2024, toYear: 2021 },
      { fromYear: 2024, toYear: 2024 },
    ],
    [
      { fromYear: 2010, toYear: 2011 },
      { fromYear: 2020, toYear: 2020 },
    ],
  ])('должен ограничивать и упорядочивать диапазон %j', (input, expected) => {
    expect(normalizeRange(input, 2020, 2025)).toEqual(expected);
  });

  it.each([NaN, Infinity, -Infinity])(
    'должен отклонять некорректную границу %s',
    (value) => {
      expect(() =>
        normalizeRange(
          { fromYear: value, toYear: 2024 },
          2020,
          2025,
        ),
      ).toThrow('Invalid year range');

      expect(() =>
        normalizeRange(
          { fromYear: 2020, toYear: value },
          2020,
          2025,
        ),
      ).toThrow('Invalid year range');
    },
  );
});

describe('buildSeasonality', () => {
  it('должен возвращать пустой результат для пустой истории', () => {
    expect(buildSeasonality([])).toEqual([]);
  });

  it('должен сортировать данные без изменения исходного массива и выбирать базу отдельно для каждого года', () => {
    const rows = [
      { date: '2024-01-03', close: 125 },
      { date: '2023-12-31', close: 50 },
      { date: '2024-01-02', close: 100 },
    ];

    const snapshot = rows.map((row) => ({ ...row }));
    const years = buildSeasonality(rows);

    expect(rows).toEqual(snapshot);
    expect(
      years.map(({ year, baselinePrice }) => [year, baselinePrice]),
    ).toEqual([
      [2023, 50],
      [2024, 100],
    ]);
    expect(years[1].baselineDate).toBe('2024-01-02');
    expect(
      years[1].points.map(({ percentage }) => percentage),
    ).toEqual([0, 25]);
    expect(years[1].points[0].chartDate).toBe('2000-01-02');
  });

  it('должен рассчитывать отрицательную доходность', () => {
    const [year] = buildSeasonality([
      { date: '2024-01-01', close: 100 },
      { date: '2024-01-02', close: 75 },
    ]);

    expect(year.points[1].percentage).toBe(-25);
  });

  it.each([0, -10])(
    'должен сохранять цены без процентов при неположительной базе %s',
    (close) => {
      const [year] = buildSeasonality([
        { date: '2024-01-01', close },
        { date: '2024-01-02', close: 10 },
      ]);

      expect(
        year.points.map((point) => point.percentage),
      ).toEqual([null, null]);
      expect(year.points[1].price).toBe(10);
    },
  );

  it('должен отклонять повторяющиеся даты независимо от порядка данных', () => {
    expect(() =>
      buildSeasonality([
        { date: '2024-01-01', close: 100 },
        { date: '2023-01-01', close: 90 },
        { date: '2024-01-01', close: 101 },
      ]),
    ).toThrow('Multiple daily candles');
  });

  it('должен отклонять переполнение при расчёте доходности', () => {
    expect(() =>
      buildSeasonality([
        { date: '2024-01-01', close: Number.MIN_VALUE },
        { date: '2024-01-02', close: Number.MAX_VALUE },
      ]),
    ).toThrow('Invalid percentage');
  });
});

describe('toSeriesData', () => {
  it('должен совмещать годы по високосному календарю и сохранять пропуски торговых дней', () => {
    const [year] = buildSeasonality([
      { date: '2023-02-28', close: 100 },
      { date: '2023-03-01', close: 110 },
    ]);

    const data = toSeriesData(year, PriceScaleMode.Normal);

    expect(data).toHaveLength(366);
    expect(data[0]).toEqual({ time: '2000-01-01' });
    expect(data[data.length - 1]).toEqual({ time: '2000-12-31' });

    expect(
      data.find((point) => point.time === '2000-02-28'),
    ).toEqual({
      time: '2000-02-28',
      value: 100,
    });

    expect(
      data.find((point) => point.time === '2000-02-29'),
    ).toEqual({
      time: '2000-02-29',
    });

    expect(
      data.find((point) => point.time === '2000-03-01'),
    ).toEqual({
      time: '2000-03-01',
      value: 110,
    });
  });

  it('должен сохранять 29 февраля и рассчитывать процентные значения', () => {
    const [year] = buildSeasonality([
      { date: '2024-02-29', close: 10 },
      { date: '2024-03-01', close: 15 },
    ]);

    const data = toSeriesData(year, PriceScaleMode.Percentage);

    expect(
      data.find((point) => point.time === '2000-02-29'),
    ).toEqual({
      time: '2000-02-29',
      value: 0,
    });

    expect(
      data.find((point) => point.time === '2000-03-01'),
    ).toEqual({
      time: '2000-03-01',
      value: 50,
    });
  });

  it('должен оставлять пропуски вместо процентов при отсутствии положительной базы', () => {
    const [year] = buildSeasonality([
      { date: '2024-01-01', close: 0 },
    ]);

    expect(
      toSeriesData(year, PriceScaleMode.Percentage)[0],
    ).toEqual({
      time: '2000-01-01',
    });

    expect(
      toSeriesData(year, PriceScaleMode.Normal)[0],
    ).toEqual({
      time: '2000-01-01',
      value: 0,
    });
  });
});

















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

import { ChartViewTypeIcon } from '@src/components/ChartViewTypeIcon';
import { ChartViewType } from '@src/types';

jest.mock('@src/components/Icon', () => ({
  ChartIcon: () => <svg data-testid="chart-icon" />,
  SeasonalityIcon: () => <svg data-testid="seasonality-icon" />,
}));

afterEach(cleanup);

describe('ChartViewTypeIcon', () => {
  it('должен показывать иконку основного графика', () => {
    const { getByTestId, queryByTestId } = render(
      <ChartViewTypeIcon type={ChartViewType.Chart} />,
    );

    expect(getByTestId('chart-icon')).toBeTruthy();
    expect(queryByTestId('seasonality-icon')).toBeNull();
  });

  it('должен показывать иконку сезонности', () => {
    const { getByTestId, queryByTestId } = render(
      <ChartViewTypeIcon type={ChartViewType.Seasonality} />,
    );

    expect(getByTestId('seasonality-icon')).toBeTruthy();
    expect(queryByTestId('chart-icon')).toBeNull();
  });

  it('должен показывать иконку основного графика по умолчанию', () => {
    const { getByTestId, queryByTestId } = render(
      <ChartViewTypeIcon />,
    );

    expect(getByTestId('chart-icon')).toBeTruthy();
    expect(queryByTestId('seasonality-icon')).toBeNull();
  });
});