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


import { act, render, screen, within } from '@testing-library/react';
import { PriceScaleMode } from 'lightweight-charts';
import { BehaviorSubject, Subject } from 'rxjs';

import { SeasonalityTable } from '@src/components/SeasonalityTable';
import { createInitialSeasonalityState } from '@src/constants';
import { SeasonalityViewType } from '@src/types';
import { setPricePrecision } from '@src/utils/precision';

import styles from '@src/components/SeasonalityTable/index.module.scss';
import tableStyles from '@src/components/Table/index.module.scss';

import type { SeasonalityState, SeasonalityTableRow } from '@src/types';

const mocks = {
  locale: 'en',
};

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

function createRow(year = 2024): SeasonalityTableRow {
  return {
    year,
    months: [
      { absolute: 12.3456, percentage: 1.23456 },
      { absolute: -2.3456, percentage: -0.87654 },
      { absolute: 0, percentage: 0 },
      { absolute: 5, percentage: null },
      ...Array(8).fill(null),
    ],
    total: {
      absolute: 15.1234,
      percentage: 2.3456,
    },
  };
}

function createTestContext(overrides: Partial<SeasonalityState> = {}) {
  const stateObs = new BehaviorSubject<SeasonalityState>({
    ...createInitialSeasonalityState(),
    status: 'ready',
    displayMode: SeasonalityViewType.Table,
    tableRows: [createRow()],
    ...overrides,
  });

  return { stateObs };
}

function getYearCells(year = 2024) {
  const row = screen.getByRole('rowheader', { name: String(year) }).closest('tr') as HTMLTableRowElement;

  return within(row).getAllByRole('cell');
}

beforeEach(() => {
  setPricePrecision(3);
  mocks.locale = 'en';
});

afterEach(() => {
  setPricePrecision();
  jest.clearAllMocks();
});

