chromium/ui/file_manager/file_manager/lib/selector_unittest.ts

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assertEquals, assertFalse, assertNotReached, assertTrue} from 'chrome://webui-test/chromeos/chai_assert.js';

import {waitForElementUpdate} from '../common/js/unittest_util.js';
import {customElement, html, XfBase} from '../widgets/xf_base.js';

import {BaseStore, Slice} from './base_store.js';
import type {TestState} from './for_tests.js';
import {combine1Selector, combine2Selectors, SelectorEmitter, SelectorNode, shallowEqual, strictlyEqual} from './selector.js';

// Test that DAG nodes only emit if at least one of their parents also emits a
// new value and if their value has changed.
export function testSelectorEmitter() {
  let state = {slice1: 123, slice2: 'asd'};

  const selectorEmitter = new SelectorEmitter();

  const rootNode = SelectorNode.createSourceNode(() => state);
  selectorEmitter.addSource(rootNode);

  const slice1Node = new SelectorNode([rootNode], (state) => state.slice1);
  const slice2Node = new SelectorNode([rootNode], (state) => state.slice2);
  const nestedSlice2Node =
      new SelectorNode([slice2Node], (slice2) => slice2 + 'qwerty');

  // Verify initial states.
  assertEquals(rootNode.get(), state);
  assertEquals(slice1Node.get(), state.slice1);
  assertEquals(slice2Node.get(), state.slice2);
  assertEquals(nestedSlice2Node.get(), 'asdqwerty');

  let rootCount = 0;
  let child1Count = 0;
  let child2Count = 0;
  let nestedChildCount = 0;
  rootNode.subscribe(() => rootCount++);
  slice1Node.subscribe(() => child1Count++);
  slice2Node.subscribe(() => child2Count++);
  nestedSlice2Node.subscribe(() => nestedChildCount++);

  // Only the root selector and the slice1 selector should emit.
  state = {...state, slice1: 9999};
  selectorEmitter.processChange();

  assertEquals(rootCount, 1);
  assertEquals(child1Count, 1);
  assertEquals(child2Count, 0);
  assertEquals(nestedChildCount, 0);
  assertEquals(slice1Node.get(), 9999);
  assertEquals(slice2Node.get(), 'asd');
  assertEquals(nestedSlice2Node.get(), 'asdqwerty');

  // Only root, slice2, and slice2's child should update.
  state = {...state, slice2: 'qwerty'};
  selectorEmitter.processChange();

  assertEquals(rootCount, 2);
  assertEquals(child1Count, 1);
  assertEquals(child2Count, 1);
  assertEquals(nestedChildCount, 1);
  assertEquals(slice1Node.get(), 9999);
  assertEquals(slice2Node.get(), 'qwerty');
  assertEquals(nestedSlice2Node.get(), 'qwertyqwerty');
}

/**
 * Tests selectors' `get()` and `subscribe()` functions.
 */
export function testSelectorGetAndSubscribe() {
  const numVisitorsSlice =
      new Slice<TestState, TestState['numVisitors']>('numVisitors');

  const increaseBy = numVisitorsSlice.addReducer(
      'increase-by',
      (state: TestState, payload: number) =>
          ({...state, numVisitors: state.numVisitors! + payload}));

  let counter = 0;
  const notifiedValues: Array<TestState['numVisitors']> = [];
  numVisitorsSlice.selector.subscribe((numVisitors) => {
    counter++;
    notifiedValues.push(numVisitors);
  });

  // The store hasn't been initialized yet, so the slice's state should be
  // undefined.
  assertEquals(numVisitorsSlice.selector.get(), undefined);

  const store = new BaseStore<TestState>({}, [numVisitorsSlice]);
  store.init({numVisitors: 0});

  // Now that the store has been initialized with `numVisitors: 0`, the slice's
  // state should be 0.
  assertEquals(numVisitorsSlice.selector.get(), 0);

  store.dispatch(increaseBy(2));

  // Similarly, it should reflect the numVisitors being increased to 2 by the
  // reducer handling the action `increaseBy(2)`.
  assertEquals(numVisitorsSlice.selector.get(), 2);
  assertEquals(counter, 2);
  assertEquals(notifiedValues[0], 0);
  assertEquals(notifiedValues[1], 2);

  // Introduce a combined selector and verify it works as expected.
  const combined = combine2Selectors(
      (storeState, sliceState) => JSON.stringify(storeState) + sliceState,
      store.selector, numVisitorsSlice.selector);
  assertEquals(combined.get(), '{"numVisitors":2}2');
  store.dispatch(increaseBy(1));
  assertEquals(combined.get(), '{"numVisitors":3}3');
}

