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


import { PriceScaleMode } from 'lightweight-charts';

import { Button } from '@src/components/Button';
import { Dropdown } from '@src/components/Dropdown';
import { RangeField } from '@src/components/FormFields';
import { CameraIcon, LineIcon, TableIcon } from '@src/components/Icon';
import { SnapshotMenu } from '@src/components/Menu';
import { SeasonalityScaleMenu } from '@src/components/Menu/SeasonalityScaleMenu';
import { Tooltip } from '@src/components/Tooltip';

import { t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { useObservable } from '@src/utils';

import styles from './index.module.scss';

import type { SeasonalityRange, SeasonalityScale, SeasonalityState } from '@src/types/seasonality';
import type { Observable } from 'rxjs';

interface SeasonalityControlsProps {
  stateObs: Observable<SeasonalityState>;
  setDisplayMode: (displayMode: SeasonalityViewType) => void;
  setScale: (scale: SeasonalityScale) => void;
  setRange: (range: SeasonalityRange) => void;
  setAverageVisible: (visible: boolean) => void;
  copyImage?: () => Promise<void>;
}

const MAX_SCALE_LABELS = 5;

function createYearMarks(minYear: number, maxYear: number): number[] {
  const span = maxYear - minYear;

  if (span <= 0) return [minYear];

  const intervals = Math.min(MAX_SCALE_LABELS - 1, span);

  return Array.from(
    { length: intervals + 1 },
    (_, index) => minYear + Math.floor((span * index) / intervals),
  );
}

export function SeasonalityControls({
  stateObs,
  setDisplayMode,
  setScale,
  setRange,
  setAverageVisible,
  copyImage,
}: SeasonalityControlsProps) {
  const state = useObservable(stateObs);

  if (!state || state.status === 'idle') {
    return null;
  }

  const selectedScaleLabel = state.scale === PriceScaleMode.Normal ? t('Price') : t('Percent');

  const isRangeDisabled = state.status === 'loading' || state.status === 'error';
  const canCopyImage = state.status === 'ready' && state.displayMode === SeasonalityViewType.Chart;

  const marks = createYearMarks(state.minYear, state.maxYear);

  const handleRangeChange = ([fromYear, toYear]: [number, number]): void => {
    setRange({ fromYear, toYear });
  };

  return (
    <div className={styles.controls}>
      <div className={styles.controls_header}>
        <div className={styles.controls_views}>
          <Tooltip location="bottom" label={t('Seasonality chart')}>
            <Button
              label={<LineIcon />}
              pressed={state.displayMode === SeasonalityViewType.Chart}
              onClick={() => setDisplayMode(SeasonalityViewType.Chart)}
            />
          </Tooltip>
          <Tooltip location="bottom" label={t('Seasonality table')}>
            <Button
              label={<TableIcon />}
              pressed={state.displayMode === SeasonalityViewType.Table}
              onClick={() => setDisplayMode(SeasonalityViewType.Table)}
            />
          </Tooltip>
        </div>

        <div className={styles.controls_actions}>
          <Tooltip location="bottom" label={t('Average for selected years')}>
            <Button
              label={
                <span className={styles.controls_buttonContent}>
                  <LineIcon />
                  <span>{t('Average')}</span>
                </span>
              }
              pressed={state.showAverage}
              disabled={state.status !== 'ready'}
              onClick={() => setAverageVisible(!state.showAverage)}
            />
          </Tooltip>

          <div className={styles.controls_scale}>
            <Tooltip location="bottom" label={t('Scale')}>
              <Dropdown selectedValue={selectedScaleLabel}>
                <SeasonalityScaleMenu selectedScale={state.scale} onClick={setScale} />
              </Dropdown>
            </Tooltip>
          </div>

          {copyImage && canCopyImage && (
            <Tooltip location="bottom" label={t('Snapshot')}>
              <Dropdown selectedValue={<CameraIcon />}>
                <SnapshotMenu onCopyImage={copyImage} />
              </Dropdown>
            </Tooltip>
          )}
        </div>
      </div>

      <div className={styles.controls_range}>
        <RangeField
          mode="range"
          value={[state.fromYear, state.toYear]}
          min={state.minYear}
          max={state.maxYear}
          marks={marks}
          step={1}
          suffix=""
          size="sm"
          disabled={isRangeDisabled}
          onRangeChange={handleRangeChange}
        />
      </div>
    </div>
  );
}

















import classNames from 'classnames';
import { type ChangeEvent, type PointerEvent, useId, useRef } from 'react';

import styles from './index.module.scss';

interface CommonRangeFieldProps {
  label?: string;
  min?: number;
  max?: number;
  step?: number;
  color?: string;
  suffix?: string;
  size?: 'sm' | 'md';
  disabled?: boolean;
}

interface SingleRangeFieldProps extends CommonRangeFieldProps {
  mode?: 'single';
  value: number;
  onChange: (event: ChangeEvent<HTMLInputElement>) => void;
}

interface IntervalRangeFieldProps extends CommonRangeFieldProps {
  mode: 'range';
  value: [number, number];
  marks?: readonly number[];
  onRangeChange: (value: [number, number]) => void;
}

interface RangeDragState {
  mode: 'range' | 'from' | 'to';
  pointerId: number;
  pointerX: number;
  from: number;
  to: number;
  trackWidth: number;
}

type RangeFieldProps = SingleRangeFieldProps | IntervalRangeFieldProps;

export const RangeField = (props: RangeFieldProps) => {
  const {
    label,
    min = 0,
    max = 100,
    step = 1,
    color = 'var(--blue-1)',
    suffix = '%',
    size = 'md',
    disabled = false,
  } = props;

  const inputId = useId();
  const trackRef = useRef<HTMLDivElement>(null);
  const dragRef = useRef<RangeDragState | null>(null);

  if (props.mode !== 'range') {
    return (
      <div
        className={classNames(styles.range, {
          [styles.range_sm]: size === 'sm',
        })}
      >
        {label && (
          <label htmlFor={inputId} className={styles.range_label}>
            {label}
          </label>
        )}

        <div className={styles.range_wrapper}>
          <input
            id={inputId}
            type="range"
            min={min}
            max={max}
            step={step}
            value={props.value}
            className={styles.range_input}
            style={{ ['--range-color' as string]: color }}
            onChange={props.onChange}
            disabled={disabled}
          />

          <span className={styles.range_value}>
            {props.value}
            {suffix}
          </span>
        </div>
      </div>
    );
  }

  const {
    value: [from, to],
    marks = [],
    onRangeChange,
  } = props;

  const span = max - min;
  const left = span > 0 ? ((from - min) / span) * 100 : 0;
  const right = span > 0 ? ((to - min) / span) * 100 : 0;
  const isDisabled = disabled || span <= 0;

  const scaleMarks =
    span > 0
      ? [
          min,
          ...Array.from(new Set(marks))
            .filter((value) => value > min && value < max)
            .sort((a, b) => a - b),
          max,
        ]
      : [min];

  const handleDragEnd = (event: PointerEvent<HTMLElement>) => {
    if (dragRef.current?.pointerId === event.pointerId) {
      dragRef.current = null;
    }
  };

  const handleDragStart = (event: PointerEvent<HTMLElement>, mode: RangeDragState['mode']) => {
    if (isDisabled || event.button !== 0 || dragRef.current || !trackRef.current) return;

    const { width } = trackRef.current.getBoundingClientRect();

    if (width <= 0) return;

    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);

    dragRef.current = {
      mode,
      pointerId: event.pointerId,
      pointerX: event.clientX,
      from,
      to,
      trackWidth: width,
    };
  };

  const handleDragMove = (event: PointerEvent<HTMLElement>) => {
    const drag = dragRef.current;

    if (!drag || drag.pointerId !== event.pointerId || isDisabled) return;

    const pointerOffset = ((event.clientX - drag.pointerX) / drag.trackWidth) * span;
    const steppedOffset = Math.round(pointerOffset / step) * step;
    let nextFrom = drag.from;
    let nextTo = drag.to;

    if (drag.mode === 'range') {
      const offset = Math.max(min - drag.from, Math.min(max - drag.to, steppedOffset));

      nextFrom += offset;
      nextTo += offset;
    } else if (drag.from === drag.to) {
      const value = Math.max(min, Math.min(max, drag.from + steppedOffset));

      nextFrom = Math.min(drag.from, value);
      nextTo = Math.max(drag.to, value);
    } else if (drag.mode === 'from') {
      nextFrom = Math.max(min, Math.min(drag.to, drag.from + steppedOffset));
    } else {
      nextTo = Math.max(drag.from, Math.min(max, drag.to + steppedOffset));
    }

    if (nextFrom !== from || nextTo !== to) {
      onRangeChange([nextFrom, nextTo]);
    }
  };

  const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    if (
      isDisabled ||
      from !== to ||
      event.button !== 0 ||
      event.defaultPrevented ||
      event.target instanceof HTMLInputElement ||
      !trackRef.current
    ) {
      return;
    }

    const { left: trackLeft, width } = trackRef.current.getBoundingClientRect();

    if (width <= 0) return;

    const value = min + Math.round((((event.clientX - trackLeft) / width) * span) / step) * step;
    const next = Math.max(min, Math.min(max, value));

    event.preventDefault();

    if (next !== from) {
      onRangeChange([Math.min(from, next), Math.max(to, next)]);
    }
  };

  const handleFromChange = (event: ChangeEvent<HTMLInputElement>) => {
    if (dragRef.current) return;

    onRangeChange([Math.min(event.currentTarget.valueAsNumber, to), to]);
  };

  const handleToChange = (event: ChangeEvent<HTMLInputElement>) => {
    if (dragRef.current) return;

    onRangeChange([from, Math.max(event.currentTarget.valueAsNumber, from)]);
  };

  const dragHandlers = {
    onPointerMove: handleDragMove,
    onPointerUp: handleDragEnd,
    onPointerCancel: handleDragEnd,
    onLostPointerCapture: handleDragEnd,
  };

  return (
    <div
      className={classNames(styles.range, styles.range_interval, {
        [styles.range_sm]: size === 'sm',
        [styles.range_disabled]: isDisabled,
        [styles.range_collapsed]: from === to,
      })}
    >
      {label && <span className={styles.range_label}>{label}</span>}

      <div
        ref={trackRef}
        className={styles.range_track}
        style={{ ['--range-color' as string]: color }}
        onPointerDown={handleTrackPointerDown}
      >
        <div className={styles.range_rail} />

        <div
          className={styles.range_selection}
          style={{
            left: `${left}%`,
            width: `${right - left}%`,
          }}
          onPointerDown={(event) => handleDragStart(event, 'range')}
          {...dragHandlers}
        />

        <input
          id={`${inputId}-from`}
          type="range"
          min={min}
          max={max}
          step={step}
          value={from}
          disabled={isDisabled}
          className={classNames(styles.range_handle, styles.range_handle_start, {
            [styles.range_handle_behind]: from === to && from === min,
          })}
          onChange={handleFromChange}
          onPointerDown={(event) => handleDragStart(event, 'from')}
          {...dragHandlers}
        />

        <input
          id={`${inputId}-to`}
          type="range"
          min={min}
          max={max}
          step={step}
          value={to}
          disabled={isDisabled}
          className={classNames(styles.range_handle, styles.range_handle_end)}
          onChange={handleToChange}
          onPointerDown={(event) => handleDragStart(event, 'to')}
          {...dragHandlers}
        />

        <button
          type="button"
          tabIndex={-1}
          className={styles.range_dragHandle}
          style={{ left: `${(left + right) / 2}%` }}
          disabled={isDisabled}
          onPointerDown={(event) => handleDragStart(event, 'range')}
          {...dragHandlers}
        >
          <svg width="12" height="8" viewBox="0 0 12 8" fill="currentColor">
            <circle cx="2" cy="2" r="1" />
            <circle cx="6" cy="2" r="1" />
            <circle cx="10" cy="2" r="1" />
            <circle cx="2" cy="6" r="1" />
            <circle cx="6" cy="6" r="1" />
            <circle cx="10" cy="6" r="1" />
          </svg>
        </button>
      </div>

      <div className={styles.range_scale}>
        {scaleMarks.map((value) => (
          <span
            key={value}
            className={styles.range_scale_label}
            style={{
              left: `${span > 0 ? ((value - min) / span) * 100 : 0}%`,
            }}
          >
            {value}
            {suffix}
          </span>
        ))}
      </div>
    </div>
  );
};

















