Загрузка данных


import {
  LineSeries,
  type BarData,
  type CreatePriceLineOptions,
  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: (data: unknown[]) => 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;

type LegendValues = Partial<
  Record<
    keyof Ohlc,
    {
      value: number | string | Time;
      color: string;
      name: string;
    }
  >
>;

class TestSeries extends BaseSeries<'Line'> {
  public readonly historyHandler = jest.fn();
  public readonly realtimeHandler = jest.fn();

  public readonly legendFormatter = jest.fn(
    (): LegendValues => ({}),
  );

  public returnEmptyFormattedData = false;

  protected dataSourceSubscription = (next: Candle[]): void => {
    this.historyHandler(next);
  };

  protected dataSourceRealtimeSubscription = (next: Candle): void => {
    this.realtimeHandler(next);
  };

  protected seriesDefinition(): SeriesDefinition<'Line'> {
    return LineSeries;
  }

  protected getDefaultOptions(): SeriesPartialOptionsMap['Line'] {
    return {
      color: '#111111',
    };
  }

  protected 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>,
    }));
  }

  protected formatLegendValues(
    currentBar: LegendData,
    prevBar: LegendData,
  ): LegendValues {
    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 createCandle = (
  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 seriesDataMock = jest.fn(
    (): readonly SeriesDataItemTypeMap<Time>['Line'][] => [],
  );

  const dataByIndexMock = jest.fn(
    (
      _logicalIndex: number,
      _mismatchDirection?: unknown,
    ): SeriesDataItemTypeMap<Time>['Line'] | null => null,
  );

  const optionsMock = jest.fn(
    (): Record<string, unknown> => ({
      visible: true,
      color: '#2962ff',
    }),
  );

  const coordinateToPriceMock = jest.fn(
    (_coordinate: number): number | null => 101,
  );

  const seriesMock = {
    applyOptions: jest.fn(),
    attachPrimitive: jest.fn(),
    barsInLogicalRange: jest.fn(() => ({
      barsBefore: 1,
      barsAfter: 2,
    })),
    coordinateToPrice: coordinateToPriceMock,
    createPriceLine: jest.fn(() => priceLine),
    data: seriesDataMock,
    pop: jest.fn((): SeriesDataItemTypeMap<Time>['Line'][] => []),
    dataByIndex: dataByIndexMock,
    detachPrimitive: jest.fn(),
    getPane: jest.fn(() => pane),
    moveToPane: jest.fn(),
    options: optionsMock,
    priceFormatter: jest.fn(() => priceFormatter),
    priceLines: jest.fn(() => [priceLine]),
    priceScale: jest.fn(() => priceScale),
    priceToCoordinate: jest.fn((_price: number): number | null => 50),
    removePriceLine: jest.fn(),
    seriesOrder: jest.fn(() => 3),
    seriesType: jest.fn(() => 'Line' as const),
    lastValueData: jest.fn(() => null),
    setData: jest.fn(),
    setSeriesOrder: jest.fn(),
    subscribeDataChanged: jest.fn(),
    unsubscribeDataChanged: jest.fn(),
    update: jest.fn(),
  };

  const lwcSeries = seriesMock as unknown as ISeriesApi<'Line'>;

  const chartMock = {
    addSeries: jest.fn(() => lwcSeries),
    removeSeries: jest.fn(),
  };

  const dataSourceMock = {
    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: chartMock as unknown as IChartApi,
    chartMock,
    lwcSeries,
    seriesMock,
    seriesDataMock,
    dataByIndexMock,
    optionsMock,
    priceScale,
    priceLine,
    pane,
    priceFormatter,
    dataSource: dataSourceMock as unknown as DataSource,
    dataSourceMock,
    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<
      BaseSeriesParams<'Line'>['mainSerie$']['value']
    >(null),
    ...overrides,
  };
}

function createSeries(
  environment: ReturnType<typeof createEnvironment>,
  overrides: Partial<BaseSeriesParams<'Line'>> = {},
): TestSeries {
  return new TestSeries(createParams(environment, overrides));
}

describe('BaseSeries', () => {
  it('должен создавать серию с объединёнными настройками', () => {
    const environment = createEnvironment();

    const series = createSeries(environment, {
      paneIndex: 3,
      seriesOptions: {
        color: '#ff0000',
        lineWidth: 3,
      },
      priceScaleOptions: {
        autoScale: false,
      },
    });

    expect(series).toBeDefined();

    expect(environment.chartMock.addSeries).toHaveBeenCalledWith(
      LineSeries,
      {
        color: '#ff0000',
        lineWidth: 3,
      },
      3,
    );

    expect(environment.priceScale.applyOptions).toHaveBeenCalledWith({
      autoScale: false,
    });
  });

  it('должен проксировать основные методы lightweight-charts серии', () => {
    const environment = createEnvironment();
    const series = createSeries(environment);

    const primitive = {} as never;

    const range = {
      from: 1,
      to: 10,
    };

    const priceLineOptions = {
      price: 100,
    } as CreatePriceLineOptions;

    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.lwcSeries);

    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(series.coordinateToPrice(50)).toBe(101);

    expect(series.createPriceLine(priceLineOptions)).toBe(
      environment.priceLine,
    );

    expect(series.data()).toEqual([]);
    expect(series.pop(2)).toEqual([]);

    series.dataByIndex(5);

    expect(environment.dataByIndexMock).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);

    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 dataChangedHandler = jest.fn();

    series.subscribeDataChanged(dataChangedHandler);

    expect(
      environment.seriesMock.subscribeDataChanged,
    ).toHaveBeenCalledWith(dataChangedHandler);

    series.unsubscribeDataChanged(dataChangedHandler);

    expect(
      environment.seriesMock.unsubscribeDataChanged,
    ).toHaveBeenCalledWith(dataChangedHandler);
  });

  it('должен устанавливать и обновлять данные серии', () => {
    const environment = createEnvironment();
    const series = createSeries(environment);

    const bar: SeriesDataItemTypeMap<Time>['Line'] = {
      time: 100 as Time,
      value: 10,
    };

    series.setData([bar]);

    expect(environment.seriesMock.setData).toHaveBeenCalledWith([bar]);

    series.update(bar);

    expect(environment.seriesMock.update).toHaveBeenCalledWith(
      bar,
      false,
    );

    series.update(bar, true);

    expect(environment.seriesMock.update).toHaveBeenCalledWith(
      bar,
      true,
    );
  });

  it('должен обновлять realtime данные как historical update', () => {
    const environment = createEnvironment();
    const series = createSeries(environment);

    const bar: SeriesDataItemTypeMap<Time>['Line'] = {
      time: 100 as Time,
      value: 10,
    };

    series.updateRealtimeForTest(bar);

    expect(environment.seriesMock.update).toHaveBeenCalledWith(
      bar,
      true,
    );
  });

  it('должен возвращать пустую легенду при отсутствии данных', () => {
    const environment = createEnvironment();
    const series = createSeries(environment);

    expect(series.getLegendData()).toEqual({});
    expect(series.legendFormatter).not.toHaveBeenCalled();
  });

  it('должен формировать легенду по последнему и предыдущему значениям', () => {
    const environment = createEnvironment();

    const previous: LineData<Time> = {
      time: 1 as Time,
      value: 10,
    };

    const current: LineData<Time> = {
      time: 2 as Time,
      value: 20,
    };

    environment.seriesDataMock.mockReturnValue([
      previous,
      current,
    ]);

    const series = createSeries(environment);

    const expected: LegendValues = {
      value: {
        value: 20,
        name: '',
        color: '#ffffff',
      },
    };

    series.legendFormatter.mockReturnValue(expected);

    expect(series.getLegendData()).toEqual(expected);

    expect(series.legendFormatter).toHaveBeenCalledWith(
      current,
      previous,
    );
  });

  it('должен формировать легенду по данным события мыши', () => {
    const environment = createEnvironment();
    const series = createSeries(environment);

    const previous: LineData<Time> = {
      time: 10 as Time,
      value: 10,
    };

    const current: LineData<Time> = {
      time: 20 as Time,
      value: 20,
    };

    environment.dataByIndexMock.mockReturnValue(previous);

    const expected: LegendValues = {
      value: {
        value: 20,
        name: '',
        color: '#ffffff',
      },
    };

    series.legendFormatter.mockReturnValue(expected);

    const event = {
      logical: 3,
      seriesData: new Map([
        [environment.lwcSeries, current],
      ]),
    } as unknown as MouseEventParams;

    expect(series.getLegendData(event)).toEqual(expected);

    expect(environment.dataByIndexMock).toHaveBeenCalledWith(
      2,
      undefined,
    );

    expect(series.legendFormatter).toHaveBeenCalledWith(
      current,
      previous,
    );
  });

  it('должен передавать null вместо предыдущего значения при logical равном null', () => {
    const environment = createEnvironment();
    const series = createSeries(environment);

    const current: LineData<Time> = {
      time: 20 as Time,
      value: 20,
    };

    const event = {
      logical: null,
      seriesData: new Map([
        [environment.lwcSeries, current],
      ]),
    } as unknown as MouseEventParams;

    series.getLegendData(event);

    expect(series.legendFormatter).toHaveBeenCalledWith(
      current,
      null,
    );

    expect(environment.dataByIndexMock).not.toHaveBeenCalled();
  });

  it('должен использовать стандартное форматирование без customFormatter', () => {
    const environment = createEnvironment();
    const series = createSeries(environment);

    const input = createCandle(100, 50);

    expect(series.formatDataForTest([input])).toEqual([
      {
        time: 100,
        value: 50,
        customValues: input,
      },
    ]);
  });

  it('должен использовать customFormatter для массива данных', () => {
    const environment = createEnvironment();

    const customFormatter = jest.fn(
      (): SeriesDataItemTypeMap<Time>['Line'][] => [
        {
          time: 100 as Time,
          value: 999,
        },
      ],
    );

    const mainSeriesData = [
      {
        time: 1 as Time,
        customValues: createCandle(1),
      },
    ];

    const mainSerie = {
      data: () => mainSeriesData,
    } as unknown as Exclude<
      BaseSeriesParams<'Line'>['mainSerie$']['value'],
      null
    >;

    const mainSerie$ = new BehaviorSubject<
      BaseSeriesParams<'Line'>['mainSerie$']['value']
    >(mainSerie);

    environment.seriesDataMock.mockReturnValue([
      {
        time: 1 as Time,
        value: 5,
      },
    ]);

    const series = createSeries(environment, {
      customFormatter,
      mainSerie$,
    });

    const result = series.formatDataForTest([
      createCandle(1),
      createCandle(2),
    ]);

    expect(result).toEqual([
      {
        time: 100,
        value: 999,
      },
    ]);

    expect(customFormatter).toHaveBeenCalledWith({
      mainSeriesData,
      selfData: [
        {
          time: 1,
          value: 5,
        },
      ],
      indicatorReference: undefined,
    });
  });

  it('должен передавать одну отформатированную свечу в customFormatter', () => {
    const environment = createEnvironment();

    const customFormatter = jest.fn(
      (): SeriesDataItemTypeMap<Time>['Line'][] => [
        {
          time: 100 as Time,
          value: 999,
        },
      ],
    );

    const series = createSeries(environment, {
      customFormatter,
    });

    const input = createCandle(100, 25);

    series.formatDataForTest([input]);

    expect(customFormatter).toHaveBeenCalledWith(
      expect.objectContaining({
        candle: {
          time: 100,
          value: 25,
          customValues: input,
        },
        indicatorReference: undefined,
      }),
    );
  });

  it('должен возвращать пустой массив если исходная свеча не сформирована', () => {
    const environment = createEnvironment();

    const customFormatter = jest.fn(
      (): SeriesDataItemTypeMap<Time>['Line'][] => [],
    );

    const series = createSeries(environment, {
      customFormatter,
    });

    series.returnEmptyFormattedData = true;

    expect(
      series.formatDataForTest([
        createCandle(),
      ]),
    ).toEqual([]);

    expect(customFormatter).not.toHaveBeenCalled();
  });

  it('должен подписываться на инструмент, историю и realtime', () => {
    const environment = createEnvironment();

    const symbolId$ = new BehaviorSubject('MOEX:SBER');
    const symbol$ = new BehaviorSubject('SBER');

    const series = createSeries(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 = [
      createCandle(1),
      createCandle(2),
    ];

    const realtime = createCandle(3);

    environment.getHistoryCallback()?.(history);
    environment.getRealtimeCallback()?.(realtime);

    expect(series.historyHandler).toHaveBeenCalledWith(history);
    expect(series.realtimeHandler).toHaveBeenCalledWith(realtime);
  });

  it('должен переподписываться при изменении symbolId', () => {
    const environment = createEnvironment();

    const symbolId$ = new BehaviorSubject('MOEX:SBER');

    const series = createSeries(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('должен скрывать название инструмента при showSymbolLabel=false', () => {
    const environment = createEnvironment();

    const series = createSeries(environment, {
      showSymbolLabel: false,
    });

    series.subscribeDataSourceForTest(environment.dataSource);

    expect(environment.seriesMock.applyOptions).toHaveBeenCalledWith({
      title: '',
    });
  });

  it('должен освобождать подписки и удалять серию при destroy', () => {
    const environment = createEnvironment();
    const series = createSeries(environment);

    series.subscribeDataSourceForTest(environment.dataSource);

    const dataSubscription =
      environment.dataSubscriptions[
        environment.dataSubscriptions.length - 1
      ];

    const realtimeSubscription =
      environment.realtimeSubscriptions[
        environment.realtimeSubscriptions.length - 1
      ];

    series.destroy();

    expect(dataSubscription?.unsubscribe).toHaveBeenCalled();
    expect(realtimeSubscription?.unsubscribe).toHaveBeenCalled();

    expect(environment.chartMock.removeSeries).toHaveBeenCalledWith(
      environment.lwcSeries,
    );
  });
});

describe('calcCandleChange', () => {
  it('должен возвращать null если текущего значения нет', () => {
    expect(calcCandleChange(null, null)).toBeNull();
  });

  it('должен возвращать текущее значение если предыдущего значения нет', () => {
    const current: LineData<Time> = {
      time: 2 as Time,
      value: 100,
    };

    expect(calcCandleChange(null, current)).toBe(current);
  });

  it('должен вычислять изменение OHLC значения', () => {
    const previous: BarData<Time> = {
      time: 1 as Time,
      open: 90,
      high: 110,
      low: 80,
      close: 100,
    };

    const current: BarData<Time> = {
      time: 2 as Time,
      open: 100,
      high: 125,
      low: 95,
      close: 120,
    };

    expect(calcCandleChange(previous, current)).toEqual({
      ...current,
      absoluteChange: 20,
      percentageChange: 20,
    });
  });

  it('должен заменять NaN изменения OHLC на ноль', () => {
    const previous: BarData<Time> = {
      time: 1 as Time,
      open: 0,
      high: 0,
      low: 0,
      close: 0,
    };

    const current: BarData<Time> = {
      time: 2 as Time,
      open: 0,
      high: 0,
      low: 0,
      close: 0,
    };

    expect(calcCandleChange(previous, current)).toEqual(
      expect.objectContaining({
        absoluteChange: 0,
        percentageChange: 0,
      }),
    );
  });

  it('должен вычислять изменение single-value значения', () => {
    const previous: LineData<Time> = {
      time: 1 as Time,
      value: 100,
    };

    const current: LineData<Time> = {
      time: 2 as Time,
      value: 110,
      customValues: {
        high: 115,
        low: 105,
      },
    };

    expect(calcCandleChange(previous, current)).toEqual({
      time: 2,
      value: 110,
      high: 115,
      low: 105,
      absoluteChange: 10,
      percentageChange: 10,
      customValues: {
        high: 115,
        low: 105,
      },
    });
  });

  it('должен использовать value как high и low при отсутствии customValues', () => {
    const previous: LineData<Time> = {
      time: 1 as Time,
      value: 10,
    };

    const current: LineData<Time> = {
      time: 2 as Time,
      value: 20,
    };

    expect(calcCandleChange(previous, current)).toEqual(
      expect.objectContaining({
        high: 20,
        low: 20,
      }),
    );
  });

  it('должен заменять NaN изменения single-value на ноль', () => {
    const previous: LineData<Time> = {
      time: 1 as Time,
      value: 0,
    };

    const current: LineData<Time> = {
      time: 2 as Time,
      value: 0,
    };

    expect(calcCandleChange(previous, current)).toEqual(
      expect.objectContaining({
        absoluteChange: 0,
        percentageChange: 0,
      }),
    );
  });

  it('должен возвращать null для несовместимых типов данных', () => {
    const previous: LineData<Time> = {
      time: 1 as Time,
      value: 10,
    };

    const current: BarData<Time> = {
      time: 2 as Time,
      open: 10,
      high: 11,
      low: 9,
      close: 10,
    };

    expect(calcCandleChange(previous, current)).toBeNull();
  });
});

















import {
  LineSeries,
  type IChartApi,
  type LineData,
  type SeriesDataItemTypeMap,
  type SeriesDefinition,
  type SeriesPartialOptionsMap,
  type SingleValueData,
  type Time,
} from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';

import type { BaseSeriesParams } from '../BaseSeries';
import { SingleValueSeriesStrategy } from '../SingleValueSeriesStrategy';

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[],
  ): SeriesDataItemTypeMap<Time>['Line'][] {
    return this.formatData(data);
  }

  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 seriesDataMock = jest.fn(
    (): readonly SeriesDataItemTypeMap<Time>['Line'][] => [],
  );

  const dataByIndexMock = jest.fn(
    (
      _index: number,
      _direction?: unknown,
    ): SeriesDataItemTypeMap<Time>['Line'] | null => null,
  );

  const optionsMock = jest.fn(
    (): Record<string, unknown> => ({
      visible: true,
      color: '#2962ff',
    }),
  );

  const seriesMock = {
    applyOptions: jest.fn(),
    setData: jest.fn(),
    update: jest.fn(),
    priceScale: jest.fn(() => priceScale),
    options: optionsMock,
    data: seriesDataMock,
    dataByIndex: dataByIndexMock,
  };

  const chartMock = {
    addSeries: jest.fn(() => seriesMock),
    removeSeries: jest.fn(),
  };

  const dataSourceMock = {
    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: chartMock as unknown as IChartApi,
    dataSource: dataSourceMock as unknown as DataSource,
    mainSymbolId$: new BehaviorSubject('MOEX:SBER'),
    mainSymbol$: new BehaviorSubject('SBER'),
    mainSerie$: new BehaviorSubject<
      BaseSeriesParams<'Line'>['mainSerie$']['value']
    >(null),
  };

  return {
    params,
    seriesMock,
    seriesDataMock,
    optionsMock,
    getHistoryCallback: () => historyCallback,
    getRealtimeCallback: () => realtimeCallback,
  };
}

function createStrategy(
  environment: ReturnType<typeof createEnvironment>,
): TestSingleValueSeriesStrategy {
  return new TestSingleValueSeriesStrategy(environment.params);
}

const createCandle = (
  time = 1,
  close = 100,
): Candle => ({
  time,
  open: close - 1,
  high: close + 2,
  low: close - 2,
  close,
  volume: 1,
});

describe('SingleValueSeriesStrategy', () => {
  it('должен принимать данные с числовым time', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.validateData([
        createCandle(),
      ]),
    ).toBe(true);

    expect(
      strategy.validateData([
        {
          time: 1,
        },
      ]),
    ).toBe(true);
  });

  it('должен отклонять данные без числового time', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.validateData([
        {
          time: undefined,
        },
      ]),
    ).toBe(false);

    expect(
      strategy.validateData(null as never),
    ).toBe(false);
  });

  it('должен преобразовывать close свечи в value', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    const input = createCandle(10, 125);

    expect(strategy.formatDataForTest([input])).toEqual([
      {
        time: 10,
        value: 125,
        customValues: input,
      },
    ]);
  });

  it('должен использовать value если close отсутствует', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    const input: LineCandle = {
      time: 10,
      value: 75,
    };

    expect(
      strategy.formatDataForTest([
        input as unknown as Candle,
      ]),
    ).toEqual([
      {
        time: 10,
        value: 75,
        customValues: input,
      },
    ]);
  });

  it('должен сохранять whitespace точку без value', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.formatDataForTest([
        {
          time: 10,
        } as Candle,
      ]),
    ).toEqual([
      {
        time: 10,
      },
    ]);
  });

  it('должен устанавливать отформатированные исторические данные', () => {
    const environment = createEnvironment();

    createStrategy(environment);

    const input = [
      createCandle(1, 10),
      createCandle(2, 20),
    ];

    environment.getHistoryCallback()?.(input);

    expect(environment.seriesMock.setData).toHaveBeenCalledWith([
      {
        time: 1,
        value: 10,
        customValues: input[0],
      },
      {
        time: 2,
        value: 20,
        customValues: input[1],
      },
    ]);
  });

  it('должен обновлять realtime данные в historical режиме', () => {
    const environment = createEnvironment();

    createStrategy(environment);

    const input = createCandle(5, 50);

    environment.getRealtimeCallback()?.(input);

    expect(environment.seriesMock.update).toHaveBeenCalledWith(
      {
        time: 5,
        value: 50,
        customValues: input,
      },
      true,
    );
  });

  it('не должен устанавливать невалидные исторические данные', () => {
    const environment = createEnvironment();

    const errorSpy = jest
      .spyOn(console, 'error')
      .mockImplementation(() => undefined);

    createStrategy(environment);

    environment.getHistoryCallback()?.([
      {
        ...createCandle(),
        time: undefined,
      } as unknown as Candle,
    ]);

    expect(environment.seriesMock.setData).not.toHaveBeenCalled();

    expect(errorSpy).toHaveBeenCalledWith(
      'LightweightAPI: Invalid data format for Line chart',
    );

    errorSpy.mockRestore();
  });

  it('не должен обновлять невалидные realtime данные', () => {
    const environment = createEnvironment();

    const errorSpy = jest
      .spyOn(console, 'error')
      .mockImplementation(() => undefined);

    createStrategy(environment);

    environment.getRealtimeCallback()?.({
      ...createCandle(),
      time: undefined,
    } as unknown as Candle);

    expect(environment.seriesMock.update).not.toHaveBeenCalled();

    expect(errorSpy).toHaveBeenCalledWith(
      'LightweightAPI: Invalid data format for Line chart',
    );

    errorSpy.mockRestore();
  });

  it('должен использовать цвет текущего бара в легенде', () => {
    const environment = createEnvironment();

    const previous: LineData<Time> = {
      time: 1 as Time,
      value: 100,
    };

    const current: LineData<Time> = {
      time: 2 as Time,
      value: 110,
      color: '#123456ff',
    };

    environment.seriesDataMock.mockReturnValue([
      previous,
      current,
    ]);

    const strategy = createStrategy(environment);

    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('должен использовать цвет серии если цвет бара отсутствует', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.getLegendColorForTest({
        time: 1 as Time,
        value: 100,
      }),
    ).toBe('#2962ff');
  });

  it('должен возвращать пустой цвет если цвет отсутствует', () => {
    const environment = createEnvironment();

    environment.optionsMock.mockReturnValue({
      visible: true,
    });

    const strategy = createStrategy(environment);

    expect(
      strategy.getLegendColorForTest({
        time: 1 as Time,
        value: 100,
      }),
    ).toBe('');
  });

  it('должен возвращать пустую легенду для не single-value данных', () => {
    const environment = createEnvironment();

    environment.seriesDataMock.mockReturnValue([
      {
        time: 1 as Time,
        open: 10,
        high: 12,
        low: 9,
        close: 11,
      } as unknown as SeriesDataItemTypeMap<Time>['Line'],
    ]);

    const strategy = createStrategy(environment);

    expect(strategy.getLegendData()).toEqual({});
  });
});


















