Загрузка данных
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');
},
);
});