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


- Исправлено применение начального интервала при открытии графика


import { BehaviorSubject, Subscription } from 'rxjs';

import { Chart } from '@core/Chart';
import { Intervals } from '@src/types/intervals';

interface ChartInternals {
  eventManager: {
    symbolId: () => BehaviorSubject<string>;
    getInterval: () => BehaviorSubject<Intervals | null>;
  };
  compareManager: {
    itemsObs: () => BehaviorSubject<{ symbolId: string }[]>;
  };
  dataSource: {
    setSymbols: jest.Mock;
    loadTill: jest.Mock;
    loadAllHistory: jest.Mock;
    getOldestTime: jest.Mock;
    getLastCandle: jest.Mock;
  };
  lwcChart: {
    timeScale: () => {
      getVisibleRange: jest.Mock;
      setVisibleRange: jest.Mock;
    };
  };
  subscriptions: Subscription;
  activeSymbolIds: string[];
  currentInterval: Intervals | null;
  setupDataSourceSubs: () => void;
}

interface ChartTestContext {
  chart: ChartInternals;
  symbolId$: BehaviorSubject<string>;
  interval$: BehaviorSubject<Intervals | null>;
  compareItems$: BehaviorSubject<{ symbolId: string }[]>;
  setSymbols: jest.Mock;
  loadTill: jest.Mock;
  loadAllHistory: jest.Mock;
  getOldestTime: jest.Mock;
  getLastCandle: jest.Mock;
  getVisibleRange: jest.Mock;
  setVisibleRange: jest.Mock;
}

interface CreateChartOptions {
  symbolId?: string;
  interval?: Intervals | null;
  compareSymbolIds?: string[];
}

interface Deferred<T> {
  promise: Promise<T>;
  resolve: (value: T | PromiseLike<T>) => void;
}

function createDeferred<T>(): Deferred<T> {
  let resolve!: Deferred<T>['resolve'];

  const promise = new Promise<T>((resolver) => {
    resolve = resolver;
  });

  return {
    promise,
    resolve,
  };
}

async function flushAsyncOperations(): Promise<void> {
  await Promise.resolve();
  await Promise.resolve();

  jest.runOnlyPendingTimers();

  await Promise.resolve();
}