import {
  LineSeries,
  LineStyle,
  type IChartApi,
} from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';

import type { BaseSeriesParams } from '../BaseSeries';
import { LineSeriesStrategy } from '../LineSeriesStrategy';

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 seriesMock = {
    applyOptions: jest.fn(),
    setData: jest.fn(),
    update: jest.fn(),
    priceScale: jest.fn(() => priceScale),
    options: jest.fn(
      (): Record<string, unknown> => ({
        visible: true,
        color: '#2962ff',
      }),
    ),
    data: jest.fn((): readonly [] => []),
    dataByIndex: jest.fn(() => null),
  };

  const chartMock = {
    addSeries: jest.fn(() => seriesMock),
    removeSeries: jest.fn(),
  };

  const dataSourceMock = {
    subscribe: jest.fn(() => new Subscription()),
    subscribeRealtime: jest.fn(() => new Subscription()),
  };

  const params: BaseSeriesParams<'Line'> = {
    lwcChart: chartMock as unknown as IChartApi,
    dataSource: dataSourceMock as unknown as DataSource,
    mainSymbolId$: new BehaviorSubject('MOEX:SBER'),
    mainSymbol$: new BehaviorSubject('SBER'),
    mainSerie$: new BehaviorSubject<
      BaseSeriesParams<'Line'>['mainSerie$']['value']
    >(null),
  };

  return {
    chartMock,
    params,
  };
}