.range_track {
  position: relative;
  touch-action: none;
}

.range_selection,
.range_handle {
  touch-action: none;
}

.range_dragHandle {
  position: absolute;
  top: 50%;
  z-index: 4;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--space-1500, 24px);
  height: var(--space-1500, 24px);
  padding: 0;
  border: 0;
  border-radius: var(--space-0250);
  color: var(--neutral-12);
  background: transparent;
  cursor: grab;
  touch-action: none;
  transform: translate(-50%, -50%);

  svg {
    pointer-events: none;
  }

  &:active {
    cursor: grabbing;
  }

  &:disabled {
    cursor: default;
  }
}

.range_collapsed .range_track {
  margin-bottom: var(--space-2000, 32px);
}

.range_collapsed .range_dragHandle {
  top: calc(100% + var(--space-0250, 4px));
  background: var(--neutral-2);
  transform: translateX(-50%);
}

















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

import { SeasonalityControls } from '@src/components/SeasonalityControls';
import { createInitialSeasonalityState } from '@src/constants';
import { SeasonalityViewType } from '@src/types';

import type { SeasonalityState } from '@src/types';
import type { ReactNode } from 'react';

interface RangeFieldTestProps {
  value: [number, number];
  min: number;
  max: number;
  marks: number[];
  disabled: boolean;
  onRangeChange: (value: [number, number]) => void;
}

