Загрузка данных
import { PriceScaleMode } from 'lightweight-charts';
import { Button } from '@src/components/Button';
import { Dropdown } from '@src/components/Dropdown';
import { RangeField } from '@src/components/FormFields';
import { LineIcon, TableIcon } from '@src/components/Icon';
import { SeasonalityScaleMenu } from '@src/components/Menu/SeasonalityScaleMenu';
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;
}
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 }: 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 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}>
<Button
label={<LineIcon />}
pressed={state.displayMode === SeasonalityViewType.Chart}
onClick={() => setDisplayMode(SeasonalityViewType.Chart)}
/>
<Button
label={<TableIcon />}
pressed={state.displayMode === SeasonalityViewType.Table}
onClick={() => setDisplayMode(SeasonalityViewType.Table)}
/>
</div>
<div className={styles.controls_scale}>
<Dropdown selectedValue={selectedScaleLabel}>
<SeasonalityScaleMenu
selectedScale={state.scale}
onClick={setScale}
/>
</Dropdown>
</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 { act, fireEvent, render, screen } 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/Dropdown', () => ({
Dropdown: ({ children, selectedValue }: { children: ReactNode; selectedValue: ReactNode }) => (
<div>
<div data-testid="selected-scale">{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();
return { stateObs, setDisplayMode, setScale, setRange };
}
describe('SeasonalityControls', () => {
it('не должен отображаться до получения состояния и при idle', () => {
const stateObs = new Subject<SeasonalityState>();
const { container } = render(
<SeasonalityControls
stateObs={stateObs}
setDisplayMode={jest.fn()}
setScale={jest.fn()}
setRange={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 = screen.getByRole('button', { name: 'Chart' });
const tableButton = screen.getByRole('button', { name: 'Table' });
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.getByRole('button', { name: 'Table' }));
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');
});
});