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


import { createChart, createSeriesMarkers, PriceScaleMode } from 'lightweight-charts';

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

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'),
  ...jest.requireActual('@src/utils/precision'),
  formatCompactNumber: (value: number) => String(value),
  formatPercent: (value: number) => `${value.toFixed(2)}%`,
  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 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,
  });

  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'));
  setPricePrecision(3);

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

  container = document.createElement('div');

  document.body.append(container);

  state = createInitialSeasonalityState();

  createManager();
});

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

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

  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.clear();

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

    await loading;

    expect(state.status).toBe('idle');
    expect(state.tableRows).toEqual([]);
    expect(mocks.charts).toHaveLength(0);
  });

  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(state.tableRows.map((row) => row.year)).toEqual([2025]);
    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(state.tableRows.map((row) => row.year)).toEqual([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.each([0, 6])('должен брать шаг цены из общей точности %s', async (precision) => {
    await loadSampleHistory();

    const series = mocks.charts[0].series[mocks.charts[0].series.length - 1];
    const count = getData.mock.calls.length;

    setPricePrecision(precision);

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

    expect(getLastCall(series.applyOptions)[0].priceFormat.minMove).toBe(10 ** -precision);

    manager.setScale(PriceScaleMode.Percentage);

    const { priceFormat } = getLastCall(series.applyOptions)[0];

    expect(priceFormat.minMove).toBe(0.01);
    expect(priceFormat.formatter(12.5)).toBe('12.50%');
    expect(getData).toHaveBeenCalledTimes(count);
    expect(mocks.charts).toHaveLength(1);
  });

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

    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.clear();
    manager.clear();

    expect(frames.size).toBe(0);
    expect(state.status).toBe('idle');

    flushAnimationFrames();

    expect(mocks.charts).toHaveLength(1);
    expect(chart.remove).toHaveBeenCalledTimes(1);
    expect(chart.unsubscribeCrosshairMove).toHaveBeenCalledWith(chart.subscribeCrosshairMove.mock.calls[0][0]);
    expect(mocks.markers[0].detach).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);
    manager.setScale(PriceScaleMode.Percentage);
    manager.setDisplayMode(SeasonalityViewType.Table);

    expect(complete).toHaveBeenCalledTimes(1);
    expect(state.displayMode).toBe(SeasonalityViewType.Chart);
    expect(state.scale).toBe(PriceScaleMode.Normal);
    expect(state.tableRows).toEqual([]);
    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);
  });

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

    getData.mockReturnValueOnce(request.promise).mockResolvedValueOnce(null);

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

    manager.setScale(PriceScaleMode.Percentage);

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

    await loading;

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

    expect(state.scale).toBe(PriceScaleMode.Percentage);
    expect(data.filter((point: LineData | WhitespaceData) => 'value' in point)).toEqual([
      { time: '2000-01-01', value: 0 },
      { time: '2000-01-02', value: 25 },
    ]);
  });

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

    const count = getData.mock.calls.length;

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

    flushAnimationFrames();

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

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

    flushAnimationFrames();

    expect(state.status).toBe('ready');
    expect(getData).toHaveBeenCalledTimes(count);
    expect(mocks.charts).toHaveLength(1);
    expect((container.lastElementChild as HTMLElement).style.display).toBe('none');
  });

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

    manager.setScale(PriceScaleMode.Percentage);
    manager.setDisplayMode(SeasonalityViewType.Table);
    manager.clear();

    expect(state.status).toBe('idle');
    expect(state.error).toBeNull();
    expect(state.scale).toBe(PriceScaleMode.Percentage);
    expect(state.displayMode).toBe(SeasonalityViewType.Table);
    expect(state.tableRows).toEqual([]);

    const count = getData.mock.calls.length;

    await loadSampleHistory();

    expect(getData).toHaveBeenCalledTimes(count + 2);
    expect(state.status).toBe('ready');
    expect(state.tableRows.map((row) => row.year)).toEqual([2025, 2023, 2020]);
    expect(mocks.charts).toHaveLength(2);
  });
});