jest.mock('@src/components/Button', () => ({
  Button: ({
    onClick,
    label,
    disabled,
    pressed,
  }: {
    onClick?: () => void;
    label?: ReactNode;
    disabled?: boolean;
    pressed?: boolean;
  }) => (
    <button type="button" onClick={onClick} disabled={disabled} data-pressed={pressed}>
      {label}
    </button>
  ),
}));

jest.mock('@src/components/Icon', () => ({
  CameraIcon: () => <span data-testid="camera-icon" />,
  LineIcon: () => <span data-testid="line-icon" />,
  TableIcon: () => <span data-testid="table-icon" />,
}));

jest.mock('@src/components/SeasonalityControls/index.module.scss', () => ({
  __esModule: true,
  default: {
    controls: 'controls',
    controls_header: 'controls_header',
    controls_views: 'controls_views',
    controls_actions: 'controls_actions',
    controls_buttonContent: 'controls_buttonContent',
    controls_scale: 'controls_scale',
    controls_range: 'controls_range',
  },
}));

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

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

jest.mock('@src/components/Dropdown', () => ({
  Dropdown: ({ children, selectedValue }: { children: ReactNode; selectedValue: ReactNode }) => (
    <div>
      <div data-testid={typeof selectedValue === 'string' ? 'selected-scale' : 'selected-snapshot'}>
        {selectedValue}
      </div>
      {children}
    </div>
  ),
}));

jest.mock('@src/components/Menu/SeasonalityScaleMenu', () => ({
  SeasonalityScaleMenu: ({ onClick }: { onClick: (scale: number) => void }) => (
    <>
      <button type="button" onClick={() => onClick(0)}>
        Set price
      </button>
      <button type="button" onClick={() => onClick(2)}>
        Set percent
      </button>
    </>
  ),
}));

jest.mock('@src/components/FormFields', () => ({
  RangeField: ({ value, min, max, marks, disabled, onRangeChange }: RangeFieldTestProps) => (
    <div data-testid="year-range" data-marks={marks.join(',')}>
      <input
        aria-label="From year"
        type="number"
        value={value[0]}
        min={min}
        max={max}
        disabled={disabled}
        onChange={(event) => onRangeChange([Number(event.target.value), value[1]])}
      />
      <input
        aria-label="To year"
        type="number"
        value={value[1]}
        min={min}
        max={max}
        disabled={disabled}
        onChange={(event) => onRangeChange([value[0], Number(event.target.value)])}
      />
    </div>
  ),
}));

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

function createTestContext(overrides: Partial<SeasonalityState> = {}) {
  const stateObs = new BehaviorSubject<SeasonalityState>({
    ...createInitialSeasonalityState(),
    status: 'ready',
    minYear: 2020,
    maxYear: 2025,
    fromYear: 2021,
    toYear: 2025,
    ...overrides,
  });

  const setDisplayMode = jest.fn();
  const setScale = jest.fn();
  const setRange = jest.fn();
  const setAverageVisible = jest.fn();

  return { stateObs, setDisplayMode, setScale, setRange, setAverageVisible };
}

