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


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

import { DragHandleIcon } from '@src/components/Icon/DragHandleIcon';

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[];
  marksPosition?: 'top' | 'bottom';
  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 = [],
    marksPosition = 'bottom',
    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,
  };

  const scale = (
    <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>
  );

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

      {marksPosition === 'top' && scale}

      <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}
        >
          <DragHandleIcon className={styles.range_dragIcon} />
        </button>
      </div>

      {marksPosition === 'bottom' && scale}
    </div>
  );
};


















@use '../../theme/mixins' as m;

[data-theme='mxt'],
[data-theme='mb'][data-mode='light'] {
  --dd-item-text-selected: var(--neutral-2);
  --dd-item-bg-selected: var(--neutral-12);
  --color-field-border: var(--neutral-11);
  --color-field-square: #00000024;
}

[data-theme='tr'],
[data-theme='mb'][data-mode='dark'] {
  --dd-item-text-selected: var(--neutral-13);
  --dd-item-bg-selected: var(--neutral-5);
  --color-field-border: var(--neutral-9);
  --color-field-square: #ffffff14;
}

$button-color-bottom: 5px;

.input {
  color: var(--neutral-12);

  input {
    color: var(--neutral-12);
  }

  &_select {
    &:focus-within {
      background-color: transparent !important;
    }

    div {
      background-color: transparent;

      &:focus-within {
        background-color: transparent !important;
      }

      .input {
        background-color: transparent;
      }
    }
  }

  &_color {
    display: flex;
    flex-direction: column;
    gap: var(--space-0250);

    .input_wrapper {
      position: relative;
      width: 100%;
    }
  }
}

.dropdown {
  li {
    &[aria-selected='true'] {
      background-color: var(--dd-item-bg-selected);

      div[aria-label='Label'] {
        color: var(--dd-item-text-selected);
      }
    }

    svg {
      color: transparent;
    }
  }
}

.button_color {
  position: absolute;
  bottom: $button-color-bottom;
  right: var(--space-0500);
  width: var(--space-1375);
  height: var(--space-1375);
  border: 1px solid var(--color-field-border);
  border-radius: var(--space-0125);
}

.input_checkbox {
  padding: var(--space-0375) 0 !important;

  span {
    font-size: var(--space-0875);
    line-height: normal;
  }
}

.input_textarea {
  textarea {
    font-size: var(--space-0875);
    line-height: normal;
  }
}