describe('SeasonalityTable', () => {
  it('не должен отображаться до получения состояния, при idle и в режиме графика', () => {
    const stateObs = new Subject<SeasonalityState>();
    const { container } = render(<SeasonalityTable stateObs={stateObs} />);

    expect(container.children).toHaveLength(0);

    act(() => stateObs.next(createInitialSeasonalityState()));

    expect(container.children).toHaveLength(0);

    act(() =>
      stateObs.next({
        ...createInitialSeasonalityState(),
        status: 'ready',
      }),
    );

    expect(container.children).toHaveLength(0);

    act(() =>
      stateObs.next({
        ...createInitialSeasonalityState(),
        displayMode: SeasonalityViewType.Table,
      }),
    );

    expect(container.children).toHaveLength(0);
  });

  it('должен отображать все месяцы, годовые итоги и строки в переданном порядке', () => {
    const props = createTestContext({
      tableRows: [createRow(2025), createRow(2024)],
    });

    const { container } = render(<SeasonalityTable {...props} />);

    expect(screen.getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual([
      'Date',
      'January',
      'February',
      'March',
      'April',
      'May',
      'June',
      'July',
      'August',
      'September',
      'October',
      'November',
      'December',
      'Year',
    ]);
    expect(screen.getAllByRole('rowheader').map((cell) => cell.textContent)).toEqual(['2025', '2024']);
    expect(container.firstElementChild?.classList.contains(tableStyles.table_stickyFirstColumn)).toBe(true);

    const cells = getYearCells(2025);

    expect(cells).toHaveLength(13);
    expect(cells[0].textContent).toBe('12.346');
    expect(cells[1].textContent).toBe('-2.346');
    expect(cells[2].textContent).toBe('0.000');
    expect(cells[3].textContent).toBe('5.000');
    expect(cells[4].textContent).toBe('—');
    expect(cells[12].textContent).toBe('15.123');
    expect(cells[0].classList.contains(styles.positive)).toBe(true);
    expect(cells[1].classList.contains(styles.negative)).toBe(true);
    expect(cells[2].classList.contains(styles.positive)).toBe(false);
    expect(cells[2].classList.contains(styles.negative)).toBe(false);
    expect(cells[12].classList.contains(styles.total)).toBe(true);
  });

  it('должен переключать значения, точность и цвета при изменении шкалы', () => {
    const props = createTestContext();

    render(<SeasonalityTable {...props} />);

    expect(getYearCells()[3].classList.contains(styles.positive)).toBe(true);

    act(() =>
      props.stateObs.next({
        ...props.stateObs.value,
        scale: PriceScaleMode.Percentage,
      }),
    );

    const cells = getYearCells();

    expect(cells[0].textContent).toBe('1.23%');
    expect(cells[1].textContent).toBe('-0.88%');
    expect(cells[2].textContent).toBe('0.00%');
    expect(cells[3].textContent).toBe('—');
    expect(cells[3].classList.contains(styles.positive)).toBe(false);
    expect(cells[3].classList.contains(styles.negative)).toBe(false);
    expect(cells[12].textContent).toBe('2.35%');
  });

  it.each([0, 6])('должен использовать общую точность цены %s', (precision) => {
    setPricePrecision(precision);

    render(<SeasonalityTable {...createTestContext()} />);

    expect(getYearCells()[0].textContent).toBe(precision === 0 ? '12' : '12.345600');
  });

  it('должен использовать текущую локаль при форматировании процентов', () => {
    mocks.locale = 'ru';

    render(
      <SeasonalityTable
        {...createTestContext({
          scale: PriceScaleMode.Percentage,
        })}
      />,
    );

    expect(getYearCells()[0].textContent).toBe('1,23%');
    expect(getYearCells()[12].textContent).toBe('2,35%');
  });

  it.each([
    ['loading', 'Loading seasonality'],
    ['error', 'Unable to load seasonality'],
    ['empty', 'No seasonality data'],
    ['ready', 'No seasonality data'],
  ] as const)('должен отображать пустое состояние при статусе %s', (status, message) => {
    render(
      <SeasonalityTable
        {...createTestContext({
          status,
          tableRows: [],
        })}
      />,
    );

    expect(screen.getByRole('cell').textContent).toBe(message);
    expect(screen.getByRole('cell').getAttribute('colspan')).toBe('14');
  });

  it('должен отображать прочерк при отсутствии годового итога', () => {
    render(
      <SeasonalityTable
        {...createTestContext({
          tableRows: [{ ...createRow(), total: null }],
        })}
      />,
    );

    expect(getYearCells()[12].textContent).toBe('—');
    expect(getYearCells()[12].classList.contains(styles.positive)).toBe(false);
    expect(getYearCells()[12].classList.contains(styles.negative)).toBe(false);
  });
});

















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

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

import styles from '@src/components/Table/index.module.scss';

import type { TableColumn } from '@src/components/Table';

interface TestRow {
  id: number;
  name: string;
  value: number;
}

const columns: TableColumn<TestRow>[] = [
  {
    id: 'name',
    header: 'Name',
    rowHeader: true,
    renderCell: (row) => row.name,
  },
  {
    id: 'value',
    header: <span>Value</span>,
    align: 'right',
    className: 'amount',
    renderCell: (row) => <strong>{row.value}</strong>,
    getCellClassName: (row) => (row.value < 0 ? 'negative' : undefined),
  },
];

const rows: TestRow[] = [
  { id: 2, name: 'Beta', value: -2 },
  { id: 1, name: 'Alpha', value: 3 },
];

const getRowKey = (row: TestRow) => row.id;

describe('Table', () => {
  it('должен отображать заголовки, строки и содержимое ячеек в переданном порядке', () => {
    const { container } = render(<Table columns={columns} rows={rows} getRowKey={getRowKey} />);

    const headers = screen.getAllByRole('columnheader');
    const bodyRows = screen.getAllByRole('row').slice(1);

    expect(headers.map((header) => header.textContent)).toEqual(['Name', 'Value']);
    expect(headers[0].getAttribute('scope')).toBe('col');
    expect(headers[0].classList.contains(styles.table_cell_left)).toBe(true);
    expect(headers[1].classList.contains(styles.table_cell_right)).toBe(true);
    expect(headers[1].classList.contains('amount')).toBe(true);
    expect(bodyRows).toHaveLength(2);
    expect(within(bodyRows[0]).getByRole('rowheader').textContent).toBe('Beta');
    expect(within(bodyRows[0]).getByRole('rowheader').getAttribute('scope')).toBe('row');
    expect(within(bodyRows[0]).getByRole('cell').classList.contains('negative')).toBe(true);
    expect(within(bodyRows[0]).getByText('-2').tagName).toBe('STRONG');
    expect(within(bodyRows[1]).getByRole('rowheader').textContent).toBe('Alpha');
    expect(within(bodyRows[1]).getByRole('cell').classList.contains('negative')).toBe(false);
    expect(container.firstElementChild?.classList.contains(styles.table_stickyHeader)).toBe(true);
    expect(container.firstElementChild?.classList.contains(styles.table_stickyFirstColumn)).toBe(false);
  });

  it('должен применять настройки закрепления, выравнивания и дополнительный класс', () => {
    const { container } = render(
      <Table
        columns={[
          {
            id: 'name',
            header: 'Name',
            align: 'center',
            renderCell: (row: TestRow) => row.name,
          },
        ]}
        rows={rows}
        getRowKey={getRowKey}
        className="custom-table"
        stickyHeader={false}
        stickyFirstColumn
      />,
    );

    expect(container.firstElementChild?.classList.contains('custom-table')).toBe(true);
    expect(container.firstElementChild?.classList.contains(styles.table_stickyHeader)).toBe(false);
    expect(container.firstElementChild?.classList.contains(styles.table_stickyFirstColumn)).toBe(true);
    expect(screen.getByRole('columnheader').classList.contains(styles.table_cell_center)).toBe(true);
    expect(screen.getAllByRole('cell')[0].classList.contains(styles.table_cell_center)).toBe(true);
  });

  it('должен отображать прочерк на всю ширину пустой таблицы', () => {
    render(<Table columns={columns} rows={[]} getRowKey={getRowKey} />);

    const cell = screen.getByRole('cell');

    expect(cell.textContent).toBe('—');
    expect(cell.getAttribute('colspan')).toBe('2');
    expect(cell.classList.contains(styles.table_empty)).toBe(true);
    expect(screen.queryByRole('rowheader')).toBeNull();
  });

  it('должен отображать переданное содержимое пустого состояния', () => {
    render(
      <Table
        columns={columns}
        rows={[]}
        getRowKey={getRowKey}
        emptyContent={<span>No rows</span>}
      />,
    );

    expect(within(screen.getByRole('cell')).getByText('No rows')).toBeDefined();
  });

  it('должен сохранять ячейки строк по ключам при изменении порядка и данных', () => {
    const { rerender } = render(<Table columns={columns} rows={rows} getRowKey={getRowKey} />);

    const alpha = screen.getByRole('rowheader', { name: 'Alpha' });

    rerender(
      <Table
        columns={columns}
        rows={[{ ...rows[1], name: 'Updated alpha' }, rows[0]]}
        getRowKey={getRowKey}
      />,
    );

    expect(screen.getAllByRole('rowheader').map((cell) => cell.textContent)).toEqual(['Updated alpha', 'Beta']);
    expect(screen.getByRole('rowheader', { name: 'Updated alpha' })).toBe(alpha);
  });
});

















import { PriceScaleMode } from 'lightweight-charts';

import { buildSeasonality, buildSeasonalityTable, 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,
    });
  });
});

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

  it('должен использовать последние закрытия месяцев и предыдущий декабрь', () => {
    const years = buildSeasonality([
      { date: '2024-02-29', close: 121 },
      { date: '2024-01-31', close: 110 },
      { date: '2023-12-29', close: 100 },
      { date: '2024-01-02', close: 105 },
    ]);

    const [row] = buildSeasonalityTable(years);

    expect(buildSeasonalityTable(years).map((item) => item.year)).toEqual([2024, 2023]);
    expect(row.months).toHaveLength(12);
    expect(row.months[0]?.absolute).toBe(10);
    expect(row.months[0]?.percentage).toBeCloseTo(10, 10);
    expect(row.months[1]?.absolute).toBe(11);
    expect(row.months[1]?.percentage).toBeCloseTo(10, 10);
    expect(row.total?.absolute).toBe(21);
    expect(row.total?.percentage).toBeCloseTo(21, 10);
    expect(row.months.slice(2)).toEqual(Array(10).fill(null));
  });

  it('должен рассчитывать первый неполный месяц и год от первой доступной цены', () => {
    const years = buildSeasonality([
      { date: '2021-10-15', close: 100 },
      { date: '2021-10-29', close: 101 },
      { date: '2021-11-30', close: 105 },
      { date: '2021-12-30', close: 106 },
    ]);

    const [row] = buildSeasonalityTable(years);

    expect(row.months.slice(0, 9)).toEqual(Array(9).fill(null));
    expect(row.months[9]?.absolute).toBe(1);
    expect(row.months[9]?.percentage).toBeCloseTo(1, 10);
    expect(row.months[10]?.absolute).toBe(4);
    expect(row.months[11]?.absolute).toBe(1);
    expect(row.total?.absolute).toBe(6);
    expect(row.total?.percentage).toBeCloseTo(6, 10);
  });

  it('должен сохранять пропуски внутри года и рассчитывать итог по доступным ценам', () => {
    const years = buildSeasonality([
      { date: '2024-01-02', close: 100 },
      { date: '2024-01-31', close: 110 },
      { date: '2024-03-15', close: 120 },
      { date: '2024-03-29', close: 132 },
      { date: '2024-04-30', close: 132 },
    ]);

    const [row] = buildSeasonalityTable(years);

    expect(row.months[1]).toBeNull();
    expect(row.months[2]).toBeNull();
    expect(row.months[3]).toEqual({
      absolute: 0,
      percentage: 0,
    });
    expect(row.total?.absolute).toBe(32);
    expect(row.total?.percentage).toBeCloseTo(32, 10);
  });

  it.each([0, -10])('должен сохранять неположительную базу предыдущего декабря %s', (baseline) => {
    const years = buildSeasonality([
      { date: '2023-12-29', close: baseline },
      { date: '2024-01-02', close: 50 },
      { date: '2024-01-31', close: 100 },
    ]);

    const [row] = buildSeasonalityTable(years);

    expect(row.months[0]).toEqual({
      absolute: 100 - baseline,
      percentage: null,
    });
    expect(row.total).toEqual({
      absolute: 100 - baseline,
      percentage: null,
    });
  });

  it('должен использовать первую цену года при отсутствии предыдущего декабря', () => {
    const years = buildSeasonality([
      { date: '2023-11-30', close: 80 },
      { date: '2024-01-02', close: 100 },
      { date: '2024-01-31', close: 110 },
    ]);

    const [row] = buildSeasonalityTable(years);

    expect(row.months[0]?.absolute).toBe(10);
    expect(row.months[0]?.percentage).toBeCloseTo(10, 10);
    expect(row.total?.absolute).toBe(10);
    expect(row.total?.percentage).toBeCloseTo(10, 10);
  });

  it('должен сохранять точность и рассчитывать отрицательный годовой итог', () => {
    const years = buildSeasonality([
      { date: '2023-12-29', close: 100 },
      { date: '2024-01-31', close: 90 },
      { date: '2024-02-29', close: 99.0044 },
    ]);

    const [row] = buildSeasonalityTable(years);

    expect(row.months[0]?.percentage).toBeCloseTo(-10, 10);
    expect(row.months[1]?.absolute).toBeCloseTo(9.0044, 10);
    expect(row.total?.absolute).toBeCloseTo(-0.9956, 10);
    expect(row.total?.percentage).toBeCloseTo(-0.9956, 10);
  });
});

















