Загрузка данных
import { PriceScaleMode } from 'lightweight-charts';
import { Button } from '@src/components/Button';
import { Dropdown } from '@src/components/Dropdown';
import { RangeField } from '@src/components/FormFields';
import { CameraIcon, LineIcon, TableIcon } from '@src/components/Icon';
import { SnapshotMenu } from '@src/components/Menu';
import { SeasonalityScaleMenu } from '@src/components/Menu/SeasonalityScaleMenu';
import { Tooltip } from '@src/components/Tooltip';
import { t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { useObservable } from '@src/utils';
import styles from './index.module.scss';
import type { SeasonalityRange, SeasonalityScale, SeasonalityState } from '@src/types/seasonality';
import type { Observable } from 'rxjs';
interface SeasonalityControlsProps {
stateObs: Observable<SeasonalityState>;
setDisplayMode: (displayMode: SeasonalityViewType) => void;
setScale: (scale: SeasonalityScale) => void;
setRange: (range: SeasonalityRange) => void;
setAverageVisible: (visible: boolean) => void;
copyImage?: () => Promise<void>;
}
const MAX_SCALE_LABELS = 5;
function createYearMarks(minYear: number, maxYear: number): number[] {
const span = maxYear - minYear;
if (span <= 0) return [minYear];
const intervals = Math.min(MAX_SCALE_LABELS - 1, span);
return Array.from(
{ length: intervals + 1 },
(_, index) => minYear + Math.floor((span * index) / intervals),
);
}
export function SeasonalityControls({
stateObs,
setDisplayMode,
setScale,
setRange,
setAverageVisible,
copyImage,
}: SeasonalityControlsProps) {
const state = useObservable(stateObs);
if (!state || state.status === 'idle') {
return null;
}
const selectedScaleLabel = state.scale === PriceScaleMode.Normal ? t('Price') : t('Percent');
const isRangeDisabled = state.status === 'loading' || state.status === 'error';
const canCopyImage = state.status === 'ready' && state.displayMode === SeasonalityViewType.Chart;
const marks = createYearMarks(state.minYear, state.maxYear);
const handleRangeChange = ([fromYear, toYear]: [number, number]): void => {
setRange({ fromYear, toYear });
};
return (
<div className={styles.controls}>
<div className={styles.controls_header}>
<div className={styles.controls_views}>
<Tooltip location="bottom" label={t('Seasonality chart')}>
<Button
label={<LineIcon />}
pressed={state.displayMode === SeasonalityViewType.Chart}
onClick={() => setDisplayMode(SeasonalityViewType.Chart)}
/>
</Tooltip>
<Tooltip location="bottom" label={t('Seasonality table')}>
<Button
label={<TableIcon />}
pressed={state.displayMode === SeasonalityViewType.Table}
onClick={() => setDisplayMode(SeasonalityViewType.Table)}
/>
</Tooltip>
</div>
<div className={styles.controls_actions}>
<Tooltip location="bottom" label={t('Average for selected years')}>
<Button
label={
<span className={styles.controls_buttonContent}>
<LineIcon />
<span>{t('Average')}</span>
</span>
}
pressed={state.showAverage}
disabled={state.status !== 'ready'}
onClick={() => setAverageVisible(!state.showAverage)}
/>
</Tooltip>
<div className={styles.controls_scale}>
<Tooltip location="bottom" label={t('Scale')}>
<Dropdown selectedValue={selectedScaleLabel}>
<SeasonalityScaleMenu selectedScale={state.scale} onClick={setScale} />
</Dropdown>
</Tooltip>
</div>
{copyImage && canCopyImage && (
<Tooltip location="bottom" label={t('Snapshot')}>
<Dropdown selectedValue={<CameraIcon />}>
<SnapshotMenu onCopyImage={copyImage} />
</Dropdown>
</Tooltip>
)}
</div>
</div>
<div className={styles.controls_range}>
<RangeField
mode="range"
value={[state.fromYear, state.toYear]}
min={state.minYear}
max={state.maxYear}
marks={marks}
step={1}
suffix=""
size="sm"
disabled={isRangeDisabled}
onRangeChange={handleRangeChange}
/>
</div>
</div>
);
}
import classNames from 'classnames';
import { type ChangeEvent, type PointerEvent, useId, useRef } from 'react';
import styles from './index.module.scss';
interface CommonRangeFieldProps {
label?: string;
min?: number;
max?: number;
step?: number;
color?: string;
suffix?: string;
size?: 'sm' | 'md';
disabled?: boolean;
}
interface SingleRangeFieldProps extends CommonRangeFieldProps {
mode?: 'single';
value: number;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
}
interface IntervalRangeFieldProps extends CommonRangeFieldProps {
mode: 'range';
value: [number, number];
marks?: readonly number[];
onRangeChange: (value: [number, number]) => void;
}
interface RangeDragState {
mode: 'range' | 'from' | 'to';
pointerId: number;
pointerX: number;
from: number;
to: number;
trackWidth: number;
}
type RangeFieldProps = SingleRangeFieldProps | IntervalRangeFieldProps;
export const RangeField = (props: RangeFieldProps) => {
const {
label,
min = 0,
max = 100,
step = 1,
color = 'var(--blue-1)',
suffix = '%',
size = 'md',
disabled = false,
} = props;
const inputId = useId();
const trackRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<RangeDragState | null>(null);
if (props.mode !== 'range') {
return (
<div
className={classNames(styles.range, {
[styles.range_sm]: size === 'sm',
})}
>
{label && (
<label htmlFor={inputId} className={styles.range_label}>
{label}
</label>
)}
<div className={styles.range_wrapper}>
<input
id={inputId}
type="range"
min={min}
max={max}
step={step}
value={props.value}
className={styles.range_input}
style={{ ['--range-color' as string]: color }}
onChange={props.onChange}
disabled={disabled}
/>
<span className={styles.range_value}>
{props.value}
{suffix}
</span>
</div>
</div>
);
}
const {
value: [from, to],
marks = [],
onRangeChange,
} = props;
const span = max - min;
const left = span > 0 ? ((from - min) / span) * 100 : 0;
const right = span > 0 ? ((to - min) / span) * 100 : 0;
const isDisabled = disabled || span <= 0;
const scaleMarks =
span > 0
? [
min,
...Array.from(new Set(marks))
.filter((value) => value > min && value < max)
.sort((a, b) => a - b),
max,
]
: [min];
const handleDragEnd = (event: PointerEvent<HTMLElement>) => {
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = null;
}
};
const handleDragStart = (event: PointerEvent<HTMLElement>, mode: RangeDragState['mode']) => {
if (isDisabled || event.button !== 0 || dragRef.current || !trackRef.current) return;
const { width } = trackRef.current.getBoundingClientRect();
if (width <= 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
mode,
pointerId: event.pointerId,
pointerX: event.clientX,
from,
to,
trackWidth: width,
};
};
const handleDragMove = (event: PointerEvent<HTMLElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId || isDisabled) return;
const pointerOffset = ((event.clientX - drag.pointerX) / drag.trackWidth) * span;
const steppedOffset = Math.round(pointerOffset / step) * step;
let nextFrom = drag.from;
let nextTo = drag.to;
if (drag.mode === 'range') {
const offset = Math.max(min - drag.from, Math.min(max - drag.to, steppedOffset));
nextFrom += offset;
nextTo += offset;
} else if (drag.from === drag.to) {
const value = Math.max(min, Math.min(max, drag.from + steppedOffset));
nextFrom = Math.min(drag.from, value);
nextTo = Math.max(drag.to, value);
} else if (drag.mode === 'from') {
nextFrom = Math.max(min, Math.min(drag.to, drag.from + steppedOffset));
} else {
nextTo = Math.max(drag.from, Math.min(max, drag.to + steppedOffset));
}
if (nextFrom !== from || nextTo !== to) {
onRangeChange([nextFrom, nextTo]);
}
};
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (
isDisabled ||
from !== to ||
event.button !== 0 ||
event.defaultPrevented ||
event.target instanceof HTMLInputElement ||
!trackRef.current
) {
return;
}
const { left: trackLeft, width } = trackRef.current.getBoundingClientRect();
if (width <= 0) return;
const value = min + Math.round((((event.clientX - trackLeft) / width) * span) / step) * step;
const next = Math.max(min, Math.min(max, value));
event.preventDefault();
if (next !== from) {
onRangeChange([Math.min(from, next), Math.max(to, next)]);
}
};
const handleFromChange = (event: ChangeEvent<HTMLInputElement>) => {
if (dragRef.current) return;
onRangeChange([Math.min(event.currentTarget.valueAsNumber, to), to]);
};
const handleToChange = (event: ChangeEvent<HTMLInputElement>) => {
if (dragRef.current) return;
onRangeChange([from, Math.max(event.currentTarget.valueAsNumber, from)]);
};
const dragHandlers = {
onPointerMove: handleDragMove,
onPointerUp: handleDragEnd,
onPointerCancel: handleDragEnd,
onLostPointerCapture: handleDragEnd,
};
return (
<div
className={classNames(styles.range, styles.range_interval, {
[styles.range_sm]: size === 'sm',
[styles.range_disabled]: isDisabled,
[styles.range_collapsed]: from === to,
})}
>
{label && <span className={styles.range_label}>{label}</span>}
<div
ref={trackRef}
className={styles.range_track}
style={{ ['--range-color' as string]: color }}
onPointerDown={handleTrackPointerDown}
>
<div className={styles.range_rail} />
<div
className={styles.range_selection}
style={{
left: `${left}%`,
width: `${right - left}%`,
}}
onPointerDown={(event) => handleDragStart(event, 'range')}
{...dragHandlers}
/>
<input
id={`${inputId}-from`}
type="range"
min={min}
max={max}
step={step}
value={from}
disabled={isDisabled}
className={classNames(styles.range_handle, styles.range_handle_start, {
[styles.range_handle_behind]: from === to && from === min,
})}
onChange={handleFromChange}
onPointerDown={(event) => handleDragStart(event, 'from')}
{...dragHandlers}
/>
<input
id={`${inputId}-to`}
type="range"
min={min}
max={max}
step={step}
value={to}
disabled={isDisabled}
className={classNames(styles.range_handle, styles.range_handle_end)}
onChange={handleToChange}
onPointerDown={(event) => handleDragStart(event, 'to')}
{...dragHandlers}
/>
<button
type="button"
tabIndex={-1}
className={styles.range_dragHandle}
style={{ left: `${(left + right) / 2}%` }}
disabled={isDisabled}
onPointerDown={(event) => handleDragStart(event, 'range')}
{...dragHandlers}
>
<svg width="12" height="8" viewBox="0 0 12 8" fill="currentColor">
<circle cx="2" cy="2" r="1" />
<circle cx="6" cy="2" r="1" />
<circle cx="10" cy="2" r="1" />
<circle cx="2" cy="6" r="1" />
<circle cx="6" cy="6" r="1" />
<circle cx="10" cy="6" r="1" />
</svg>
</button>
</div>
<div className={styles.range_scale}>
{scaleMarks.map((value) => (
<span
key={value}
className={styles.range_scale_label}
style={{
left: `${span > 0 ? ((value - min) / span) * 100 : 0}%`,
}}
>
{value}
{suffix}
</span>
))}
</div>
</div>
);
};
.range_track {
position: relative;
touch-action: none;
}
.range_selection,
.range_handle {
touch-action: none;
}
.range_dragHandle {
position: absolute;
top: 50%;
z-index: 4;
display: flex;
align-items: center;
justify-content: center;
width: var(--space-1500, 24px);
height: var(--space-1500, 24px);
padding: 0;
border: 0;
border-radius: var(--space-0250);
color: var(--neutral-12);
background: transparent;
cursor: grab;
touch-action: none;
transform: translate(-50%, -50%);
svg {
pointer-events: none;
}
&:active {
cursor: grabbing;
}
&:disabled {
cursor: default;
}
}
.range_collapsed .range_track {
margin-bottom: var(--space-2000, 32px);
}
.range_collapsed .range_dragHandle {
top: calc(100% + var(--space-0250, 4px));
background: var(--neutral-2);
transform: translateX(-50%);
}
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { PriceScaleMode } from 'lightweight-charts';
import { BehaviorSubject, Subject } from 'rxjs';
import { SeasonalityControls } from '@src/components/SeasonalityControls';
import { createInitialSeasonalityState } from '@src/constants';
import { SeasonalityViewType } from '@src/types';
import type { SeasonalityState } from '@src/types';
import type { ReactNode } from 'react';
interface RangeFieldTestProps {
value: [number, number];
min: number;
max: number;
marks: number[];
disabled: boolean;
onRangeChange: (value: [number, number]) => void;
}
jest.mock('@src/components/Button', () => ({
Button: ({
onClick,
label,
disabled,
pressed,
}: {
onClick?: () => void;
label?: ReactNode;
disabled?: boolean;
pressed?: boolean;
}) => (
<button type="button" onClick={onClick} disabled={disabled} data-pressed={pressed}>
{label}
</button>
),
}));
jest.mock('@src/components/Icon', () => ({
CameraIcon: () => <span data-testid="camera-icon" />,
LineIcon: () => <span data-testid="line-icon" />,
TableIcon: () => <span data-testid="table-icon" />,
}));
jest.mock('@src/components/SeasonalityControls/index.module.scss', () => ({
__esModule: true,
default: {
controls: 'controls',
controls_header: 'controls_header',
controls_views: 'controls_views',
controls_actions: 'controls_actions',
controls_buttonContent: 'controls_buttonContent',
controls_scale: 'controls_scale',
controls_range: 'controls_range',
},
}));
jest.mock('@src/components/Tooltip', () => ({
Tooltip: ({ children, label, location }: { children: ReactNode; label: string; location: string }) => (
<div data-testid={`tooltip-${label}`} data-location={location}>
{children}
</div>
),
}));
jest.mock('@src/components/Menu', () => ({
SnapshotMenu: ({ onCopyImage }: { onCopyImage: () => Promise<void> }) => (
<button type="button" onClick={onCopyImage}>
Copy image
</button>
),
}));
jest.mock('@src/components/Dropdown', () => ({
Dropdown: ({ children, selectedValue }: { children: ReactNode; selectedValue: ReactNode }) => (
<div>
<div data-testid={typeof selectedValue === 'string' ? 'selected-scale' : 'selected-snapshot'}>
{selectedValue}
</div>
{children}
</div>
),
}));
jest.mock('@src/components/Menu/SeasonalityScaleMenu', () => ({
SeasonalityScaleMenu: ({ onClick }: { onClick: (scale: number) => void }) => (
<>
<button type="button" onClick={() => onClick(0)}>
Set price
</button>
<button type="button" onClick={() => onClick(2)}>
Set percent
</button>
</>
),
}));
jest.mock('@src/components/FormFields', () => ({
RangeField: ({ value, min, max, marks, disabled, onRangeChange }: RangeFieldTestProps) => (
<div data-testid="year-range" data-marks={marks.join(',')}>
<input
aria-label="From year"
type="number"
value={value[0]}
min={min}
max={max}
disabled={disabled}
onChange={(event) => onRangeChange([Number(event.target.value), value[1]])}
/>
<input
aria-label="To year"
type="number"
value={value[1]}
min={min}
max={max}
disabled={disabled}
onChange={(event) => onRangeChange([value[0], Number(event.target.value)])}
/>
</div>
),
}));
jest.mock('@src/translations', () => ({
t: (key: string) => key,
}));
function createTestContext(overrides: Partial<SeasonalityState> = {}) {
const stateObs = new BehaviorSubject<SeasonalityState>({
...createInitialSeasonalityState(),
status: 'ready',
minYear: 2020,
maxYear: 2025,
fromYear: 2021,
toYear: 2025,
...overrides,
});
const setDisplayMode = jest.fn();
const setScale = jest.fn();
const setRange = jest.fn();
const setAverageVisible = jest.fn();
return { stateObs, setDisplayMode, setScale, setRange, setAverageVisible };
}
describe('SeasonalityControls', () => {
it('не должен отображаться до получения состояния и при idle', () => {
const stateObs = new Subject<SeasonalityState>();
const { container } = render(
<SeasonalityControls
stateObs={stateObs}
setDisplayMode={jest.fn()}
setScale={jest.fn()}
setRange={jest.fn()}
setAverageVisible={jest.fn()}
/>,
);
expect(container.children).toHaveLength(0);
act(() => stateObs.next(createInitialSeasonalityState()));
expect(container.children).toHaveLength(0);
});
it('должен отображать выбранный диапазон и границы доступных лет', () => {
const props = createTestContext();
render(<SeasonalityControls {...props} />);
const from = screen.getByLabelText('From year') as HTMLInputElement;
const to = screen.getByLabelText('To year') as HTMLInputElement;
expect(from.value).toBe('2021');
expect(to.value).toBe('2025');
expect(from.min).toBe('2020');
expect(from.max).toBe('2025');
expect(screen.getByTestId('selected-scale').textContent).toBe('Price');
});
it('должен переключать вид и обновлять выбранную кнопку из состояния', () => {
const props = createTestContext();
render(<SeasonalityControls {...props} />);
const chartButton = within(screen.getByTestId('tooltip-Seasonality chart')).getByRole('button');
const tableButton = screen.getByTestId('table-icon').closest('button') as HTMLButtonElement;
expect(chartButton.getAttribute('data-pressed')).toBe('true');
expect(tableButton.getAttribute('data-pressed')).toBe('false');
fireEvent.click(tableButton);
expect(props.setDisplayMode).toHaveBeenNthCalledWith(1, SeasonalityViewType.Table);
expect(chartButton.getAttribute('data-pressed')).toBe('true');
act(() =>
props.stateObs.next({
...props.stateObs.value,
displayMode: SeasonalityViewType.Table,
}),
);
expect(chartButton.getAttribute('data-pressed')).toBe('false');
expect(tableButton.getAttribute('data-pressed')).toBe('true');
fireEvent.click(chartButton);
expect(props.setDisplayMode).toHaveBeenNthCalledWith(2, SeasonalityViewType.Chart);
act(() =>
props.stateObs.next({
...props.stateObs.value,
displayMode: SeasonalityViewType.Chart,
}),
);
expect(chartButton.getAttribute('data-pressed')).toBe('true');
expect(tableButton.getAttribute('data-pressed')).toBe('false');
});
it.each(['loading', 'error', 'empty'] as const)(
'должен позволять переключить вид при статусе %s',
(status) => {
const props = createTestContext({ status });
render(<SeasonalityControls {...props} />);
fireEvent.click(screen.getByTestId('table-icon'));
expect(props.setDisplayMode).toHaveBeenCalledWith(SeasonalityViewType.Table);
},
);
it('должен передавать изменение обеих границ диапазона', () => {
const props = createTestContext();
render(<SeasonalityControls {...props} />);
fireEvent.change(screen.getByLabelText('From year'), {
target: { value: '2022' },
});
fireEvent.change(screen.getByLabelText('To year'), {
target: { value: '2024' },
});
expect(props.setRange).toHaveBeenNthCalledWith(1, {
fromYear: 2022,
toYear: 2025,
});
expect(props.setRange).toHaveBeenNthCalledWith(2, {
fromYear: 2021,
toYear: 2024,
});
});
it('должен переключать шкалу и обновлять подпись из состояния', () => {
const props = createTestContext();
render(<SeasonalityControls {...props} />);
fireEvent.click(screen.getByText('Set percent'));
fireEvent.click(screen.getByText('Set price'));
expect(props.setScale).toHaveBeenNthCalledWith(1, PriceScaleMode.Percentage);
expect(props.setScale).toHaveBeenNthCalledWith(2, PriceScaleMode.Normal);
act(() =>
props.stateObs.next({
...props.stateObs.value,
scale: PriceScaleMode.Percentage,
}),
);
expect(screen.getByTestId('selected-scale').textContent).toBe('Percent');
});
it.each(['loading', 'error'] as const)('должен блокировать диапазон при статусе %s', (status) => {
const props = createTestContext({ status });
render(<SeasonalityControls {...props} />);
expect((screen.getByLabelText('From year') as HTMLInputElement).disabled).toBe(true);
expect((screen.getByLabelText('To year') as HTMLInputElement).disabled).toBe(true);
act(() =>
props.stateObs.next({
...props.stateObs.value,
status: 'ready',
}),
);
expect((screen.getByLabelText('From year') as HTMLInputElement).disabled).toBe(false);
});
it('должен позволять изменить пустой диапазон и скрываться после очистки', () => {
const props = createTestContext({ status: 'empty' });
const { container } = render(<SeasonalityControls {...props} />);
fireEvent.change(screen.getByLabelText('From year'), {
target: { value: '2020' },
});
expect(props.setRange).toHaveBeenCalledWith({
fromYear: 2020,
toYear: 2025,
});
act(() =>
props.stateObs.next({
...props.stateObs.value,
status: 'idle',
}),
);
expect(container.children).toHaveLength(0);
});
it('должен корректно отображать единственный доступный год', () => {
const props = createTestContext({
minYear: 2025,
maxYear: 2025,
fromYear: 2025,
toYear: 2025,
});
render(<SeasonalityControls {...props} />);
expect(screen.getByTestId('year-range').getAttribute('data-marks')).toBe('2025');
expect((screen.getByLabelText('From year') as HTMLInputElement).value).toBe('2025');
expect((screen.getByLabelText('To year') as HTMLInputElement).value).toBe('2025');
});
});
describe('SeasonalityControls: среднее и подсказки', () => {
it('должен переключать среднее и обновлять pressed только из состояния', () => {
const props = createTestContext();
render(<SeasonalityControls {...props} />);
const button = screen.getByRole('button', { name: 'Average' });
expect(button.getAttribute('data-pressed')).toBe('false');
expect(within(button).queryByTestId('line-icon')).not.toBeNull();
fireEvent.click(button);
expect(props.setAverageVisible).toHaveBeenLastCalledWith(true);
expect(button.getAttribute('data-pressed')).toBe('false');
act(() => props.stateObs.next({ ...props.stateObs.value, showAverage: true }));
expect(button.getAttribute('data-pressed')).toBe('true');
fireEvent.click(button);
expect(props.setAverageVisible).toHaveBeenLastCalledWith(false);
});
it.each(['loading', 'error', 'empty'] as const)(
'должен блокировать переключение среднего при статусе %s',
(status) => {
const props = createTestContext({ status });
render(<SeasonalityControls {...props} />);
const button = screen.getByRole('button', { name: 'Average' }) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.click(button);
expect(props.setAverageVisible).not.toHaveBeenCalled();
},
);
it('должен сохранять pressed среднего при переключении на таблицу', () => {
const props = createTestContext({ showAverage: true });
render(<SeasonalityControls {...props} />);
act(() =>
props.stateObs.next({
...props.stateObs.value,
displayMode: SeasonalityViewType.Table,
}),
);
expect(screen.getByRole('button', { name: 'Average' }).getAttribute('data-pressed')).toBe('true');
expect(props.setAverageVisible).not.toHaveBeenCalled();
});
it('должен передавать подписи и расположение подсказок', () => {
const props = createTestContext();
render(<SeasonalityControls {...props} copyImage={jest.fn().mockResolvedValue(undefined)} />);
['Seasonality chart', 'Seasonality table', 'Average for selected years', 'Scale', 'Snapshot'].forEach(
(label) => {
expect(screen.getByTestId('tooltip-' + label).getAttribute('data-location')).toBe('bottom');
},
);
});
it('должен передавать копирование снимка из правой панели', () => {
const props = createTestContext();
const copyImage = jest.fn().mockResolvedValue(undefined);
const { container } = render(<SeasonalityControls {...props} copyImage={copyImage} />);
const camera = screen.getByTestId('camera-icon');
expect(container.querySelector('.controls_actions')?.contains(camera)).toBe(true);
expect(container.querySelector('.controls_views')?.contains(camera)).toBe(false);
fireEvent.click(screen.getByRole('button', { name: 'Copy image' }));
expect(copyImage).toHaveBeenCalledTimes(1);
});
it.each([
['ready', SeasonalityViewType.Table],
['loading', SeasonalityViewType.Chart],
['error', SeasonalityViewType.Chart],
['empty', SeasonalityViewType.Chart],
] as const)('не должен отображать снимок при статусе %s и виде %s', (status, displayMode) => {
const props = createTestContext({ status, displayMode });
render(<SeasonalityControls {...props} copyImage={jest.fn().mockResolvedValue(undefined)} />);
expect(screen.queryByTestId('camera-icon')).toBeNull();
expect(screen.queryByRole('button', { name: 'Copy image' })).toBeNull();
});
it('не должен отображать снимок без обработчика копирования', () => {
render(<SeasonalityControls {...createTestContext()} />);
expect(screen.queryByTestId('camera-icon')).toBeNull();
});
it('должен ограничивать количество отметок и сохранять крайние годы', () => {
render(<SeasonalityControls {...createTestContext({ minYear: 2000, maxYear: 2025 })} />);
const marks = screen.getByTestId('year-range').getAttribute('data-marks')!.split(',').map(Number);
expect(marks).toHaveLength(5);
expect(marks[0]).toBe(2000);
expect(marks[marks.length - 1]).toBe(2025);
expect(new Set(marks).size).toBe(marks.length);
});
});
import { fireEvent, render, screen } from '@testing-library/react';
import { SeasonalityHeader } from '@src/components/Header/SeasonalityHeader';
jest.mock('@src/components/Header/index.module.scss', () => ({
__esModule: true,
default: {
group: 'group',
},
}));
describe('SeasonalityHeader', () => {
it('должен отображать переключатель вида и сохранять его обработчик', () => {
const setChartView = jest.fn();
const copyImage = jest.fn();
render(
<SeasonalityHeader
viewControl={
<button type="button" onClick={setChartView}>
Chart view
</button>
}
snapshotControl={
<button type="button" onClick={copyImage}>
Snapshot
</button>
}
/>,
);
fireEvent.click(screen.getByText('Chart view'));
expect(setChartView).toHaveBeenCalledTimes(1);
expect(copyImage).not.toHaveBeenCalled();
expect(screen.queryByText('Snapshot')).toBeNull();
expect(screen.queryByRole('separator')).toBeNull();
});
it('должен сохранять переключатель вида при изменении доступности снимка', () => {
const viewControl = <button type="button">Chart view</button>;
const snapshotControl = <button type="button">Snapshot</button>;
const { rerender } = render(
<SeasonalityHeader viewControl={viewControl} snapshotControl={snapshotControl} />,
);
rerender(<SeasonalityHeader viewControl={viewControl} snapshotControl={null} />);
expect(screen.queryByText('Chart view')).not.toBeNull();
expect(screen.queryByText('Snapshot')).toBeNull();
rerender(<SeasonalityHeader viewControl={viewControl} snapshotControl={snapshotControl} />);
expect(screen.queryByText('Chart view')).not.toBeNull();
expect(screen.queryByText('Snapshot')).toBeNull();
});
it('не должен отображать снимок без переключателя вида', () => {
render(
<SeasonalityHeader
viewControl={null}
snapshotControl={<button type="button">Snapshot</button>}
/>,
);
expect(screen.queryByRole('button')).toBeNull();
expect(screen.queryByRole('separator')).toBeNull();
});
});
import { cleanup, fireEvent, render } from '@testing-library/react';
import { RangeField } from '@src/components/FormFields/RangeField';
import { useState, type ComponentProps } from 'react';
jest.mock('@src/components/FormFields/RangeField/index.module.scss', () => ({
__esModule: true,
default: {
range: 'range',
range_sm: 'range_sm',
range_interval: 'range_interval',
range_disabled: 'range_disabled',
range_collapsed: 'range_collapsed',
range_label: 'range_label',
range_wrapper: 'range_wrapper',
range_input: 'range_input',
range_value: 'range_value',
range_track: 'range_track',
range_rail: 'range_rail',
range_selection: 'range_selection',
range_handle: 'range_handle',
range_handle_start: 'range_handle_start',
range_handle_end: 'range_handle_end',
range_handle_behind: 'range_handle_behind',
range_scale: 'range_scale',
range_scale_label: 'range_scale_label',
range_dragHandle: 'range_dragHandle',
},
}));
afterEach(() => {
cleanup();
jest.restoreAllMocks();
});
type IntervalRangeProps = Extract<ComponentProps<typeof RangeField>, { mode: 'range' }>;
function renderRangeField(props: Partial<IntervalRangeProps> = {}) {
const onRangeChange = jest.fn();
const result = render(
<RangeField
mode="range"
value={[20, 40]}
min={0}
max={100}
step={5}
onRangeChange={onRangeChange}
{...props}
/>,
);
const handles = result.getAllByRole('slider') as HTMLInputElement[];
const selection = result.container.querySelector<HTMLDivElement>('div[style*="left:"]')!;
const track = selection.parentElement!;
const dragHandle = result.getByRole('button');
jest.spyOn(track, 'getBoundingClientRect').mockReturnValue({
left: 0,
width: 200,
} as DOMRect);
[selection, dragHandle, ...handles].forEach((element) => {
Object.defineProperty(element, 'setPointerCapture', {
configurable: true,
value: jest.fn(),
});
});
return {
...result,
handles,
selection,
dragHandle,
track,
onRangeChange,
};
}
function pointer(element: HTMLElement, type: string, clientX: number, button = 0, pointerId = 1) {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
clientX,
button,
});
Object.defineProperty(event, 'pointerId', {
value: pointerId,
});
fireEvent(element, event);
}
describe('RangeField', () => {
it('должен сохранять поведение одиночного слайдера и связь с подписью', () => {
const onChange = jest.fn();
const result = render(<RangeField label="Opacity" value={50} onChange={onChange} />);
const input = result.getByLabelText('Opacity') as HTMLInputElement;
expect(result.container.textContent).toContain('50%');
fireEvent.change(input, {
target: { value: '60' },
});
expect(onChange).toHaveBeenCalledTimes(1);
});
it('не должен допускать пересечения границ диапазона', () => {
const { handles, onRangeChange } = renderRangeField();
fireEvent.change(handles[0], {
target: { value: '70' },
});
expect(onRangeChange).toHaveBeenLastCalledWith([40, 40]);
fireEvent.change(handles[1], {
target: { value: '10' },
});
expect(onRangeChange).toHaveBeenLastCalledWith([20, 20]);
});
it('должен вычислять положение диапазона относительно минимальной и максимальной границ', () => {
const { selection } = renderRangeField({
value: [2020, 2025],
min: 2010,
max: 2030,
suffix: '',
});
expect(selection.style.left).toBe('50%');
expect(selection.style.width).toBe('25%');
});
it('должен сортировать отметки и исключать повторяющиеся значения', () => {
const { container } = renderRangeField({
marks: [100, 50, 25, 50, -1, 200, 0],
});
const marks = [...container.querySelectorAll('span[style*="left:"]')].map(
(node) => node.textContent,
);
expect(marks).toEqual(['0%', '25%', '50%', '100%']);
});
it('должен переносить диапазон с учётом шага и сохранять его ширину', () => {
const { selection, onRangeChange } = renderRangeField();
pointer(selection, 'pointerdown', 100);
pointer(selection, 'pointermove', 121);
expect(onRangeChange).toHaveBeenLastCalledWith([30, 50]);
expect(selection.setPointerCapture).toHaveBeenCalledWith(1);
});
it.each([
[1000, [80, 100]],
[-1000, [0, 20]],
] as const)('должен ограничивать перенос границами шкалы при x=%s', (clientX, expected) => {
const { selection, onRangeChange } = renderRangeField();
pointer(selection, 'pointerdown', 100);
pointer(selection, 'pointermove', clientX);
expect(onRangeChange).toHaveBeenLastCalledWith(expected);
});
it.each(['pointerup', 'pointercancel', 'lostpointercapture'])(
'должен завершать перенос после события %s',
(endEvent) => {
const { selection, onRangeChange } = renderRangeField();
pointer(selection, 'pointerdown', 100);
pointer(selection, endEvent, 100);
pointer(selection, 'pointermove', 150);
expect(onRangeChange).not.toHaveBeenCalled();
},
);
it('не должен начинать перенос без нажатия основной кнопки', () => {
const { selection, onRangeChange } = renderRangeField();
pointer(selection, 'pointermove', 150);
pointer(selection, 'pointerdown', 100, 2);
pointer(selection, 'pointermove', 150);
expect(onRangeChange).not.toHaveBeenCalled();
});
it('не должен переносить диапазон при нулевой ширине шкалы', () => {
const { selection, onRangeChange } = renderRangeField();
jest.mocked(selection.parentElement!.getBoundingClientRect).mockReturnValue({
width: 0,
} as DOMRect);
pointer(selection, 'pointerdown', 100);
pointer(selection, 'pointermove', 150);
expect(onRangeChange).not.toHaveBeenCalled();
});
it('должен отключать обе границы и перенос диапазона', () => {
const { handles, selection, onRangeChange } = renderRangeField({
disabled: true,
});
expect(handles.every((input) => input.disabled)).toBe(true);
pointer(selection, 'pointerdown', 100);
pointer(selection, 'pointermove', 150);
expect(onRangeChange).not.toHaveBeenCalled();
});
it('должен отключать диапазон нулевой длины и сохранять корректные позиции', () => {
const { handles, selection, container } = renderRangeField({
min: 2025,
max: 2025,
value: [2025, 2025],
suffix: '',
});
expect(handles.every((input) => input.disabled)).toBe(true);
expect(selection.style.left).toBe('0%');
expect(selection.style.width).toBe('0%');
expect(
[...container.querySelectorAll('span[style*="left:"]')].map((node) => node.textContent),
).toEqual(['2025']);
});
});
describe('RangeField: границы и ручка переноса', () => {
it('должен переносить диапазон за отдельную ручку с сохранением ширины', () => {
const { dragHandle, onRangeChange } = renderRangeField();
pointer(dragHandle, 'pointerdown', 100);
pointer(dragHandle, 'pointermove', 121);
expect(onRangeChange).toHaveBeenLastCalledWith([30, 50]);
expect(dragHandle.setPointerCapture).toHaveBeenCalledWith(1);
});
it('должен переносить совпавшие границы только за ручку диапазона', () => {
const { dragHandle, onRangeChange } = renderRangeField({
min: 2020,
max: 2025,
value: [2022, 2022],
step: 1,
});
pointer(dragHandle, 'pointerdown', 100);
pointer(dragHandle, 'pointermove', 140);
expect(onRangeChange).toHaveBeenLastCalledWith([2023, 2023]);
pointer(dragHandle, 'pointermove', 60);
expect(onRangeChange).toHaveBeenLastCalledWith([2021, 2021]);
});
it.each([0, 1])('должен раздвигать совпавшие границы в обе стороны за точку %s', (index) => {
const { handles, onRangeChange } = renderRangeField({
min: 2020,
max: 2025,
value: [2022, 2022],
step: 1,
});
pointer(handles[index], 'pointerdown', 100);
pointer(handles[index], 'pointermove', 60);
expect(onRangeChange).toHaveBeenLastCalledWith([2021, 2022]);
pointer(handles[index], 'pointermove', 140);
expect(onRangeChange).toHaveBeenLastCalledWith([2022, 2023]);
});
it.each([
[0, 160, [40, 40]],
[1, 40, [20, 20]],
] as const)(
'должен менять только выбранную границу %s и не допускать пересечения',
(index, x, expected) => {
const { handles, onRangeChange } = renderRangeField();
pointer(handles[index], 'pointerdown', 100);
pointer(handles[index], 'pointermove', x);
expect(onRangeChange).toHaveBeenLastCalledWith(expected);
},
);
it.each([
[1000, [100, 100]],
[-1000, [0, 0]],
] as const)('должен ограничивать перенос совпавших границ при x=%s', (x, expected) => {
const { dragHandle, onRangeChange } = renderRangeField({ value: [40, 40] });
pointer(dragHandle, 'pointerdown', 100);
pointer(dragHandle, 'pointermove', x);
expect(onRangeChange).toHaveBeenLastCalledWith(expected);
});
it('не должен принимать события другого указателя или повторное начало переноса', () => {
const { dragHandle, handles, onRangeChange } = renderRangeField();
pointer(dragHandle, 'pointerdown', 100);
pointer(handles[0], 'pointerdown', 50, 0, 2);
pointer(dragHandle, 'pointermove', 150, 0, 2);
pointer(dragHandle, 'pointerup', 150, 0, 2);
expect(onRangeChange).not.toHaveBeenCalled();
pointer(dragHandle, 'pointermove', 120);
expect(onRangeChange).toHaveBeenLastCalledWith([30, 50]);
});
it.each(['pointerup', 'pointercancel', 'lostpointercapture'])(
'должен завершать перенос ручки после события %s',
(endEvent) => {
const { dragHandle, onRangeChange } = renderRangeField();
pointer(dragHandle, 'pointerdown', 100);
pointer(dragHandle, endEvent, 100);
pointer(dragHandle, 'pointermove', 150);
expect(onRangeChange).not.toHaveBeenCalled();
},
);
it('должен позволять свести границы, перенести их парой и снова раздвинуть', () => {
const onRangeChange = jest.fn();
function ControlledRange() {
const [value, setValue] = useState<[number, number]>([20, 40]);
return (
<RangeField
mode="range"
min={0}
max={100}
step={5}
value={value}
onRangeChange={(next) => {
onRangeChange(next);
setValue(next);
}}
/>
);
}
const { container, getAllByRole, getByRole } = render(<ControlledRange />);
const handles = getAllByRole('slider') as HTMLInputElement[];
const dragHandle = getByRole('button');
const track = dragHandle.parentElement!;
jest.spyOn(track, 'getBoundingClientRect').mockReturnValue({ width: 200 } as DOMRect);
[dragHandle, ...handles].forEach((element) => {
Object.defineProperty(element, 'setPointerCapture', {
configurable: true,
value: jest.fn(),
});
});
fireEvent.change(handles[0], { target: { value: '40' } });
expect(container.firstElementChild?.classList.contains('range_collapsed')).toBe(true);
pointer(dragHandle, 'pointerdown', 100);
pointer(dragHandle, 'pointermove', 80);
pointer(dragHandle, 'pointerup', 80);
expect(handles.map((input) => input.value)).toEqual(['30', '30']);
pointer(handles[1], 'pointerdown', 100);
pointer(handles[1], 'pointermove', 80);
pointer(handles[1], 'pointerup', 80);
expect(handles.map((input) => input.value)).toEqual(['20', '30']);
expect(container.firstElementChild?.classList.contains('range_collapsed')).toBe(false);
expect(onRangeChange).toHaveBeenLastCalledWith([20, 30]);
});
it('должен блокировать ручку и обе точки при отключении диапазона', () => {
const { handles, dragHandle, onRangeChange } = renderRangeField({ disabled: true });
expect((dragHandle as HTMLButtonElement).disabled).toBe(true);
expect(handles.every((input) => input.disabled)).toBe(true);
pointer(dragHandle, 'pointerdown', 100);
pointer(dragHandle, 'pointermove', 140);
pointer(handles[0], 'pointerdown', 100);
pointer(handles[0], 'pointermove', 140);
expect(onRangeChange).not.toHaveBeenCalled();
});
});
import { act, render, screen, within } from '@testing-library/react';
import { PriceScaleMode } from 'lightweight-charts';
import { BehaviorSubject, Subject } from 'rxjs';
import { SeasonalityTable } from '@src/components/SeasonalityTable';
import styles from '@src/components/SeasonalityTable/index.module.scss';
import tableStyles from '@src/components/Table/index.module.scss';
import { createInitialSeasonalityState } from '@src/constants';
import { SeasonalityViewType } from '@src/types';
import { setPricePrecision } from '@src/utils/precision';
import type { SeasonalityState, SeasonalityTableRow } from '@src/types';
jest.mock('@src/components/SeasonalityTable/index.module.scss', () => ({
__esModule: true,
default: {
seasonality: 'seasonality',
positive: 'positive',
negative: 'negative',
total: 'total',
average: 'average',
},
}));
jest.mock('@src/components/Table/index.module.scss', () => ({
__esModule: true,
default: {
table: 'table',
table_content: 'table_content',
table_stickyHeader: 'table_stickyHeader',
table_stickyFirstColumn: 'table_stickyFirstColumn',
table_cell: 'table_cell',
table_cell_left: 'table_cell_left',
table_cell_center: 'table_cell_center',
table_cell_right: 'table_cell_right',
table_empty: 'table_empty',
},
}));
const mocks = {
locale: 'en',
};
jest.mock('@src/translations', () => ({
t: (key: string) => key,
getLocale: () => mocks.locale,
}));
function createRow(year = 2024): SeasonalityTableRow {
return {
year,
months: [
{ absolute: 12.3456, percentage: 1.23456 },
{ absolute: -2.3456, percentage: -0.87654 },
{ absolute: 0, percentage: 0 },
{ absolute: 5, percentage: null },
...Array(8).fill(null),
],
total: {
absolute: 15.1234,
percentage: 2.3456,
},
};
}
function createTestContext(overrides: Partial<SeasonalityState> = {}) {
const stateObs = new BehaviorSubject<SeasonalityState>({
...createInitialSeasonalityState(),
status: 'ready',
displayMode: SeasonalityViewType.Table,
tableRows: [createRow()],
...overrides,
});
return { stateObs };
}
function getYearCells(year: number | string = 2024) {
const row = screen.getByRole('rowheader', { name: String(year) }).closest('tr') as HTMLTableRowElement;
return within(row).getAllByRole('cell');
}
beforeEach(() => {
setPricePrecision(3);
mocks.locale = 'en';
});
afterEach(() => {
setPricePrecision();
jest.clearAllMocks();
});
describe('SeasonalityTable', () => {
it('не должен отображаться до получения состояния, при idle и в режиме графика', () => {
const stateObs = new Subject<SeasonalityState>();
const { container } = render(<SeasonalityTable stateObs={stateObs} />);
expect(container.children).toHaveLength(0);
act(() => stateObs.next(createInitialSeasonalityState()));
expect(container.children).toHaveLength(0);
act(() =>
stateObs.next({
...createInitialSeasonalityState(),
status: 'ready',
}),
);
expect(container.children).toHaveLength(0);
act(() =>
stateObs.next({
...createInitialSeasonalityState(),
displayMode: SeasonalityViewType.Table,
}),
);
expect(container.children).toHaveLength(0);
});
it('должен отображать все месяцы, годовые итоги и строки в переданном порядке', () => {
const props = createTestContext({
tableRows: [createRow(2025), createRow(2024)],
});
const { container } = render(<SeasonalityTable {...props} />);
expect(screen.getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual([
'Date',
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
'Year',
]);
expect(screen.getAllByRole('rowheader').map((cell) => cell.textContent)).toEqual([
'2025',
'2024',
]);
expect(container.firstElementChild?.classList.contains(tableStyles.table_stickyFirstColumn)).toBe(
true,
);
const cells = getYearCells(2025);
expect(cells).toHaveLength(13);
expect(cells[0].textContent).toBe('12.346');
expect(cells[1].textContent).toBe('-2.346');
expect(cells[2].textContent).toBe('0.000');
expect(cells[3].textContent).toBe('5.000');
expect(cells[4].textContent).toBe('—');
expect(cells[12].textContent).toBe('15.123');
expect(cells[0].classList.contains(styles.positive)).toBe(true);
expect(cells[1].classList.contains(styles.negative)).toBe(true);
expect(cells[2].classList.contains(styles.positive)).toBe(false);
expect(cells[2].classList.contains(styles.negative)).toBe(false);
expect(cells[12].classList.contains(styles.total)).toBe(true);
});
it('должен переключать значения, точность и цвета при изменении шкалы', () => {
const props = createTestContext();
render(<SeasonalityTable {...props} />);
expect(getYearCells()[3].classList.contains(styles.positive)).toBe(true);
act(() =>
props.stateObs.next({
...props.stateObs.value,
scale: PriceScaleMode.Percentage,
}),
);
const cells = getYearCells();
expect(cells[0].textContent).toBe('1.23%');
expect(cells[1].textContent).toBe('-0.88%');
expect(cells[2].textContent).toBe('0.00%');
expect(cells[3].textContent).toBe('—');
expect(cells[3].classList.contains(styles.positive)).toBe(false);
expect(cells[3].classList.contains(styles.negative)).toBe(false);
expect(cells[12].textContent).toBe('2.35%');
});
it.each([0, 6])('должен использовать общую точность цены %s', (precision) => {
setPricePrecision(precision);
render(<SeasonalityTable {...createTestContext()} />);
expect(getYearCells()[0].textContent).toBe(precision === 0 ? '12' : '12.345600');
});
it('должен использовать текущую локаль при форматировании процентов', () => {
mocks.locale = 'ru';
render(
<SeasonalityTable
{...createTestContext({
scale: PriceScaleMode.Percentage,
})}
/>,
);
expect(getYearCells()[0].textContent).toBe('1,23%');
expect(getYearCells()[12].textContent).toBe('2,35%');
});
it.each([
['loading', 'Loading seasonality'],
['error', 'Unable to load seasonality'],
['empty', 'No seasonality data'],
['ready', 'No seasonality data'],
] as const)('должен отображать пустое состояние при статусе %s', (status, message) => {
render(
<SeasonalityTable
{...createTestContext({
status,
tableRows: [],
})}
/>,
);
expect(screen.getByRole('cell').textContent).toBe(message);
expect(screen.getByRole('cell').getAttribute('colspan')).toBe('14');
});
it('должен отображать прочерк при отсутствии годового итога', () => {
render(
<SeasonalityTable
{...createTestContext({
tableRows: [{ ...createRow(), total: null }],
})}
/>,
);
expect(getYearCells()[12].textContent).toBe('—');
expect(getYearCells()[12].classList.contains(styles.positive)).toBe(false);
expect(getYearCells()[12].classList.contains(styles.negative)).toBe(false);
});
});
describe('SeasonalityTable: среднее', () => {
it('должен добавлять строку среднего снизу и учитывать нули и пропуски', () => {
const second = createRow(2025);
second.months[0] = { absolute: 0, percentage: 0 };
second.months[1] = null;
second.months[2] = { absolute: -2, percentage: null };
second.total = { absolute: 20, percentage: 4 };
const tableRows = [second, createRow()];
const snapshot = JSON.parse(JSON.stringify(tableRows));
const props = createTestContext({ showAverage: true, tableRows });
render(<SeasonalityTable {...props} />);
expect(screen.getAllByRole('rowheader').map((cell) => cell.textContent)).toEqual([
'2025',
'2024',
'Average',
]);
const cells = getYearCells('Average');
const row = screen.getByRole('rowheader', { name: 'Average' }).closest('tr')!;
expect(cells[0].textContent).toBe('6.173');
expect(cells[1].textContent).toBe('-2.346');
expect(cells[2].textContent).toBe('-1.000');
expect(cells[4].textContent).toBe('—');
expect(cells[12].textContent).toBe('17.562');
expect(cells[0].classList.contains(styles.positive)).toBe(true);
expect(cells[1].classList.contains(styles.negative)).toBe(true);
expect([...row.children].every((cell) => cell.classList.contains(styles.average))).toBe(true);
expect(props.stateObs.value.tableRows).toEqual(snapshot);
act(() =>
props.stateObs.next({
...props.stateObs.value,
scale: PriceScaleMode.Percentage,
}),
);
const percentCells = getYearCells('Average');
expect(percentCells[0].textContent).toBe('0.62%');
expect(percentCells[1].textContent).toBe('-0.88%');
expect(percentCells[2].textContent).toBe('0.00%');
expect(percentCells[2].classList.contains(styles.negative)).toBe(false);
expect(percentCells[3].textContent).toBe('—');
expect(percentCells[12].textContent).toBe('3.17%');
});
it('должен пересчитывать среднее при изменении строк и удалять его при отключении', () => {
const props = createTestContext({ showAverage: true });
render(<SeasonalityTable {...props} />);
expect(getYearCells('Average')[0].textContent).toBe('12.346');
const row = createRow(2025);
row.months[0] = { absolute: -10, percentage: -5 };
act(() => props.stateObs.next({ ...props.stateObs.value, tableRows: [row] }));
expect(getYearCells('Average')[0].textContent).toBe('-10.000');
expect(screen.queryByRole('rowheader', { name: '2024' })).toBeNull();
act(() => props.stateObs.next({ ...props.stateObs.value, showAverage: false }));
expect(screen.queryByRole('rowheader', { name: 'Average' })).toBeNull();
expect(screen.queryByRole('rowheader', { name: '2025' })).not.toBeNull();
});
it('должен оставлять нулевое среднее и пропуски нейтральными', () => {
const row = createRow();
row.months[0] = { absolute: 0, percentage: 0 };
row.total = null;
render(
<SeasonalityTable
{...createTestContext({ showAverage: true, tableRows: [row] })}
/>,
);
const cells = getYearCells('Average');
expect(cells[0].textContent).toBe('0.000');
expect(cells[0].classList.contains(styles.positive)).toBe(false);
expect(cells[0].classList.contains(styles.negative)).toBe(false);
expect(cells[12].textContent).toBe('—');
expect(cells[12].classList.contains(styles.average)).toBe(true);
});
it('не должен добавлять среднее без строк или при выключенном флаге', () => {
const props = createTestContext();
render(<SeasonalityTable {...props} />);
expect(screen.queryByRole('rowheader', { name: 'Average' })).toBeNull();
act(() =>
props.stateObs.next({
...props.stateObs.value,
showAverage: true,
tableRows: [],
}),
);
expect(screen.queryByRole('rowheader', { name: 'Average' })).toBeNull();
expect(screen.getByRole('cell').textContent).toBe('No seasonality data');
});
});
import { PriceScaleMode } from 'lightweight-charts';
import {
buildSeasonality,
buildSeasonalityAverage,
buildSeasonalityTable,
buildSeasonalityTableAverage,
normalizeRange,
toSeriesData,
} from '@src/utils/seasonality';
import type { SeasonalityTableRow } from '@src/types';
describe('normalizeRange', () => {
it.each([
[
{ fromYear: 2010, toYear: 2040 },
{ fromYear: 2020, toYear: 2025 },
],
[
{ fromYear: 2022.6, toYear: 2024.4 },
{ fromYear: 2023, toYear: 2024 },
],
[
{ fromYear: 2024, toYear: 2021 },
{ fromYear: 2024, toYear: 2024 },
],
[
{ fromYear: 2010, toYear: 2011 },
{ fromYear: 2020, toYear: 2020 },
],
])('должен ограничивать и упорядочивать диапазон %j', (input, expected) => {
expect(normalizeRange(input, 2020, 2025)).toEqual(expected);
});
it.each([NaN, Infinity, -Infinity])('должен отклонять некорректную границу %s', (value) => {
expect(() =>
normalizeRange({ fromYear: value, toYear: 2024 }, 2020, 2025),
).toThrow('Invalid year range');
expect(() =>
normalizeRange({ fromYear: 2020, toYear: value }, 2020, 2025),
).toThrow('Invalid year range');
});
});
describe('buildSeasonality', () => {
it('должен возвращать пустой результат для пустой истории', () => {
expect(buildSeasonality([])).toEqual([]);
});
it('должен сортировать данные без изменения исходного массива и выбирать базу отдельно для каждого года', () => {
const rows = [
{ date: '2024-01-03', close: 125 },
{ date: '2023-12-31', close: 50 },
{ date: '2024-01-02', close: 100 },
];
const snapshot = rows.map((row) => ({ ...row }));
const years = buildSeasonality(rows);
expect(rows).toEqual(snapshot);
expect(years.map(({ year, baselinePrice }) => [year, baselinePrice])).toEqual([
[2023, 50],
[2024, 100],
]);
expect(years[1].baselineDate).toBe('2024-01-02');
expect(years[1].points.map(({ percentage }) => percentage)).toEqual([0, 25]);
expect(years[1].points[0].chartDate).toBe('2000-01-02');
});
it('должен рассчитывать отрицательную доходность', () => {
const [year] = buildSeasonality([
{ date: '2024-01-01', close: 100 },
{ date: '2024-01-02', close: 75 },
]);
expect(year.points[1].percentage).toBe(-25);
});
it.each([0, -10])(
'должен сохранять цены без процентов при неположительной базе %s',
(close) => {
const [year] = buildSeasonality([
{ date: '2024-01-01', close },
{ date: '2024-01-02', close: 10 },
]);
expect(year.points.map((point) => point.percentage)).toEqual([null, null]);
expect(year.points[1].price).toBe(10);
},
);
it('должен отклонять повторяющиеся даты независимо от порядка данных', () => {
expect(() =>
buildSeasonality([
{ date: '2024-01-01', close: 100 },
{ date: '2023-01-01', close: 90 },
{ date: '2024-01-01', close: 101 },
]),
).toThrow('Multiple daily candles');
});
it('должен отклонять переполнение при расчёте доходности', () => {
expect(() =>
buildSeasonality([
{ date: '2024-01-01', close: Number.MIN_VALUE },
{ date: '2024-01-02', close: Number.MAX_VALUE },
]),
).toThrow('Invalid percentage');
});
});
describe('toSeriesData', () => {
it('должен совмещать годы по високосному календарю и сохранять пропуски торговых дней', () => {
const [year] = buildSeasonality([
{ date: '2023-02-28', close: 100 },
{ date: '2023-03-01', close: 110 },
]);
const data = toSeriesData(year, PriceScaleMode.Normal);
expect(data).toHaveLength(366);
expect(data[0]).toEqual({ time: '2000-01-01' });
expect(data[data.length - 1]).toEqual({ time: '2000-12-31' });
expect(data.find((point) => point.time === '2000-02-28')).toEqual({
time: '2000-02-28',
value: 100,
});
expect(data.find((point) => point.time === '2000-02-29')).toEqual({
time: '2000-02-29',
});
expect(data.find((point) => point.time === '2000-03-01')).toEqual({
time: '2000-03-01',
value: 110,
});
});
it('должен сохранять 29 февраля и рассчитывать процентные значения', () => {
const [year] = buildSeasonality([
{ date: '2024-02-29', close: 10 },
{ date: '2024-03-01', close: 15 },
]);
const data = toSeriesData(year, PriceScaleMode.Percentage);
expect(data.find((point) => point.time === '2000-02-29')).toEqual({
time: '2000-02-29',
value: 0,
});
expect(data.find((point) => point.time === '2000-03-01')).toEqual({
time: '2000-03-01',
value: 50,
});
});
it('должен оставлять пропуски вместо процентов при отсутствии положительной базы', () => {
const [year] = buildSeasonality([{ date: '2024-01-01', close: 0 }]);
expect(toSeriesData(year, PriceScaleMode.Percentage)[0]).toEqual({
time: '2000-01-01',
});
expect(toSeriesData(year, PriceScaleMode.Normal)[0]).toEqual({
time: '2000-01-01',
value: 0,
});
});
});
describe('buildSeasonalityTable', () => {
it('должен возвращать пустую таблицу без истории', () => {
expect(buildSeasonalityTable([])).toEqual([]);
});
it('должен использовать последние закрытия месяцев и предыдущий декабрь', () => {
const years = buildSeasonality([
{ date: '2024-02-29', close: 121 },
{ date: '2024-01-31', close: 110 },
{ date: '2023-12-29', close: 100 },
{ date: '2024-01-02', close: 105 },
]);
const [row] = buildSeasonalityTable(years);
expect(buildSeasonalityTable(years).map((item) => item.year)).toEqual([2024, 2023]);
expect(row.months).toHaveLength(12);
expect(row.months[0]?.absolute).toBe(10);
expect(row.months[0]?.percentage).toBeCloseTo(10, 10);
expect(row.months[1]?.absolute).toBe(11);
expect(row.months[1]?.percentage).toBeCloseTo(10, 10);
expect(row.total?.absolute).toBe(21);
expect(row.total?.percentage).toBeCloseTo(21, 10);
expect(row.months.slice(2)).toEqual(Array(10).fill(null));
});
it('должен рассчитывать первый неполный месяц и год от первой доступной цены', () => {
const years = buildSeasonality([
{ date: '2021-10-15', close: 100 },
{ date: '2021-10-29', close: 101 },
{ date: '2021-11-30', close: 105 },
{ date: '2021-12-30', close: 106 },
]);
const [row] = buildSeasonalityTable(years);
expect(row.months.slice(0, 9)).toEqual(Array(9).fill(null));
expect(row.months[9]?.absolute).toBe(1);
expect(row.months[9]?.percentage).toBeCloseTo(1, 10);
expect(row.months[10]?.absolute).toBe(4);
expect(row.months[11]?.absolute).toBe(1);
expect(row.total?.absolute).toBe(6);
expect(row.total?.percentage).toBeCloseTo(6, 10);
});
it('должен сохранять пропуски внутри года и рассчитывать итог по доступным ценам', () => {
const years = buildSeasonality([
{ date: '2024-01-02', close: 100 },
{ date: '2024-01-31', close: 110 },
{ date: '2024-03-15', close: 120 },
{ date: '2024-03-29', close: 132 },
{ date: '2024-04-30', close: 132 },
]);
const [row] = buildSeasonalityTable(years);
expect(row.months[1]).toBeNull();
expect(row.months[2]).toBeNull();
expect(row.months[3]).toEqual({
absolute: 0,
percentage: 0,
});
expect(row.total?.absolute).toBe(32);
expect(row.total?.percentage).toBeCloseTo(32, 10);
});
it.each([0, -10])(
'должен сохранять неположительную базу предыдущего декабря %s',
(baseline) => {
const years = buildSeasonality([
{ date: '2023-12-29', close: baseline },
{ date: '2024-01-02', close: 50 },
{ date: '2024-01-31', close: 100 },
]);
const [row] = buildSeasonalityTable(years);
expect(row.months[0]).toEqual({
absolute: 100 - baseline,
percentage: null,
});
expect(row.total).toEqual({
absolute: 100 - baseline,
percentage: null,
});
},
);
it('должен использовать первую цену года при отсутствии предыдущего декабря', () => {
const years = buildSeasonality([
{ date: '2023-11-30', close: 80 },
{ date: '2024-01-02', close: 100 },
{ date: '2024-01-31', close: 110 },
]);
const [row] = buildSeasonalityTable(years);
expect(row.months[0]?.absolute).toBe(10);
expect(row.months[0]?.percentage).toBeCloseTo(10, 10);
expect(row.total?.absolute).toBe(10);
expect(row.total?.percentage).toBeCloseTo(10, 10);
});
it('должен сохранять точность и рассчитывать отрицательный годовой итог', () => {
const years = buildSeasonality([
{ date: '2023-12-29', close: 100 },
{ date: '2024-01-31', close: 90 },
{ date: '2024-02-29', close: 99.0044 },
]);
const [row] = buildSeasonalityTable(years);
expect(row.months[0]?.percentage).toBeCloseTo(-10, 10);
expect(row.months[1]?.absolute).toBeCloseTo(9.0044, 10);
expect(row.total?.absolute).toBeCloseTo(-0.9956, 10);
expect(row.total?.percentage).toBeCloseTo(-0.9956, 10);
});
});
describe('buildSeasonalityAverage', () => {
it('должен возвращать календарь без значений для пустой истории', () => {
const data = buildSeasonalityAverage([], PriceScaleMode.Normal);
expect(data).toHaveLength(366);
expect(data[0]).toEqual({ time: '2000-01-01' });
expect(data[data.length - 1]).toEqual({ time: '2000-12-31' });
expect(data.every((point) => !('value' in point))).toBe(true);
});
it('должен усреднять только доступные цены, включая нули', () => {
const years = buildSeasonality([
{ date: '2023-01-01', close: 100 },
{ date: '2023-01-02', close: 200 },
{ date: '2024-01-01', close: 200 },
{ date: '2024-01-02', close: 200 },
{ date: '2025-01-01', close: 0 },
{ date: '2025-01-03', close: 20 },
]);
const data = buildSeasonalityAverage(years, PriceScaleMode.Normal);
expect(data[0]).toEqual({ time: '2000-01-01', value: 100 });
expect(data[1]).toEqual({ time: '2000-01-02', value: 200 });
expect(data[2]).toEqual({ time: '2000-01-03', value: 20 });
expect(data[3]).toEqual({ time: '2000-01-04' });
});
it('должен усреднять проценты каждого года и исключать отсутствующую базу', () => {
const years = buildSeasonality([
{ date: '2023-01-01', close: 100 },
{ date: '2023-01-02', close: 200 },
{ date: '2024-01-01', close: 200 },
{ date: '2024-01-02', close: 200 },
{ date: '2025-01-01', close: 0 },
{ date: '2025-01-03', close: 20 },
]);
const data = buildSeasonalityAverage(years, PriceScaleMode.Percentage);
expect(data[0]).toEqual({ time: '2000-01-01', value: 0 });
expect(data[1]).toEqual({ time: '2000-01-02', value: 50 });
expect(data[2]).toEqual({ time: '2000-01-03' });
});
it('должен сохранять нулевое среднее отрицательных и положительных процентов', () => {
const years = buildSeasonality([
{ date: '2023-01-01', close: 100 },
{ date: '2023-01-02', close: 0 },
{ date: '2024-01-01', close: 100 },
{ date: '2024-01-02', close: 200 },
]);
expect(buildSeasonalityAverage(years, PriceScaleMode.Percentage)[1]).toEqual({
time: '2000-01-02',
value: 0,
});
});
it('должен учитывать 29 февраля без заполнения отсутствующих дат', () => {
const years = buildSeasonality([
{ date: '2023-02-28', close: 100 },
{ date: '2024-02-29', close: 200 },
]);
const data = buildSeasonalityAverage(years, PriceScaleMode.Normal);
expect(data.find((point) => point.time === '2000-02-28')).toEqual({
time: '2000-02-28',
value: 100,
});
expect(data.find((point) => point.time === '2000-02-29')).toEqual({
time: '2000-02-29',
value: 200,
});
expect(data.find((point) => point.time === '2000-03-01')).toEqual({
time: '2000-03-01',
});
});
it('должен сохранять точность и не изменять исходные годы', () => {
const years = buildSeasonality([
{ date: '2023-01-01', close: 3.123456 },
{ date: '2024-01-01', close: 7.987654 },
]);
const snapshot = JSON.parse(JSON.stringify(years));
const [point] = buildSeasonalityAverage(years, PriceScaleMode.Normal);
expect('value' in point && point.value).toBeCloseTo(5.555555, 10);
expect(years).toEqual(snapshot);
});
});
describe('buildSeasonalityTableAverage', () => {
it('должен возвращать null без строк', () => {
expect(buildSeasonalityTableAverage([])).toBeNull();
});
it('должен отдельно усреднять месяцы и годовые итоги с учётом нулей и пропусков', () => {
const rows: SeasonalityTableRow[] = [
{
year: 2023,
months: [
{ absolute: 10, percentage: 10 },
null,
{ absolute: 0, percentage: 0 },
],
total: { absolute: 30, percentage: 30 },
},
{
year: 2024,
months: [
{ absolute: 20, percentage: null },
{ absolute: -5, percentage: -5 },
],
total: { absolute: -10, percentage: -10 },
},
{
year: 2025,
months: [{ absolute: 0, percentage: 0 }],
total: null,
},
];
const snapshot = JSON.parse(JSON.stringify(rows));
const average = buildSeasonalityTableAverage(rows)!;
expect(average.months).toHaveLength(12);
expect(average.months[0]).toEqual({ absolute: 10, percentage: 5 });
expect(average.months[1]).toEqual({ absolute: -5, percentage: -5 });
expect(average.months[2]).toEqual({ absolute: 0, percentage: 0 });
expect(average.months.slice(3)).toEqual(Array(9).fill(null));
expect(average.total).toEqual({ absolute: 10, percentage: 10 });
expect(rows).toEqual(snapshot);
});
it('должен сохранять абсолютное среднее при отсутствии процентных значений', () => {
const average = buildSeasonalityTableAverage([
{
year: 2024,
months: [{ absolute: 0, percentage: null }],
total: { absolute: -2, percentage: null },
},
])!;
expect(average.months[0]).toEqual({ absolute: 0, percentage: null });
expect(average.total).toEqual({ absolute: -2, percentage: null });
});
it('должен возвращать пропуски для полностью пустых колонок и итогов', () => {
expect(
buildSeasonalityTableAverage([{ year: 2024, months: [], total: null }]),
).toEqual({
months: Array(12).fill(null),
total: null,
});
});
it('не должен округлять средние значения до точности отображения', () => {
const average = buildSeasonalityTableAverage([
{
year: 2023,
months: [],
total: { absolute: 3.123456, percentage: 1.123456 },
},
{
year: 2024,
months: [],
total: { absolute: 7.987654, percentage: 2.987654 },
},
])!;
expect(average.total?.absolute).toBeCloseTo(5.555555, 10);
expect(average.total?.percentage).toBeCloseTo(2.055555, 10);
});
});
import { createChart, createSeriesMarkers, LineStyle, PriceScaleMode } from 'lightweight-charts';
import { SeasonalityManager } from '@core/SeasonalityManager';
import { createInitialSeasonalityState } from '@src/constants';
import { getThemeStore } from '@src/theme/store';
import { SeasonalityViewType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { canvasToBlob, drawTextLayer } from '@src/utils';
import { setPricePrecision } from '@src/utils/precision';
import type { Candle } from '@src/types';
import type {
ChartOptions,
DeepPartial,
LineData,
LineSeriesPartialOptions,
WhitespaceData,
} from 'lightweight-charts';
interface SeriesTestContext {
options: LineSeriesPartialOptions;
applyOptions: jest.Mock;
setData: jest.Mock;
priceFormatter: () => {
format: (value: number) => string;
};
}
interface ChartTestContext {
options: DeepPartial<ChartOptions>;
series: SeriesTestContext[];
addSeries: jest.Mock;
applyOptions: jest.Mock;
removeSeries: jest.Mock;
remove: jest.Mock;
timeScale: () => {
setVisibleRange: jest.Mock;
};
subscribeCrosshairMove: jest.Mock;
unsubscribeCrosshairMove: jest.Mock;
takeScreenshot: jest.Mock;
chartElement: () => HTMLElement;
}
interface MarkersTestContext {
setMarkers: jest.Mock;
detach: jest.Mock;
}
const mocks = {
charts: [] as ChartTestContext[],
markers: [] as MarkersTestContext[],
};
function createSeriesContext(options: LineSeriesPartialOptions): SeriesTestContext {
return {
options,
applyOptions: jest.fn(),
setData: jest.fn(),
priceFormatter: () => ({
format: (value: number) => value.toFixed(2),
}),
};
}
function createChartContext(
container: HTMLElement,
options: DeepPartial<ChartOptions>,
): ChartTestContext {
const timeScale = {
setVisibleRange: jest.fn(),
};
const series: SeriesTestContext[] = [];
return {
options,
series,
addSeries: jest.fn((_type: unknown, seriesOptions: LineSeriesPartialOptions) => {
const item = createSeriesContext(seriesOptions);
series.push(item);
return item;
}),
applyOptions: jest.fn(),
removeSeries: jest.fn(),
remove: jest.fn(),
timeScale: () => timeScale,
subscribeCrosshairMove: jest.fn(),
unsubscribeCrosshairMove: jest.fn(),
takeScreenshot: jest.fn(() => document.createElement('canvas')),
chartElement: () => container,
};
}
jest.mock('lightweight-charts', () => ({
...jest.requireActual('lightweight-charts'),
createChart: jest.fn(),
createSeriesMarkers: jest.fn(),
}));
jest.mock('@src/theme/store', () => ({
getThemeStore: jest.fn(),
}));
jest.mock('@src/translations', () => ({
t: (key: string) => key,
getLocale: () => 'en',
}));
jest.mock('@src/utils', () => ({
...jest.requireActual('@src/utils/seasonality'),
...jest.requireActual('@src/utils/precision'),
formatCompactNumber: (value: number) => String(value),
formatPercent: (value: number) => `${value.toFixed(2)}%`,
canvasToBlob: jest.fn(),
drawTextLayer: jest.fn(),
}));
function getLastCall(mock: jest.Mock) {
return mock.mock.calls[mock.mock.calls.length - 1];
}
function createCandle(date: string, close = 100, milliseconds = false): Candle {
return {
time: Date.parse(`${date}T12:00:00Z`) / (milliseconds ? 1 : 1000),
open: close,
high: close,
low: close,
close,
volume: 1,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
let container: HTMLDivElement;
let manager: SeasonalityManager;
let getData: jest.Mock;
let state: ReturnType<typeof createInitialSeasonalityState>;
let frames: Map<number, FrameRequestCallback>;
let nextFrame: number;
let clipboardDescriptor: PropertyDescriptor | undefined;
let clipboardItemDescriptor: PropertyDescriptor | undefined;
const colors = {
chartBackground: '#ffffff',
chartTextPrimary: '#111111',
chartPaneSeparator: '#222222',
chartGridLine: '#333333',
chartCrosshairLine: '#444444',
chartCrosshairLabel: '#555555',
};
function createManager() {
manager = new SeasonalityManager({
container,
getData,
});
manager.getState().subscribe((value) => {
state = value;
});
}
function flushAnimationFrames() {
const callbacks = [...frames.values()];
frames.clear();
callbacks.forEach((callback) => callback(0));
}
async function loadSampleHistory() {
getData
.mockResolvedValueOnce([
createCandle('2020-01-01'),
createCandle('2023-01-01'),
createCandle('2025-01-01'),
createCandle('2025-01-02', 125),
])
.mockResolvedValueOnce(null);
await manager.show('AAA', {
fromYear: 2020,
toYear: 2025,
});
}
beforeEach(() => {
clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
clipboardItemDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'ClipboardItem');
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-06-01T12:00:00Z'));
setPricePrecision(3);
jest.spyOn(console, 'error').mockImplementation(() => {});
mocks.charts.length = 0;
mocks.markers.length = 0;
(createChart as jest.Mock).mockImplementation(
(element: HTMLElement, options: DeepPartial<ChartOptions>) => {
const chart = createChartContext(element, options);
mocks.charts.push(chart);
return chart;
},
);
(createSeriesMarkers as jest.Mock).mockImplementation(() => {
const markers = {
setMarkers: jest.fn(),
detach: jest.fn(),
};
mocks.markers.push(markers);
return markers;
});
frames = new Map();
nextFrame = 0;
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
frames.set(++nextFrame, callback);
return nextFrame;
});
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => {
frames.delete(id);
});
jest.mocked(getThemeStore).mockReturnValue({
colors,
} as ReturnType<typeof getThemeStore>);
getData = jest.fn();
container = document.createElement('div');
document.body.append(container);
state = createInitialSeasonalityState();
createManager();
});
afterEach(() => {
manager.destroy();
container.remove();
setPricePrecision();
if (clipboardDescriptor) {
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
if (clipboardItemDescriptor) {
Object.defineProperty(globalThis, 'ClipboardItem', clipboardItemDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'ClipboardItem');
}
jest.restoreAllMocks();
jest.clearAllMocks();
jest.useRealTimers();
});
describe('SeasonalityManager: загрузка истории', () => {
it('должен загружать дневные страницы, нормализовать время и удалять граничные дубликаты', async () => {
const boundary = createCandle('2025-01-02', 125);
getData
.mockResolvedValueOnce([boundary, createCandle('2025-01-03', 150, true)])
.mockResolvedValueOnce([createCandle('2025-01-01', 100), boundary])
.mockResolvedValueOnce(null);
await manager.show('AAA');
expect(state.status).toBe('ready');
expect(getData.mock.calls.map((args) => args.slice(0, 2))).toEqual(
Array(3).fill([Timeframes['1d'], 'AAA']),
);
expect(getData.mock.calls[1][2].time).toBe(boundary.time);
manager.setScale(PriceScaleMode.Normal);
const data = getLastCall(mocks.charts[0].series[0].setData)[0];
expect(data.filter((point: LineData | WhitespaceData) => 'value' in point)).toEqual([
{ time: '2000-01-01', value: 100 },
{ time: '2000-01-02', value: 125 },
{ time: '2000-01-03', value: 150 },
]);
});
it('должен завершать загрузку при повторении только граничной свечи', async () => {
const first = createCandle('2025-01-01');
getData.mockResolvedValueOnce([first]).mockResolvedValueOnce([first]);
await manager.show('AAA');
expect(state.status).toBe('ready');
expect(getData).toHaveBeenCalledTimes(2);
});
it('должен показывать ошибку при отсутствии прогресса пагинации', async () => {
getData
.mockResolvedValueOnce([createCandle('2025-01-01')])
.mockResolvedValueOnce([createCandle('2025-01-02')]);
await manager.show('AAA');
expect(state.status).toBe('error');
expect(state.error).toContain('made no progress');
});
it.each([{ page: null }, { page: [] }])(
'должен показывать пустое состояние для страницы $page',
async ({ page }) => {
getData.mockResolvedValueOnce(page);
await manager.show('AAA');
expect(state.status).toBe('empty');
expect(mocks.charts).toHaveLength(0);
expect(container.textContent).toContain('No seasonality data');
},
);
it('не должен запрашивать историю без выбранного инструмента', async () => {
await manager.show('');
expect(state.status).toBe('error');
expect(getData).not.toHaveBeenCalled();
});
it.each(['time', 'close'] as const)('должен отклонять некорректное поле %s', async (field) => {
getData.mockResolvedValueOnce([
{
...createCandle('2025-01-01'),
[field]: NaN,
},
]);
await manager.show('AAA');
expect(state.status).toBe('error');
expect(state.error).toContain('Invalid daily candle');
});
it('должен повторно загружать историю после ошибки', async () => {
getData.mockRejectedValueOnce(new Error('network'));
await manager.show('AAA');
expect(state.status).toBe('error');
await loadSampleHistory();
expect(state.status).toBe('ready');
expect(state.error).toBeNull();
});
it('не должен применять ответ после очистки сезонности', async () => {
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise);
const loading = manager.show('AAA');
expect(state.status).toBe('loading');
manager.clear();
request.resolve([createCandle('2025-01-01')]);
await loading;
expect(state.status).toBe('idle');
expect(state.tableRows).toEqual([]);
expect(mocks.charts).toHaveLength(0);
});
it('не должен применять ошибку предыдущего инструмента', async () => {
const old = createDeferred<Candle[]>();
getData.mockReturnValueOnce(old.promise);
const loading = manager.show('OLD');
getData
.mockResolvedValueOnce([createCandle('2025-01-01')])
.mockResolvedValueOnce(null);
await manager.show('NEW');
old.reject(new Error('late failure'));
await loading;
expect(state.symbolId).toBe('NEW');
expect(state.status).toBe('ready');
expect(state.tableRows.map((row) => row.year)).toEqual([2025]);
expect(console.error).not.toHaveBeenCalled();
});
it('не должен применять данные предыдущего инструмента', async () => {
const old = createDeferred<Candle[]>();
getData.mockReturnValueOnce(old.promise);
const loading = manager.show('OLD');
getData
.mockResolvedValueOnce([createCandle('2025-01-01')])
.mockResolvedValueOnce(null);
await manager.show('NEW');
old.resolve([createCandle('2020-01-01')]);
await loading;
expect(state.symbolId).toBe('NEW');
expect(state.minYear).toBe(2025);
expect(state.tableRows.map((row) => row.year)).toEqual([2025]);
expect(mocks.charts).toHaveLength(1);
});
it('не должен повторно запрашивать загружаемый инструмент', async () => {
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise);
const loading = manager.show('AAA');
await manager.show('AAA');
expect(getData).toHaveBeenCalledTimes(1);
request.resolve([]);
await loading;
});
it('должен использовать загруженную историю при изменении диапазона', async () => {
await loadSampleHistory();
const count = getData.mock.calls.length;
await manager.show('AAA', {
fromYear: 2025,
toYear: 2025,
});
flushAnimationFrames();
expect(getData).toHaveBeenCalledTimes(count);
expect(state.fromYear).toBe(2025);
expect(mocks.charts[0].removeSeries).toHaveBeenCalledTimes(2);
});
it('должен выбирать последние пять календарных лет по умолчанию', async () => {
getData
.mockResolvedValueOnce([
createCandle('2020-01-01'),
createCandle('2025-01-01'),
])
.mockResolvedValueOnce(null);
await manager.show('AAA');
expect(state.fromYear).toBe(2021);
expect(state.toYear).toBe(2025);
});
});
describe('SeasonalityManager: отображение и очистка', () => {
it('должен объединять изменения диапазона в один кадр и применять последнее значение', async () => {
await loadSampleHistory();
manager.setRange({
fromYear: 2020,
toYear: 2023,
});
manager.setRange({
fromYear: 2023,
toYear: 2023,
});
expect(frames.size).toBe(1);
flushAnimationFrames();
expect(state.status).toBe('ready');
expect(container.textContent).toContain('2023');
expect(container.textContent).not.toContain('2025');
expect(mocks.markers[0].detach).toHaveBeenCalledTimes(1);
});
it('не должен перерисовывать прежний диапазон и должен поддерживать диапазон без данных', async () => {
await loadSampleHistory();
manager.setRange({
fromYear: 2020,
toYear: 2025,
});
expect(frames.size).toBe(0);
manager.setRange({
fromYear: 2021,
toYear: 2022,
});
flushAnimationFrames();
expect(state.status).toBe('empty');
expect(container.textContent).toContain('No seasonality data');
});
it('должен переключать формат значений без пересоздания графика', async () => {
await loadSampleHistory();
manager.setScale(PriceScaleMode.Normal);
manager.setScale(PriceScaleMode.Percentage);
const series = mocks.charts[0].series[mocks.charts[0].series.length - 1];
const { priceFormat } = getLastCall(series.applyOptions)[0];
expect(priceFormat.formatter(12.5)).toBe('12.50%');
expect(priceFormat.minMove).toBe(0.01);
manager.setScale(PriceScaleMode.Normal);
expect(getLastCall(series.applyOptions)[0].priceFormat.minMove).toBe(0.001);
expect(mocks.charts).toHaveLength(1);
expect(mocks.charts[0].timeScale().setVisibleRange).toHaveBeenCalledWith({
from: '2000-01-01',
to: '2000-12-31',
});
expect(getLastCall(mocks.markers[0].setMarkers)[0][0].time).toBe('2000-01-02');
});
it.each([0, 6])('должен брать шаг цены из общей точности %s', async (precision) => {
await loadSampleHistory();
const series = mocks.charts[0].series[mocks.charts[0].series.length - 1];
const count = getData.mock.calls.length;
setPricePrecision(precision);
manager.setScale(PriceScaleMode.Percentage);
manager.setScale(PriceScaleMode.Normal);
expect(getLastCall(series.applyOptions)[0].priceFormat.minMove).toBe(10 ** -precision);
manager.setScale(PriceScaleMode.Percentage);
const { priceFormat } = getLastCall(series.applyOptions)[0];
expect(priceFormat.minMove).toBe(0.01);
expect(priceFormat.formatter(12.5)).toBe('12.50%');
expect(getData).toHaveBeenCalledTimes(count);
expect(mocks.charts).toHaveLength(1);
});
it('должен показывать предупреждение о базе только для процентной шкалы', async () => {
getData
.mockResolvedValueOnce([createCandle('2025-01-01', 0)])
.mockResolvedValueOnce(null);
await manager.show('AAA');
manager.setScale(PriceScaleMode.Percentage);
expect(container.textContent).toContain('Percentage requires a positive baseline');
expect(getLastCall(mocks.markers[0].setMarkers)[0]).toEqual([]);
manager.setScale(PriceScaleMode.Normal);
expect((container.lastElementChild as HTMLElement).style.display).toBe('none');
});
it('должен применять актуальные цвета темы при создании и обновлении графика', async () => {
await loadSampleHistory();
expect(mocks.charts[0].options.crosshair).toMatchObject({
vertLine: {
color: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
},
horzLine: {
color: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
},
});
jest.mocked(getThemeStore).mockReturnValue({
colors: {
...colors,
chartTextPrimary: '#999999',
chartCrosshairLine: '#aabbcc',
chartCrosshairLabel: '#ddeeff',
},
} as ReturnType<typeof getThemeStore>);
manager.updateTheme();
expect(getLastCall(mocks.charts[0].applyOptions)[0]).toMatchObject({
layout: {
textColor: '#999999',
},
crosshair: {
vertLine: {
color: '#aabbcc',
labelBackgroundColor: '#ddeeff',
},
horzLine: {
color: '#aabbcc',
labelBackgroundColor: '#ddeeff',
},
},
});
});
it('должен форматировать все варианты времени как день и месяц', async () => {
await loadSampleHistory();
const formatter = mocks.charts[0].options.localization?.timeFormatter;
if (typeof formatter !== 'function') {
throw new Error('Time formatter is not configured');
}
expect(
formatter({
year: 2000,
month: 2,
day: 9,
}),
).toBe('09.02');
expect(formatter('2000-02-09')).toBe('09.02');
expect(formatter(Date.UTC(2000, 1, 9) / 1000)).toBe('09.02');
});
it('должен обновлять легенду по перекрестию и обозначать отсутствующие значения', async () => {
await loadSampleHistory();
const chart = mocks.charts[0];
const listener = chart.subscribeCrosshairMove.mock.calls[0][0];
listener({
time: '2000-01-01',
seriesData: new Map([[chart.series[0], { value: 42 }]]),
});
expect(container.textContent).toContain('42.00');
expect(container.textContent).toContain('—');
listener({
time: undefined,
seriesData: new Map(),
});
expect(container.textContent).not.toContain('—');
});
it('должен копировать готовый график вместе с легендой', async () => {
const blob = new Blob(['image'], {
type: 'image/png',
});
const write = jest.fn().mockResolvedValue(undefined);
jest.mocked(canvasToBlob).mockResolvedValueOnce(blob);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { write },
});
Object.defineProperty(globalThis, 'ClipboardItem', {
configurable: true,
value: class {
constructor(public data: Record<string, Blob>) {}
},
});
await manager.copyImage();
expect(write).not.toHaveBeenCalled();
await loadSampleHistory();
await manager.copyImage();
expect(mocks.charts[0].takeScreenshot).toHaveBeenCalledWith(true, false);
expect(drawTextLayer).toHaveBeenCalledTimes(1);
expect(canvasToBlob).toHaveBeenCalledTimes(1);
expect(write).toHaveBeenCalledTimes(1);
const items = write.mock.calls[0][0];
expect(items[0].data['image/png']).toBe(blob);
});
it('должен отменять кадры и освобождать ресурсы при очистке сезонности', async () => {
await loadSampleHistory();
manager.setRange({
fromYear: 2023,
toYear: 2025,
});
const chart = mocks.charts[0];
manager.clear();
manager.clear();
expect(frames.size).toBe(0);
expect(state.status).toBe('idle');
flushAnimationFrames();
expect(mocks.charts).toHaveLength(1);
expect(chart.remove).toHaveBeenCalledTimes(1);
expect(chart.unsubscribeCrosshairMove).toHaveBeenCalledWith(
chart.subscribeCrosshairMove.mock.calls[0][0],
);
expect(mocks.markers[0].detach).toHaveBeenCalledTimes(1);
});
it('должен завершать состояние и игнорировать ответ после уничтожения', async () => {
const complete = jest.fn();
manager.getState().subscribe({ complete });
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise);
const loading = manager.show('AAA');
manager.destroy();
manager.destroy();
manager.clear();
request.resolve([createCandle('2025-01-01')]);
await loading;
await manager.show('BBB');
manager.setRange({
fromYear: 2020,
toYear: 2025,
});
manager.setScale(PriceScaleMode.Normal);
manager.setScale(PriceScaleMode.Percentage);
manager.setDisplayMode(SeasonalityViewType.Table);
expect(complete).toHaveBeenCalledTimes(1);
expect(state.displayMode).toBe(SeasonalityViewType.Chart);
expect(state.scale).toBe(PriceScaleMode.Normal);
expect(state.tableRows).toEqual([]);
expect(container.children).toHaveLength(0);
expect(mocks.charts).toHaveLength(0);
expect(getData).toHaveBeenCalledTimes(1);
});
it('не должен изменять диапазон без загруженной истории', () => {
const initial = state;
manager.setRange({
fromYear: 2020,
toYear: 2025,
});
manager.setScale(PriceScaleMode.Normal);
expect(state).toBe(initial);
expect(frames.size).toBe(0);
});
it('должен применять шкалу, выбранную во время загрузки', async () => {
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise).mockResolvedValueOnce(null);
const loading = manager.show('AAA');
manager.setScale(PriceScaleMode.Percentage);
request.resolve([
createCandle('2025-01-01'),
createCandle('2025-01-02', 125),
]);
await loading;
const data = getLastCall(mocks.charts[0].series[0].setData)[0];
expect(state.scale).toBe(PriceScaleMode.Percentage);
expect(data.filter((point: LineData | WhitespaceData) => 'value' in point)).toEqual([
{ time: '2000-01-01', value: 0 },
{ time: '2000-01-02', value: 25 },
]);
});
it('должен восстанавливать отображение после выбора пустого диапазона', async () => {
await loadSampleHistory();
const count = getData.mock.calls.length;
manager.setRange({
fromYear: 2021,
toYear: 2022,
});
flushAnimationFrames();
expect(state.status).toBe('empty');
manager.setRange({
fromYear: 2023,
toYear: 2025,
});
flushAnimationFrames();
expect(state.status).toBe('ready');
expect(getData).toHaveBeenCalledTimes(count);
expect(mocks.charts).toHaveLength(1);
expect((container.lastElementChild as HTMLElement).style.display).toBe('none');
});
it('должен сбрасывать статус и повторно загружать инструмент после очистки', async () => {
await loadSampleHistory();
manager.setScale(PriceScaleMode.Percentage);
manager.setDisplayMode(SeasonalityViewType.Table);
manager.clear();
expect(state.status).toBe('idle');
expect(state.error).toBeNull();
expect(state.scale).toBe(PriceScaleMode.Percentage);
expect(state.displayMode).toBe(SeasonalityViewType.Table);
expect(state.tableRows).toEqual([]);
const count = getData.mock.calls.length;
await loadSampleHistory();
expect(getData).toHaveBeenCalledTimes(count + 2);
expect(state.status).toBe('ready');
expect(state.tableRows.map((row) => row.year)).toEqual([2025, 2023, 2020]);
expect(mocks.charts).toHaveLength(2);
});
});
describe('SeasonalityManager: табличный режим', () => {
it('должен сохранять строки и состояние при повторном открытии загруженного инструмента', async () => {
await loadSampleHistory();
manager.setDisplayMode(SeasonalityViewType.Table);
const previous = state;
const count = getData.mock.calls.length;
await manager.show('AAA');
expect(state).toBe(previous);
expect(state.tableRows).toBe(previous.tableRows);
expect(getData).toHaveBeenCalledTimes(count);
expect(mocks.charts).toHaveLength(1);
});
it('не должен повторно отправлять состояние при выборе текущего вида', () => {
const next = jest.fn();
const subscription = manager.getState().subscribe(next);
next.mockClear();
manager.setDisplayMode(SeasonalityViewType.Chart);
expect(next).not.toHaveBeenCalled();
manager.setDisplayMode(SeasonalityViewType.Table);
const previous = state;
manager.setDisplayMode(SeasonalityViewType.Table);
expect(next).toHaveBeenCalledTimes(1);
expect(state).toBe(previous);
expect(state.displayMode).toBe(SeasonalityViewType.Table);
subscription.unsubscribe();
});
it('должен показывать изменение первого неполного месяца и годовой итог', async () => {
getData
.mockResolvedValueOnce([
createCandle('2021-10-15', 100),
createCandle('2021-10-29', 101),
createCandle('2021-11-30', 105),
createCandle('2021-12-30', 106),
])
.mockResolvedValueOnce(null);
await manager.show('AAA');
const [row] = state.tableRows;
expect(row.months.slice(0, 9)).toEqual(Array(9).fill(null));
expect(row.months[9]?.absolute).toBe(1);
expect(row.months[9]?.percentage).toBeCloseTo(1, 10);
expect(row.total?.absolute).toBe(6);
expect(row.total?.percentage).toBeCloseTo(6, 10);
});
it('должен использовать декабрь с предыдущей страницы за пределами выбранного диапазона', async () => {
getData
.mockResolvedValueOnce([
createCandle('2024-01-02', 105),
createCandle('2024-01-31', 110),
createCandle('2024-02-29', 121),
])
.mockResolvedValueOnce([createCandle('2023-12-29', 100)])
.mockResolvedValueOnce(null);
await manager.show('AAA', {
fromYear: 2024,
toYear: 2024,
});
expect(state.tableRows).toHaveLength(1);
const [row] = state.tableRows;
expect(row.year).toBe(2024);
expect(row.months[0]?.absolute).toBe(10);
expect(row.months[0]?.percentage).toBeCloseTo(10, 10);
expect(row.total?.absolute).toBe(21);
expect(row.total?.percentage).toBeCloseTo(21, 10);
expect(getData).toHaveBeenCalledTimes(3);
});
it('должен рассчитывать месячные и годовые изменения по загруженной истории', async () => {
getData
.mockResolvedValueOnce([
createCandle('2023-12-29', 100),
createCandle('2024-01-02', 104),
createCandle('2024-01-31', 110),
createCandle('2024-02-29', 121),
createCandle('2024-12-31', 120),
createCandle('2025-01-31', 132),
])
.mockResolvedValueOnce(null);
await manager.show('AAA', {
fromYear: 2024,
toYear: 2025,
});
expect(state.tableRows.map((row) => row.year)).toEqual([2025, 2024]);
const row = state.tableRows[1];
expect(row.months).toHaveLength(12);
expect(row.months[0]?.absolute).toBe(10);
expect(row.months[0]?.percentage).toBeCloseTo(10);
expect(row.months[1]?.absolute).toBe(11);
expect(row.months[1]?.percentage).toBeCloseTo(10);
expect(row.total?.absolute).toBe(20);
expect(row.total?.percentage).toBeCloseTo(20);
const count = getData.mock.calls.length;
manager.setDisplayMode(SeasonalityViewType.Table);
manager.setRange({
fromYear: 2025,
toYear: 2025,
});
flushAnimationFrames();
expect(state.tableRows).toHaveLength(1);
expect(state.tableRows[0].year).toBe(2025);
expect(state.tableRows[0].months[0]?.absolute).toBe(12);
expect(state.tableRows[0].months[0]?.percentage).toBeCloseTo(10);
expect(state.tableRows[0].total?.absolute).toBe(12);
expect(state.tableRows[0].total?.percentage).toBeCloseTo(10);
expect(getData).toHaveBeenCalledTimes(count);
});
it('должен отличать отсутствующую базу, пропущенный месяц и нулевое изменение', async () => {
getData
.mockResolvedValueOnce([
createCandle('2024-01-31', 0),
createCandle('2024-02-29', 10),
createCandle('2024-04-30', 15),
createCandle('2024-05-31', 15),
])
.mockResolvedValueOnce(null);
await manager.show('AAA');
const row = state.tableRows[0];
expect(row.months[0]).toEqual({
absolute: 0,
percentage: null,
});
expect(row.months[1]).toEqual({
absolute: 10,
percentage: null,
});
expect(row.months[2]).toBeNull();
expect(row.months[3]).toBeNull();
expect(row.months[4]).toEqual({
absolute: 0,
percentage: 0,
});
expect(row.months.slice(5)).toEqual(Array(7).fill(null));
expect(row.total).toEqual({
absolute: 15,
percentage: null,
});
});
it('должен переключать вид без повторной загрузки и пересоздания графика', async () => {
await loadSampleHistory();
const count = getData.mock.calls.length;
const chart = mocks.charts[0];
const rows = state.tableRows;
manager.setDisplayMode(SeasonalityViewType.Table);
expect(state.displayMode).toBe(SeasonalityViewType.Table);
expect(state.status).toBe('ready');
expect(state.tableRows).toBe(rows);
manager.setScale(PriceScaleMode.Percentage);
manager.setDisplayMode(SeasonalityViewType.Chart);
expect(state.displayMode).toBe(SeasonalityViewType.Chart);
expect(state.scale).toBe(PriceScaleMode.Percentage);
expect(state.fromYear).toBe(2020);
expect(state.toYear).toBe(2025);
expect(state.tableRows).toBe(rows);
expect(getData).toHaveBeenCalledTimes(count);
expect(mocks.charts).toHaveLength(1);
expect(chart.remove).not.toHaveBeenCalled();
});
it('должен сохранять вид и шкалу, выбранные во время загрузки', async () => {
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise).mockResolvedValueOnce(null);
const loading = manager.show('AAA');
manager.setDisplayMode(SeasonalityViewType.Table);
manager.setScale(PriceScaleMode.Percentage);
expect(state.status).toBe('loading');
expect(state.tableRows).toEqual([]);
request.resolve([
createCandle('2024-12-31', 100),
createCandle('2025-01-31', 125),
]);
await loading;
expect(state.status).toBe('ready');
expect(state.displayMode).toBe(SeasonalityViewType.Table);
expect(state.scale).toBe(PriceScaleMode.Percentage);
expect(state.tableRows[0].months[0]).toEqual({
absolute: 25,
percentage: 25,
});
expect(state.tableRows[0].total).toEqual({
absolute: 25,
percentage: 25,
});
});
it('не должен копировать снимок графика в табличном режиме', async () => {
await loadSampleHistory();
manager.setDisplayMode(SeasonalityViewType.Table);
await manager.copyImage();
expect(mocks.charts[0].takeScreenshot).not.toHaveBeenCalled();
expect(drawTextLayer).not.toHaveBeenCalled();
expect(canvasToBlob).not.toHaveBeenCalled();
});
it('должен очищать строки при загрузке нового инструмента и ошибке запроса', async () => {
await loadSampleHistory();
manager.setDisplayMode(SeasonalityViewType.Table);
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise);
const loading = manager.show('NEW');
expect(state.symbolId).toBe('NEW');
expect(state.status).toBe('loading');
expect(state.tableRows).toEqual([]);
request.reject(new Error('network'));
await loading;
expect(state.status).toBe('error');
expect(state.displayMode).toBe(SeasonalityViewType.Table);
expect(state.tableRows).toEqual([]);
});
it('должен восстанавливать строки после выбора пустого диапазона', async () => {
await loadSampleHistory();
const count = getData.mock.calls.length;
manager.setDisplayMode(SeasonalityViewType.Table);
manager.setRange({
fromYear: 2021,
toYear: 2022,
});
flushAnimationFrames();
expect(state.status).toBe('empty');
expect(state.tableRows).toEqual([]);
manager.setRange({
fromYear: 2023,
toYear: 2025,
});
flushAnimationFrames();
expect(state.status).toBe('ready');
expect(state.displayMode).toBe(SeasonalityViewType.Table);
expect(state.tableRows.map((row) => row.year)).toEqual([2025, 2023]);
expect(getData).toHaveBeenCalledTimes(count);
});
});
describe('SeasonalityManager: среднее', () => {
it('должен добавлять среднюю серию по запросу и удалять её без загрузки истории', async () => {
await loadSampleHistory();
const chart = mocks.charts[0];
expect(chart.series).toHaveLength(3);
expect(state.showAverage).toBe(false);
manager.setAverageVisible(true);
const average = chart.series[3];
expect(state.showAverage).toBe(true);
expect(average.options).toEqual(
expect.objectContaining({
title: 'Avg',
color: colors.chartTextPrimary,
lineStyle: LineStyle.Dashed,
lineWidth: 2,
priceLineVisible: false,
}),
);
expect(getLastCall(average.setData)[0][0]).toEqual({
time: '2000-01-01',
value: 100,
});
expect(getLastCall(average.setData)[0][1]).toEqual({
time: '2000-01-02',
value: 125,
});
expect(container.textContent).toContain('Avg');
manager.setAverageVisible(true);
expect(chart.series).toHaveLength(4);
expect(average.setData).toHaveBeenCalledTimes(1);
manager.setAverageVisible(false);
expect(state.showAverage).toBe(false);
expect(chart.removeSeries).toHaveBeenCalledWith(average);
expect(container.textContent).not.toContain('Avg');
expect(getData).toHaveBeenCalledTimes(2);
expect(createChart).toHaveBeenCalledTimes(1);
});
it('должен пересчитывать среднее при изменении шкалы и диапазона', async () => {
await loadSampleHistory();
manager.setAverageVisible(true);
const chart = mocks.charts[0];
const average = chart.series[3];
manager.setScale(PriceScaleMode.Percentage);
expect(getLastCall(average.setData)[0][0]).toEqual({
time: '2000-01-01',
value: 0,
});
expect(getLastCall(average.setData)[0][1]).toEqual({
time: '2000-01-02',
value: 25,
});
expect(getLastCall(average.applyOptions)[0].priceFormat.minMove).toBe(0.01);
expect(getLastCall(average.applyOptions)[0].priceFormat.formatter(25)).toBe('25.00%');
manager.setRange({ fromYear: 2020, toYear: 2023 });
flushAnimationFrames();
expect(getLastCall(average.setData)[0][1]).toEqual({
time: '2000-01-02',
});
manager.setScale(PriceScaleMode.Normal);
expect(getLastCall(average.setData)[0][0]).toEqual({
time: '2000-01-01',
value: 100,
});
expect(getLastCall(average.applyOptions)[0].priceFormat.minMove).toBe(0.001);
expect(chart.series.filter((series) => series.options.title === 'Avg')).toHaveLength(1);
expect(getData).toHaveBeenCalledTimes(2);
});
it('должен обновлять среднее в легенде по курсору и показывать пропуски', async () => {
await loadSampleHistory();
manager.setAverageVisible(true);
const chart = mocks.charts[0];
const average = chart.series[3];
const onCrosshairMove = getLastCall(chart.subscribeCrosshairMove)[0];
const label = [...container.querySelectorAll('span')].find(
(element) => element.textContent === 'Avg',
)!;
const value = label.nextElementSibling!;
expect(value.textContent).toBe('125.00');
onCrosshairMove({
time: '2000-01-01',
seriesData: new Map([[average, { value: 0 }]]),
});
expect(value.textContent).toBe('0.00');
onCrosshairMove({
time: '2000-01-02',
seriesData: new Map(),
});
expect(value.textContent).toBe('—');
onCrosshairMove({});
expect(value.textContent).toBe('125.00');
});
it('должен сохранять среднее между видами и при повторной загрузке после очистки', async () => {
await loadSampleHistory();
manager.setAverageVisible(true);
const chart = mocks.charts[0];
manager.setDisplayMode(SeasonalityViewType.Table);
manager.setDisplayMode(SeasonalityViewType.Chart);
expect(state.showAverage).toBe(true);
expect(chart.series).toHaveLength(4);
expect(getData).toHaveBeenCalledTimes(2);
manager.clear();
expect(state.showAverage).toBe(true);
expect(chart.remove).toHaveBeenCalledTimes(1);
expect(container.textContent).not.toContain('Avg');
await loadSampleHistory();
expect(
mocks.charts[1].series.some((series) => series.options.title === 'Avg'),
).toBe(true);
expect(container.textContent).toContain('Avg');
});
it('должен удалять среднее для пустого диапазона и восстанавливать при выборе данных', async () => {
await loadSampleHistory();
manager.setAverageVisible(true);
const chart = mocks.charts[0];
const average = chart.series[3];
manager.setRange({ fromYear: 2021, toYear: 2022 });
flushAnimationFrames();
expect(state.status).toBe('empty');
expect(state.showAverage).toBe(true);
expect(chart.removeSeries).toHaveBeenCalledWith(average);
expect(container.textContent).not.toContain('Avg');
manager.setRange({ fromYear: 2025, toYear: 2025 });
flushAnimationFrames();
expect(state.status).toBe('ready');
expect(chart.series[chart.series.length - 1].options.title).toBe('Avg');
expect(container.textContent).toContain('Avg');
expect(getData).toHaveBeenCalledTimes(2);
});
it('должен применять выбранное среднее после загрузки истории', async () => {
manager.setAverageVisible(true);
expect(createChart).not.toHaveBeenCalled();
await loadSampleHistory();
expect(state.showAverage).toBe(true);
expect(mocks.charts[0].series[3].options.title).toBe('Avg');
});
it('должен обновлять цвет средней линии и её значения из темы', async () => {
await loadSampleHistory();
manager.setAverageVisible(true);
const average = mocks.charts[0].series[3];
jest.mocked(getThemeStore).mockReturnValue({
colors: {
...colors,
chartTextPrimary: '#abcdef',
},
} as ReturnType<typeof getThemeStore>);
manager.updateTheme();
expect(average.applyOptions).toHaveBeenLastCalledWith({
color: '#abcdef',
});
const label = [...container.querySelectorAll('span')].find(
(element) => element.textContent === 'Avg',
)!;
expect((label.nextElementSibling as HTMLElement).style.color).toBe(
'rgb(171, 205, 239)',
);
});
it('не должен создавать среднюю серию после уничтожения менеджера', async () => {
await loadSampleHistory();
const chart = mocks.charts[0];
manager.destroy();
manager.setAverageVisible(true);
expect(chart.series).toHaveLength(3);
expect(chart.remove).toHaveBeenCalledTimes(1);
});
});