.range {
  --range-input-height: var(--space-1000);
  --range-track-height: var(--space-0750);
  --range-thumb-size: var(--space-1000);
  --range-pattern-size: var(--space-0500);
  --range-label-font-size: var(--space-0875);
  --range-value-font-size: var(--space-0750);
  --range-wrapper-gap: var(--space-0500);

  display: flex;
  flex-direction: column;
  gap: var(--space-0250);

  &_sm {
    --range-input-height: var(--space-0875);
    --range-track-height: var(--space-0500);
    --range-thumb-size: var(--space-0875);
    --range-pattern-size: var(--space-0375);
    --range-label-font-size: var(--space-0750);
    --range-wrapper-gap: var(--space-0375);
  }

  &_label {
    color: var(--neutral-12);
    font-size: var(--range-label-font-size);
  }

  &_wrapper {
    display: grid;
    align-items: center;
    grid-template: 1fr / 1fr auto;
    gap: var(--range-wrapper-gap);
  }

  &_value {
    min-width: var(--space-2000);
    color: var(--neutral-12);
    font-size: var(--range-value-font-size);
    line-height: normal;
    text-align: right;
  }

  &_input {
    --range-color: var(--blue-1);

    width: 100%;
    height: var(--range-input-height);
    appearance: none;
    background: transparent;
    cursor: pointer;

    &::-webkit-slider-runnable-track {
      height: var(--range-track-height);
      border: 1px solid var(--color-field-border);
      border-radius: 999px;
      background: linear-gradient(to right, transparent 0%, var(--range-color) 100%),
        linear-gradient(45deg, var(--color-field-square) 25%, transparent 25%),
        linear-gradient(-45deg, var(--color-field-square) 25%, transparent 25%),
        linear-gradient(45deg, transparent 75%, var(--color-field-square) 75%),
        linear-gradient(-45deg, transparent 75%, var(--color-field-square) 75%);
      background-size:
        100% 100%,
        var(--range-pattern-size) var(--range-pattern-size),
        var(--range-pattern-size) var(--range-pattern-size),
        var(--range-pattern-size) var(--range-pattern-size),
        var(--range-pattern-size) var(--range-pattern-size);
      background-position:
        0 0,
        0 0,
        0 calc(var(--range-pattern-size) / 2),
        calc(var(--range-pattern-size) / 2) calc(var(--range-pattern-size) / -2),
        calc(var(--range-pattern-size) / -2) 0;
    }

    &::-webkit-slider-thumb {
      width: var(--range-thumb-size);
      height: var(--range-thumb-size);
      margin-top: calc((var(--range-track-height) - var(--range-thumb-size)) / 2 - 1px);
      appearance: none;
      border: 1px solid var(--color-field-border);
      border-radius: 50%;
      background-color: var(--neutral-panel);
    }

    &::-moz-range-track {
      height: var(--range-track-height);
      border: 1px solid var(--color-field-border);
      border-radius: 999px;
      background: linear-gradient(to right, transparent 0%, var(--range-color) 100%),
        linear-gradient(45deg, var(--color-field-square) 25%, transparent 25%),
        linear-gradient(-45deg, var(--color-field-square) 25%, transparent 25%),
        linear-gradient(45deg, transparent 75%, var(--color-field-square) 75%),
        linear-gradient(-45deg, transparent 75%, var(--color-field-square) 75%);
      background-size:
        100% 100%,
        var(--range-pattern-size) var(--range-pattern-size),
        var(--range-pattern-size) var(--range-pattern-size),
        var(--range-pattern-size) var(--range-pattern-size),
        var(--range-pattern-size) var(--range-pattern-size);
      background-position:
        0 0,
        0 0,
        0 calc(var(--range-pattern-size) / 2),
        calc(var(--range-pattern-size) / 2) calc(var(--range-pattern-size) / -2),
        calc(var(--range-pattern-size) / -2) 0;
    }

    &::-moz-range-progress {
      background: transparent;
    }

    &::-moz-range-thumb {
      width: var(--range-thumb-size);
      height: var(--range-thumb-size);
      border: 1px solid var(--color-field-border);
      border-radius: 50%;
      background-color: var(--neutral-panel);
    }
  }

  &_interval {
    min-width: 0;
    gap: 0;
  }

  &_disabled {
    opacity: 0.5;

    .range_selection {
      cursor: default;
      pointer-events: none;
    }
  }

  &_track,
  &_scale {
    position: relative;
    margin-inline: calc(var(--range-thumb-size) / 2);
  }

  &_track {
    height: var(--range-thumb-size);
    touch-action: none;
    user-select: none;
  }

  &_rail,
  &_selection,
  &_handle,
  &_scale_label {
    position: absolute;
  }

  &_rail,
  &_selection {
    top: 50%;
    height: var(--range-track-height);
    transform: translateY(-50%);
    border-radius: 999px;
  }

  &_rail {
    inset-inline: 0;
    background-color: var(--neutral-9);
  }

  &_selection {
    z-index: 1;
    background-color: var(--range-color);
    cursor: grab;
    touch-action: none;

    &:active {
      cursor: grabbing;
    }
  }

  &_handle {
    inset: 0;
    box-sizing: border-box;
    width: calc(100% + var(--range-thumb-size));
    height: var(--range-thumb-size);
    margin: 0;
    margin-left: calc(var(--range-thumb-size) / -2);
    padding: 0;
    appearance: none;
    border: 0;
    outline: none;
    background-color: transparent;
    pointer-events: none;
    touch-action: none;

    &::-webkit-slider-runnable-track {
      height: var(--range-track-height);
      border: 0;
      background-color: transparent;
    }

    &::-moz-range-track {
      height: var(--range-track-height);
      border: 0;
      background-color: transparent;
    }

    &::-moz-range-progress {
      background-color: transparent;
    }

    &::-webkit-slider-thumb {
      box-sizing: border-box;
      width: var(--range-thumb-size);
      height: var(--range-thumb-size);
      margin-top: calc((var(--range-track-height) - var(--range-thumb-size)) / 2);
      appearance: none;
      border: 2px solid var(--neutral-12);
      border-radius: 50%;
      background-color: var(--neutral-panel);
      cursor: ew-resize;
      pointer-events: auto;
    }

    &::-moz-range-thumb {
      box-sizing: border-box;
      width: var(--range-thumb-size);
      height: var(--range-thumb-size);
      border: 2px solid var(--neutral-12);
      border-radius: 50%;
      background-color: var(--neutral-panel);
      cursor: ew-resize;
      pointer-events: auto;
    }

    &_start {
      z-index: 4;
    }

    &_end {
      z-index: 3;
    }

    &_behind {
      z-index: 2;
    }

    &:disabled::-webkit-slider-thumb {
      cursor: default;
      pointer-events: none;
    }

    &:disabled::-moz-range-thumb {
      cursor: default;
      pointer-events: none;
    }
  }

  &_scale {
    height: var(--space-1000);
    color: var(--neutral-12);
    font-size: var(--range-value-font-size);
    line-height: var(--space-1000);
    font-variant-numeric: tabular-nums;
    pointer-events: none;
    user-select: none;
  }

  &_scale_label {
    top: 0;
    width: max-content;
    white-space: nowrap;
    transform: translateX(-50%);

    &:first-child {
      transform: none;
    }

    &:last-child:not(:first-child) {
      transform: translateX(-100%);
    }
  }

  &_values {
    display: flex;
    justify-content: space-between;
    color: var(--neutral-12);
    font-size: var(--range-value-font-size);
  }

  &_dragHandle {
    position: absolute;
    top: 50%;
    z-index: 4;
    display: flex;
    align-items: center;
    justify-content: center;
    width: var(--space-1500, 24px);
    height: var(--space-1000, 16px);
    box-sizing: border-box;
    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;
    }
  }

  &_dragIcon {
    transform: rotate(90deg);
  }

  &_collapsed &_dragHandle {
    top: auto;
    bottom: calc(100% + var(--space-0250, 4px));
    transform: translateX(-50%);
  }

  &_marksTop.range_collapsed &_dragHandle {
    top: calc(100% + var(--space-0250, 4px));
    bottom: auto;
    border: var(--borderWidth-md) solid var(--color-field-border);
    background: var(--neutral-panel);
  }
}