import { BehaviorSubject, Subject } from 'rxjs';

import { Header } from '@components/Header';
import { Chart } from '@core/Chart';
import { ContainerManager } from '@core/ContainerManager';
import { DataSource } from '@core/DataSource';
import { EventManager } from '@core/EventManager';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { ModalRenderer } from '@core/ModalRenderer';
import { type ChartCollectionPreset, type IMoexChart, MoexChart } from '@core/MoexChart';
import { ReactRenderer } from '@core/ReactRenderer';
import { TimeScaleHoverController } from '@core/TimescaleHoverController';
import { SeasonalityControls } from '@src/components/SeasonalityControls';
import { SeasonalityTable } from '@src/components/SeasonalityTable';
import { createInitialSeasonalityState } from '@src/constants';
import { FullscreenController } from '@src/core/Fullscreen';
import { SeasonalityManager } from '@src/core/SeasonalityManager';
import { configureThemeStore } from '@src/theme/store';
import { ChartViewType, SeasonalityViewType } from '@src/types';

import type { SeasonalityRange } from '@src/types';
import type { ReactElement } from 'react';
import type { Observable } from 'rxjs';

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

jest.mock('@core/ContainerManager', () => ({
  ContainerManager: {
    createContainers: jest.fn(),
    clearContainers: jest.fn(),
  },
}));

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

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

