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


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

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

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

jest.mock('@src/components/FormFields/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_marksTop: 'range_marksTop',
    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',
    range_dragIcon: 'range_dragIcon',
  },
}));

jest.mock('@src/components/Icon', () => ({
  DragHandleIcon: ({ className }: { className?: string }) => <svg className={className} />,
}));

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>('.range_selection')!;
  const track = result.container.querySelector<HTMLDivElement>('.range_track')!;
  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, dragHandle, container } = renderRangeField({
      min: 2025,
      max: 2025,
      value: [2025, 2025],
      suffix: '',
    });

    expect(handles.every((input) => input.disabled)).toBe(true);
    expect((dragHandle as HTMLButtonElement).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();
  });
});

describe('RangeField: расположение подписей', () => {
  it('должен отображать подписи под полосой по умолчанию', () => {
    const { container, track } = renderRangeField();
    const scale = container.querySelector('.range_scale')!;

    expect(track.nextElementSibling).toBe(scale);
    expect(container.firstElementChild?.classList.contains('range_marksTop')).toBe(false);
  });

  it('должен отображать подписи над полосой после названия поля', () => {
    const { container, track } = renderRangeField({
      marksPosition: 'top',
      label: 'Years',
    });
    const scale = container.querySelector('.range_scale')!;

    expect(container.firstElementChild?.firstElementChild?.textContent).toBe('Years');
    expect(scale.nextElementSibling).toBe(track);
    expect(container.firstElementChild?.classList.contains('range_marksTop')).toBe(true);
  });

  it('должен менять расположение без дублирования подписей и изменения значений', () => {
    const { container, handles, onRangeChange, rerender } = renderRangeField({
      marksPosition: 'top',
    });

    rerender(
      <RangeField
        mode="range"
        value={[20, 40]}
        min={0}
        max={100}
        step={5}
        marksPosition="bottom"
        onRangeChange={onRangeChange}
      />,
    );

    const track = container.querySelector('.range_track')!;
    const scale = container.querySelector('.range_scale')!;

    expect(container.querySelectorAll('.range_scale')).toHaveLength(1);
    expect(track.nextElementSibling).toBe(scale);
    expect(handles.map((input) => input.value)).toEqual(['20', '40']);
    expect(onRangeChange).not.toHaveBeenCalled();
  });

  it.each(['top', 'bottom'] as const)(
    'должен сохранять перенос диапазона при расположении подписей %s',
    (marksPosition) => {
      const { dragHandle, onRangeChange } = renderRangeField({ marksPosition });

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

      expect(onRangeChange).toHaveBeenLastCalledWith([30, 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[];
  marksPosition?: 'top' | 'bottom';
  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',
    controls_dropdown: 'controls_dropdown',
  },
}));

jest.mock('@src/components/Tooltip', () => ({
  Tooltip: ({ children, label }: { children: ReactNode; label: string }) => (
    <div data-testid={`tooltip-${label}`}>{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,
    className,
    renderTrigger,
  }: {
    children: ReactNode;
    selectedValue: ReactNode;
    className?: string;
    renderTrigger?: (props: { isOpen: boolean; onToggle: () => void; onClose: () => void }) => ReactNode;
  }) => {
    const { useState } = jest.requireActual<typeof import('react')>('react');
    const [isOpen, setIsOpen] = useState(false);

    return (
      <div className={className}>
        <div data-testid={typeof selectedValue === 'string' ? 'selected-scale' : 'selected-snapshot'}>
          {renderTrigger
            ? renderTrigger({
                isOpen,
                onToggle: () => setIsOpen((value) => !value),
                onClose: () => setIsOpen(false),
              })
            : 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, marksPosition, disabled, onRangeChange }: RangeFieldTestProps) => (
    <div
      data-testid="year-range"
      data-marks={marks.join(',')}
      data-marks-position={marksPosition}
    >
      <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');
    expect(screen.getByTestId('year-range').getAttribute('data-marks-position')).toBe('top');
  });

  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(within(screen.getByTestId(`tooltip-${label}`)).getByRole('button')).not.toBeNull();
    });
  });

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

describe('SeasonalityControls: кнопки открытия меню', () => {
  it.each([
    ['selected-scale', 'Scale'],
    ['selected-snapshot', 'Snapshot'],
  ])('должен привязывать подсказку %s к кнопке открытия и сохранять её обработчик', (testId, label) => {
    const props = createTestContext();

    render(
      <SeasonalityControls
        {...props}
        copyImage={jest.fn().mockResolvedValue(undefined)}
      />,
    );

    const trigger = screen.getByTestId(testId);
    const tooltip = screen.getByTestId(`tooltip-${label}`);
    const button = within(tooltip).getByRole('button');

    expect(trigger.contains(tooltip)).toBe(true);
    expect(button.getAttribute('data-pressed')).toBe('false');

    fireEvent.click(button);

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

    fireEvent.click(button);

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

















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

import { Header } from '@src/components/Header';
import { ChartViewType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';

import type { HeaderProps } from '@src/components/Header/types';
import type { UndoRedoState } from '@src/core/UndoRedo';
import type { ChartSeriesType } from '@src/types';
import type { ReactNode } from 'react';

jest.mock('exchange-elements/v2', () => ({
  Divider: () => <hr />,
}));

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/IndicatorsSelect', () => ({
  IndicatorsSelect: () => <div>Indicators</div>,
}));

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

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

jest.mock('@src/components/Icon', () => ({
  BurgerMenuIcon: () => <span>Drawing toolbar</span>,
  CameraIcon: () => <span>Camera</span>,
  FullscreenIcon: () => <span>Fullscreen</span>,
  PlusCircleIcon: () => <span>Compare</span>,
  RedoIcon: () => <span>Redo</span>,
  SearchIcon: () => <span>Search</span>,
  UndoIcon: () => <span>Undo</span>,
}));

jest.mock('@src/components/SeriesTypeIcon', () => ({
  SeriesTypeIcon: ({ type }: { type: string }) => <span>{type}</span>,
}));

jest.mock('@src/components/ChartViewTypeIcon', () => ({
  ChartViewTypeIcon: ({ type }: { type: string }) => <span>{type}</span>,
}));

jest.mock('@src/components/Menu', () => ({
  TimeframesMenu: ({ onClick }: { onClick: (value: string) => void }) => (
    <button
      type="button"
      onClick={() => onClick('1d')}
    >
      Set timeframe
    </button>
  ),
  SeriesMenu: ({ onClick }: { onClick: (value: string) => void }) => (
    <button
      type="button"
      onClick={() => onClick('Line')}
    >
      Set series
    </button>
  ),
  SnapshotMenu: ({ onCopyImage }: { onCopyImage: () => Promise<void> }) => (
    <button
      type="button"
      onClick={() => onCopyImage()}
    >
      Copy image
    </button>
  ),
}));

jest.mock('@src/components/Menu/ChartViewMenu', () => ({
  ChartViewMenu: ({ onClick }: { onClick: (view: string) => void }) => (
    <>
      <button
        type="button"
        onClick={() => onClick('chart')}
      >
        Chart view
      </button>
      <button
        type="button"
        onClick={() => onClick('seasonality')}
      >
        Seasonality view
      </button>
    </>
  ),
}));

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

function createTestContext(overrides: Partial<HeaderProps> = {}) {
  const chartViewObs = new BehaviorSubject(ChartViewType.Chart);
  const canCopyImageObs = new BehaviorSubject(true);
  const selectedTimeframeObs = new BehaviorSubject(Timeframes['1d']);
  const selectedSeriesObs = new BehaviorSubject<ChartSeriesType>('Candlestick');
  const undoRedoState = new BehaviorSubject<UndoRedoState>({
    canUndo: false,
    canRedo: false,
  });

  const undo = jest.fn();
  const redo = jest.fn();
  const unsubscribe = jest.fn();

  const fullscreen = {
    isFullscreen: false,
    onChange: jest.fn<() => void, [() => void]>(() => unsubscribe),
    toggle: jest.fn(),
  };

  const props: HeaderProps = {
    chartViewObs,
    canCopyImageObs,
    timeframes: [Timeframes['1d']],
    selectedTimeframeObs,
    setTimeframe: jest.fn(),
    seriesTypes: ['Candlestick', 'Line'],
    setSelectedSeries: jest.fn(),
    selectedSeriesObs,
    showSettingsModal: jest.fn(),
    addIndicatorToChart: jest.fn(),
    toggleToolbarVisible: jest.fn(() => false),
    showCompareButton: true,
    showSymbolSearchButton: true,
    undoRedo: {
      getState: () => undoRedoState,
      undo,
      redo,
    } as unknown as HeaderProps['undoRedo'],
    showMenuButton: true,
    showFullscreenButton: true,
    fullscreen: fullscreen as unknown as HeaderProps['fullscreen'],
    openCompareModal: jest.fn(),
    openSymbolSearchModal: jest.fn(),
    copyImage: jest.fn().mockResolvedValue(undefined),
    setChartView: jest.fn(),
    isMXT: false,
    ...overrides,
  };

  return {
    props,
    chartViewObs,
    canCopyImageObs,
    selectedTimeframeObs,
    selectedSeriesObs,
    undoRedoState,
    undo,
    redo,
    fullscreen,
    unsubscribe,
  };
}

describe('Header', () => {
  it('должен копировать снимок в режиме основного графика', () => {
    const { props } = createTestContext();

    render(<Header {...props} />);
    fireEvent.click(screen.getByText('Copy image'));

    expect(document.querySelector('[data-tooltip="Snapshot"]')).not.toBeNull();
    expect(props.copyImage).toHaveBeenCalledTimes(1);
  });

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

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

    expect(screen.queryByText('Set timeframe')).not.toBeNull();

    act(() => chartViewObs.next(ChartViewType.Seasonality));

    [
      'Drawing toolbar',
      'Search',
      'Compare',
      'Settings',
      'Indicators',
      'Set timeframe',
      'Set series',
      'Undo',
      'Redo',
      'Fullscreen',
      'Copy image',
      'Camera',
    ].forEach((label) => {
      expect(screen.queryByText(label)).toBeNull();
    });

    expect(screen.queryByText('Chart view')).not.toBeNull();
    expect(screen.queryByText('Seasonality view')).not.toBeNull();
    expect(document.querySelector('[data-tooltip="Snapshot"]')).toBeNull();

    act(() => chartViewObs.next(ChartViewType.Chart));

    expect(screen.queryByText('Set timeframe')).not.toBeNull();
    expect(screen.queryByText('Copy image')).not.toBeNull();
    expect(document.querySelector('[data-tooltip="Snapshot"]')).not.toBeNull();
  });

  it('должен обновлять доступность снимка основного графика из потока', () => {
    const { props, canCopyImageObs } = createTestContext();

    canCopyImageObs.next(false);

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

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

    act(() => canCopyImageObs.next(true));

    expect(screen.queryByText('Copy image')).not.toBeNull();

    act(() => canCopyImageObs.next(false));

    expect(screen.queryByText('Copy image')).toBeNull();
  });

  it('не должен возвращать снимок в хедер сезонности при изменении его доступности', () => {
    const { props, chartViewObs, canCopyImageObs } = createTestContext();

    chartViewObs.next(ChartViewType.Seasonality);
    canCopyImageObs.next(false);

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

    expect(screen.queryByText('Copy image')).toBeNull();

    act(() => canCopyImageObs.next(true));

    expect(screen.queryByText('Copy image')).toBeNull();
    expect(document.querySelector('[data-tooltip="Snapshot"]')).toBeNull();

    act(() => chartViewObs.next(ChartViewType.Chart));

    expect(screen.queryByText('Copy image')).not.toBeNull();

    act(() => chartViewObs.next(ChartViewType.Seasonality));

    expect(screen.queryByText('Copy image')).toBeNull();
    expect(props.copyImage).not.toHaveBeenCalled();
  });

  it('не должен показывать снимок без callback', () => {
    const { props } = createTestContext({
      copyImage: undefined,
    });

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

    expect(screen.queryByText('Copy image')).toBeNull();
  });

  it('должен передавать выбранный режим в setChartView', () => {
    const { props } = createTestContext();

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

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

    expect(props.setChartView).toHaveBeenNthCalledWith(1, ChartViewType.Seasonality);
    expect(props.setChartView).toHaveBeenNthCalledWith(2, ChartViewType.Chart);
  });

  it('не должен показывать переключение без callback', () => {
    const { props } = createTestContext({
      setChartView: undefined,
    });

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

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

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

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

    expect(screen.getByRole('button', { name: 'Drawing toolbar' }).getAttribute('data-pressed')).toBe('true');

    fireEvent.click(screen.getByText('Drawing toolbar'));

    expect(props.toggleToolbarVisible).toHaveBeenCalledTimes(1);
    expect(screen.getByRole('button', { name: 'Drawing toolbar' }).getAttribute('data-pressed')).toBe('false');

    act(() => chartViewObs.next(ChartViewType.Seasonality));
    act(() => chartViewObs.next(ChartViewType.Chart));

    expect(screen.getByRole('button', { name: 'Drawing toolbar' }).getAttribute('data-pressed')).toBe('false');
    expect(props.toggleToolbarVisible).toHaveBeenCalledTimes(1);
  });

  it('должен вызывать действия основного графика', () => {
    const { props } = createTestContext();

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

    fireEvent.click(screen.getByText('Search'));
    fireEvent.click(screen.getByText('Compare'));
    fireEvent.click(screen.getByText('Settings'));
    fireEvent.click(screen.getByText('Set timeframe'));
    fireEvent.click(screen.getByText('Set series'));

    expect(props.openSymbolSearchModal).toHaveBeenCalledTimes(1);
    expect(props.openCompareModal).toHaveBeenCalledTimes(1);
    expect(props.showSettingsModal).toHaveBeenCalledTimes(1);
    expect(props.setTimeframe).toHaveBeenCalledWith(Timeframes['1d']);
    expect(props.setSelectedSeries).toHaveBeenCalledWith('Line');
  });

  it('должен обновлять выбранные таймфрейм и тип графика', () => {
    const { props, selectedTimeframeObs, selectedSeriesObs } = createTestContext();

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

    act(() => {
      selectedTimeframeObs.next(Timeframes['1h']);
      selectedSeriesObs.next('Line');
    });

    expect(screen.queryByText(Timeframes['1h'])).not.toBeNull();
    expect(screen.queryByText('Line')).not.toBeNull();
    expect(screen.queryByText('Candlestick')).toBeNull();
  });

  it('должен обновлять доступность undo и redo из потока', () => {
    const { props, undoRedoState, undo, redo } = createTestContext();

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

    const undoButton = screen.getByRole('button', { name: 'Undo' }) as HTMLButtonElement;
    const redoButton = screen.getByRole('button', { name: 'Redo' }) as HTMLButtonElement;

    expect(undoButton.disabled).toBe(true);
    expect(redoButton.disabled).toBe(true);

    act(() =>
      undoRedoState.next({
        canUndo: true,
        canRedo: false,
      }),
    );

    fireEvent.click(undoButton);
    fireEvent.click(redoButton);

    expect(undo).toHaveBeenCalledTimes(1);
    expect(redo).not.toHaveBeenCalled();

    act(() =>
      undoRedoState.next({
        canUndo: false,
        canRedo: true,
      }),
    );

    fireEvent.click(redoButton);

    expect(undoButton.disabled).toBe(true);
    expect(redo).toHaveBeenCalledTimes(1);
  });

  it('должен скрывать отключённые необязательные действия', () => {
    const { props } = createTestContext({
      showMenuButton: false,
      showCompareButton: false,
      showSymbolSearchButton: false,
      showSettingsModal: undefined,
      showFullscreenButton: false,
      undoRedo: undefined,
      copyImage: undefined,
    });

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

    ['Drawing toolbar', 'Search', 'Compare', 'Settings', 'Fullscreen', 'Undo', 'Redo', 'Copy image'].forEach(
      (label) => {
        expect(screen.queryByText(label)).toBeNull();
      },
    );

    expect(screen.queryByText('Set timeframe')).not.toBeNull();
  });

  it('должен обновлять fullscreen и отписываться при смене режима', () => {
    const { props, chartViewObs, fullscreen, unsubscribe } = createTestContext();

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

    fireEvent.click(screen.getByText('Fullscreen'));

    expect(fullscreen.toggle).toHaveBeenCalledTimes(1);

    act(() => {
      fullscreen.isFullscreen = true;
      fullscreen.onChange.mock.calls[0][0]();
    });

    expect(screen.getByRole('button', { name: 'Fullscreen' }).getAttribute('data-pressed')).toBe('true');
    expect(document.querySelector('[data-tooltip="Exit fullscreen"]')).not.toBeNull();

    act(() => chartViewObs.next(ChartViewType.Seasonality));

    expect(unsubscribe).toHaveBeenCalledTimes(1);

    act(() => chartViewObs.next(ChartViewType.Chart));

    expect(screen.getByRole('button', { name: 'Fullscreen' }).getAttribute('data-pressed')).toBe('true');
    expect(fullscreen.onChange).toHaveBeenCalledTimes(2);
  });

  it('должен отписываться от fullscreen при размонтировании', () => {
    const { props, unsubscribe } = createTestContext();
    const { unmount } = render(<Header {...props} />);

    unmount();

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