.color {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-1000);

  &_label {
    font-size: var(--space-0875);
    color: var(--neutral-12);
    font-weight: var(--font-medium);
  }

  &_menu {
    --dropdown-width: max-content;
    padding: var(--space-1000);
  }

  &_trigger {
    min-width: 0;
    width: var(--space-2000);
    height: var(--space-2000);
    display: flex;
    justify-content: center;
    align-items: center;
    border-radius: var(--space-0375);
    border: var(--borderWidth-md) solid var(--neutral-9);

    p {
      padding: 0;
    }
  }

  &_preview {
    width: var(--space-1375);
    height: var(--space-1375);
    border-radius: var(--space-0250);
  }
}


















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 { ReactNode } from 'react';
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>;
}

interface ControlTooltipProps {
  label: string;
  children: ReactNode;
}

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}>
          <ControlTooltip label={t('Seasonality chart')}>
            <Button
              label={<LineIcon />}
              pressed={state.displayMode === SeasonalityViewType.Chart}
              onClick={() => setDisplayMode(SeasonalityViewType.Chart)}
            />
          </ControlTooltip>
          <ControlTooltip label={t('Seasonality table')}>
            <Button
              label={<TableIcon />}
              pressed={state.displayMode === SeasonalityViewType.Table}
              onClick={() => setDisplayMode(SeasonalityViewType.Table)}
            />
          </ControlTooltip>
        </div>

        <div className={styles.controls_actions}>
          <ControlTooltip 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)}
            />
          </ControlTooltip>

          <div className={styles.controls_scale}>
            <Dropdown
              className={styles.controls_dropdown}
              selectedValue={selectedScaleLabel}
              renderTrigger={({ isOpen, onToggle }) => (
                <ControlTooltip label={t('Scale')}>
                  <Button
                    pressed={isOpen}
                    onClick={onToggle}
                    label={
                      <span className={styles.controls_buttonContent}>
                        <span>{selectedScaleLabel}</span>
                        <span
                          className={`${styles.controls_chevron} ${isOpen ? styles.controls_chevronOpen : ''}`}
                        />
                      </span>
                    }
                  />
                </ControlTooltip>
              )}
            >
              <SeasonalityScaleMenu selectedScale={state.scale} onClick={setScale} />
            </Dropdown>
          </div>

          {copyImage && canCopyImage && (
            <Dropdown
              className={styles.controls_dropdown}
              selectedValue={<CameraIcon />}
              renderTrigger={({ isOpen, onToggle }) => (
                <ControlTooltip label={t('Snapshot')}>
                  <Button label={<CameraIcon />} pressed={isOpen} onClick={onToggle} />
                </ControlTooltip>
              )}
            >
              <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>
  );
}

