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


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

jest.mock('@src/components/Table/index.module.scss', () => ({
  __esModule: true,
  default: {
    table: 'table',
    table_content: 'table_content',
    table_stickyHeader: 'table_stickyHeader',
    table_stickyFirstColumn: 'table_stickyFirstColumn',
    table_cell: 'table_cell',
    table_cell_left: 'table_cell_left',
    table_cell_center: 'table_cell_center',
    table_cell_right: 'table_cell_right',
    table_empty: 'table_empty',
  },
}));

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 { 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';

jest.mock('@src/components/SeasonalityTable/index.module.scss', () => ({
  __esModule: true,
  default: {
    seasonality: 'seasonality',
    positive: 'positive',
    negative: 'negative',
    total: 'total',
  },
}));

jest.mock('@src/components/Table/index.module.scss', () => ({
  __esModule: true,
  default: {
    table: 'table',
    table_content: 'table_content',
    table_stickyHeader: 'table_stickyHeader',
    table_stickyFirstColumn: 'table_stickyFirstColumn',
    table_cell: 'table_cell',
    table_cell_left: 'table_cell_left',
    table_cell_center: 'table_cell_center',
    table_cell_right: 'table_cell_right',
    table_empty: 'table_empty',
  },
}));

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