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


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

import { BehaviorSubject } from 'rxjs';

import { Header } from '@src/components/Header';

import type { ReactNode } from 'react';

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

jest.mock('@src/components/IndicatorsSelect', () => ({
  IndicatorsSelect: () => <div />,
}));

jest.mock('@src/components/Tooltip', () => ({
  Tooltip: ({ children, label }: { children: ReactNode; label?: string }) => <div data-tooltip={label}>{children}</div>,
}));

jest.mock('@src/components/Dropdown', () => ({
  Dropdown: ({ children, selectedValue }: { children: ReactNode; selectedValue?: ReactNode }) => (
    <div>
      <div>{selectedValue}</div>
      {children}
    </div>
  ),
}));

jest.mock('@src/components/Menu', () => ({
  TimeframesMenu: () => <div />,
  SeriesMenu: () => <div />,
  SnapshotMenu: ({ onCopyImage }: { onCopyImage: () => Promise<void> }) => (
    <button
      type="button"
      onClick={() => onCopyImage()}
    >
      Copy image
    </button>
  ),
}));

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

describe('Header', () => {
  it('должен отображать меню снимка и вызывать copyImage', () => {
    const copyImage = jest.fn().mockResolvedValue(undefined);

    const fullscreen = {
      isFullscreen: false,
      onChange: jest.fn(() => jest.fn()),
      toggle: jest.fn(),
    };

    render(
      <Header
        timeframes={[]}
        selectedTimeframeObs={new BehaviorSubject(null) as never}
        setTimeframe={jest.fn()}
        seriesTypes={[]}
        setSelectedSeries={jest.fn()}
        selectedSeriesObs={new BehaviorSubject('Candlestick') as never}
        showSettingsModal={undefined}
        addIndicatorToChart={jest.fn()}
        toggleToolbarVisible={jest.fn(() => false)}
        showCompareButton={false}
        showSymbolSearchButton={false}
        undoRedo={undefined}
        showMenuButton={false}
        showFullscreenButton={false}
        fullscreen={fullscreen as never}
        copyImage={copyImage}
        isMXT={false}
      />,
    );

    expect(document.querySelector('[data-tooltip="Snapshot"]')).not.toBeNull();

    fireEvent.click(screen.getByText('Copy image'));

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

















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 { SeasonalityManager } from '@core/SeasonalityManager';
import { createInitialSeasonalityState } from '@src/constants';
import { getThemeStore } from '@src/theme/store';
import { ChartViewType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { canvasToBlob, drawTextLayer } from '@src/utils';

import type { Candle } from '@src/types';
import type { ChartOptions, DeepPartial, LineData, LineSeriesPartialOptions, WhitespaceData } from 'lightweight-charts';

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(),
  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];

    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;

    if (typeof formatter !== 'function') {
      throw new Error('Time formatter is not configured');
    }

    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 blob = new Blob(['image'], {
      type: 'image/png',
    });
    const write = jest.fn().mockResolvedValue(undefined);

    jest.mocked(canvasToBlob).mockResolvedValueOnce(blob);

    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']).toBe(blob);
  });

  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);
  });
});