function createStrategy(
  environment: ReturnType<typeof createEnvironment>,
): LineSeriesStrategy {
  return new LineSeriesStrategy(environment.params);
}

describe('LineSeriesStrategy', () => {
  it('должен создавать LineSeries с настройками темы', () => {
    const environment = createEnvironment();

    const strategy = createStrategy(environment);

    expect(strategy).toBeDefined();

    expect(environment.chartMock.addSeries).toHaveBeenCalledWith(
      LineSeries,
      {
        color: '#2962ff',
        lineWidth: 2,
        lineStyle: LineStyle.Solid,
      },
      0,
    );
  });

  it('должен возвращать тип Line', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(strategy.getTypeName()).toBe('Line');
  });
});


















import {
  HistogramSeries,
  type IChartApi,
} from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';

import type { BaseSeriesParams } from '../BaseSeries';
import { HistogramSeriesStrategy } from '../HistogramSeriesStrategy';

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 seriesMock = {
    applyOptions: jest.fn(),
    setData: jest.fn(),
    update: jest.fn(),
    priceScale: jest.fn(() => priceScale),
    options: jest.fn(
      (): Record<string, unknown> => ({
        visible: true,
      }),
    ),
    data: jest.fn((): readonly [] => []),
    dataByIndex: jest.fn(() => null),
  };

  const chartMock = {
    addSeries: jest.fn(() => seriesMock),
    removeSeries: jest.fn(),
  };

  const dataSourceMock = {
    subscribe: jest.fn(() => new Subscription()),
    subscribeRealtime: jest.fn(() => new Subscription()),
  };

  const params: BaseSeriesParams<'Histogram'> = {
    lwcChart: chartMock as unknown as IChartApi,
    dataSource: dataSourceMock as unknown as DataSource,
    mainSymbolId$: new BehaviorSubject('MOEX:SBER'),
    mainSymbol$: new BehaviorSubject('SBER'),
    mainSerie$: new BehaviorSubject<
      BaseSeriesParams<'Histogram'>['mainSerie$']['value']
    >(null),
  };

  return {
    chartMock,
    params,
  };
}

