Загрузка данных
import { type LineData, PriceScaleMode, type WhitespaceData } from 'lightweight-charts';
import type {
DailyClose,
SeasonalityChange,
SeasonalityRange,
SeasonalityScale,
SeasonalityTableRow,
SeasonalityYear,
} from '@src/types';
type SeriesDatum = LineData | WhitespaceData;
const CALENDAR = Array.from({ length: 366 }, (_, day) =>
new Date(Date.UTC(2000, 0, day + 1)).toISOString().slice(0, 10),
);
export function normalizeRange(range: SeasonalityRange, minYear: number, maxYear: number): SeasonalityRange {
if (!Number.isFinite(range.fromYear) || !Number.isFinite(range.toYear)) {
throw new Error('Invalid year range');
}
const fromYear = Math.max(minYear, Math.min(maxYear, Math.round(range.fromYear)));
const toYear = Math.max(fromYear, Math.min(maxYear, Math.round(range.toYear)));
return { fromYear, toYear };
}
export function buildSeasonality(rows: readonly DailyClose[]): SeasonalityYear[] {
const years: SeasonalityYear[] = [];
const sorted = [...rows].sort((a, b) => a.date.localeCompare(b.date));
for (const row of sorted) {
const year = Number(row.date.slice(0, 4));
let current = years[years.length - 1];
if (!current || current.year !== year) {
current = {
year,
baselineDate: row.date,
baselinePrice: row.close,
points: [],
};
years.push(current);
}
if (current.points[current.points.length - 1]?.date === row.date) {
throw new Error(`Multiple daily candles on ${row.date}`);
}
const percentage = current.baselinePrice > 0 ? (row.close / current.baselinePrice - 1) * 100 : null;
if (percentage !== null && !Number.isFinite(percentage)) {
throw new Error('Invalid percentage');
}
current.points.push({
date: row.date,
chartDate: `2000-${row.date.slice(5)}`,
price: row.close,
percentage,
});
}
return years;
}
export function toSeriesData(year: SeasonalityYear, scale: SeasonalityScale): SeriesDatum[] {
const isPercentage = scale === PriceScaleMode.Percentage;
let index = 0;
return CALENDAR.map((time) => {
const point = year.points[index];
if (point?.chartDate !== time) {
return { time };
}
index += 1;
const value = isPercentage ? point.percentage : point.price;
return value === null ? { time } : { time, value };
});
}
export function buildSeasonalityTable(years: readonly SeasonalityYear[]): SeasonalityTableRow[] {
const closes = new Map<number, number>();
for (const year of years) {
for (const point of year.points) {
const month = Number(point.date.slice(5, 7)) - 1;
closes.set(year.year * 12 + month, point.price);
}
}
return years.map((year) => {
const firstMonth = year.year * 12;
const baselineMonth = Number(year.baselineDate.slice(5, 7)) - 1;
const lastPoint = year.points[year.points.length - 1];
const baselinePrice = closes.get(firstMonth - 1) ?? year.baselinePrice;
return {
year: year.year,
months: Array.from({ length: 12 }, (_, month) => {
const key = firstMonth + month;
const fromPrice = closes.get(key - 1) ?? (month === baselineMonth ? year.baselinePrice : undefined);
return calculateChange(fromPrice, closes.get(key));
}),
total: calculateChange(baselinePrice, lastPoint.price),
};
}).reverse();
}
function calculateChange(from: number | undefined, to: number | undefined): SeasonalityChange | null {
if (from === undefined || to === undefined) {
return null;
}
return {
absolute: to - from,
percentage: from > 0 ? (to / from - 1) * 100 : null,
};
}
import { createChart, createSeriesMarkers, 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();
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 () => {
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);
});
});