// Test that Lit components work with selector's ReactiveControllers.
export async function testSelectorController(done: () => void) {
  const slice = new Slice<TestState, TestState['numVisitors']>('numVisitors');

  const increaseBy = slice.addReducer(
      'increase-by',
      (state: TestState, payload: number) =>
          ({...state, numVisitors: state.numVisitors! + payload}));

  const store = new BaseStore<TestState>({}, [slice]);
  store.init({numVisitors: 0});

  // Create a test lit component using our selector's controller.
  @customElement('xf-test')
  class XfTest extends XfBase {
    testCtrl = slice.selector.createController(this);

    override render() {
      return html`
      <div id="test">${this.testCtrl.value}</div>
    `;
    }
  }

  // Silence unused var TS error.
  console.log(!!XfTest);

  // Create test element and add it to the DOM, then wait for it to be ready on
  // the next event loop cycle.
  const testEl = document.createElement('xf-test');
  document.body.appendChild(testEl);
  await waitForElementUpdate(testEl);

  const testDiv = testEl.shadowRoot!.querySelector('#test') as HTMLDivElement;
  assertEquals(testDiv.textContent, '0');

  // Update the store and verify that the component automatically re-renders.
  store.dispatch(increaseBy(2));
  await waitForElementUpdate(testEl);


  assertEquals(testDiv.textContent, '2');

  done();
}

// Test that if one selector's subscriber errors out, the remaining subscribers
// are still notified.
export function testSubscriberErrorIsSelfContained() {
  let state = 0;

  const selectorEmitter = new SelectorEmitter();

  const rootNode = SelectorNode.createSourceNode(() => state);
  selectorEmitter.addSource(rootNode);

  let count = 0;
  rootNode.subscribe(() => count++);
  rootNode.subscribe(() => {
    throw new Error('Boom!');
  });
  rootNode.subscribe(() => count++);

  state = 1;
  selectorEmitter.processChange();

  assertEquals(count, 2);
}

// Test that updates are no longer received after the selector subscription is
// cancelled.
export function testUnsubscribing() {
  let state = 0;

  const selectorEmitter = new SelectorEmitter();

  const rootNode = SelectorNode.createSourceNode(() => state);
  selectorEmitter.addSource(rootNode);

  let count = 0;
  const unsubscribe = rootNode.subscribe(() => count++);

  state = 1;
  selectorEmitter.processChange();
  assertEquals(count, 1);

  // Count should no longer increase after calling unsubscribe.
  unsubscribe();

  state = 2;
  selectorEmitter.processChange();
  assertEquals(count, 1);  // Count should still be 1, not 2.
}

// Test that deleted selectors no longer provide updates.
export function testDeletingSelector() {
  let state = 0;

  const selectorEmitter = new SelectorEmitter();

  const rootNode = SelectorNode.createSourceNode(() => state);
  selectorEmitter.addSource(rootNode);

  let count = 0;
  rootNode.subscribe(() => count++);

  state = 1;
  selectorEmitter.processChange();
  assertEquals(count, 1);

  // Count should no longer increase after deleting the selector.
  rootNode.delete();

  state = 2;
  selectorEmitter.processChange();
  assertEquals(count, 1);  // Count should still be 1, not 2.
}