function createStrategy(
  environment: ReturnType<typeof createEnvironment>,
): HistogramSeriesStrategy {
  return new HistogramSeriesStrategy(environment.params);
}

describe('HistogramSeriesStrategy', () => {
  it('должен создавать HistogramSeries с пустыми настройками по умолчанию', () => {
    const environment = createEnvironment();

    const strategy = createStrategy(environment);

    expect(strategy).toBeDefined();

    expect(environment.chartMock.addSeries).toHaveBeenCalledWith(
      HistogramSeries,
      {},
      0,
    );
  });

  it('должен возвращать тип Histogram', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(strategy.getTypeName()).toBe('Histogram');
  });
});


















import {
  BaselineSeries,
  LineStyle,
  type IChartApi,
  type SeriesDataItemTypeMap,
  type SingleValueData,
  type Time,
} from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';

import type { BaseSeriesParams } from '../BaseSeries';
import { BaselineSeriesStrategy } from '../BaselineSeriesStrategy';

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 nextId = 0;

  const callbacks = new Map<number, FrameRequestCallback>();

  const requestAnimationFrameMock = jest.fn(
    (callback: FrameRequestCallback): number => {
      nextId += 1;
      callbacks.set(nextId, callback);

      return nextId;
    },
  );

  const cancelAnimationFrameMock = jest.fn(
    (frameId: number): void => {
      callbacks.delete(frameId);
    },
  );

  const flush = (): void => {
    const pendingCallbacks = [...callbacks.values()];

    callbacks.clear();

    pendingCallbacks.forEach((callback) => {
      callback(0);
    });
  };

  Object.defineProperty(globalThis, 'requestAnimationFrame', {
    configurable: true,
    writable: true,
    value: requestAnimationFrameMock,
  });

  Object.defineProperty(globalThis, 'cancelAnimationFrame', {
    configurable: true,
    writable: 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): void => {
        rangeHandler = handler;
      },
    ),

    unsubscribeVisibleLogicalRangeChange: jest.fn(),

    subscribeSizeChange: jest.fn(
      (handler: () => void): void => {
        sizeHandler = handler;
      },
    ),

    unsubscribeSizeChange: jest.fn(),
  };

  const coordinateToPriceMock = jest.fn(
    (_coordinate: number): number | null => 100,
  );

  const optionsMock = jest.fn(
    (): Record<string, unknown> => ({
      visible: true,
      baseValue: {
        type: 'price',
        price: 100,
      },
      topLineColor: '#00aa00',
      bottomLineColor: '#aa0000',
    }),
  );

  const seriesDataMock = jest.fn(
    (): readonly SeriesDataItemTypeMap<Time>['Baseline'][] => [],
  );

  const seriesMock = {
    applyOptions: jest.fn(),
    setData: jest.fn(),
    update: jest.fn(),
    priceScale: jest.fn(() => priceScale),
    options: optionsMock,
    data: seriesDataMock,
    dataByIndex: jest.fn(() => null),
    getPane: jest.fn(() => pane),
    coordinateToPrice: coordinateToPriceMock,
    createPriceLine: jest.fn(() => priceLine),
    removePriceLine: jest.fn(),

    subscribeDataChanged: jest.fn(
      (handler: () => void): void => {
        dataChangedHandler = handler;
      },
    ),

    unsubscribeDataChanged: jest.fn(),
  };

  const chartMock = {
    addSeries: jest.fn(() => seriesMock),
    removeSeries: jest.fn(),
    timeScale: jest.fn(() => timeScale),
  };

  const dataSourceMock = {
    subscribe: jest.fn(() => new Subscription()),
    subscribeRealtime: jest.fn(() => new Subscription()),
  };

  const params: BaseSeriesParams<'Baseline'> = {
    lwcChart: chartMock as unknown as IChartApi,
    dataSource: dataSourceMock as unknown as DataSource,
    mainSymbolId$: new BehaviorSubject('MOEX:SBER'),
    mainSymbol$: new BehaviorSubject('SBER'),
    mainSerie$: new BehaviorSubject<
      BaseSeriesParams<'Baseline'>['mainSerie$']['value']
    >(null),
  };

  return {
    chartMock,
    seriesMock,
    coordinateToPriceMock,
    optionsMock,
    priceLine,
    pane,
    timeScale,
    params,
    getRangeHandler: () => rangeHandler,
    getSizeHandler: () => sizeHandler,
    getDataChangedHandler: () => dataChangedHandler,
  };
}