describe('SeasonalityControls', () => {
  it('не должен отображаться до получения состояния и при idle', () => {
    const stateObs = new Subject<SeasonalityState>();

    const { container } = render(
      <SeasonalityControls
        stateObs={stateObs}
        setDisplayMode={jest.fn()}
        setScale={jest.fn()}
        setRange={jest.fn()}
        setAverageVisible={jest.fn()}
      />,
    );

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

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

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

  it('должен отображать выбранный диапазон и границы доступных лет', () => {
    const props = createTestContext();

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

    const from = screen.getByLabelText('From year') as HTMLInputElement;
    const to = screen.getByLabelText('To year') as HTMLInputElement;

    expect(from.value).toBe('2021');
    expect(to.value).toBe('2025');
    expect(from.min).toBe('2020');
    expect(from.max).toBe('2025');
    expect(screen.getByTestId('selected-scale').textContent).toBe('Price');
  });

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

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

    const chartButton = within(screen.getByTestId('tooltip-Seasonality chart')).getByRole('button');
    const tableButton = screen.getByTestId('table-icon').closest('button') as HTMLButtonElement;

    expect(chartButton.getAttribute('data-pressed')).toBe('true');
    expect(tableButton.getAttribute('data-pressed')).toBe('false');

    fireEvent.click(tableButton);

    expect(props.setDisplayMode).toHaveBeenNthCalledWith(1, SeasonalityViewType.Table);
    expect(chartButton.getAttribute('data-pressed')).toBe('true');

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

    expect(chartButton.getAttribute('data-pressed')).toBe('false');
    expect(tableButton.getAttribute('data-pressed')).toBe('true');

    fireEvent.click(chartButton);

    expect(props.setDisplayMode).toHaveBeenNthCalledWith(2, SeasonalityViewType.Chart);

    act(() =>
      props.stateObs.next({
        ...props.stateObs.value,
        displayMode: SeasonalityViewType.Chart,
      }),
    );

    expect(chartButton.getAttribute('data-pressed')).toBe('true');
    expect(tableButton.getAttribute('data-pressed')).toBe('false');
  });

  it.each(['loading', 'error', 'empty'] as const)(
    'должен позволять переключить вид при статусе %s',
    (status) => {
      const props = createTestContext({ status });

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

      fireEvent.click(screen.getByTestId('table-icon'));

      expect(props.setDisplayMode).toHaveBeenCalledWith(SeasonalityViewType.Table);
    },
  );

  it('должен передавать изменение обеих границ диапазона', () => {
    const props = createTestContext();

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

    fireEvent.change(screen.getByLabelText('From year'), {
      target: { value: '2022' },
    });
    fireEvent.change(screen.getByLabelText('To year'), {
      target: { value: '2024' },
    });

    expect(props.setRange).toHaveBeenNthCalledWith(1, {
      fromYear: 2022,
      toYear: 2025,
    });
    expect(props.setRange).toHaveBeenNthCalledWith(2, {
      fromYear: 2021,
      toYear: 2024,
    });
  });

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

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

    fireEvent.click(screen.getByText('Set percent'));
    fireEvent.click(screen.getByText('Set price'));

    expect(props.setScale).toHaveBeenNthCalledWith(1, PriceScaleMode.Percentage);
    expect(props.setScale).toHaveBeenNthCalledWith(2, PriceScaleMode.Normal);

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

    expect(screen.getByTestId('selected-scale').textContent).toBe('Percent');
  });

  it.each(['loading', 'error'] as const)('должен блокировать диапазон при статусе %s', (status) => {
    const props = createTestContext({ status });

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

    expect((screen.getByLabelText('From year') as HTMLInputElement).disabled).toBe(true);
    expect((screen.getByLabelText('To year') as HTMLInputElement).disabled).toBe(true);

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

    expect((screen.getByLabelText('From year') as HTMLInputElement).disabled).toBe(false);
  });

  it('должен позволять изменить пустой диапазон и скрываться после очистки', () => {
    const props = createTestContext({ status: 'empty' });
    const { container } = render(<SeasonalityControls {...props} />);

    fireEvent.change(screen.getByLabelText('From year'), {
      target: { value: '2020' },
    });

    expect(props.setRange).toHaveBeenCalledWith({
      fromYear: 2020,
      toYear: 2025,
    });

    act(() =>
      props.stateObs.next({
        ...props.stateObs.value,
        status: 'idle',
      }),
    );

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

  it('должен корректно отображать единственный доступный год', () => {
    const props = createTestContext({
      minYear: 2025,
      maxYear: 2025,
      fromYear: 2025,
      toYear: 2025,
    });

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

    expect(screen.getByTestId('year-range').getAttribute('data-marks')).toBe('2025');
    expect((screen.getByLabelText('From year') as HTMLInputElement).value).toBe('2025');
    expect((screen.getByLabelText('To year') as HTMLInputElement).value).toBe('2025');
  });
});

