Загрузка данных
import {
LineSeries,
type BarData,
type CustomData,
type HistogramData,
type IChartApi,
type ISeriesApi,
type LineData,
type MouseEventParams,
type SeriesDataItemTypeMap,
type SeriesDefinition,
type SeriesPartialOptionsMap,
type Time,
} from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';
import { BaseSeries, calcCandleChange, type BaseSeriesParams } from '../BaseSeries';
import type { DataSource } from '@core/DataSource';
import type { Ohlc } from '@core/Legend';
import type { Candle } from '@src/types';
jest.mock('@src/constants', () => ({
MAIN_PANE_INDEX: 0,
}));
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => `price:${value}`,
getPricePrecisionStep: () => 0.01,
normalizeSeriesData: jest.fn((data) => data),
isBarData: (data: unknown) => {
if (!data || typeof data !== 'object') {
return false;
}
return (
'open' in data &&
'high' in data &&
'low' in data &&
'close' in data
);
},
}));
type LegendData = BarData | LineData | HistogramData | CustomData | null;
class TestSeries extends BaseSeries<'Line'> {
public dataSourceSubscription = jest.fn((_next: Candle[]) => undefined);
public dataSourceRealtimeSubscription = jest.fn((_next: Candle) => undefined);
public legendFormatter = jest.fn(
(_current: LegendData, _previous: LegendData) => ({}),
);
public returnEmptyFormattedData = false;
public getDefaultOptions(): SeriesPartialOptionsMap['Line'] {
return {
color: '#111111',
};
}
public seriesDefinition(): SeriesDefinition<'Line'> {
return LineSeries;
}
public formatMainSerie(
inputData: Candle[],
): SeriesDataItemTypeMap<Time>['Line'][] {
if (this.returnEmptyFormattedData) {
return [];
}
return inputData.map((point) => ({
time: point.time as Time,
value: point.close,
customValues: point as unknown as Record<string, unknown>,
}));
}
public formatLegendValues(
currentBar: LegendData,
prevBar: LegendData,
): Partial<
Record<
keyof Ohlc,
{
value: number | string | Time;
color: string;
name: string;
}
>
> {
return this.legendFormatter(currentBar, prevBar);
}
public updateRealtimeForTest(
bar: SeriesDataItemTypeMap<Time>['Line'],
): void {
this.updateRealtime(bar);
}
public formatDataForTest(
data: Candle[],
): SeriesDataItemTypeMap<Time>['Line'][] {
return this.formatData(data);
}
public subscribeDataSourceForTest(dataSource: DataSource): void {
this.subscribeDataSource(dataSource);
}
}
const candle = (
time = 100,
close = 20,
): Candle => ({
time,
open: close - 2,
high: close + 2,
low: close - 3,
close,
volume: 10,
});
function createEnvironment() {
let historyCallback: ((data: Candle[]) => void) | undefined;
let realtimeCallback: ((data: Candle) => void) | undefined;
const dataSubscriptions: Array<{ unsubscribe: jest.Mock }> = [];
const realtimeSubscriptions: Array<{ unsubscribe: jest.Mock }> = [];
const priceLine = {
applyOptions: jest.fn(),
};
const pane = {
getHeight: jest.fn(() => 400),
};
const priceScale = {
applyOptions: jest.fn(),
};
const priceFormatter = {
format: jest.fn(),
};
const series = {
applyOptions: jest.fn(),
attachPrimitive: jest.fn(),
barsInLogicalRange: jest.fn(() => ({
barsBefore: 1,
barsAfter: 2,
})),
coordinateToPrice: jest.fn(() => 101),
createPriceLine: jest.fn(() => priceLine),
data: jest.fn(() => []),
pop: jest.fn(() => []),
dataByIndex: jest.fn(() => null),
detachPrimitive: jest.fn(),
getPane: jest.fn(() => pane),
moveToPane: jest.fn(),
options: jest.fn(() => ({
visible: true,
color: '#2962ff',
})),
priceFormatter: jest.fn(() => priceFormatter),
priceLines: jest.fn(() => [priceLine]),
priceScale: jest.fn(() => priceScale),
priceToCoordinate: jest.fn(() => 50),
removePriceLine: jest.fn(),
seriesOrder: jest.fn(() => 3),
seriesType: jest.fn(() => 'Line'),
lastValueData: jest.fn(() => null),
setData: jest.fn(),
setSeriesOrder: jest.fn(),
subscribeDataChanged: jest.fn(),
unsubscribeDataChanged: jest.fn(),
update: jest.fn(),
};
const chart = {
addSeries: jest.fn(() => series),
removeSeries: jest.fn(),
};
const dataSource = {
subscribe: jest.fn((_symbolId: string, callback: (data: Candle[]) => void) => {
historyCallback = callback;
const subscription = {
unsubscribe: jest.fn(),
};
dataSubscriptions.push(subscription);
return subscription as unknown as Subscription;
}),
subscribeRealtime: jest.fn(
(_symbolId: string, callback: (data: Candle) => void) => {
realtimeCallback = callback;
const subscription = {
unsubscribe: jest.fn(),
};
realtimeSubscriptions.push(subscription);
return subscription as unknown as Subscription;
},
),
};
return {
chart: chart as unknown as IChartApi,
chartMock: chart,
series: series as unknown as ISeriesApi<'Line'>,
seriesMock: series,
priceScale,
priceLine,
pane,
priceFormatter,
dataSource: dataSource as unknown as DataSource,
dataSourceMock: dataSource,
dataSubscriptions,
realtimeSubscriptions,
getHistoryCallback: () => historyCallback,
getRealtimeCallback: () => realtimeCallback,
};
}
function createParams(
environment: ReturnType<typeof createEnvironment>,
overrides: Partial<BaseSeriesParams<'Line'>> = {},
): BaseSeriesParams<'Line'> {
return {
lwcChart: environment.chart,
dataSource: environment.dataSource,
mainSymbolId$: new BehaviorSubject('MOEX:SBER'),
mainSymbol$: new BehaviorSubject('SBER'),
mainSerie$: new BehaviorSubject(null) as BaseSeriesParams<'Line'>['mainSerie$'],
...overrides,
};
}
describe('BaseSeries', () => {
it('creates series with merged options and applies price scale options', () => {
const environment = createEnvironment();
new TestSeries(
createParams(environment, {
paneIndex: 3,
seriesOptions: {
color: '#ff0000',
lineWidth: 3,
},
priceScaleOptions: {
autoScale: false,
},
}),
);
expect(environment.chartMock.addSeries).toHaveBeenCalledWith(
LineSeries,
{
color: '#ff0000',
lineWidth: 3,
},
3,
);
expect(environment.priceScale.applyOptions).toHaveBeenCalledWith({
autoScale: false,
});
});
it('proxies public ISeriesApi methods to lightweight-charts series', () => {
const environment = createEnvironment();
const series = new TestSeries(createParams(environment));
const primitive = {} as never;
const range = {
from: 1,
to: 10,
};
const priceLineOptions = {
price: 100,
color: '#ffffff',
lineWidth: 1 as const,
lineStyle: 0,
axisLabelVisible: true,
title: '',
};
series.show();
expect(environment.seriesMock.applyOptions).toHaveBeenCalledWith({
visible: true,
});
series.hide();
expect(environment.seriesMock.applyOptions).toHaveBeenCalledWith({
visible: false,
});
expect(series.isVisible()).toBe(true);
expect(series.getLwcSeries()).toBe(environment.series);
series.applyOptions({
lineWidth: 2,
});
expect(environment.seriesMock.applyOptions).toHaveBeenCalledWith({
lineWidth: 2,
});
series.attachPrimitive(primitive);
expect(environment.seriesMock.attachPrimitive).toHaveBeenCalledWith(primitive);
expect(series.barsInLogicalRange(range)).toEqual({
barsBefore: 1,
barsAfter: 2,
});
expect(environment.seriesMock.barsInLogicalRange).toHaveBeenCalledWith(range);
expect(series.coordinateToPrice(50)).toBe(101);
expect(environment.seriesMock.coordinateToPrice).toHaveBeenCalledWith(50);
expect(series.createPriceLine(priceLineOptions)).toBe(environment.priceLine);
expect(series.data()).toEqual([]);
expect(series.pop(2)).toEqual([]);
series.dataByIndex(5);
expect(environment.seriesMock.dataByIndex).toHaveBeenCalledWith(5, undefined);
series.detachPrimitive(primitive);
expect(environment.seriesMock.detachPrimitive).toHaveBeenCalledWith(primitive);
expect(series.getPane()).toBe(environment.pane);
series.moveToPane(2);
expect(environment.seriesMock.moveToPane).toHaveBeenCalledWith(2);
expect(series.options()).toEqual({
visible: true,
color: '#2962ff',
});
expect(series.priceFormatter()).toBe(environment.priceFormatter);
expect(series.priceLines()).toEqual([environment.priceLine]);
expect(series.priceScale()).toBe(environment.priceScale);
expect(series.priceToCoordinate(100)).toBe(50);
expect(environment.seriesMock.priceToCoordinate).toHaveBeenCalledWith(100);
series.removePriceLine(environment.priceLine as never);
expect(environment.seriesMock.removePriceLine).toHaveBeenCalledWith(
environment.priceLine,
);
expect(series.seriesOrder()).toBe(3);
expect(series.seriesType()).toBe('Line');
series.lastValueData(true);
expect(environment.seriesMock.lastValueData).toHaveBeenCalledWith(true);
series.setSeriesOrder(4);
expect(environment.seriesMock.setSeriesOrder).toHaveBeenCalledWith(4);
const changedHandler = jest.fn();
series.subscribeDataChanged(changedHandler);
expect(environment.seriesMock.subscribeDataChanged).toHaveBeenCalledWith(
changedHandler,
);
series.unsubscribeDataChanged(changedHandler);
expect(environment.seriesMock.unsubscribeDataChanged).toHaveBeenCalledWith(
changedHandler,
);
});
it('sets and updates series data', () => {
const environment = createEnvironment();
const series = new TestSeries(createParams(environment));
const data = [
{
time: 100 as Time,
value: 10,
},
];
series.setData(data);
expect(environment.seriesMock.setData).toHaveBeenCalledWith(data);
series.update(data[0]);
expect(environment.seriesMock.update).toHaveBeenCalledWith(
data[0],
false,
);
series.update(data[0], true);
expect(environment.seriesMock.update).toHaveBeenCalledWith(
data[0],
true,
);
});
it('updates realtime data as historical update', () => {
const environment = createEnvironment();
const series = new TestSeries(createParams(environment));
const bar = {
time: 100 as Time,
value: 10,
};
series.updateRealtimeForTest(bar);
expect(environment.seriesMock.update).toHaveBeenCalledWith(
bar,
true,
);
});
it('returns empty legend when series has no data', () => {
const environment = createEnvironment();
environment.seriesMock.data.mockReturnValue([]);
const series = new TestSeries(createParams(environment));
expect(series.getLegendData()).toEqual({});
expect(series.legendFormatter).not.toHaveBeenCalled();
});
it('formats legend from last and previous bars', () => {
const environment = createEnvironment();
const previous = {
time: 1 as Time,
value: 10,
};
const current = {
time: 2 as Time,
value: 20,
};
environment.seriesMock.data.mockReturnValue([
previous,
current,
]);
const series = new TestSeries(createParams(environment));
const expected = {
value: {
value: 20,
color: '#fff',
name: '',
},
};
series.legendFormatter.mockReturnValue(expected);
expect(series.getLegendData()).toEqual(expected);
expect(series.legendFormatter).toHaveBeenCalledWith(
current,
previous,
);
});
it('formats legend for mouse event and previous logical bar', () => {
const environment = createEnvironment();
const series = new TestSeries(createParams(environment));
const current = {
time: 20 as Time,
value: 20,
};
const previous = {
time: 10 as Time,
value: 10,
};
environment.seriesMock.dataByIndex.mockReturnValue(previous);
const expected = {
value: {
value: 20,
color: '#fff',
name: '',
},
};
series.legendFormatter.mockReturnValue(expected);
const event = {
logical: 3,
seriesData: new Map([
[environment.series, current],
]),
} as unknown as MouseEventParams;
expect(series.getLegendData(event)).toEqual(expected);
expect(environment.seriesMock.dataByIndex).toHaveBeenCalledWith(
2,
undefined,
);
expect(series.legendFormatter).toHaveBeenCalledWith(
current,
previous,
);
});
it('uses null previous bar when mouse logical index is null', () => {
const environment = createEnvironment();
const series = new TestSeries(createParams(environment));
const current = {
time: 20 as Time,
value: 20,
};
const event = {
logical: null,
seriesData: new Map([
[environment.series, current],
]),
} as unknown as MouseEventParams;
series.getLegendData(event);
expect(series.legendFormatter).toHaveBeenCalledWith(
current,
null,
);
expect(environment.seriesMock.dataByIndex).not.toHaveBeenCalled();
});
it('formats main series data when custom formatter is absent', () => {
const environment = createEnvironment();
const series = new TestSeries(createParams(environment));
expect(
series.formatDataForTest([
candle(100, 50),
]),
).toEqual([
{
time: 100,
value: 50,
customValues: candle(100, 50),
},
]);
});
it('uses custom formatter for multiple bars', () => {
const environment = createEnvironment();
const customFormatter = jest.fn(() => [
{
time: 100 as Time,
value: 999,
},
]) as NonNullable<BaseSeriesParams<'Line'>['customFormatter']>;
const mainSeriesData = [
{
time: 1,
customValues: candle(1),
},
];
const mainSerie$ = new BehaviorSubject({
data: () => mainSeriesData,
} as never);
environment.seriesMock.data.mockReturnValue([
{
time: 1,
value: 5,
},
]);
const series = new TestSeries(
createParams(environment, {
customFormatter,
mainSerie$: mainSerie$ as BaseSeriesParams<'Line'>['mainSerie$'],
}),
);
const result = series.formatDataForTest([
candle(1),
candle(2),
]);
expect(result).toEqual([
{
time: 100,
value: 999,
},
]);
expect(customFormatter).toHaveBeenCalledWith({
mainSeriesData,
selfData: [
{
time: 1,
value: 5,
},
],
indicatorReference: undefined,
});
});
it('passes single formatted candle to custom formatter', () => {
const environment = createEnvironment();
const customFormatter = jest.fn(() => [
{
time: 100 as Time,
value: 999,
},
]) as NonNullable<BaseSeriesParams<'Line'>['customFormatter']>;
const series = new TestSeries(
createParams(environment, {
customFormatter,
}),
);
const input = candle(100, 25);
series.formatDataForTest([input]);
expect(customFormatter).toHaveBeenCalledWith(
expect.objectContaining({
candle: {
time: 100,
value: 25,
customValues: input,
},
indicatorReference: undefined,
}),
);
});
it('returns empty data when main formatter produces no candle', () => {
const environment = createEnvironment();
const customFormatter = jest.fn() as NonNullable<
BaseSeriesParams<'Line'>['customFormatter']
>;
const series = new TestSeries(
createParams(environment, {
customFormatter,
}),
);
series.returnEmptyFormattedData = true;
expect(series.formatDataForTest([candle()])).toEqual([]);
expect(customFormatter).not.toHaveBeenCalled();
});
it('subscribes to symbol, history and realtime changes', () => {
const environment = createEnvironment();
const symbolId$ = new BehaviorSubject('MOEX:SBER');
const symbol$ = new BehaviorSubject('SBER');
const series = new TestSeries(
createParams(environment, {
mainSymbolId$: symbolId$,
mainSymbol$: symbol$,
}),
);
series.subscribeDataSourceForTest(environment.dataSource);
expect(environment.seriesMock.applyOptions).toHaveBeenCalledWith(
expect.objectContaining({
priceFormat: expect.objectContaining({
type: 'custom',
minMove: 0.01,
}),
}),
);
expect(environment.seriesMock.applyOptions).toHaveBeenCalledWith({
title: 'SBER',
});
expect(environment.dataSourceMock.subscribe).toHaveBeenCalledWith(
'MOEX:SBER',
expect.any(Function),
);
expect(environment.dataSourceMock.subscribeRealtime).toHaveBeenCalledWith(
'MOEX:SBER',
expect.any(Function),
);
const history = [
candle(1),
candle(2),
];
environment.getHistoryCallback()?.(history);
environment.getRealtimeCallback()?.(candle(3));
expect(series.dataSourceSubscription).toHaveBeenCalledWith(history);
expect(series.dataSourceRealtimeSubscription).toHaveBeenCalledWith(
candle(3),
);
});
it('resubscribes when symbol id changes and ignores duplicate value', () => {
const environment = createEnvironment();
const symbolId$ = new BehaviorSubject('MOEX:SBER');
const series = new TestSeries(
createParams(environment, {
mainSymbolId$: symbolId$,
}),
);
series.subscribeDataSourceForTest(environment.dataSource);
expect(environment.dataSourceMock.subscribe).toHaveBeenCalledTimes(1);
symbolId$.next('MOEX:GAZP');
expect(environment.dataSubscriptions[0].unsubscribe).toHaveBeenCalled();
expect(environment.realtimeSubscriptions[0].unsubscribe).toHaveBeenCalled();
expect(environment.dataSourceMock.subscribe).toHaveBeenCalledTimes(2);
expect(environment.dataSourceMock.subscribe).toHaveBeenLastCalledWith(
'MOEX:GAZP',
expect.any(Function),
);
symbolId$.next('MOEX:GAZP');
expect(environment.dataSourceMock.subscribe).toHaveBeenCalledTimes(2);
});
it('hides symbol title when showSymbolLabel is disabled', () => {
const environment = createEnvironment();
const series = new TestSeries(
createParams(environment, {
showSymbolLabel: false,
}),
);
series.subscribeDataSourceForTest(environment.dataSource);
expect(environment.seriesMock.applyOptions).toHaveBeenCalledWith({
title: '',
});
});
it('destroys subscriptions and removes lightweight series', () => {
const environment = createEnvironment();
const series = new TestSeries(createParams(environment));
series.subscribeDataSourceForTest(environment.dataSource);
const currentDataSubscription =
environment.dataSubscriptions[
environment.dataSubscriptions.length - 1
];
const currentRealtimeSubscription =
environment.realtimeSubscriptions[
environment.realtimeSubscriptions.length - 1
];
series.destroy();
expect(currentDataSubscription.unsubscribe).toHaveBeenCalled();
expect(currentRealtimeSubscription.unsubscribe).toHaveBeenCalled();
expect(environment.chartMock.removeSeries).toHaveBeenCalledWith(
environment.series,
);
});
});
describe('calcCandleChange', () => {
it('returns null when current bar is absent', () => {
expect(calcCandleChange(null, null)).toBeNull();
});
it('returns current bar when previous bar is absent', () => {
const current = {
time: 2 as Time,
value: 100,
};
expect(calcCandleChange(null, current)).toBe(current);
});
it('calculates OHLC absolute and percentage change', () => {
const previous = {
time: 1 as Time,
open: 90,
high: 110,
low: 80,
close: 100,
};
const current = {
time: 2 as Time,
open: 100,
high: 125,
low: 95,
close: 120,
};
expect(calcCandleChange(previous, current)).toEqual({
...current,
absoluteChange: 20,
percentageChange: 20,
});
});
it('replaces NaN OHLC percentage with zero', () => {
const previous = {
time: 1 as Time,
open: 0,
high: 0,
low: 0,
close: 0,
};
const current = {
time: 2 as Time,
open: 0,
high: 0,
low: 0,
close: 0,
};
expect(calcCandleChange(previous, current)).toEqual(
expect.objectContaining({
absoluteChange: 0,
percentageChange: 0,
}),
);
});
it('calculates single-value change with custom high and low', () => {
const previous = {
time: 1 as Time,
value: 100,
};
const current = {
time: 2 as Time,
value: 110,
customValues: {
high: 115,
low: 105,
},
};
expect(
calcCandleChange(
previous as LineData<Time>,
current as LineData<Time>,
),
).toEqual({
time: 2,
value: 110,
high: 115,
low: 105,
absoluteChange: 10,
percentageChange: 10,
customValues: {
high: 115,
low: 105,
},
});
});
it('uses current value as high and low when custom values are absent', () => {
const result = calcCandleChange(
{
time: 1 as Time,
value: 10,
},
{
time: 2 as Time,
value: 20,
},
);
expect(result).toEqual(
expect.objectContaining({
high: 20,
low: 20,
}),
);
});
it('replaces NaN single-value percentage with zero', () => {
const result = calcCandleChange(
{
time: 1 as Time,
value: 0,
},
{
time: 2 as Time,
value: 0,
},
);
expect(result).toEqual(
expect.objectContaining({
absoluteChange: 0,
percentageChange: 0,
}),
);
});
it('returns null for incompatible data types', () => {
expect(
calcCandleChange(
{
time: 1 as Time,
value: 10,
},
{
time: 2 as Time,
open: 10,
high: 11,
low: 9,
close: 10,
},
),
).toBeNull();
});
});
import {
LineSeries,
type IChartApi,
type ISeriesApi,
type LineData,
type SeriesDefinition,
type SeriesPartialOptionsMap,
type SingleValueData,
type Time,
} from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';
import { SingleValueSeriesStrategy } from '../SingleValueSeriesStrategy';
import type { BaseSeriesParams } from '../BaseSeries';
import type { DataSource } from '@core/DataSource';
import type { Candle, LineCandle } from '@src/types';
jest.mock('@src/constants', () => ({
MAIN_PANE_INDEX: 0,
}));
jest.mock('@src/translations', () => ({
t: (value: string) => value,
}));
jest.mock('@src/utils/removeAlphaFromHex', () => ({
removeAlphaFromHex: (color: string) =>
color.length === 9 ? color.slice(0, 7) : color,
}));
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => String(value),
getPricePrecisionStep: () => 0.01,
normalizeSeriesData: (data: unknown[]) => data,
formatCompactNumber: (value: number) => String(value),
isBarData: (data: unknown) => {
if (!data || typeof data !== 'object') {
return false;
}
return (
'open' in data &&
'high' in data &&
'low' in data &&
'close' in data
);
},
}));
class TestSingleValueSeriesStrategy extends SingleValueSeriesStrategy<'Line'> {
protected seriesDefinition(): SeriesDefinition<'Line'> {
return LineSeries;
}
protected getDefaultOptions(): SeriesPartialOptionsMap['Line'] {
return {
color: '#2962ff',
};
}
public getTypeName(): string {
return 'Line';
}
public formatDataForTest(
data: Candle[],
): LineData<Time>[] {
return this.formatData(data) as LineData<Time>[];
}
public getLegendColorForTest(
currentBar: SingleValueData<Time>,
): string {
return this.getLegendColor(currentBar);
}
}
function createEnvironment() {
let historyCallback: ((data: Candle[]) => void) | undefined;
let realtimeCallback: ((data: Candle) => void) | undefined;
const priceScale = {
applyOptions: jest.fn(),
};
const series = {
applyOptions: jest.fn(),
setData: jest.fn(),
update: jest.fn(),
priceScale: jest.fn(() => priceScale),
options: jest.fn(() => ({
visible: true,
color: '#2962ff',
})),
data: jest.fn(() => []),
dataByIndex: jest.fn(() => null),
};
const chart = {
addSeries: jest.fn(() => series),
removeSeries: jest.fn(),
};
const dataSource = {
subscribe: jest.fn((_symbol: string, callback: (data: Candle[]) => void) => {
historyCallback = callback;
return new Subscription();
}),
subscribeRealtime: jest.fn(
(_symbol: string, callback: (data: Candle) => void) => {
realtimeCallback = callback;
return new Subscription();
},
),
};
const params: BaseSeriesParams<'Line'> = {
lwcChart: chart as unknown as IChartApi,
dataSource: dataSource as unknown as DataSource,
mainSymbolId$: new BehaviorSubject('MOEX:SBER'),
mainSymbol$: new BehaviorSubject('SBER'),
mainSerie$: new BehaviorSubject(null) as BaseSeriesParams<'Line'>['mainSerie$'],
};
return {
params,
chart,
series,
dataSource,
getHistoryCallback: () => historyCallback,
getRealtimeCallback: () => realtimeCallback,
};
}
const candle = (
time = 1,
close = 100,
): Candle => ({
time,
open: close - 1,
high: close + 2,
low: close - 2,
close,
volume: 1,
});
describe('SingleValueSeriesStrategy', () => {
it('accepts data with numeric time', () => {
const environment = createEnvironment();
const strategy = new TestSingleValueSeriesStrategy(environment.params);
expect(
strategy.validateData([
candle(),
]),
).toBe(true);
expect(
strategy.validateData([
{
time: 1,
} as Partial<Candle>,
]),
).toBe(true);
});
it('rejects data without numeric time', () => {
const environment = createEnvironment();
const strategy = new TestSingleValueSeriesStrategy(environment.params);
expect(
strategy.validateData([
{
time: undefined,
},
]),
).toBe(false);
expect(
strategy.validateData(null as unknown as Array<Partial<Candle>>),
).toBe(false);
});
it('formats candle close as single value', () => {
const environment = createEnvironment();
const strategy = new TestSingleValueSeriesStrategy(environment.params);
const input = candle(10, 125);
expect(strategy.formatDataForTest([input])).toEqual([
{
time: 10,
value: 125,
customValues: input,
},
]);
});
it('uses value when close is absent', () => {
const environment = createEnvironment();
const strategy = new TestSingleValueSeriesStrategy(environment.params);
const input = {
time: 10,
value: 75,
} as LineCandle;
expect(
strategy.formatDataForTest([
input as unknown as Candle,
]),
).toEqual([
{
time: 10,
value: 75,
customValues: input,
},
]);
});
it('formats whitespace data without value', () => {
const environment = createEnvironment();
const strategy = new TestSingleValueSeriesStrategy(environment.params);
expect(
strategy.formatDataForTest([
{
time: 10,
} as unknown as Candle,
]),
).toEqual([
{
time: 10,
},
]);
});
it('sets formatted history data', () => {
const environment = createEnvironment();
new TestSingleValueSeriesStrategy(environment.params);
const input = [
candle(1, 10),
candle(2, 20),
];
environment.getHistoryCallback()?.(input);
expect(environment.series.setData).toHaveBeenCalledWith([
{
time: 1,
value: 10,
customValues: input[0],
},
{
time: 2,
value: 20,
customValues: input[1],
},
]);
});
it('updates realtime data using historical update mode', () => {
const environment = createEnvironment();
new TestSingleValueSeriesStrategy(environment.params);
const input = candle(5, 50);
environment.getRealtimeCallback()?.(input);
expect(environment.series.update).toHaveBeenCalledWith(
{
time: 5,
value: 50,
customValues: input,
},
true,
);
});
it('does not set invalid history data', () => {
const environment = createEnvironment();
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
new TestSingleValueSeriesStrategy(environment.params);
environment.getHistoryCallback()?.([
{
...candle(),
time: undefined,
} as unknown as Candle,
]);
expect(environment.series.setData).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(
'LightweightAPI: Invalid data format for Line chart',
);
errorSpy.mockRestore();
});
it('does not update invalid realtime data', () => {
const environment = createEnvironment();
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
new TestSingleValueSeriesStrategy(environment.params);
environment.getRealtimeCallback()?.({
...candle(),
time: undefined,
} as unknown as Candle);
expect(environment.series.update).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(
'LightweightAPI: Invalid data format for Line chart',
);
errorSpy.mockRestore();
});
it('uses current bar color for legend', () => {
const environment = createEnvironment();
environment.series.data.mockReturnValue([
{
time: 1,
value: 100,
},
{
time: 2,
value: 110,
color: '#123456ff',
},
]);
const strategy = new TestSingleValueSeriesStrategy(environment.params);
expect(strategy.getLegendData()).toEqual({
value: {
value: '110',
name: '',
color: '#123456',
},
absoluteChange: {
value: '10',
name: 'Change',
color: '#123456',
},
percentageChange: {
value: '10%',
name: 'Change',
color: '#123456',
},
time: {
value: 2,
name: 'Time',
color: '#123456',
},
});
});
it('uses series color when bar color is absent', () => {
const environment = createEnvironment();
const strategy = new TestSingleValueSeriesStrategy(environment.params);
expect(
strategy.getLegendColorForTest({
time: 1,
value: 100,
}),
).toBe('#2962ff');
});
it('returns empty color when neither bar nor series has color', () => {
const environment = createEnvironment();
environment.series.options.mockReturnValue({
visible: true,
});
const strategy = new TestSingleValueSeriesStrategy(environment.params);
expect(
strategy.getLegendColorForTest({
time: 1,
value: 100,
}),
).toBe('');
});
it('returns empty legend for non single-value data', () => {
const environment = createEnvironment();
environment.series.data.mockReturnValue([
{
time: 1,
open: 10,
high: 12,
low: 9,
close: 11,
},
]);
const strategy = new TestSingleValueSeriesStrategy(environment.params);
expect(strategy.getLegendData()).toEqual({});
});
});
import {
LineSeries,
LineStyle,
type IChartApi,
} from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';
import { LineSeriesStrategy } from '../LineSeriesStrategy';
import type { BaseSeriesParams } from '../BaseSeries';
import type { DataSource } from '@core/DataSource';
jest.mock('@src/constants', () => ({
MAIN_PANE_INDEX: 0,
}));
jest.mock('@src/theme/store', () => ({
getThemeStore: () => ({
colors: {
chartLineColor: '#2962ff',
},
}),
}));
jest.mock('@src/translations', () => ({
t: (value: string) => value,
}));
jest.mock('@src/utils/removeAlphaFromHex', () => ({
removeAlphaFromHex: (color: string) => color,
}));
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => String(value),
getPricePrecisionStep: () => 0.01,
normalizeSeriesData: (data: unknown[]) => data,
formatCompactNumber: (value: number) => String(value),
isBarData: () => false,
}));
function createEnvironment() {
const priceScale = {
applyOptions: jest.fn(),
};
const series = {
applyOptions: jest.fn(),
setData: jest.fn(),
update: jest.fn(),
priceScale: jest.fn(() => priceScale),
options: jest.fn(() => ({
visible: true,
color: '#2962ff',
})),
data: jest.fn(() => []),
dataByIndex: jest.fn(() => null),
};
const chart = {
addSeries: jest.fn(() => series),
removeSeries: jest.fn(),
};
const dataSource = {
subscribe: jest.fn(() => new Subscription()),
subscribeRealtime: jest.fn(() => new Subscription()),
};
const params: BaseSeriesParams<'Line'> = {
lwcChart: chart as unknown as IChartApi,
dataSource: dataSource as unknown as DataSource,
mainSymbolId$: new BehaviorSubject('MOEX:SBER'),
mainSymbol$: new BehaviorSubject('SBER'),
mainSerie$: new BehaviorSubject(null) as BaseSeriesParams<'Line'>['mainSerie$'],
};
return {
chart,
series,
params,
};
}
describe('LineSeriesStrategy', () => {
it('creates lightweight LineSeries with expected defaults', () => {
const environment = createEnvironment();
new LineSeriesStrategy(environment.params);
expect(environment.chart.addSeries).toHaveBeenCalledWith(
LineSeries,
{
color: '#2962ff',
lineWidth: 2,
lineStyle: LineStyle.Solid,
},
0,
);
});
it('returns Line type name', () => {
const environment = createEnvironment();
const strategy = new LineSeriesStrategy(environment.params);
expect(strategy.getTypeName()).toBe('Line');
});
});
import {
HistogramSeries,
type IChartApi,
} from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';
import { HistogramSeriesStrategy } from '../HistogramSeriesStrategy';
import type { BaseSeriesParams } from '../BaseSeries';
import type { DataSource } from '@core/DataSource';
jest.mock('@src/constants', () => ({
MAIN_PANE_INDEX: 0,
}));
jest.mock('@src/translations', () => ({
t: (value: string) => value,
}));
jest.mock('@src/utils/removeAlphaFromHex', () => ({
removeAlphaFromHex: (color: string) => color,
}));
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => String(value),
getPricePrecisionStep: () => 0.01,
normalizeSeriesData: (data: unknown[]) => data,
formatCompactNumber: (value: number) => String(value),
isBarData: () => false,
}));
function createEnvironment() {
const priceScale = {
applyOptions: jest.fn(),
};
const series = {
applyOptions: jest.fn(),
setData: jest.fn(),
update: jest.fn(),
priceScale: jest.fn(() => priceScale),
options: jest.fn(() => ({
visible: true,
})),
data: jest.fn(() => []),
dataByIndex: jest.fn(() => null),
};
const chart = {
addSeries: jest.fn(() => series),
removeSeries: jest.fn(),
};
const dataSource = {
subscribe: jest.fn(() => new Subscription()),
subscribeRealtime: jest.fn(() => new Subscription()),
};
const params: BaseSeriesParams<'Histogram'> = {
lwcChart: chart as unknown as IChartApi,
dataSource: dataSource as unknown as DataSource,
mainSymbolId$: new BehaviorSubject('MOEX:SBER'),
mainSymbol$: new BehaviorSubject('SBER'),
mainSerie$: new BehaviorSubject(null) as BaseSeriesParams<'Histogram'>['mainSerie$'],
};
return {
chart,
params,
};
}
describe('HistogramSeriesStrategy', () => {
it('creates lightweight HistogramSeries with empty defaults', () => {
const environment = createEnvironment();
new HistogramSeriesStrategy(environment.params);
expect(environment.chart.addSeries).toHaveBeenCalledWith(
HistogramSeries,
{},
0,
);
});
it('returns Histogram type name', () => {
const environment = createEnvironment();
const strategy = new HistogramSeriesStrategy(environment.params);
expect(strategy.getTypeName()).toBe('Histogram');
});
});
import {
BaselineSeries,
LineStyle,
type IChartApi,
type SingleValueData,
type Time,
} from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';
import { BaselineSeriesStrategy } from '../BaselineSeriesStrategy';
import type { BaseSeriesParams } from '../BaseSeries';
import type { DataSource } from '@core/DataSource';
jest.mock('@src/constants', () => ({
MAIN_PANE_INDEX: 0,
}));
jest.mock('@src/theme/store', () => ({
getThemeStore: () => ({
colors: {
chartCrosshairLine: '#999999',
},
}),
}));
jest.mock('@src/translations', () => ({
t: (value: string) => value,
}));
jest.mock('@src/utils/removeAlphaFromHex', () => ({
removeAlphaFromHex: (color: string) => color,
}));
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => String(value),
getPricePrecisionStep: () => 0.01,
normalizeSeriesData: (data: unknown[]) => data,
formatCompactNumber: (value: number) => String(value),
isBarData: () => false,
}));
class TestBaselineSeriesStrategy extends BaselineSeriesStrategy {
public getLegendColorForTest(
currentBar: SingleValueData<Time>,
): string {
return this.getLegendColor(currentBar);
}
}
function createAnimationFrameController() {
let id = 0;
const callbacks = new Map<number, FrameRequestCallback>();
const requestAnimationFrameMock = jest.fn(
(callback: FrameRequestCallback): number => {
id += 1;
callbacks.set(id, callback);
return id;
},
);
const cancelAnimationFrameMock = jest.fn(
(frameId: number): void => {
callbacks.delete(frameId);
},
);
const flush = (): void => {
const pending = [...callbacks.entries()];
callbacks.clear();
pending.forEach(([, callback]) => {
callback(0);
});
};
Object.defineProperty(globalThis, 'requestAnimationFrame', {
writable: true,
configurable: true,
value: requestAnimationFrameMock,
});
Object.defineProperty(globalThis, 'cancelAnimationFrame', {
writable: true,
configurable: true,
value: cancelAnimationFrameMock,
});
return {
flush,
requestAnimationFrameMock,
cancelAnimationFrameMock,
};
}
function createEnvironment() {
let rangeHandler: (() => void) | undefined;
let sizeHandler: (() => void) | undefined;
let dataChangedHandler: (() => void) | undefined;
const priceLine = {
applyOptions: jest.fn(),
};
const pane = {
getHeight: jest.fn(() => 600),
};
const priceScale = {
applyOptions: jest.fn(),
};
const timeScale = {
subscribeVisibleLogicalRangeChange: jest.fn((handler: () => void) => {
rangeHandler = handler;
}),
unsubscribeVisibleLogicalRangeChange: jest.fn(),
subscribeSizeChange: jest.fn((handler: () => void) => {
sizeHandler = handler;
}),
unsubscribeSizeChange: jest.fn(),
};
const series = {
applyOptions: jest.fn(),
setData: jest.fn(),
update: jest.fn(),
priceScale: jest.fn(() => priceScale),
options: jest.fn(() => ({
visible: true,
baseValue: {
type: 'price',
price: 100,
},
topLineColor: '#00aa00',
bottomLineColor: '#aa0000',
})),
data: jest.fn(() => []),
dataByIndex: jest.fn(() => null),
getPane: jest.fn(() => pane),
coordinateToPrice: jest.fn(() => 100),
createPriceLine: jest.fn(() => priceLine),
removePriceLine: jest.fn(),
subscribeDataChanged: jest.fn((handler: () => void) => {
dataChangedHandler = handler;
}),
unsubscribeDataChanged: jest.fn(),
};
const chart = {
addSeries: jest.fn(() => series),
removeSeries: jest.fn(),
timeScale: jest.fn(() => timeScale),
};
const dataSource = {
subscribe: jest.fn(() => new Subscription()),
subscribeRealtime: jest.fn(() => new Subscription()),
};
const params: BaseSeriesParams<'Baseline'> = {
lwcChart: chart as unknown as IChartApi,
dataSource: dataSource as unknown as DataSource,
mainSymbolId$: new BehaviorSubject('MOEX:SBER'),
mainSymbol$: new BehaviorSubject('SBER'),
mainSerie$: new BehaviorSubject(null) as BaseSeriesParams<'Baseline'>['mainSerie$'],
};
return {
chart,
series,
priceLine,
pane,
timeScale,
params,
getRangeHandler: () => rangeHandler,
getSizeHandler: () => sizeHandler,
getDataChangedHandler: () => dataChangedHandler,
};
}
describe('BaselineSeriesStrategy', () => {
let animationFrame: ReturnType<typeof createAnimationFrameController>;
beforeEach(() => {
animationFrame = createAnimationFrameController();
});
it('creates BaselineSeries with relative gradient enabled', () => {
const environment = createEnvironment();
new TestBaselineSeriesStrategy(environment.params);
expect(environment.chart.addSeries).toHaveBeenCalledWith(
BaselineSeries,
{
relativeGradient: true,
},
0,
);
});
it('subscribes to range, size and data changes', () => {
const environment = createEnvironment();
new TestBaselineSeriesStrategy(environment.params);
expect(
environment.timeScale.subscribeVisibleLogicalRangeChange,
).toHaveBeenCalledWith(expect.any(Function));
expect(
environment.timeScale.subscribeSizeChange,
).toHaveBeenCalledWith(expect.any(Function));
expect(
environment.series.subscribeDataChanged,
).toHaveBeenCalledWith(expect.any(Function));
});
it('sets baseline to vertical center of pane', () => {
const environment = createEnvironment();
environment.pane.getHeight.mockReturnValue(600);
environment.series.coordinateToPrice.mockReturnValue(100);
new TestBaselineSeriesStrategy(environment.params);
animationFrame.flush();
expect(environment.series.coordinateToPrice).toHaveBeenCalledWith(300);
expect(environment.series.applyOptions).toHaveBeenCalledWith({
baseValue: {
type: 'price',
price: 100,
},
});
});
it('creates base price line after calculating center price', () => {
const environment = createEnvironment();
new TestBaselineSeriesStrategy(environment.params);
animationFrame.flush();
expect(environment.series.createPriceLine).toHaveBeenCalledWith({
price: 100,
color: '#999999',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
axisLabelVisible: false,
});
});
it('does nothing when pane height is zero', () => {
const environment = createEnvironment();
environment.pane.getHeight.mockReturnValue(0);
new TestBaselineSeriesStrategy(environment.params);
animationFrame.flush();
expect(environment.series.coordinateToPrice).not.toHaveBeenCalled();
expect(environment.series.createPriceLine).not.toHaveBeenCalled();
});
it('does nothing when coordinate cannot be converted to price', () => {
const environment = createEnvironment();
environment.series.coordinateToPrice.mockReturnValue(null);
new TestBaselineSeriesStrategy(environment.params);
animationFrame.flush();
expect(environment.series.createPriceLine).not.toHaveBeenCalled();
expect(environment.series.applyOptions).not.toHaveBeenCalledWith(
expect.objectContaining({
baseValue: expect.anything(),
}),
);
});
it('updates existing price line instead of creating a new one', () => {
const environment = createEnvironment();
new TestBaselineSeriesStrategy(environment.params);
animationFrame.flush();
expect(environment.series.createPriceLine).toHaveBeenCalledTimes(1);
environment.series.coordinateToPrice.mockReturnValue(120);
environment.getRangeHandler()?.();
animationFrame.flush();
expect(environment.series.createPriceLine).toHaveBeenCalledTimes(1);
expect(environment.priceLine.applyOptions).toHaveBeenCalledWith({
price: 120,
});
expect(environment.series.applyOptions).toHaveBeenCalledWith({
baseValue: {
type: 'price',
price: 120,
},
});
});
it('recalculates baseline after size change', () => {
const environment = createEnvironment();
new TestBaselineSeriesStrategy(environment.params);
animationFrame.flush();
environment.series.coordinateToPrice.mockClear();
environment.pane.getHeight.mockReturnValue(800);
environment.getSizeHandler()?.();
animationFrame.flush();
expect(environment.series.coordinateToPrice).toHaveBeenCalledWith(400);
});
it('recalculates baseline after data change', () => {
const environment = createEnvironment();
new TestBaselineSeriesStrategy(environment.params);
animationFrame.flush();
environment.series.coordinateToPrice.mockClear();
environment.getDataChangedHandler()?.();
animationFrame.flush();
expect(environment.series.coordinateToPrice).toHaveBeenCalledWith(300);
});
it('cancels previous scheduled update before scheduling another', () => {
const environment = createEnvironment();
new TestBaselineSeriesStrategy(environment.params);
environment.getRangeHandler()?.();
expect(animationFrame.cancelAnimationFrameMock).toHaveBeenCalledTimes(1);
expect(animationFrame.requestAnimationFrameMock).toHaveBeenCalledTimes(2);
});
it('uses top line color for values above base value', () => {
const environment = createEnvironment();
const strategy = new TestBaselineSeriesStrategy(environment.params);
expect(
strategy.getLegendColorForTest({
time: 1,
value: 110,
}),
).toBe('#00aa00');
});
it('uses top line color for value equal to base value', () => {
const environment = createEnvironment();
const strategy = new TestBaselineSeriesStrategy(environment.params);
expect(
strategy.getLegendColorForTest({
time: 1,
value: 100,
}),
).toBe('#00aa00');
});
it('uses bottom line color for values below base value', () => {
const environment = createEnvironment();
const strategy = new TestBaselineSeriesStrategy(environment.params);
expect(
strategy.getLegendColorForTest({
time: 1,
value: 90,
}),
).toBe('#aa0000');
});
it('removes listeners and price line on destroy', () => {
const environment = createEnvironment();
const strategy = new TestBaselineSeriesStrategy(environment.params);
animationFrame.flush();
const rangeHandler =
environment.timeScale.subscribeVisibleLogicalRangeChange.mock.calls[0][0];
const sizeHandler =
environment.timeScale.subscribeSizeChange.mock.calls[0][0];
const dataHandler =
environment.series.subscribeDataChanged.mock.calls[0][0];
strategy.destroy();
expect(
environment.timeScale.unsubscribeVisibleLogicalRangeChange,
).toHaveBeenCalledWith(rangeHandler);
expect(
environment.timeScale.unsubscribeSizeChange,
).toHaveBeenCalledWith(sizeHandler);
expect(
environment.series.unsubscribeDataChanged,
).toHaveBeenCalledWith(dataHandler);
expect(environment.series.removePriceLine).toHaveBeenCalledWith(
environment.priceLine,
);
expect(environment.chart.removeSeries).toHaveBeenCalledWith(
environment.series,
);
});
it('cancels pending frame during destroy', () => {
const environment = createEnvironment();
const strategy = new TestBaselineSeriesStrategy(environment.params);
strategy.destroy();
expect(animationFrame.cancelAnimationFrameMock).toHaveBeenCalled();
expect(environment.series.removePriceLine).not.toHaveBeenCalled();
});
it('returns Baseline type name', () => {
const environment = createEnvironment();
const strategy = new TestBaselineSeriesStrategy(environment.params);
expect(strategy.getTypeName()).toBe('Baseline');
});
});
import {
CandlestickSeries,
type IChartApi,
type Time,
} from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';
import { CandlestickSeriesStrategy } from '../CandlestickSeriesStrategy';
import type { BaseSeriesParams } from '../BaseSeries';
import type { DataSource } from '@core/DataSource';
import type { Candle } from '@src/types';
jest.mock('@src/constants', () => ({
MAIN_PANE_INDEX: 0,
}));
jest.mock('@src/theme/store', () => ({
getThemeStore: () => ({
colors: {
chartCandleUp: '#00aa00',
chartCandleDown: '#aa0000',
chartCandleWickUp: '#11bb11ff',
chartCandleWickDown: '#bb1111ff',
},
}),
}));
jest.mock('@src/translations', () => ({
t: (value: string) => value,
}));
jest.mock('@src/utils/removeAlphaFromHex', () => ({
removeAlphaFromHex: (color: string) =>
color.length === 9 ? color.slice(0, 7) : color,
}));
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => String(value),
getPricePrecisionStep: () => 0.01,
normalizeSeriesData: (data: unknown[]) => data,
formatCompactNumber: (value: number) => String(value),
ensureDefined: <T>(value: T | null | undefined): T => {
if (value === null || value === undefined) {
throw new Error('Expected defined value');
}
return value;
},
isBarData: (data: unknown) => {
if (!data || typeof data !== 'object') {
return false;
}
return (
'open' in data &&
'high' in data &&
'low' in data &&
'close' in data
);
},
}));
const candle = (
time: number,
open: number,
close: number,
): Candle => ({
time,
open,
close,
high: Math.max(open, close) + 5,
low: Math.min(open, close) - 5,
volume: 10,
});
function createEnvironment() {
let historyCallback: ((data: Candle[]) => void) | undefined;
let realtimeCallback: ((data: Candle) => void) | undefined;
const priceScale = {
applyOptions: jest.fn(),
};
const series = {
applyOptions: jest.fn(),
setData: jest.fn(),
update: jest.fn(),
priceScale: jest.fn(() => priceScale),
options: jest.fn(() => ({
visible: true,
})),
data: jest.fn(() => []),
dataByIndex: jest.fn(() => null),
};
const chart = {
addSeries: jest.fn(() => series),
removeSeries: jest.fn(),
};
const dataSource = {
subscribe: jest.fn((_symbol: string, callback: (data: Candle[]) => void) => {
historyCallback = callback;
return new Subscription();
}),
subscribeRealtime: jest.fn(
(_symbol: string, callback: (data: Candle) => void) => {
realtimeCallback = callback;
return new Subscription();
},
),
};
const params: BaseSeriesParams<'Candlestick'> = {
lwcChart: chart as unknown as IChartApi,
dataSource: dataSource as unknown as DataSource,
mainSymbolId$: new BehaviorSubject('MOEX:SBER'),
mainSymbol$: new BehaviorSubject('SBER'),
mainSerie$: new BehaviorSubject(null) as BaseSeriesParams<'Candlestick'>['mainSerie$'],
};
return {
chart,
series,
params,
getHistoryCallback: () => historyCallback,
getRealtimeCallback: () => realtimeCallback,
};
}
describe('CandlestickSeriesStrategy', () => {
it('creates CandlestickSeries with theme options', () => {
const environment = createEnvironment();
new CandlestickSeriesStrategy(environment.params);
expect(environment.chart.addSeries).toHaveBeenCalledWith(
CandlestickSeries,
{
upColor: '#00aa00',
downColor: '#aa0000',
borderVisible: false,
wickUpColor: '#11bb11ff',
wickDownColor: '#bb1111ff',
},
0,
);
});
it('returns Candlestick type name', () => {
const environment = createEnvironment();
const strategy = new CandlestickSeriesStrategy(environment.params);
expect(strategy.getTypeName()).toBe('Candlestick');
});
it('accepts valid OHLC data', () => {
const environment = createEnvironment();
const strategy = new CandlestickSeriesStrategy(environment.params);
expect(
strategy.validateData([
candle(1, 100, 110),
]),
).toBe(true);
});
it('accepts whitespace data containing only time', () => {
const environment = createEnvironment();
const strategy = new CandlestickSeriesStrategy(environment.params);
expect(
strategy.validateData([
{
time: 1,
},
]),
).toBe(true);
});
it('rejects data without numeric time', () => {
const environment = createEnvironment();
const strategy = new CandlestickSeriesStrategy(environment.params);
expect(
strategy.validateData([
{
time: undefined,
},
]),
).toBe(false);
});
it('rejects invalid high value', () => {
const environment = createEnvironment();
const strategy = new CandlestickSeriesStrategy(environment.params);
expect(
strategy.validateData([
{
time: 1,
open: 100,
high: 105,
low: 90,
close: 110,
},
]),
).toBe(false);
});
it('rejects invalid low value', () => {
const environment = createEnvironment();
const strategy = new CandlestickSeriesStrategy(environment.params);
expect(
strategy.validateData([
{
time: 1,
open: 100,
high: 120,
low: 105,
close: 110,
},
]),
).toBe(false);
});
it('sets formatted history data', () => {
const environment = createEnvironment();
new CandlestickSeriesStrategy(environment.params);
const input = [
candle(1, 100, 110),
candle(2, 110, 105),
];
environment.getHistoryCallback()?.(input);
expect(environment.series.setData).toHaveBeenCalledWith(
input.map((point) => ({
time: point.time,
open: point.open,
high: point.high,
low: point.low,
close: point.close,
customValues: point,
})),
);
});
it('updates formatted realtime candle using historical mode', () => {
const environment = createEnvironment();
new CandlestickSeriesStrategy(environment.params);
const input = candle(10, 100, 110);
environment.getRealtimeCallback()?.(input);
expect(environment.series.update).toHaveBeenCalledWith(
{
time: 10,
open: input.open,
high: input.high,
low: input.low,
close: input.close,
customValues: input,
},
true,
);
});
it('does not set invalid history data', () => {
const environment = createEnvironment();
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
new CandlestickSeriesStrategy(environment.params);
environment.getHistoryCallback()?.([
{
...candle(1, 100, 110),
high: 105,
},
]);
expect(environment.series.setData).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
it('does not update invalid realtime data', () => {
const environment = createEnvironment();
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
new CandlestickSeriesStrategy(environment.params);
environment.getRealtimeCallback()?.({
...candle(1, 100, 110),
low: 105,
});
expect(environment.series.update).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
it('formats bullish legend using wick up color', () => {
const environment = createEnvironment();
environment.series.data.mockReturnValue([
{
time: 1 as Time,
open: 100,
high: 110,
low: 90,
close: 100,
},
{
time: 2 as Time,
open: 100,
high: 125,
low: 95,
close: 120,
},
]);
const strategy = new CandlestickSeriesStrategy(environment.params);
expect(strategy.getLegendData()).toEqual({
open: {
value: '100',
name: 'Open',
color: '#11bb11',
},
high: {
value: '125',
name: 'High',
color: '#11bb11',
},
low: {
value: '95',
name: 'Low',
color: '#11bb11',
},
close: {
value: '120',
name: 'Close',
color: '#11bb11',
},
absoluteChange: {
value: '20',
name: 'Change',
color: '#11bb11',
},
percentageChange: {
value: '20%',
name: 'Change',
color: '#11bb11',
},
time: {
value: 2,
name: 'Time',
color: '#11bb11',
},
});
});
it('formats bearish legend using wick down color', () => {
const environment = createEnvironment();
environment.series.data.mockReturnValue([
{
time: 1 as Time,
open: 110,
high: 115,
low: 95,
close: 100,
},
{
time: 2 as Time,
open: 110,
high: 115,
low: 85,
close: 90,
},
]);
const strategy = new CandlestickSeriesStrategy(environment.params);
const legend = strategy.getLegendData();
expect(legend.close?.color).toBe('#bb1111');
});
it('returns empty legend for non OHLC data', () => {
const environment = createEnvironment();
environment.series.data.mockReturnValue([
{
time: 1 as Time,
value: 10,
},
]);
const strategy = new CandlestickSeriesStrategy(environment.params);
expect(strategy.getLegendData()).toEqual({});
});
});
import {
BarSeries,
type IChartApi,
type Time,
} from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';
import { BarSeriesStrategy } from '../BarSeriesStrategy';
import type { BaseSeriesParams } from '../BaseSeries';
import type { DataSource } from '@core/DataSource';
import type { Candle } from '@src/types';
jest.mock('@src/constants', () => ({
MAIN_PANE_INDEX: 0,
}));
jest.mock('@src/theme', () => ({
getThemeStore: () => ({
colors: {
chartCandleUp: '#00aa00',
chartCandleDown: '#aa0000',
chartCandleWickUp: '#11bb11ff',
chartCandleWickDown: '#bb1111ff',
},
}),
}));
jest.mock('@src/translations', () => ({
t: (value: string) => value,
}));
jest.mock('@src/utils/removeAlphaFromHex', () => ({
removeAlphaFromHex: (color: string) =>
color.length === 9 ? color.slice(0, 7) : color,
}));
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => String(value),
getPricePrecisionStep: () => 0.01,
normalizeSeriesData: (data: unknown[]) => data,
formatCompactNumber: (value: number) => String(value),
ensureDefined: <T>(value: T | null | undefined): T => {
if (value === null || value === undefined) {
throw new Error('Expected defined value');
}
return value;
},
isBarData: (data: unknown) => {
if (!data || typeof data !== 'object') {
return false;
}
return (
'open' in data &&
'high' in data &&
'low' in data &&
'close' in data
);
},
}));
const candle = (
time: number,
open: number,
close: number,
): Candle => ({
time,
open,
close,
high: Math.max(open, close) + 5,
low: Math.min(open, close) - 5,
volume: 10,
});
function createEnvironment() {
let historyCallback: ((data: Candle[]) => void) | undefined;
let realtimeCallback: ((data: Candle) => void) | undefined;
const priceScale = {
applyOptions: jest.fn(),
};
const series = {
applyOptions: jest.fn(),
setData: jest.fn(),
update: jest.fn(),
priceScale: jest.fn(() => priceScale),
options: jest.fn(() => ({
visible: true,
})),
data: jest.fn(() => []),
dataByIndex: jest.fn(() => null),
};
const chart = {
addSeries: jest.fn(() => series),
removeSeries: jest.fn(),
};
const dataSource = {
subscribe: jest.fn((_symbol: string, callback: (data: Candle[]) => void) => {
historyCallback = callback;
return new Subscription();
}),
subscribeRealtime: jest.fn(
(_symbol: string, callback: (data: Candle) => void) => {
realtimeCallback = callback;
return new Subscription();
},
),
};
const params: BaseSeriesParams<'Bar'> = {
lwcChart: chart as unknown as IChartApi,
dataSource: dataSource as unknown as DataSource,
mainSymbolId$: new BehaviorSubject('MOEX:SBER'),
mainSymbol$: new BehaviorSubject('SBER'),
mainSerie$: new BehaviorSubject(null) as BaseSeriesParams<'Bar'>['mainSerie$'],
};
return {
chart,
series,
params,
getHistoryCallback: () => historyCallback,
getRealtimeCallback: () => realtimeCallback,
};
}
describe('BarSeriesStrategy', () => {
it('creates BarSeries with theme options', () => {
const environment = createEnvironment();
new BarSeriesStrategy(environment.params);
expect(environment.chart.addSeries).toHaveBeenCalledWith(
BarSeries,
{
upColor: '#00aa00',
downColor: '#aa0000',
},
0,
);
});
it('returns Bar type name', () => {
const environment = createEnvironment();
const strategy = new BarSeriesStrategy(environment.params);
expect(strategy.getTypeName()).toBe('Bar');
});
it('accepts data with numeric time', () => {
const environment = createEnvironment();
const strategy = new BarSeriesStrategy(environment.params);
expect(
strategy.validateData([
candle(1, 100, 110),
]),
).toBe(true);
expect(
strategy.validateData([
{
time: 1,
},
]),
).toBe(true);
});
it('rejects data without numeric time', () => {
const environment = createEnvironment();
const strategy = new BarSeriesStrategy(environment.params);
expect(
strategy.validateData([
{
time: undefined,
},
]),
).toBe(false);
expect(
strategy.validateData(null as unknown as []),
).toBe(false);
});
it('sets formatted history data', () => {
const environment = createEnvironment();
new BarSeriesStrategy(environment.params);
const input = [
candle(1, 100, 110),
candle(2, 110, 105),
];
environment.getHistoryCallback()?.(input);
expect(environment.series.setData).toHaveBeenCalledWith(
input.map((point) => ({
time: point.time,
open: point.open,
high: point.high,
low: point.low,
close: point.close,
customValues: point,
})),
);
});
it('updates realtime data using historical mode', () => {
const environment = createEnvironment();
new BarSeriesStrategy(environment.params);
const input = candle(10, 100, 110);
environment.getRealtimeCallback()?.(input);
expect(environment.series.update).toHaveBeenCalledWith(
{
time: input.time,
open: input.open,
high: input.high,
low: input.low,
close: input.close,
customValues: input,
},
true,
);
});
it('does not set invalid history data', () => {
const environment = createEnvironment();
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
new BarSeriesStrategy(environment.params);
environment.getHistoryCallback()?.([
{
...candle(1, 100, 110),
time: undefined,
} as unknown as Candle,
]);
expect(environment.series.setData).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
it('does not update invalid realtime data', () => {
const environment = createEnvironment();
const errorSpy = jest.spyOn(console, 'error').mockImplementation();
new BarSeriesStrategy(environment.params);
environment.getRealtimeCallback()?.({
...candle(1, 100, 110),
time: undefined,
} as unknown as Candle);
expect(environment.series.update).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
it('formats bullish legend with wick up color', () => {
const environment = createEnvironment();
environment.series.data.mockReturnValue([
{
time: 1 as Time,
open: 100,
high: 110,
low: 90,
close: 100,
},
{
time: 2 as Time,
open: 100,
high: 125,
low: 95,
close: 120,
},
]);
const strategy = new BarSeriesStrategy(environment.params);
const legend = strategy.getLegendData();
expect(legend.open).toEqual({
value: '100',
name: 'Open',
color: '#11bb11',
});
expect(legend.close).toEqual({
value: '120',
name: 'Close',
color: '#11bb11',
});
expect(legend.absoluteChange).toEqual({
value: '20',
name: 'Change',
color: '#11bb11',
});
expect(legend.percentageChange).toEqual({
value: '20%',
name: 'Change',
color: '#11bb11',
});
});
it('formats bearish legend with wick down color', () => {
const environment = createEnvironment();
environment.series.data.mockReturnValue([
{
time: 1 as Time,
open: 110,
high: 115,
low: 95,
close: 100,
},
{
time: 2 as Time,
open: 110,
high: 115,
low: 85,
close: 90,
},
]);
const strategy = new BarSeriesStrategy(environment.params);
expect(strategy.getLegendData().close?.color).toBe('#bb1111');
});
it('returns empty legend for non OHLC data', () => {
const environment = createEnvironment();
environment.series.data.mockReturnValue([
{
time: 1 as Time,
value: 10,
},
]);
const strategy = new BarSeriesStrategy(environment.params);
expect(strategy.getLegendData()).toEqual({});
});
});