function createStrategy(
  environment: ReturnType<typeof createEnvironment>,
): TestBaselineSeriesStrategy {
  return new TestBaselineSeriesStrategy(environment.params);
}

describe('BaselineSeriesStrategy', () => {
  let animationFrame: ReturnType<
    typeof createAnimationFrameController
  >;

  beforeEach(() => {
    animationFrame = createAnimationFrameController();
  });

  it('должен создавать BaselineSeries с relativeGradient', () => {
    const environment = createEnvironment();

    const strategy = createStrategy(environment);

    expect(strategy).toBeDefined();

    expect(environment.chartMock.addSeries).toHaveBeenCalledWith(
      BaselineSeries,
      {
        relativeGradient: true,
      },
      0,
    );
  });

  it('должен подписываться на изменение диапазона, размера и данных', () => {
    const environment = createEnvironment();

    createStrategy(environment);

    expect(
      environment.timeScale.subscribeVisibleLogicalRangeChange,
    ).toHaveBeenCalledWith(expect.any(Function));

    expect(
      environment.timeScale.subscribeSizeChange,
    ).toHaveBeenCalledWith(expect.any(Function));

    expect(
      environment.seriesMock.subscribeDataChanged,
    ).toHaveBeenCalledWith(expect.any(Function));
  });

  it('должен устанавливать baseline по центру панели', () => {
    const environment = createEnvironment();

    environment.pane.getHeight.mockReturnValue(600);
    environment.coordinateToPriceMock.mockReturnValue(100);

    createStrategy(environment);

    animationFrame.flush();

    expect(environment.coordinateToPriceMock).toHaveBeenCalledWith(300);

    expect(environment.seriesMock.applyOptions).toHaveBeenCalledWith({
      baseValue: {
        type: 'price',
        price: 100,
      },
    });
  });

  it('должен создавать горизонтальную линию baseline', () => {
    const environment = createEnvironment();

    createStrategy(environment);

    animationFrame.flush();

    expect(environment.seriesMock.createPriceLine).toHaveBeenCalledWith({
      price: 100,
      color: '#999999',
      lineWidth: 1,
      lineStyle: LineStyle.Dashed,
      axisLabelVisible: false,
    });
  });

  it('не должен обновлять baseline при нулевой высоте панели', () => {
    const environment = createEnvironment();

    environment.pane.getHeight.mockReturnValue(0);

    createStrategy(environment);

    animationFrame.flush();

    expect(environment.coordinateToPriceMock).not.toHaveBeenCalled();

    expect(
      environment.seriesMock.createPriceLine,
    ).not.toHaveBeenCalled();
  });

  it('не должен обновлять baseline если координата не преобразуется в цену', () => {
    const environment = createEnvironment();

    environment.coordinateToPriceMock.mockReturnValue(null);

    createStrategy(environment);

    animationFrame.flush();

    expect(
      environment.seriesMock.createPriceLine,
    ).not.toHaveBeenCalled();

    expect(
      environment.seriesMock.applyOptions,
    ).not.toHaveBeenCalledWith(
      expect.objectContaining({
        baseValue: expect.anything(),
      }),
    );
  });

  it('должен обновлять существующую линию вместо создания новой', () => {
    const environment = createEnvironment();

    createStrategy(environment);

    animationFrame.flush();

    expect(
      environment.seriesMock.createPriceLine,
    ).toHaveBeenCalledTimes(1);

    environment.coordinateToPriceMock.mockReturnValue(120);

    environment.getRangeHandler()?.();

    animationFrame.flush();

    expect(
      environment.seriesMock.createPriceLine,
    ).toHaveBeenCalledTimes(1);

    expect(environment.priceLine.applyOptions).toHaveBeenCalledWith({
      price: 120,
    });

    expect(environment.seriesMock.applyOptions).toHaveBeenCalledWith({
      baseValue: {
        type: 'price',
        price: 120,
      },
    });
  });

  it('должен пересчитывать baseline после изменения размера', () => {
    const environment = createEnvironment();

    createStrategy(environment);

    animationFrame.flush();

    environment.coordinateToPriceMock.mockClear();
    environment.pane.getHeight.mockReturnValue(800);

    environment.getSizeHandler()?.();

    animationFrame.flush();

    expect(environment.coordinateToPriceMock).toHaveBeenCalledWith(400);
  });

  it('должен пересчитывать baseline после изменения данных', () => {
    const environment = createEnvironment();

    createStrategy(environment);

    animationFrame.flush();

    environment.coordinateToPriceMock.mockClear();

    environment.getDataChangedHandler()?.();

    animationFrame.flush();

    expect(environment.coordinateToPriceMock).toHaveBeenCalledWith(300);
  });

  it('должен отменять предыдущий запланированный пересчёт', () => {
    const environment = createEnvironment();

    createStrategy(environment);

    environment.getRangeHandler()?.();

    expect(
      animationFrame.cancelAnimationFrameMock,
    ).toHaveBeenCalledTimes(1);

    expect(
      animationFrame.requestAnimationFrameMock,
    ).toHaveBeenCalledTimes(2);
  });

  it('должен использовать верхний цвет выше baseline', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.getLegendColorForTest({
        time: 1 as Time,
        value: 110,
      }),
    ).toBe('#00aa00');
  });

  it('должен использовать верхний цвет на уровне baseline', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.getLegendColorForTest({
        time: 1 as Time,
        value: 100,
      }),
    ).toBe('#00aa00');
  });

  it('должен использовать нижний цвет ниже baseline', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.getLegendColorForTest({
        time: 1 as Time,
        value: 90,
      }),
    ).toBe('#aa0000');
  });

  it('должен очищать подписки и удалять price line при destroy', () => {
    const environment = createEnvironment();

    const strategy = createStrategy(environment);

    animationFrame.flush();

    const rangeHandler =
      environment.getRangeHandler();

    const sizeHandler =
      environment.getSizeHandler();

    const dataHandler =
      environment.getDataChangedHandler();

    strategy.destroy();

    expect(
      environment.timeScale.unsubscribeVisibleLogicalRangeChange,
    ).toHaveBeenCalledWith(rangeHandler);

    expect(
      environment.timeScale.unsubscribeSizeChange,
    ).toHaveBeenCalledWith(sizeHandler);

    expect(
      environment.seriesMock.unsubscribeDataChanged,
    ).toHaveBeenCalledWith(dataHandler);

    expect(
      environment.seriesMock.removePriceLine,
    ).toHaveBeenCalledWith(environment.priceLine);

    expect(environment.chartMock.removeSeries).toHaveBeenCalledWith(
      environment.seriesMock,
    );
  });

  it('должен отменять запланированный frame при destroy', () => {
    const environment = createEnvironment();

    const strategy = createStrategy(environment);

    strategy.destroy();

    expect(
      animationFrame.cancelAnimationFrameMock,
    ).toHaveBeenCalled();

    expect(
      environment.seriesMock.removePriceLine,
    ).not.toHaveBeenCalled();
  });

  it('должен возвращать тип Baseline', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(strategy.getTypeName()).toBe('Baseline');
  });
});


