describe('SeasonalityControls: среднее и подсказки', () => {
  it('должен переключать среднее и обновлять pressed только из состояния', () => {
    const props = createTestContext();
    render(<SeasonalityControls {...props} />);

    const button = screen.getByRole('button', { name: 'Average' });

    expect(button.getAttribute('data-pressed')).toBe('false');
    expect(within(button).queryByTestId('line-icon')).not.toBeNull();

    fireEvent.click(button);

    expect(props.setAverageVisible).toHaveBeenLastCalledWith(true);
    expect(button.getAttribute('data-pressed')).toBe('false');

    act(() => props.stateObs.next({ ...props.stateObs.value, showAverage: true }));

    expect(button.getAttribute('data-pressed')).toBe('true');

    fireEvent.click(button);

    expect(props.setAverageVisible).toHaveBeenLastCalledWith(false);
  });

  it.each(['loading', 'error', 'empty'] as const)(
    'должен блокировать переключение среднего при статусе %s',
    (status) => {
      const props = createTestContext({ status });
      render(<SeasonalityControls {...props} />);

      const button = screen.getByRole('button', { name: 'Average' }) as HTMLButtonElement;

      expect(button.disabled).toBe(true);

      fireEvent.click(button);

      expect(props.setAverageVisible).not.toHaveBeenCalled();
    },
  );

  it('должен сохранять pressed среднего при переключении на таблицу', () => {
    const props = createTestContext({ showAverage: true });
    render(<SeasonalityControls {...props} />);

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

    expect(screen.getByRole('button', { name: 'Average' }).getAttribute('data-pressed')).toBe('true');
    expect(props.setAverageVisible).not.toHaveBeenCalled();
  });

  it('должен передавать подписи и расположение подсказок', () => {
    const props = createTestContext();
    render(<SeasonalityControls {...props} copyImage={jest.fn().mockResolvedValue(undefined)} />);

    ['Seasonality chart', 'Seasonality table', 'Average for selected years', 'Scale', 'Snapshot'].forEach(
      (label) => {
        expect(screen.getByTestId('tooltip-' + label).getAttribute('data-location')).toBe('bottom');
      },
    );
  });

  it('должен передавать копирование снимка из правой панели', () => {
    const props = createTestContext();
    const copyImage = jest.fn().mockResolvedValue(undefined);
    const { container } = render(<SeasonalityControls {...props} copyImage={copyImage} />);

    const camera = screen.getByTestId('camera-icon');

    expect(container.querySelector('.controls_actions')?.contains(camera)).toBe(true);
    expect(container.querySelector('.controls_views')?.contains(camera)).toBe(false);

    fireEvent.click(screen.getByRole('button', { name: 'Copy image' }));

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

  it.each([
    ['ready', SeasonalityViewType.Table],
    ['loading', SeasonalityViewType.Chart],
    ['error', SeasonalityViewType.Chart],
    ['empty', SeasonalityViewType.Chart],
  ] as const)('не должен отображать снимок при статусе %s и виде %s', (status, displayMode) => {
    const props = createTestContext({ status, displayMode });
    render(<SeasonalityControls {...props} copyImage={jest.fn().mockResolvedValue(undefined)} />);

    expect(screen.queryByTestId('camera-icon')).toBeNull();
    expect(screen.queryByRole('button', { name: 'Copy image' })).toBeNull();
  });

  it('не должен отображать снимок без обработчика копирования', () => {
    render(<SeasonalityControls {...createTestContext()} />);

    expect(screen.queryByTestId('camera-icon')).toBeNull();
  });

  it('должен ограничивать количество отметок и сохранять крайние годы', () => {
    render(<SeasonalityControls {...createTestContext({ minYear: 2000, maxYear: 2025 })} />);

    const marks = screen.getByTestId('year-range').getAttribute('data-marks')!.split(',').map(Number);

    expect(marks).toHaveLength(5);
    expect(marks[0]).toBe(2000);
    expect(marks[marks.length - 1]).toBe(2025);
    expect(new Set(marks).size).toBe(marks.length);
  });
});

















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

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

jest.mock('@src/components/Header/index.module.scss', () => ({
  __esModule: true,
  default: {
    group: 'group',
  },
}));

describe('SeasonalityHeader', () => {
  it('должен отображать переключатель вида и сохранять его обработчик', () => {
    const setChartView = jest.fn();
    const copyImage = jest.fn();

    render(
      <SeasonalityHeader
        viewControl={
          <button type="button" onClick={setChartView}>
            Chart view
          </button>
        }
        snapshotControl={
          <button type="button" onClick={copyImage}>
            Snapshot
          </button>
        }
      />,
    );

    fireEvent.click(screen.getByText('Chart view'));

    expect(setChartView).toHaveBeenCalledTimes(1);
    expect(copyImage).not.toHaveBeenCalled();
    expect(screen.queryByText('Snapshot')).toBeNull();
    expect(screen.queryByRole('separator')).toBeNull();
  });

  it('должен сохранять переключатель вида при изменении доступности снимка', () => {
    const viewControl = <button type="button">Chart view</button>;
    const snapshotControl = <button type="button">Snapshot</button>;
    const { rerender } = render(
      <SeasonalityHeader viewControl={viewControl} snapshotControl={snapshotControl} />,
    );

    rerender(<SeasonalityHeader viewControl={viewControl} snapshotControl={null} />);

    expect(screen.queryByText('Chart view')).not.toBeNull();
    expect(screen.queryByText('Snapshot')).toBeNull();

    rerender(<SeasonalityHeader viewControl={viewControl} snapshotControl={snapshotControl} />);

    expect(screen.queryByText('Chart view')).not.toBeNull();
    expect(screen.queryByText('Snapshot')).toBeNull();
  });

  it('не должен отображать снимок без переключателя вида', () => {
    render(
      <SeasonalityHeader
        viewControl={null}
        snapshotControl={<button type="button">Snapshot</button>}
      />,
    );

    expect(screen.queryByRole('button')).toBeNull();
    expect(screen.queryByRole('separator')).toBeNull();
  });
});


















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

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

import { useState, type ComponentProps } from 'react';

jest.mock('@src/components/FormFields/RangeField/index.module.scss', () => ({
  __esModule: true,
  default: {
    range: 'range',
    range_sm: 'range_sm',
    range_interval: 'range_interval',
    range_disabled: 'range_disabled',
    range_collapsed: 'range_collapsed',
    range_label: 'range_label',
    range_wrapper: 'range_wrapper',
    range_input: 'range_input',
    range_value: 'range_value',
    range_track: 'range_track',
    range_rail: 'range_rail',
    range_selection: 'range_selection',
    range_handle: 'range_handle',
    range_handle_start: 'range_handle_start',
    range_handle_end: 'range_handle_end',
    range_handle_behind: 'range_handle_behind',
    range_scale: 'range_scale',
    range_scale_label: 'range_scale_label',
    range_dragHandle: 'range_dragHandle',
  },
}));

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

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

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

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

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

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

  [selection, dragHandle, ...handles].forEach((element) => {
    Object.defineProperty(element, 'setPointerCapture', {
      configurable: true,
      value: jest.fn(),
    });
  });

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

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

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

  fireEvent(element, event);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

describe('RangeField: границы и ручка переноса', () => {
  it('должен переносить диапазон за отдельную ручку с сохранением ширины', () => {
    const { dragHandle, onRangeChange } = renderRangeField();

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

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

  it('должен переносить совпавшие границы только за ручку диапазона', () => {
    const { dragHandle, onRangeChange } = renderRangeField({
      min: 2020,
      max: 2025,
      value: [2022, 2022],
      step: 1,
    });

    pointer(dragHandle, 'pointerdown', 100);
    pointer(dragHandle, 'pointermove', 140);

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

    pointer(dragHandle, 'pointermove', 60);

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

  it.each([0, 1])('должен раздвигать совпавшие границы в обе стороны за точку %s', (index) => {
    const { handles, onRangeChange } = renderRangeField({
      min: 2020,
      max: 2025,
      value: [2022, 2022],
      step: 1,
    });

    pointer(handles[index], 'pointerdown', 100);
    pointer(handles[index], 'pointermove', 60);

    expect(onRangeChange).toHaveBeenLastCalledWith([2021, 2022]);

    pointer(handles[index], 'pointermove', 140);

    expect(onRangeChange).toHaveBeenLastCalledWith([2022, 2023]);
  });

  it.each([
    [0, 160, [40, 40]],
    [1, 40, [20, 20]],
  ] as const)(
    'должен менять только выбранную границу %s и не допускать пересечения',
    (index, x, expected) => {
      const { handles, onRangeChange } = renderRangeField();

      pointer(handles[index], 'pointerdown', 100);
      pointer(handles[index], 'pointermove', x);

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

  it.each([
    [1000, [100, 100]],
    [-1000, [0, 0]],
  ] as const)('должен ограничивать перенос совпавших границ при x=%s', (x, expected) => {
    const { dragHandle, onRangeChange } = renderRangeField({ value: [40, 40] });

    pointer(dragHandle, 'pointerdown', 100);
    pointer(dragHandle, 'pointermove', x);

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

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

    pointer(dragHandle, 'pointerdown', 100);
    pointer(handles[0], 'pointerdown', 50, 0, 2);
    pointer(dragHandle, 'pointermove', 150, 0, 2);
    pointer(dragHandle, 'pointerup', 150, 0, 2);

    expect(onRangeChange).not.toHaveBeenCalled();

    pointer(dragHandle, 'pointermove', 120);

    expect(onRangeChange).toHaveBeenLastCalledWith([30, 50]);
  });

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

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

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

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

    function ControlledRange() {
      const [value, setValue] = useState<[number, number]>([20, 40]);

      return (
        <RangeField
          mode="range"
          min={0}
          max={100}
          step={5}
          value={value}
          onRangeChange={(next) => {
            onRangeChange(next);
            setValue(next);
          }}
        />
      );
    }

    const { container, getAllByRole, getByRole } = render(<ControlledRange />);
    const handles = getAllByRole('slider') as HTMLInputElement[];
    const dragHandle = getByRole('button');
    const track = dragHandle.parentElement!;

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

    [dragHandle, ...handles].forEach((element) => {
      Object.defineProperty(element, 'setPointerCapture', {
        configurable: true,
        value: jest.fn(),
      });
    });

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

    expect(container.firstElementChild?.classList.contains('range_collapsed')).toBe(true);

    pointer(dragHandle, 'pointerdown', 100);
    pointer(dragHandle, 'pointermove', 80);
    pointer(dragHandle, 'pointerup', 80);

    expect(handles.map((input) => input.value)).toEqual(['30', '30']);

    pointer(handles[1], 'pointerdown', 100);
    pointer(handles[1], 'pointermove', 80);
    pointer(handles[1], 'pointerup', 80);

    expect(handles.map((input) => input.value)).toEqual(['20', '30']);
    expect(container.firstElementChild?.classList.contains('range_collapsed')).toBe(false);
    expect(onRangeChange).toHaveBeenLastCalledWith([20, 30]);
  });

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

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

    pointer(dragHandle, 'pointerdown', 100);
    pointer(dragHandle, 'pointermove', 140);
    pointer(handles[0], 'pointerdown', 100);
    pointer(handles[0], 'pointermove', 140);

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

















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 styles from '@src/components/SeasonalityTable/index.module.scss';
import tableStyles from '@src/components/Table/index.module.scss';
import { createInitialSeasonalityState } from '@src/constants';
import { SeasonalityViewType } from '@src/types';
import { setPricePrecision } from '@src/utils/precision';

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',
    average: 'average',
  },
}));

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: number | string = 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);
  });
});