describe('SeasonalityManager: табличный режим', () => {
  it('должен рассчитывать месячные и годовые изменения по загруженной истории', async () => {
    getData
      .mockResolvedValueOnce([
        createCandle('2023-12-29', 100),
        createCandle('2024-01-02', 104),
        createCandle('2024-01-31', 110),
        createCandle('2024-02-29', 121),
        createCandle('2024-12-31', 120),
        createCandle('2025-01-31', 132),
      ])
      .mockResolvedValueOnce(null);

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

    expect(state.tableRows.map((row) => row.year)).toEqual([2025, 2024]);

    const row = state.tableRows[1];

    expect(row.months).toHaveLength(12);
    expect(row.months[0]?.absolute).toBe(10);
    expect(row.months[0]?.percentage).toBeCloseTo(10);
    expect(row.months[1]?.absolute).toBe(11);
    expect(row.months[1]?.percentage).toBeCloseTo(10);
    expect(row.total?.absolute).toBe(20);
    expect(row.total?.percentage).toBeCloseTo(20);

    const count = getData.mock.calls.length;

    manager.setDisplayMode(SeasonalityViewType.Table);
    manager.setRange({
      fromYear: 2025,
      toYear: 2025,
    });

    flushAnimationFrames();

    expect(state.tableRows).toHaveLength(1);
    expect(state.tableRows[0].year).toBe(2025);
    expect(state.tableRows[0].months[0]?.absolute).toBe(12);
    expect(state.tableRows[0].months[0]?.percentage).toBeCloseTo(10);
    expect(state.tableRows[0].total?.absolute).toBe(12);
    expect(state.tableRows[0].total?.percentage).toBeCloseTo(10);
    expect(getData).toHaveBeenCalledTimes(count);
  });

  it('должен отличать отсутствующую базу, пропущенный месяц и нулевое изменение', async () => {
    getData
      .mockResolvedValueOnce([
        createCandle('2024-01-31', 0),
        createCandle('2024-02-29', 10),
        createCandle('2024-04-30', 15),
        createCandle('2024-05-31', 15),
      ])
      .mockResolvedValueOnce(null);

    await manager.show('AAA');

    const row = state.tableRows[0];

    expect(row.months[0]).toBeNull();
    expect(row.months[1]).toEqual({
      absolute: 10,
      percentage: null,
    });
    expect(row.months[2]).toBeNull();
    expect(row.months[3]).toBeNull();
    expect(row.months[4]).toEqual({
      absolute: 0,
      percentage: 0,
    });
    expect(row.months.slice(5)).toEqual(Array(7).fill(null));
    expect(row.total).toBeNull();
  });

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

    const count = getData.mock.calls.length;
    const chart = mocks.charts[0];
    const rows = state.tableRows;

    manager.setDisplayMode(SeasonalityViewType.Table);

    expect(state.displayMode).toBe(SeasonalityViewType.Table);
    expect(state.status).toBe('ready');
    expect(state.tableRows).toBe(rows);

    manager.setScale(PriceScaleMode.Percentage);
    manager.setDisplayMode(SeasonalityViewType.Chart);

    expect(state.displayMode).toBe(SeasonalityViewType.Chart);
    expect(state.scale).toBe(PriceScaleMode.Percentage);
    expect(state.fromYear).toBe(2020);
    expect(state.toYear).toBe(2025);
    expect(state.tableRows).toBe(rows);
    expect(getData).toHaveBeenCalledTimes(count);
    expect(mocks.charts).toHaveLength(1);
    expect(chart.remove).not.toHaveBeenCalled();
  });

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

    getData.mockReturnValueOnce(request.promise).mockResolvedValueOnce(null);

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

    manager.setDisplayMode(SeasonalityViewType.Table);
    manager.setScale(PriceScaleMode.Percentage);

    expect(state.status).toBe('loading');
    expect(state.tableRows).toEqual([]);

    request.resolve([
      createCandle('2024-12-31', 100),
      createCandle('2025-01-31', 125),
    ]);

    await loading;

    expect(state.status).toBe('ready');
    expect(state.displayMode).toBe(SeasonalityViewType.Table);
    expect(state.scale).toBe(PriceScaleMode.Percentage);
    expect(state.tableRows[0].months[0]).toEqual({
      absolute: 25,
      percentage: 25,
    });
    expect(state.tableRows[0].total).toEqual({
      absolute: 25,
      percentage: 25,
    });
  });

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

    manager.setDisplayMode(SeasonalityViewType.Table);

    await manager.copyImage();

    expect(mocks.charts[0].takeScreenshot).not.toHaveBeenCalled();
    expect(drawTextLayer).not.toHaveBeenCalled();
    expect(canvasToBlob).not.toHaveBeenCalled();
  });

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

    manager.setDisplayMode(SeasonalityViewType.Table);

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

    getData.mockReturnValueOnce(request.promise);

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

    expect(state.symbolId).toBe('NEW');
    expect(state.status).toBe('loading');
    expect(state.tableRows).toEqual([]);

    request.reject(new Error('network'));

    await loading;

    expect(state.status).toBe('error');
    expect(state.displayMode).toBe(SeasonalityViewType.Table);
    expect(state.tableRows).toEqual([]);
  });

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

    const count = getData.mock.calls.length;

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

    flushAnimationFrames();

    expect(state.status).toBe('empty');
    expect(state.tableRows).toEqual([]);

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

    flushAnimationFrames();

    expect(state.status).toBe('ready');
    expect(state.displayMode).toBe(SeasonalityViewType.Table);
    expect(state.tableRows.map((row) => row.year)).toEqual([2025, 2023]);
    expect(getData).toHaveBeenCalledTimes(count);
  });
});