import {
  CandlestickSeries,
  type IChartApi,
  type SeriesDataItemTypeMap,
  type Time,
} from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';

import type { BaseSeriesParams } from '../BaseSeries';
import { CandlestickSeriesStrategy } from '../CandlestickSeriesStrategy';

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 createCandle = (
  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 seriesDataMock = jest.fn(
    (): readonly SeriesDataItemTypeMap<Time>['Candlestick'][] => [],
  );

  const dataByIndexMock = jest.fn(
    (
      _index: number,
      _direction?: unknown,
    ): SeriesDataItemTypeMap<Time>['Candlestick'] | null => null,
  );

  const seriesMock = {
    applyOptions: jest.fn(),
    setData: jest.fn(),
    update: jest.fn(),
    priceScale: jest.fn(() => priceScale),
    options: jest.fn(
      (): Record<string, unknown> => ({
        visible: true,
      }),
    ),
    data: seriesDataMock,
    dataByIndex: dataByIndexMock,
  };

  const chartMock = {
    addSeries: jest.fn(() => seriesMock),
    removeSeries: jest.fn(),
  };

  const dataSourceMock = {
    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: chartMock as unknown as IChartApi,
    dataSource: dataSourceMock as unknown as DataSource,
    mainSymbolId$: new BehaviorSubject('MOEX:SBER'),
    mainSymbol$: new BehaviorSubject('SBER'),
    mainSerie$: new BehaviorSubject<
      BaseSeriesParams<'Candlestick'>['mainSerie$']['value']
    >(null),
  };

  return {
    chartMock,
    seriesMock,
    seriesDataMock,
    params,
    getHistoryCallback: () => historyCallback,
    getRealtimeCallback: () => realtimeCallback,
  };
}