describe('SeasonalityTable: среднее', () => {
  it('должен добавлять строку среднего снизу и учитывать нули и пропуски', () => {
    const second = createRow(2025);

    second.months[0] = { absolute: 0, percentage: 0 };
    second.months[1] = null;
    second.months[2] = { absolute: -2, percentage: null };
    second.total = { absolute: 20, percentage: 4 };

    const tableRows = [second, createRow()];
    const snapshot = JSON.parse(JSON.stringify(tableRows));
    const props = createTestContext({ showAverage: true, tableRows });

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

    expect(screen.getAllByRole('rowheader').map((cell) => cell.textContent)).toEqual([
      '2025',
      '2024',
      'Average',
    ]);

    const cells = getYearCells('Average');
    const row = screen.getByRole('rowheader', { name: 'Average' }).closest('tr')!;

    expect(cells[0].textContent).toBe('6.173');
    expect(cells[1].textContent).toBe('-2.346');
    expect(cells[2].textContent).toBe('-1.000');
    expect(cells[4].textContent).toBe('—');
    expect(cells[12].textContent).toBe('17.562');
    expect(cells[0].classList.contains(styles.positive)).toBe(true);
    expect(cells[1].classList.contains(styles.negative)).toBe(true);
    expect([...row.children].every((cell) => cell.classList.contains(styles.average))).toBe(true);
    expect(props.stateObs.value.tableRows).toEqual(snapshot);

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

    const percentCells = getYearCells('Average');

    expect(percentCells[0].textContent).toBe('0.62%');
    expect(percentCells[1].textContent).toBe('-0.88%');
    expect(percentCells[2].textContent).toBe('0.00%');
    expect(percentCells[2].classList.contains(styles.negative)).toBe(false);
    expect(percentCells[3].textContent).toBe('—');
    expect(percentCells[12].textContent).toBe('3.17%');
  });

  it('должен пересчитывать среднее при изменении строк и удалять его при отключении', () => {
    const props = createTestContext({ showAverage: true });
    render(<SeasonalityTable {...props} />);

    expect(getYearCells('Average')[0].textContent).toBe('12.346');

    const row = createRow(2025);
    row.months[0] = { absolute: -10, percentage: -5 };

    act(() => props.stateObs.next({ ...props.stateObs.value, tableRows: [row] }));

    expect(getYearCells('Average')[0].textContent).toBe('-10.000');
    expect(screen.queryByRole('rowheader', { name: '2024' })).toBeNull();

    act(() => props.stateObs.next({ ...props.stateObs.value, showAverage: false }));

    expect(screen.queryByRole('rowheader', { name: 'Average' })).toBeNull();
    expect(screen.queryByRole('rowheader', { name: '2025' })).not.toBeNull();
  });

  it('должен оставлять нулевое среднее и пропуски нейтральными', () => {
    const row = createRow();
    row.months[0] = { absolute: 0, percentage: 0 };
    row.total = null;

    render(
      <SeasonalityTable
        {...createTestContext({ showAverage: true, tableRows: [row] })}
      />,
    );

    const cells = getYearCells('Average');

    expect(cells[0].textContent).toBe('0.000');
    expect(cells[0].classList.contains(styles.positive)).toBe(false);
    expect(cells[0].classList.contains(styles.negative)).toBe(false);
    expect(cells[12].textContent).toBe('—');
    expect(cells[12].classList.contains(styles.average)).toBe(true);
  });

  it('не должен добавлять среднее без строк или при выключенном флаге', () => {
    const props = createTestContext();
    render(<SeasonalityTable {...props} />);

    expect(screen.queryByRole('rowheader', { name: 'Average' })).toBeNull();

    act(() =>
      props.stateObs.next({
        ...props.stateObs.value,
        showAverage: true,
        tableRows: [],
      }),
    );

    expect(screen.queryByRole('rowheader', { name: 'Average' })).toBeNull();
    expect(screen.getByRole('cell').textContent).toBe('No seasonality data');
  });
});

