describe('Chart', () => {
  function createChartInternals({
    symbolId = '',
    interval = null,
    compareSymbolIds = ['SBER', 'SBER'],
  }: CreateChartOptions = {}): ChartTestContext {
    const symbolId$ = new BehaviorSubject(symbolId);
    const interval$ = new BehaviorSubject<Intervals | null>(interval);
    const compareItems$ = new BehaviorSubject(compareSymbolIds.map((value) => ({ symbolId: value })));

    const setSymbols = jest.fn();
    const loadTill = jest.fn(() => Promise.resolve());
    const loadAllHistory = jest.fn(() => Promise.resolve());
    const getOldestTime = jest.fn(() => 100);
    const getLastCandle = jest.fn(() => ({ time: 200 }));

    const getVisibleRange = jest.fn(() => null);
    const setVisibleRange = jest.fn();

    const chart = Object.assign(Object.create(Chart.prototype), {
      eventManager: {
        symbolId: () => symbolId$,
        getInterval: () => interval$,
      },
      compareManager: {
        itemsObs: () => compareItems$,
      },
      dataSource: {
        setSymbols,
        loadTill,
        loadAllHistory,
        getOldestTime,
        getLastCandle,
      },
      lwcChart: {
        timeScale: () => ({
          getVisibleRange,
          setVisibleRange,
        }),
      },
      subscriptions: new Subscription(),
      activeSymbolIds: [],
      currentInterval: null,
    }) as ChartInternals;

    return {
      chart,
      symbolId$,
      interval$,
      compareItems$,
      setSymbols,
      loadTill,
      loadAllHistory,
      getOldestTime,
      getLastCandle,
      getVisibleRange,
      setVisibleRange,
    };
  }

  beforeEach(() => {
    jest.useFakeTimers();

    jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
      return window.setTimeout(() => callback(performance.now()), 16);
    });

    jest.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => {
      window.clearTimeout(id);
    });
  });

  afterEach(() => {
    jest.clearAllTimers();
    jest.useRealTimers();
    jest.restoreAllMocks();
  });

  it('должен исключать пустой основной symbolId и удалять дубликаты', () => {
    const { chart, setSymbols } = createChartInternals();

    chart.setupDataSourceSubs();

    expect(setSymbols).toHaveBeenLastCalledWith(['SBER']);

    chart.subscriptions.unsubscribe();
  });

  it('должен зарегистрировать символ до загрузки данных начального интервала', () => {
    const { chart, setSymbols, loadTill } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals['1Y'],
    });

    chart.setupDataSourceSubs();

    expect(setSymbols).toHaveBeenCalledWith(['SBER']);
    expect(loadTill).toHaveBeenCalledTimes(1);

    expect(setSymbols.mock.invocationCallOrder[0]).toBeLessThan(loadTill.mock.invocationCallOrder[0]);

    chart.subscriptions.unsubscribe();
  });

  it('должен применить начальный интервал после загрузки данных', async () => {
    const { chart, setVisibleRange } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals['1Y'],
    });

    chart.setupDataSourceSubs();

    await flushAsyncOperations();

    expect(setVisibleRange).toHaveBeenCalledTimes(1);
    expect(setVisibleRange).toHaveBeenCalledWith({
      from: expect.any(Number),
      to: expect.any(Number),
    });

    chart.subscriptions.unsubscribe();
  });

  it('не должен применять интервал, если для инструментов нет данных', async () => {
    const { chart, getLastCandle, setVisibleRange } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals['1Y'],
    });

    getLastCandle.mockReturnValue(null);

    chart.setupDataSourceSubs();

    await flushAsyncOperations();

    expect(setVisibleRange).not.toHaveBeenCalled();

    chart.subscriptions.unsubscribe();
  });

  it('не должен применять устаревший интервал после выбора нового', async () => {
    const firstLoad = createDeferred<void>();
    const secondLoad = createDeferred<void>();

    const { chart, interval$, loadTill, setVisibleRange } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals['1Y'],
    });

    loadTill
      .mockImplementationOnce(() => firstLoad.promise)
      .mockImplementationOnce(() => secondLoad.promise);

    chart.setupDataSourceSubs();

    interval$.next(Intervals['1M']);

    secondLoad.resolve();

    await flushAsyncOperations();

    expect(setVisibleRange).toHaveBeenCalledTimes(1);

    firstLoad.resolve();

    await flushAsyncOperations();

    expect(setVisibleRange).toHaveBeenCalledTimes(1);

    chart.subscriptions.unsubscribe();
  });

  it('должен загрузить всю историю и применить диапазон для интервала All', async () => {
    const {
      chart,
      loadAllHistory,
      getOldestTime,
      getLastCandle,
      setVisibleRange,
    } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: ['GAZP'],
      interval: Intervals.All,
    });

    getOldestTime.mockImplementation((symbolId: string) => {
      return symbolId === 'SBER' ? 100 : 50;
    });

    getLastCandle.mockImplementation((symbolId: string) => {
      return {
        time: symbolId === 'SBER' ? 200 : 300,
      };
    });

    chart.setupDataSourceSubs();

    await flushAsyncOperations();

    expect(loadAllHistory).toHaveBeenCalledTimes(2);
    expect(loadAllHistory).toHaveBeenCalledWith('SBER');
    expect(loadAllHistory).toHaveBeenCalledWith('GAZP');

    expect(setVisibleRange).toHaveBeenCalledWith({
      from: 50,
      to: 300,
    });

    chart.subscriptions.unsubscribe();
  });

  it('не должен применять диапазон после отписки', async () => {
    const loading = createDeferred<void>();

    const { chart, loadTill, setVisibleRange } = createChartInternals({
      symbolId: 'SBER',
      compareSymbolIds: [],
      interval: Intervals['1Y'],
    });

    loadTill.mockReturnValue(loading.promise);

    chart.setupDataSourceSubs();

    chart.subscriptions.unsubscribe();

    loading.resolve();

    await flushAsyncOperations();

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