function createStrategy(
  environment: ReturnType<typeof createEnvironment>,
): CandlestickSeriesStrategy {
  return new CandlestickSeriesStrategy(environment.params);
}

describe('CandlestickSeriesStrategy', () => {
  it('должен создавать CandlestickSeries с настройками темы', () => {
    const environment = createEnvironment();

    const strategy = createStrategy(environment);

    expect(strategy).toBeDefined();

    expect(environment.chartMock.addSeries).toHaveBeenCalledWith(
      CandlestickSeries,
      {
        upColor: '#00aa00',
        downColor: '#aa0000',
        borderVisible: false,
        wickUpColor: '#11bb11ff',
        wickDownColor: '#bb1111ff',
      },
      0,
    );
  });

  it('должен возвращать тип Candlestick', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(strategy.getTypeName()).toBe('Candlestick');
  });

  it('должен принимать корректные OHLC данные', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.validateData([
        createCandle(1, 100, 110),
      ]),
    ).toBe(true);
  });

  it('должен принимать whitespace данные только с time', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.validateData([
        {
          time: 1,
        },
      ]),
    ).toBe(true);
  });

  it('должен отклонять данные без числового time', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.validateData([
        {
          time: undefined,
        },
      ]),
    ).toBe(false);

    expect(
      strategy.validateData(null as never),
    ).toBe(false);

    expect(
      strategy.validateData([
        null as never,
      ]),
    ).toBe(false);
  });

  it('должен отклонять свечу с некорректным high', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.validateData([
        {
          time: 1,
          open: 100,
          high: 105,
          low: 90,
          close: 110,
        },
      ]),
    ).toBe(false);
  });

  it('должен отклонять свечу с некорректным low', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.validateData([
        {
          time: 1,
          open: 100,
          high: 120,
          low: 105,
          close: 110,
        },
      ]),
    ).toBe(false);
  });

  it('должен устанавливать отформатированные исторические данные', () => {
    const environment = createEnvironment();

    createStrategy(environment);

    const input = [
      createCandle(1, 100, 110),
      createCandle(2, 110, 105),
    ];

    environment.getHistoryCallback()?.(input);

    expect(environment.seriesMock.setData).toHaveBeenCalledWith(
      input.map((point) => ({
        time: point.time,
        open: point.open,
        high: point.high,
        low: point.low,
        close: point.close,
        customValues: point,
      })),
    );
  });

  it('должен обновлять realtime свечу в historical режиме', () => {
    const environment = createEnvironment();

    createStrategy(environment);

    const input = createCandle(10, 100, 110);

    environment.getRealtimeCallback()?.(input);

    expect(environment.seriesMock.update).toHaveBeenCalledWith(
      {
        time: 10,
        open: input.open,
        high: input.high,
        low: input.low,
        close: input.close,
        customValues: input,
      },
      true,
    );
  });

  it('не должен устанавливать невалидные исторические данные', () => {
    const environment = createEnvironment();

    const errorSpy = jest
      .spyOn(console, 'error')
      .mockImplementation(() => undefined);

    createStrategy(environment);

    environment.getHistoryCallback()?.([
      {
        ...createCandle(1, 100, 110),
        high: 105,
      },
    ]);

    expect(environment.seriesMock.setData).not.toHaveBeenCalled();

    expect(errorSpy).toHaveBeenCalledWith(
      'LightweightAPI: Invalid data format for Candlestick chart',
    );

    errorSpy.mockRestore();
  });

  it('не должен обновлять невалидные realtime данные', () => {
    const environment = createEnvironment();

    const errorSpy = jest
      .spyOn(console, 'error')
      .mockImplementation(() => undefined);

    createStrategy(environment);

    environment.getRealtimeCallback()?.({
      ...createCandle(1, 100, 110),
      low: 105,
    });

    expect(environment.seriesMock.update).not.toHaveBeenCalled();

    expect(errorSpy).toHaveBeenCalledWith(
      'LightweightAPI: Invalid data format for Candlestick chart',
    );

    errorSpy.mockRestore();
  });

  it('должен формировать bullish легенду с цветом растущей свечи', () => {
    const environment = createEnvironment();

    environment.seriesDataMock.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 = createStrategy(environment);

    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('должен использовать цвет падающей свечи для bearish легенды', () => {
    const environment = createEnvironment();

    environment.seriesDataMock.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 = createStrategy(environment);

    expect(strategy.getLegendData().close?.color).toBe('#bb1111');
  });

  it('должен возвращать пустую легенду для не OHLC данных', () => {
    const environment = createEnvironment();

    environment.seriesDataMock.mockReturnValue([
      {
        time: 1 as Time,
        value: 10,
      } as unknown as SeriesDataItemTypeMap<Time>['Candlestick'],
    ]);

    const strategy = createStrategy(environment);

    expect(strategy.getLegendData()).toEqual({});
  });
});


