jest.mock('@core/Hotkeys', () => ({
  ...jest.requireActual('@core/Hotkeys'),
  Hotkeys: jest.fn(),
}));

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

jest.mock('@core/TimescaleHoverController', () => ({
  TimeScaleHoverController: jest.fn(),
}));

jest.mock('@src/core/Fullscreen', () => ({
  FullscreenController: jest.fn(),
}));

jest.mock('@src/core/SeasonalityManager', () => ({
  SeasonalityManager: jest.fn(),
}));

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

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

jest.mock('@components/Header', () => ({
  Header: () => null,
}));

jest.mock('@components/ControlBar', () => ({
  ControlBar: () => null,
}));

jest.mock('@components/Footer', () => ({
  Footer: () => null,
}));

jest.mock('@src/components/FloatingToolbar', () => ({
  FloatingDrawingToolbar: () => null,
}));

jest.mock('@src/components/SeasonalityControls', () => ({
  SeasonalityControls: () => null,
}));

jest.mock('@src/components/SeasonalityTable', () => ({
  SeasonalityTable: () => null,
}));

jest.mock('@src/components/SettingsModal', () => ({
  SettingsModal: () => null,
}));

jest.mock('@src/components/Toolbar', () => ({
  __esModule: true,
  default: () => null,
}));

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

