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


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({});
  });
});