import {
  BarSeries,
  type IChartApi,
  type SeriesDataItemTypeMap,
  type Time,
} from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';

import type { BaseSeriesParams } from '../BaseSeries';
import { BarSeriesStrategy } from '../BarSeriesStrategy';

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 createCandle = (
  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 seriesDataMock = jest.fn(
    (): readonly SeriesDataItemTypeMap<Time>['Bar'][] => [],
  );

  const dataByIndexMock = jest.fn(
    (
      _index: number,
      _direction?: unknown,
    ): SeriesDataItemTypeMap<Time>['Bar'] | null => null,
  );

  const seriesMock = {
    applyOptions: jest.fn(),
    setData: jest.fn(),
    update: jest.fn(),
    priceScale: jest.fn(() => priceScale),
    options: jest.fn(
      (): Record<string, unknown> => ({
        visible: true,
      }),
    ),
    data: seriesDataMock,
    dataByIndex: dataByIndexMock,
  };

  const chartMock = {
    addSeries: jest.fn(() => seriesMock),
    removeSeries: jest.fn(),
  };

  const dataSourceMock = {
    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: chartMock as unknown as IChartApi,
    dataSource: dataSourceMock as unknown as DataSource,
    mainSymbolId$: new BehaviorSubject('MOEX:SBER'),
    mainSymbol$: new BehaviorSubject('SBER'),
    mainSerie$: new BehaviorSubject<
      BaseSeriesParams<'Bar'>['mainSerie$']['value']
    >(null),
  };

  return {
    chartMock,
    seriesMock,
    seriesDataMock,
    params,
    getHistoryCallback: () => historyCallback,
    getRealtimeCallback: () => realtimeCallback,
  };
}

function createStrategy(
  environment: ReturnType<typeof createEnvironment>,
): BarSeriesStrategy {
  return new BarSeriesStrategy(environment.params);
}

describe('BarSeriesStrategy', () => {
  it('должен создавать BarSeries с настройками темы', () => {
    const environment = createEnvironment();

    const strategy = createStrategy(environment);

    expect(strategy).toBeDefined();

    expect(environment.chartMock.addSeries).toHaveBeenCalledWith(
      BarSeries,
      {
        upColor: '#00aa00',
        downColor: '#aa0000',
      },
      0,
    );
  });

  it('должен возвращать тип Bar', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(strategy.getTypeName()).toBe('Bar');
  });

  it('должен принимать данные с числовым time', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.validateData([
        createCandle(1, 100, 110),
      ]),
    ).toBe(true);

    expect(
      strategy.validateData([
        {
          time: 1,
        },
      ]),
    ).toBe(true);
  });

  it('должен отклонять данные без числового time', () => {
    const environment = createEnvironment();
    const strategy = createStrategy(environment);

    expect(
      strategy.validateData([
        {
          time: undefined,
        },
      ]),
    ).toBe(false);

    expect(
      strategy.validateData(null as never),
    ).toBe(false);
  });

  it('должен устанавливать отформатированные исторические данные', () => {
    const environment = createEnvironment();

    createStrategy(environment);

    const input = [
      createCandle(1, 100, 110),
      createCandle(2, 110, 105),
    ];

    environment.getHistoryCallback()?.(input);

    expect(environment.seriesMock.setData).toHaveBeenCalledWith(
      input.map((point) => ({
        time: point.time,
        open: point.open,
        high: point.high,
        low: point.low,
        close: point.close,
        customValues: point,
      })),
    );
  });

  it('должен обновлять realtime данные в historical режиме', () => {
    const environment = createEnvironment();

    createStrategy(environment);

    const input = createCandle(10, 100, 110);

    environment.getRealtimeCallback()?.(input);

    expect(environment.seriesMock.update).toHaveBeenCalledWith(
      {
        time: input.time,
        open: input.open,
        high: input.high,
        low: input.low,
        close: input.close,
        customValues: input,
      },
      true,
    );
  });

  it('не должен устанавливать невалидные исторические данные', () => {
    const environment = createEnvironment();

    const errorSpy = jest
      .spyOn(console, 'error')
      .mockImplementation(() => undefined);

    createStrategy(environment);

    environment.getHistoryCallback()?.([
      {
        ...createCandle(1, 100, 110),
        time: undefined,
      } as unknown as Candle,
    ]);

    expect(environment.seriesMock.setData).not.toHaveBeenCalled();

    expect(errorSpy).toHaveBeenCalledWith(
      'LightweightAPI: Invalid data format for Bar chart',
    );

    errorSpy.mockRestore();
  });

  it('не должен обновлять невалидные realtime данные', () => {
    const environment = createEnvironment();

    const errorSpy = jest
      .spyOn(console, 'error')
      .mockImplementation(() => undefined);

    createStrategy(environment);

    environment.getRealtimeCallback()?.({
      ...createCandle(1, 100, 110),
      time: undefined,
    } as unknown as Candle);

    expect(environment.seriesMock.update).not.toHaveBeenCalled();

    expect(errorSpy).toHaveBeenCalledWith(
      'LightweightAPI: Invalid data format for Bar chart',
    );

    errorSpy.mockRestore();
  });

  it('должен формировать bullish легенду с цветом растущего бара', () => {
    const environment = createEnvironment();

    environment.seriesDataMock.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 = createStrategy(environment);

    const legend = strategy.getLegendData();

    expect(legend.open).toEqual({
      value: '100',
      name: 'Open',
      color: '#11bb11',
    });

    expect(legend.high).toEqual({
      value: '125',
      name: 'High',
      color: '#11bb11',
    });

    expect(legend.low).toEqual({
      value: '95',
      name: 'Low',
      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',
    });

    expect(legend.time).toEqual({
      value: 2,
      name: 'Time',
      color: '#11bb11',
    });
  });

  it('должен использовать цвет падающего бара для bearish легенды', () => {
    const environment = createEnvironment();

    environment.seriesDataMock.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 = createStrategy(environment);

    expect(strategy.getLegendData().close?.color).toBe('#bb1111');
  });

  it('должен возвращать пустую легенду для не OHLC данных', () => {
    const environment = createEnvironment();

    environment.seriesDataMock.mockReturnValue([
      {
        time: 1 as Time,
        value: 10,
      } as unknown as SeriesDataItemTypeMap<Time>['Bar'],
    ]);

    const strategy = createStrategy(environment);

    expect(strategy.getLegendData()).toEqual({});
  });
});