interface HeaderTestProps {
  copyImage?: () => Promise<void>;
  setChartView?: (view: ChartViewType) => void;
  chartViewObs: Observable<ChartViewType>;
  canCopyImageObs: Observable<boolean>;
}

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),
    setSleeping: jest.fn(),
    getDrawingsCollectionManager: () => drawingsCollectionManager,
  };

  const seasonalityManager = {
    clear: jest.fn(),
    show: jest.fn().mockResolvedValue(undefined),
    copyImage: jest.fn().mockResolvedValue(undefined),
  };

  const eventManager = {
    exportChartSettings: jest.fn(() => ({
      symbolInfo: {
        symbolId: 'SBER',
      },
    })),
    importChartSettings: jest.fn(),
    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 backToChart = jest.fn();
  const chartView = new BehaviorSubject(ChartViewType.Chart);
  const canCopyImageObs = new BehaviorSubject(true);

  const rootContainer = document.createElement('div');
  const chartContainer = document.createElement('div');
  const chartAreaContainer = document.createElement('div');
  const footerContainer = document.createElement('div');
  const seasonalityContainer = document.createElement('div');

  rootContainer.style.gridTemplateRows = '36px minmax(0, 1fr) 32px';
  seasonalityContainer.style.visibility = 'hidden';
  seasonalityContainer.tabIndex = -1;

  rootContainer.append(chartContainer, footerContainer, seasonalityContainer);
  chartContainer.append(chartAreaContainer);
  document.body.append(rootContainer);

  const seasonalityLayout = {
    rootContainer,
    chartContainer,
    chartAreaContainer,
    footerContainer,
    seasonalityContainer,
    chartGridTemplateRows: rootContainer.style.gridTemplateRows,
  };

  const chart = Object.assign(Object.create(MoexChart.prototype), {
    chart: mainChart,
    chartView,
    canCopyImageObs,
    seasonalityLayout,
    backToChart,
    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,
    chartView,
    canCopyImageObs,
    seasonalityLayout,
    backToChart,
  };
}

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

  context.chartView.next(seasonalityActive ? ChartViewType.Seasonality : ChartViewType.Chart);

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

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

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

    chart.clearSymbol();

    expect(backToChart).toHaveBeenCalledTimes(1);
    expect(eventManager.clearSymbol).toHaveBeenCalledTimes(1);
    expect(backToChart.mock.invocationCallOrder[0]).toBeLessThan(eventManager.clearSymbol.mock.invocationCallOrder[0]);
  });

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

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

    chart.setSymbol(symbol);

    expect(backToChart).toHaveBeenCalledTimes(1);
    expect(eventManager.setSymbol).toHaveBeenCalledTimes(1);
    expect(eventManager.setSymbol).toHaveBeenCalledWith(symbol);
    expect(backToChart.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, chartView, canCopyImageObs } = renderHeader();

    expect(header.props.chartViewObs).toBe(chartView);
    expect(header.props.canCopyImageObs).toBe(canCopyImageObs);
  });

  it('должен выбирать источник снимка по текущему режиму в момент нажатия', async () => {
    const { header, chartView, mainChart, seasonalityManager } = renderHeader({
      showSnapshotButton: true,
    });

    chartView.next(ChartViewType.Seasonality);

    await header.props.copyImage?.();

    chartView.next(ChartViewType.Chart);

    await header.props.copyImage?.();

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

  it('должен выйти из сезонности перед применением настроек', () => {
    const { chart, eventManager, backToChart } = createTestContext();
    const settings = {} as Parameters<MoexChart['setSettings']>[0];

    chart.setSettings(settings);

    expect(backToChart).toHaveBeenCalledTimes(1);
    expect(eventManager.importChartSettings).toHaveBeenCalledWith(settings);
    expect(backToChart.mock.invocationCallOrder[0]).toBeLessThan(
      eventManager.importChartSettings.mock.invocationCallOrder[0],
    );
  });

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

    const range: SeasonalityRange = {
      fromYear: 2021,
      toYear: 2025,
    };

    const focus = jest.spyOn(seasonalityLayout.seasonalityContainer, 'focus');

    await chart.showSeasonality(range);

    expect(mainChart.setSleeping).toHaveBeenCalledWith(true);
    expect(chartView.value).toBe(ChartViewType.Seasonality);
    expect(seasonalityLayout.rootContainer.style.gridTemplateRows).toBe('auto minmax(0, 1fr)');
    expect(seasonalityLayout.chartContainer.style.visibility).toBe('hidden');
    expect(seasonalityLayout.chartContainer.inert).toBe(true);
    expect(seasonalityLayout.footerContainer.style.display).toBe('none');
    expect(seasonalityLayout.seasonalityContainer.style.visibility).toBe('visible');
    expect(seasonalityLayout.seasonalityContainer.style.pointerEvents).toBe('auto');
    expect(seasonalityLayout.seasonalityContainer.inert).toBe(false);
    expect(focus).toHaveBeenCalledTimes(1);
    expect(seasonalityManager.show).toHaveBeenCalledWith('SBER', range);
    expect(mainChart.setSleeping.mock.invocationCallOrder[0]).toBeLessThan(
      seasonalityManager.show.mock.invocationCallOrder[0],
    );
  });

  it('не должен повторно переводить график в сон при обновлении диапазона сезонности', async () => {
    const { chart, mainChart, seasonalityManager, seasonalityLayout } = createTestContext();

    const focus = jest.spyOn(seasonalityLayout.seasonalityContainer, 'focus');

    const range: SeasonalityRange = {
      fromYear: 2023,
      toYear: 2025,
    };

    await chart.showSeasonality();
    await chart.showSeasonality(range);

    expect(mainChart.setSleeping).toHaveBeenCalledTimes(1);
    expect(focus).toHaveBeenCalledTimes(1);
    expect(seasonalityManager.show).toHaveBeenLastCalledWith('SBER', range);
  });
});

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

interface RendererTestContext {
  renderComponent: jest.Mock;
  destroy: jest.Mock;
}