import { PriceScaleMode } from 'lightweight-charts';

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

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

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

describe('buildSeasonalityAverage', () => {
  it('должен возвращать календарь без значений для пустой истории', () => {
    const data = buildSeasonalityAverage([], 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.every((point) => !('value' in point))).toBe(true);
  });

  it('должен усреднять только доступные цены, включая нули', () => {
    const years = buildSeasonality([
      { date: '2023-01-01', close: 100 },
      { date: '2023-01-02', close: 200 },
      { date: '2024-01-01', close: 200 },
      { date: '2024-01-02', close: 200 },
      { date: '2025-01-01', close: 0 },
      { date: '2025-01-03', close: 20 },
    ]);

    const data = buildSeasonalityAverage(years, PriceScaleMode.Normal);

    expect(data[0]).toEqual({ time: '2000-01-01', value: 100 });
    expect(data[1]).toEqual({ time: '2000-01-02', value: 200 });
    expect(data[2]).toEqual({ time: '2000-01-03', value: 20 });
    expect(data[3]).toEqual({ time: '2000-01-04' });
  });

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

    const data = buildSeasonalityAverage(years, PriceScaleMode.Percentage);

    expect(data[0]).toEqual({ time: '2000-01-01', value: 0 });
    expect(data[1]).toEqual({ time: '2000-01-02', value: 50 });
    expect(data[2]).toEqual({ time: '2000-01-03' });
  });

  it('должен сохранять нулевое среднее отрицательных и положительных процентов', () => {
    const years = buildSeasonality([
      { date: '2023-01-01', close: 100 },
      { date: '2023-01-02', close: 0 },
      { date: '2024-01-01', close: 100 },
      { date: '2024-01-02', close: 200 },
    ]);

    expect(buildSeasonalityAverage(years, PriceScaleMode.Percentage)[1]).toEqual({
      time: '2000-01-02',
      value: 0,
    });
  });

  it('должен учитывать 29 февраля без заполнения отсутствующих дат', () => {
    const years = buildSeasonality([
      { date: '2023-02-28', close: 100 },
      { date: '2024-02-29', close: 200 },
    ]);

    const data = buildSeasonalityAverage(years, PriceScaleMode.Normal);

    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',
      value: 200,
    });
    expect(data.find((point) => point.time === '2000-03-01')).toEqual({
      time: '2000-03-01',
    });
  });

  it('должен сохранять точность и не изменять исходные годы', () => {
    const years = buildSeasonality([
      { date: '2023-01-01', close: 3.123456 },
      { date: '2024-01-01', close: 7.987654 },
    ]);
    const snapshot = JSON.parse(JSON.stringify(years));
    const [point] = buildSeasonalityAverage(years, PriceScaleMode.Normal);

    expect('value' in point && point.value).toBeCloseTo(5.555555, 10);
    expect(years).toEqual(snapshot);
  });
});

describe('buildSeasonalityTableAverage', () => {
  it('должен возвращать null без строк', () => {
    expect(buildSeasonalityTableAverage([])).toBeNull();
  });

  it('должен отдельно усреднять месяцы и годовые итоги с учётом нулей и пропусков', () => {
    const rows: SeasonalityTableRow[] = [
      {
        year: 2023,
        months: [
          { absolute: 10, percentage: 10 },
          null,
          { absolute: 0, percentage: 0 },
        ],
        total: { absolute: 30, percentage: 30 },
      },
      {
        year: 2024,
        months: [
          { absolute: 20, percentage: null },
          { absolute: -5, percentage: -5 },
        ],
        total: { absolute: -10, percentage: -10 },
      },
      {
        year: 2025,
        months: [{ absolute: 0, percentage: 0 }],
        total: null,
      },
    ];
    const snapshot = JSON.parse(JSON.stringify(rows));
    const average = buildSeasonalityTableAverage(rows)!;

    expect(average.months).toHaveLength(12);
    expect(average.months[0]).toEqual({ absolute: 10, percentage: 5 });
    expect(average.months[1]).toEqual({ absolute: -5, percentage: -5 });
    expect(average.months[2]).toEqual({ absolute: 0, percentage: 0 });
    expect(average.months.slice(3)).toEqual(Array(9).fill(null));
    expect(average.total).toEqual({ absolute: 10, percentage: 10 });
    expect(rows).toEqual(snapshot);
  });

  it('должен сохранять абсолютное среднее при отсутствии процентных значений', () => {
    const average = buildSeasonalityTableAverage([
      {
        year: 2024,
        months: [{ absolute: 0, percentage: null }],
        total: { absolute: -2, percentage: null },
      },
    ])!;

    expect(average.months[0]).toEqual({ absolute: 0, percentage: null });
    expect(average.total).toEqual({ absolute: -2, percentage: null });
  });

  it('должен возвращать пропуски для полностью пустых колонок и итогов', () => {
    expect(
      buildSeasonalityTableAverage([{ year: 2024, months: [], total: null }]),
    ).toEqual({
      months: Array(12).fill(null),
      total: null,
    });
  });

  it('не должен округлять средние значения до точности отображения', () => {
    const average = buildSeasonalityTableAverage([
      {
        year: 2023,
        months: [],
        total: { absolute: 3.123456, percentage: 1.123456 },
      },
      {
        year: 2024,
        months: [],
        total: { absolute: 7.987654, percentage: 2.987654 },
      },
    ])!;

    expect(average.total?.absolute).toBeCloseTo(5.555555, 10);
    expect(average.total?.percentage).toBeCloseTo(2.055555, 10);
  });
});

