function ControlTooltip({ label, children }: ControlTooltipProps) {
  return (
    <div className={styles.controls_tooltip}>
      <Tooltip location="top" label={label}>
        <span className={styles.controls_tooltipTarget}>{children}</span>
      </Tooltip>
    </div>
  );
}


















.controls {
  display: flex;
  flex-direction: column;
  min-width: 0;
  gap: var(--space-0500);

  &_header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: var(--space-0500);
    min-width: 0;
  }

  &_views {
    display: flex;
    align-items: center;
    gap: var(--space-0125);
    border: var(--borderWidth-md) solid var(--neutral-7);
    border-radius: var(--space-0250);
  }

  &_scale {
    border: var(--borderWidth-md) solid var(--neutral-7);
    border-radius: var(--space-0250);
  }

  &_actions {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    flex-wrap: wrap;
    gap: var(--space-0250);
    margin-left: auto;
  }

  &_tooltip,
  &_tooltipTarget {
    display: inline-flex;
    align-items: center;
    flex: 0 0 auto;
    vertical-align: middle;
  }

  &_tooltip > * {
    display: inline-flex;
    align-items: center;
  }

  &_dropdown {
    display: inline-flex;
    align-items: center;

    > div:first-child {
      display: flex;
      align-items: center;
    }
  }

  &_buttonContent {
    display: inline-flex;
    align-items: center;
    gap: var(--space-0250);
    white-space: nowrap;
  }

  &_chevron {
    display: inline-block;
    width: var(--space-0375, 6px);
    height: var(--space-0375, 6px);
    border-right: var(--borderWidth-md) solid currentColor;
    border-bottom: var(--borderWidth-md) solid currentColor;
    transform: translateY(-1px) rotate(45deg);
  }

  &_chevronOpen {
    transform: translateY(1px) rotate(225deg);
  }

  &_range {
    min-width: 0;
  }
}


















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

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

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_tooltip: 'controls_tooltip',
    controls_tooltipTarget: 'controls_tooltipTarget',
    controls_dropdown: 'controls_dropdown',
    controls_chevron: 'controls_chevron',
    controls_chevronOpen: 'controls_chevronOpen',
  },
}));

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,
    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(screen.getByTestId('tooltip-' + label).getAttribute('data-location')).toBe('top');
      },
    );
  });

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