const constructedCharts: MoexChart[] = [];

function createConstructorContext(useSeparateProvider = false) {
  const containers = {
    chartAreaContainer: document.createElement('div'),
    chartContainer: document.createElement('div'),
    seasonalityContainer: document.createElement('div'),
    seasonalityControlsContainer: document.createElement('div'),
    seasonalityPlotContainer: document.createElement('div'),
    seasonalityTableContainer: document.createElement('div'),
    toolBarContainer: document.createElement('div'),
    headerContainer: document.createElement('div'),
    modalContainer: document.createElement('div'),
    controlBarContainer: document.createElement('div'),
    drawingToolbarContainer: document.createElement('div'),
    footerContainer: document.createElement('div'),
    toggleToolbar: jest.fn(() => false),
  };

  const container = document.createElement('div');

  container.style.gridTemplateRows = '36px minmax(0, 1fr) 32px';
  containers.chartAreaContainer.tabIndex = -1;
  containers.seasonalityContainer.tabIndex = -1;
  containers.modalContainer.hidden = true;
  container.append(containers.chartContainer, containers.seasonalityContainer, containers.footerContainer);
  containers.chartContainer.append(containers.chartAreaContainer);
  containers.seasonalityContainer.append(
    containers.seasonalityPlotContainer,
    containers.seasonalityTableContainer,
  );
  document.body.append(container);

  const stateObs = new BehaviorSubject(createInitialSeasonalityState());
  const symbolId = new Subject<string>();

  const seasonalityManager = {
    getState: jest.fn(() => stateObs.asObservable()),
    show: jest.fn().mockResolvedValue(undefined),
    clear: jest.fn(),
    destroy: jest.fn(),
    updateTheme: jest.fn(),
    setDisplayMode: jest.fn(),
    setScale: jest.fn(),
    setRange: jest.fn(),
  };

  const realtime = {
    getSymbols: jest.fn(() => ['SBER']),
    getTimeframe: jest.fn(),
    update: jest.fn(),
  };

  const mainChart = {
    getRealtimeApi: jest.fn(() => realtime),
    getDrawingsCollectionManager: jest.fn(() => ({
      selectedDrawing: jest.fn(),
    })),
    updateTheme: jest.fn(),
    setSleeping: jest.fn(),
    destroy: jest.fn(),
  };

  const eventManager = {
    symbolId: jest.fn(() => symbolId.asObservable()),
    exportChartSettings: jest.fn(() => ({
      symbolInfo: {
        symbolId: 'SBER',
      },
    })),
    getTimeframeObs: jest.fn(),
    getSelectedSeries: jest.fn(),
    destroy: jest.fn(),
  };

  const hotkeys = {
    register: jest.fn(() => jest.fn()),
    destroy: jest.fn(),
  };

  const dataSource = {
    destroy: jest.fn(),
  };

  const hoverController = {
    destroy: jest.fn(),
  };

  const renderers: RendererTestContext[] = [];

  (Chart as unknown as jest.Mock).mockImplementation(() => mainChart);
  (SeasonalityManager as unknown as jest.Mock).mockImplementation(() => seasonalityManager);
  (EventManager as unknown as jest.Mock).mockImplementation(() => eventManager);
  (DataSource as unknown as jest.Mock).mockImplementation(() => dataSource);
  (Hotkeys as unknown as jest.Mock).mockImplementation(() => hotkeys);
  (FullscreenController as unknown as jest.Mock).mockImplementation(() => ({}));
  (ModalRenderer as unknown as jest.Mock).mockImplementation(() => ({}));
  (TimeScaleHoverController as unknown as jest.Mock).mockImplementation(() => hoverController);
  (ContainerManager.createContainers as jest.Mock).mockReturnValue(containers);

  (configureThemeStore as jest.Mock).mockReturnValue({
    theme: 'tr',
    mode: 'light',
    theme$: new BehaviorSubject('tr'),
    mode$: new BehaviorSubject('light'),
  });

  (ReactRenderer as unknown as jest.Mock).mockImplementation(() => {
    const renderer = {
      renderComponent: jest.fn(),
      destroy: jest.fn(),
    };

    renderers.push(renderer);

    return renderer;
  });

  const getDataSource = jest.fn();
  const getSeasonalityDataSource = jest.fn();
  const stopRealtime = jest.fn();

  const config = {
    container,
    snapshot: {
      charts: [{ symbolId: 'SBER', panes: [] }],
    },
    chartCollectionPreset: {
      getDataSource,
      getSeasonalityDataSource: useSeparateProvider ? getSeasonalityDataSource : undefined,
      startRealtime: jest.fn(() => stopRealtime),
      supportedTimeframes: [],
      supportedChartSeriesTypes: [],
      ohlc: {
        precision: 3,
      },
      locale: 'en',
      theme: 'tr',
      showSeasonalityButton: true,
    },
  } as unknown as IMoexChart;

  const chart = new MoexChart(config);

  constructedCharts.push(chart);

  function getRendered<T = Record<string, unknown>>(component: unknown): ReactElement<T> {
    const call = renderers
      .flatMap((renderer) => renderer.renderComponent.mock.calls)
      .find(([element]) => element.type === component);

    if (!call) {
      throw new Error('Component was not rendered');
    }

    return call[0] as ReactElement<T>;
  }

  return {
    chart,
    config,
    container,
    containers,
    stateObs,
    symbolId,
    seasonalityManager,
    mainChart,
    eventManager,
    hotkeys,
    dataSource,
    hoverController,
    renderers,
    getDataSource,
    getSeasonalityDataSource,
    stopRealtime,
    getRendered,
  };
}