import { createChart, createSeriesMarkers, LineStyle, 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);
  });
});

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

    const chart = mocks.charts[0];

    expect(chart.series).toHaveLength(3);
    expect(state.showAverage).toBe(false);

    manager.setAverageVisible(true);

    const average = chart.series[3];

    expect(state.showAverage).toBe(true);
    expect(average.options).toEqual(
      expect.objectContaining({
        title: 'Avg',
        color: colors.chartTextPrimary,
        lineStyle: LineStyle.Dashed,
        lineWidth: 2,
        priceLineVisible: false,
      }),
    );
    expect(getLastCall(average.setData)[0][0]).toEqual({
      time: '2000-01-01',
      value: 100,
    });
    expect(getLastCall(average.setData)[0][1]).toEqual({
      time: '2000-01-02',
      value: 125,
    });
    expect(container.textContent).toContain('Avg');

    manager.setAverageVisible(true);

    expect(chart.series).toHaveLength(4);
    expect(average.setData).toHaveBeenCalledTimes(1);

    manager.setAverageVisible(false);

    expect(state.showAverage).toBe(false);
    expect(chart.removeSeries).toHaveBeenCalledWith(average);
    expect(container.textContent).not.toContain('Avg');
    expect(getData).toHaveBeenCalledTimes(2);
    expect(createChart).toHaveBeenCalledTimes(1);
  });

  it('должен пересчитывать среднее при изменении шкалы и диапазона', async () => {
    await loadSampleHistory();
    manager.setAverageVisible(true);

    const chart = mocks.charts[0];
    const average = chart.series[3];

    manager.setScale(PriceScaleMode.Percentage);

    expect(getLastCall(average.setData)[0][0]).toEqual({
      time: '2000-01-01',
      value: 0,
    });
    expect(getLastCall(average.setData)[0][1]).toEqual({
      time: '2000-01-02',
      value: 25,
    });
    expect(getLastCall(average.applyOptions)[0].priceFormat.minMove).toBe(0.01);
    expect(getLastCall(average.applyOptions)[0].priceFormat.formatter(25)).toBe('25.00%');

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

    expect(getLastCall(average.setData)[0][1]).toEqual({
      time: '2000-01-02',
    });

    manager.setScale(PriceScaleMode.Normal);

    expect(getLastCall(average.setData)[0][0]).toEqual({
      time: '2000-01-01',
      value: 100,
    });
    expect(getLastCall(average.applyOptions)[0].priceFormat.minMove).toBe(0.001);
    expect(chart.series.filter((series) => series.options.title === 'Avg')).toHaveLength(1);
    expect(getData).toHaveBeenCalledTimes(2);
  });

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

    const chart = mocks.charts[0];
    const average = chart.series[3];
    const onCrosshairMove = getLastCall(chart.subscribeCrosshairMove)[0];
    const label = [...container.querySelectorAll('span')].find(
      (element) => element.textContent === 'Avg',
    )!;
    const value = label.nextElementSibling!;

    expect(value.textContent).toBe('125.00');

    onCrosshairMove({
      time: '2000-01-01',
      seriesData: new Map([[average, { value: 0 }]]),
    });

    expect(value.textContent).toBe('0.00');

    onCrosshairMove({
      time: '2000-01-02',
      seriesData: new Map(),
    });

    expect(value.textContent).toBe('—');

    onCrosshairMove({});

    expect(value.textContent).toBe('125.00');
  });

  it('должен сохранять среднее между видами и при повторной загрузке после очистки', async () => {
    await loadSampleHistory();
    manager.setAverageVisible(true);

    const chart = mocks.charts[0];

    manager.setDisplayMode(SeasonalityViewType.Table);
    manager.setDisplayMode(SeasonalityViewType.Chart);

    expect(state.showAverage).toBe(true);
    expect(chart.series).toHaveLength(4);
    expect(getData).toHaveBeenCalledTimes(2);

    manager.clear();

    expect(state.showAverage).toBe(true);
    expect(chart.remove).toHaveBeenCalledTimes(1);
    expect(container.textContent).not.toContain('Avg');

    await loadSampleHistory();

    expect(
      mocks.charts[1].series.some((series) => series.options.title === 'Avg'),
    ).toBe(true);
    expect(container.textContent).toContain('Avg');
  });

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

    const chart = mocks.charts[0];
    const average = chart.series[3];

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

    expect(state.status).toBe('empty');
    expect(state.showAverage).toBe(true);
    expect(chart.removeSeries).toHaveBeenCalledWith(average);
    expect(container.textContent).not.toContain('Avg');

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

    expect(state.status).toBe('ready');
    expect(chart.series[chart.series.length - 1].options.title).toBe('Avg');
    expect(container.textContent).toContain('Avg');
    expect(getData).toHaveBeenCalledTimes(2);
  });

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

    expect(createChart).not.toHaveBeenCalled();

    await loadSampleHistory();

    expect(state.showAverage).toBe(true);
    expect(mocks.charts[0].series[3].options.title).toBe('Avg');
  });

  it('должен обновлять цвет средней линии и её значения из темы', async () => {
    await loadSampleHistory();
    manager.setAverageVisible(true);

    const average = mocks.charts[0].series[3];

    jest.mocked(getThemeStore).mockReturnValue({
      colors: {
        ...colors,
        chartTextPrimary: '#abcdef',
      },
    } as ReturnType<typeof getThemeStore>);

    manager.updateTheme();

    expect(average.applyOptions).toHaveBeenLastCalledWith({
      color: '#abcdef',
    });

    const label = [...container.querySelectorAll('span')].find(
      (element) => element.textContent === 'Avg',
    )!;

    expect((label.nextElementSibling as HTMLElement).style.color).toBe(
      'rgb(171, 205, 239)',
    );
  });

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

    const chart = mocks.charts[0];

    manager.destroy();
    manager.setAverageVisible(true);

    expect(chart.series).toHaveLength(3);
    expect(chart.remove).toHaveBeenCalledTimes(1);
  });
});