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


import { PriceScaleMode } from 'lightweight-charts';

import { Button } from '@src/components/Button';
import { Dropdown } from '@src/components/Dropdown';
import { RangeField } from '@src/components/FormFields';
import { AverageLineIcon, 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 label={t('Seasonality chart')}>
            <Button
              label={<LineIcon />}
              pressed={state.displayMode === SeasonalityViewType.Chart}
              onClick={() => setDisplayMode(SeasonalityViewType.Chart)}
            />
          </Tooltip>
          <Tooltip label={t('Seasonality table')}>
            <Button
              label={<TableIcon />}
              pressed={state.displayMode === SeasonalityViewType.Table}
              onClick={() => setDisplayMode(SeasonalityViewType.Table)}
            />
          </Tooltip>
        </div>

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

          <div className={styles.controls_scale}>
            <Dropdown
              className={styles.controls_dropdown}
              selectedValue={selectedScaleLabel}
              renderTrigger={({ isOpen, onToggle }) => (
                <Tooltip label={t('Scale')}>
                  <Button
                    pressed={isOpen}
                    onClick={onToggle}
                    label={selectedScaleLabel}
                  />
                </Tooltip>
              )}
            >
              <SeasonalityScaleMenu
                selectedScale={state.scale}
                onClick={setScale}
              />
            </Dropdown>
          </div>

          {copyImage && canCopyImage && (
            <Dropdown
              className={styles.controls_dropdown}
              selectedValue={<CameraIcon />}
              renderTrigger={({ isOpen, onToggle }) => (
                <Tooltip label={t('Snapshot')}>
                  <Button
                    label={<CameraIcon />}
                    pressed={isOpen}
                    onClick={onToggle}
                  />
                </Tooltip>
              )}
            >
              <SnapshotMenu onCopyImage={copyImage} />
            </Dropdown>
          )}
        </div>
      </div>

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


















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" />,
  AverageLineIcon: () => <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');
  });
});