describe('MoexChart: жизненный цикл сезонности', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  afterEach(() => {
    constructedCharts.splice(0).forEach((chart) => chart.destroy());
  });

  it.each([false, true])('должен выбирать загрузчик сезонности при отдельном источнике %s', (separate) => {
    const context = createConstructorContext(separate);

    expect(SeasonalityManager).toHaveBeenCalledWith({
      container: context.containers.seasonalityPlotContainer,
      getData: separate ? context.getSeasonalityDataSource : context.getDataSource,
    });
    expect(context.getRendered(SeasonalityControls).props.setDisplayMode).toBe(
      context.seasonalityManager.setDisplayMode,
    );
    expect(context.getRendered(SeasonalityControls).props.setScale).toBe(context.seasonalityManager.setScale);
    expect(context.getRendered(SeasonalityControls).props.setRange).toBe(context.seasonalityManager.setRange);
    expect(context.getRendered(SeasonalityTable).props.stateObs).toBeDefined();
    expect(context.renderers).toHaveLength(5);
  });

  it('должен переключать видимость графика и таблицы по состоянию менеджера', () => {
    const { stateObs, containers } = createConstructorContext();

    expect(containers.seasonalityPlotContainer.style.visibility).toBe('');
    expect(containers.seasonalityPlotContainer.inert).toBe(false);
    expect(containers.seasonalityTableContainer.hidden).toBe(true);
    expect(containers.seasonalityTableContainer.inert).toBe(true);

    stateObs.next({
      ...stateObs.value,
      displayMode: SeasonalityViewType.Table,
    });

    expect(containers.seasonalityPlotContainer.style.visibility).toBe('hidden');
    expect(containers.seasonalityPlotContainer.style.pointerEvents).toBe('none');
    expect(containers.seasonalityPlotContainer.inert).toBe(true);
    expect(containers.seasonalityTableContainer.hidden).toBe(false);
    expect(containers.seasonalityTableContainer.inert).toBe(false);

    stateObs.next({
      ...stateObs.value,
      displayMode: SeasonalityViewType.Chart,
    });

    expect(containers.seasonalityPlotContainer.style.visibility).toBe('');
    expect(containers.seasonalityPlotContainer.style.pointerEvents).toBe('');
    expect(containers.seasonalityPlotContainer.inert).toBe(false);
    expect(containers.seasonalityTableContainer.hidden).toBe(true);
  });

  it('должен разрешать снимок только для основного графика и готового графика сезонности', async () => {
    const { chart, stateObs, getRendered } = createConstructorContext();
    const values: boolean[] = [];

    const subscription = getRendered<HeaderTestProps>(Header).props.canCopyImageObs.subscribe((value) =>
      values.push(value),
    );

    await chart.showSeasonality();

    stateObs.next({
      ...stateObs.value,
      status: 'ready',
    });
    stateObs.next({
      ...stateObs.value,
      displayMode: SeasonalityViewType.Table,
    });
    stateObs.next({
      ...stateObs.value,
      status: 'loading',
    });
    stateObs.next({
      ...stateObs.value,
      status: 'ready',
      displayMode: SeasonalityViewType.Chart,
    });

    expect(values).toEqual([true, false, true, false, true]);

    subscription.unsubscribe();
  });

  it('должен возвращать основной график, раскладку и фокус через переключатель Header', () => {
    const { chart, getRendered, mainChart, seasonalityManager, containers, container } = createConstructorContext();
    const header = getRendered<HeaderTestProps>(Header);

    header.props.setChartView?.(ChartViewType.Seasonality);
    header.props.setChartView?.(ChartViewType.Chart);

    expect(seasonalityManager.show).toHaveBeenCalledWith('SBER', undefined);
    expect(seasonalityManager.clear).toHaveBeenCalledTimes(1);
    expect(mainChart.setSleeping.mock.calls).toEqual([[true], [false]]);
    expect(container.style.gridTemplateRows).toBe('36px minmax(0, 1fr) 32px');
    expect(containers.chartContainer.style.visibility).toBe('visible');
    expect(containers.chartContainer.inert).toBe(false);
    expect(containers.footerContainer.style.display).toBe('');
    expect(containers.seasonalityContainer.style.visibility).toBe('hidden');
    expect(containers.seasonalityContainer.style.pointerEvents).toBe('none');
    expect(containers.seasonalityContainer.inert).toBe(true);
    expect(document.activeElement).toBe(containers.chartAreaContainer);

    chart.backToChart();

    expect(seasonalityManager.clear).toHaveBeenCalledTimes(1);
  });

  it('должен выходить из сезонности при смене инструмента', async () => {
    const { chart, symbolId, seasonalityManager, mainChart } = createConstructorContext();

    await chart.showSeasonality();

    symbolId.next('GAZP');

    expect(seasonalityManager.clear).toHaveBeenCalledTimes(1);
    expect(mainChart.setSleeping).toHaveBeenLastCalledWith(false);
  });

  it('должен уничтожать рендереры и прекращать обновление раскладки после очистки', () => {
    const context = createConstructorContext();

    context.chart.destroy();
    constructedCharts.splice(constructedCharts.indexOf(context.chart), 1);

    expect(context.seasonalityManager.destroy).toHaveBeenCalledTimes(1);
    expect(context.renderers.every((renderer) => renderer.destroy.mock.calls.length === 1)).toBe(true);
    expect(context.mainChart.destroy).toHaveBeenCalledTimes(1);
    expect(context.eventManager.destroy).toHaveBeenCalledTimes(1);
    expect(context.dataSource.destroy).toHaveBeenCalledTimes(1);
    expect(context.hotkeys.destroy).toHaveBeenCalledTimes(1);
    expect(context.hoverController.destroy).toHaveBeenCalledTimes(1);
    expect(context.stopRealtime).toHaveBeenCalledTimes(1);
    expect(ContainerManager.clearContainers).toHaveBeenCalledWith(context.container);

    context.stateObs.next({
      ...context.stateObs.value,
      displayMode: SeasonalityViewType.Table,
    });

    expect(context.containers.seasonalityPlotContainer.style.visibility).toBe('');
    expect(context.containers.seasonalityTableContainer.hidden).toBe(true);
  });
});

















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

    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 () => {
    await loadSampleHistory();

    manager.setDisplayMode(SeasonalityViewType.Table);

    const previous = state;
    const count = getData.mock.calls.length;

    await manager.show('AAA');

    expect(state).toBe(previous);
    expect(state.tableRows).toBe(previous.tableRows);
    expect(getData).toHaveBeenCalledTimes(count);
    expect(mocks.charts).toHaveLength(1);
  });

  it('не должен повторно отправлять состояние при выборе текущего вида', () => {
    const next = jest.fn();
    const subscription = manager.getState().subscribe(next);

    next.mockClear();

    manager.setDisplayMode(SeasonalityViewType.Chart);

    expect(next).not.toHaveBeenCalled();

    manager.setDisplayMode(SeasonalityViewType.Table);

    const previous = state;

    manager.setDisplayMode(SeasonalityViewType.Table);

    expect(next).toHaveBeenCalledTimes(1);
    expect(state).toBe(previous);
    expect(state.displayMode).toBe(SeasonalityViewType.Table);

    subscription.unsubscribe();
  });

  it('должен показывать изменение первого неполного месяца и годовой итог', async () => {
    getData
      .mockResolvedValueOnce([
        createCandle('2021-10-15', 100),
        createCandle('2021-10-29', 101),
        createCandle('2021-11-30', 105),
        createCandle('2021-12-30', 106),
      ])
      .mockResolvedValueOnce(null);

    await manager.show('AAA');

    const [row] = state.tableRows;

    expect(row.months.slice(0, 9)).toEqual(Array(9).fill(null));
    expect(row.months[9]?.absolute).toBe(1);
    expect(row.months[9]?.percentage).toBeCloseTo(1, 10);
    expect(row.total?.absolute).toBe(6);
    expect(row.total?.percentage).toBeCloseTo(6, 10);
  });

  it('должен использовать декабрь с предыдущей страницы за пределами выбранного диапазона', async () => {
    getData
      .mockResolvedValueOnce([
        createCandle('2024-01-02', 105),
        createCandle('2024-01-31', 110),
        createCandle('2024-02-29', 121),
      ])
      .mockResolvedValueOnce([createCandle('2023-12-29', 100)])
      .mockResolvedValueOnce(null);

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

    expect(state.tableRows).toHaveLength(1);

    const [row] = state.tableRows;

    expect(row.year).toBe(2024);
    expect(row.months[0]?.absolute).toBe(10);
    expect(row.months[0]?.percentage).toBeCloseTo(10, 10);
    expect(row.total?.absolute).toBe(21);
    expect(row.total?.percentage).toBeCloseTo(21, 10);
    expect(getData).toHaveBeenCalledTimes(3);
  });

  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]).toEqual({
      absolute: 0,
      percentage: null,
    });
    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).toEqual({
      absolute: 15,
      percentage: null,
    });
  });

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