// Test that Selector Emitter nodes are explored in the ascending order of
// depth.
export function testSelectorEmitterTraversal() {
  let state = 0;

  const selectorEmitter = new SelectorEmitter();

  const rootNode = SelectorNode.createSourceNode(() => state);
  selectorEmitter.addSource(rootNode);

  const child1 = new SelectorNode([rootNode], (r) => r + 1);
  const child2 = new SelectorNode([rootNode, child1], (r, c1) => r + c1 + 1);
  const child3 = new SelectorNode([rootNode, child2], (r, c2) => r + c2 + 1);

  let order = '';
  rootNode.subscribe(() => order += 0);
  child1.subscribe(() => order += 1);
  child2.subscribe(() => order += 2);
  child3.subscribe(() => order += 3);

  state = 1;
  selectorEmitter.processChange();

  // Verify nodes emit in the expected order (lowest depth first).
  // Explanation: after the root is explored, all 3 children should be queued
  // for later exploration. If they are explored in the wrong order, we'd get
  // '0321', instead of '0123'. Note: Although all child nodes are children of
  // root, child1 has a depth of 1, child2 of 2, and child3 of 3 - because the
  // root node isn't their only parent.
  assertEquals(order, '0123');
}

// Test that strictlyEqual and shallowEqual works in different ways.
export function testCustomCompare() {
  const obj1 = {a: 'aaa', b: 123};
  const obj2 = {a: 'aaa', b: 123};

  assertTrue(shallowEqual(obj1, obj2));
  assertFalse(strictlyEqual(obj1, obj2));

  // shallowEqual only compares the first level, it doesn't go deeper.
  const b = {b: 'bbb'};
  const obj3 = {a: b};
  const obj4 = {a: {b: 'bbb'}};
  const obj5 = {a: b};

  assertTrue(shallowEqual(obj3, obj5));
  assertFalse(shallowEqual(obj3, obj4));
  assertFalse(strictlyEqual(obj3, obj4));
  assertFalse(strictlyEqual(obj3, obj5));

  // shallowEqual can't be called with non-object.
  try {
    shallowEqual('aaa' as any, 'bbb' as any);
    assertNotReached();
  } catch (e: unknown) {
    assertTrue(e instanceof Error);
  }
}

// Test that selector can accepts a custom isEqual function which will be used
// when checking changes.
export function testSelectorCustomEqual() {
  const slice1 = new Slice<TestState, TestState['numVisitors']>('numVisitors');
  const slice2 =
      new Slice<TestState, TestState['latestPayload']>('latestPayload');

  const increaseBy = slice1.addReducer(
      'increase-by',
      (state: TestState, payload: number) =>
          ({...state, numVisitors: state.numVisitors! + payload}));

  const store = new BaseStore<TestState>({}, [slice1, slice2]);
  store.init({numVisitors: 0, latestPayload: 'aaa'});

  // Construct 2 selectors which returns a plain object (whose reference will
  // change every time the selector runs) contains "latestPayload" only.
  const selectorWithStrictEqual = combine1Selector(
      (state: TestState) => ({a: state.latestPayload}), store.selector,
      'strictly-equal');
  const selectorWithShallowEqual = combine1Selector(
      (state: TestState) => ({a: state.latestPayload}), store.selector,
      'shallow-equal', shallowEqual);

  let counter1 = 0;
  let counter2 = 0;
  selectorWithStrictEqual.subscribe(() => {
    counter1++;
  });
  selectorWithShallowEqual.subscribe(() => {
    counter2++;
  });

  // Change "numVisitors" twice.
  store.dispatch(increaseBy(1));
  store.dispatch(increaseBy(2));

  // Expect the selector calls its callback twice because its returned object
  // changes its reference every time (strict equal).
  assertEquals(2, counter1);
  // Expect the selector never calls its callback because its returned object
  // never changes its content (shallow equal).
  assertEquals(0, counter2);
}