chromium/ui/file_manager/file_manager/widgets/xf_tree_item_unittest.ts

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

import {getTrustedHTML} from 'chrome://resources/js/static_types.js';
import {assertEquals, assertFalse, assertNotEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {eventToPromise} from 'chrome://webui-test/test_util.js';

import {waitForElementUpdate} from '../common/js/unittest_util.js';
import {ICON_TYPES} from '../foreground/js/constants.js';

import type {XfIcon} from './xf_icon.js';
import type {XfTree} from './xf_tree.js';
import {TREE_ITEM_INDENT, type TreeItemCollapsedEvent, type TreeItemExpandedEvent, XfTreeItem} from './xf_tree_item.js';

/** Construct a single tree item. */
async function setUpSingleTreeItem() {
  document.body.innerHTML = getTrustedHTML`
    <xf-tree>
      <xf-tree-item id="item1" label="item1"></xf-tree-item>
    </xf-tree>
  `;
  const element = document.querySelector('xf-tree-item');
  assertNotEquals(null, element);
  await waitForElementUpdate(element!);
}

/** Construct a tree with nested tree items. */
async function setUpNestedTreeItems() {
  // Tree structure:
  // ── item1
  //    ├── item1a
  //    └── item1b
  //        └── item1bi
  // ── item2
  document.body.innerHTML = getTrustedHTML`
    <xf-tree><xf-tree>
  `;
  const tree = document.querySelector('xf-tree')!;
  assertNotEquals(null, tree);

  const item1 = document.createElement('xf-tree-item');
  item1.id = 'item1';
  const item1a = document.createElement('xf-tree-item');
  item1a.id = 'item1a';
  const item1b = document.createElement('xf-tree-item');
  item1b.id = 'item1b';
  const item1bi = document.createElement('xf-tree-item');
  item1bi.id = 'item1bi';
  const item2 = document.createElement('xf-tree-item');
  item2.id = 'item2';

  item1b.appendChild(item1bi);
  item1.appendChild(item1a);
  item1.appendChild(item1b);
  tree.appendChild(item1);
  tree.appendChild(item2);

  await waitForElementUpdate(tree);
}

/** Helper method to get tree item by id. */
function getTree(): XfTree {
  return document.querySelector('xf-tree')!;
}

/** Helper method to get tree item by id. */
function getTreeItemById(id: string): XfTreeItem {
  return document.querySelector(`xf-tree-item#${id}`)!;
}

/** Helper method to get inner elements from a tree item. */
function getTreeItemInnerElements(treeItem: XfTreeItem): {
  root: HTMLLIElement,
  treeRow: HTMLDivElement,
  expandIcon: HTMLSpanElement,
  treeLabel: HTMLSpanElement,
  treeLabelIcon: XfIcon,
  trailingIcon: HTMLSlotElement,
  treeChildren: HTMLUListElement,
} {
  return {
    root: treeItem.shadowRoot!.querySelector('li')!,
    treeRow: treeItem.shadowRoot!.querySelector('.tree-row')!,
    expandIcon: treeItem.shadowRoot!.querySelector('.expand-icon')!,
    treeLabel: treeItem.shadowRoot!.querySelector('.tree-label')!,
    treeLabelIcon: treeItem.shadowRoot!.querySelector('.tree-label-icon')!,
    trailingIcon:
        treeItem.shadowRoot!.querySelector('slot[name="trailingIcon"]')!,
    treeChildren: treeItem.shadowRoot!.querySelector('.tree-children')!,
  };
}

/** Tests tree item can be rendered without tree or child tree items. */
export async function testRenderWithSingleTreeItem(done: () => void) {
  await setUpSingleTreeItem();
  const item1 = getTreeItemById('item1');
  const {root, treeRow, expandIcon, treeLabel, treeChildren} =
      getTreeItemInnerElements(item1);

  // Check item1's parent/children.
  assertEquals(1, item1.level);
  assertEquals(0, item1.items.length);
  assertEquals(null, item1.parentItem);
  assertEquals(getTree(), item1.tree);

  // Test attributes on the root element.
  assertEquals('treeitem', root.getAttribute('role'));
  assertEquals('false', root.getAttribute('aria-selected'));
  assertFalse(root.hasAttribute('aria-expanded'));
  assertEquals('false', root.getAttribute('aria-disabled'));
  assertEquals(treeLabel.id, root.getAttribute('aria-labelledby'));

  // Test inner elements.
  assertEquals('0px', window.getComputedStyle(treeRow).paddingInlineStart);
  assertEquals('hidden', window.getComputedStyle(expandIcon).visibility);
  assertEquals('item1', treeLabel.textContent);
  assertEquals('group', treeChildren.getAttribute('role'));

  done();
}

/** Tests tree item can be rendered with child tree items. */
export async function testRenderWithTreeItems(done: () => void) {
  await setUpNestedTreeItems();
  const tree = getTree();

  // Check item1's parent/children.
  const item1 = getTreeItemById('item1');
  const {root: root1, expandIcon: expandIcon1} =
      getTreeItemInnerElements(item1);
  assertEquals('false', root1.getAttribute('aria-expanded'));
  assertEquals(2, item1.items.length);
  assertEquals('item1a', item1.items[0]!.id);
  assertEquals('item1b', item1.items[1]!.id);
  assertEquals(null, item1.parentItem);
  assertEquals(tree, item1.tree);
  assertEquals('visible', window.getComputedStyle(expandIcon1).visibility);

  // Check item1b's parent/children.
  const item1b = getTreeItemById('item1b');
  const {root: root1b, expandIcon: expandIcon1b} =
      getTreeItemInnerElements(item1);
  assertEquals('false', root1b.getAttribute('aria-expanded'));
  assertEquals(1, item1b.items.length);
  assertEquals('item1bi', item1b.items[0]!.id);
  assertEquals(item1, item1b.parentItem);
  assertEquals(tree, item1b.tree);
  assertEquals('visible', window.getComputedStyle(expandIcon1b).visibility);

  done();
}

/** Tests "may-have-children" attribute. */
export async function testMayHaveChildrenAttribute(done: () => void) {
  await setUpSingleTreeItem();

  const item1 = getTreeItemById('item1');
  const {root} = getTreeItemInnerElements(item1);
  // Expand icon is hidden by default (no aria-expanded).
  assertFalse(root.hasAttribute('aria-expanded'));
  // Set may-have-children=true.
  item1.mayHaveChildren = true;
  await waitForElementUpdate(item1);
  // Expand-icon should be visible now (has aria-expanded).
  assertTrue(root.hasAttribute('aria-expanded'));
  assertTrue(item1.hasChildren());

  done();
}

/** Tests tree item level will be correctly updated. */
export async function testTreeItemLevel(done: () => void) {
  await setUpNestedTreeItems();

  const item1 = getTreeItemById('item1');
  const item1a = getTreeItemById('item1a');
  const item1b = getTreeItemById('item1b');
  const item1bi = getTreeItemById('item1bi');
  const item2 = getTreeItemById('item2');

  const tree = getTree();
  tree.style.setProperty('--xf-tree-item-indent', TREE_ITEM_INDENT.toString());

  const {treeRow: treeRow1} = getTreeItemInnerElements(item1);
  assertEquals(1, item1.level);
  assertEquals('0px', window.getComputedStyle(treeRow1).paddingInlineStart);

  const {treeRow: treeRow1a} = getTreeItemInnerElements(item1a);
  assertEquals(2, item1a.level);
  assertEquals(
      `${TREE_ITEM_INDENT}px`,
      window.getComputedStyle(treeRow1a).paddingInlineStart);

  const {treeRow: treeRow1b} = getTreeItemInnerElements(item1b);
  assertEquals(2, item1b.level);
  assertEquals(
      `${TREE_ITEM_INDENT}px`,
      window.getComputedStyle(treeRow1b).paddingInlineStart);

  const {treeRow: treeRow1bi} = getTreeItemInnerElements(item1bi);
  assertEquals(3, item1bi.level);
  assertEquals(
      `${TREE_ITEM_INDENT * 2}px`,
      window.getComputedStyle(treeRow1bi).paddingInlineStart);

  const {treeRow: treeRow2} = getTreeItemInnerElements(item2);
  assertEquals(1, item2.level);
  assertEquals('0px', window.getComputedStyle(treeRow2).paddingInlineStart);

  done();
}

/** Tests trialing icon can be rendered correctly. */
export async function testTrailingIcon(done: () => void) {
  await setUpSingleTreeItem();
  const item1 = getTreeItemById('item1');

  // Add a trailing icon for item1.
  const icon = document.createElement('span');
  icon.slot = 'trailingIcon';
  item1.appendChild(icon);

  const {trailingIcon} = getTreeItemInnerElements(item1);
  const slotElements = trailingIcon.assignedElements();
  assertEquals(1, slotElements.length);
  assertEquals(icon, slotElements[0]);

  done();
}

/**
 * Tests disabled tree item won't be included in the tabbable items.
 */
export async function testDisabledTreeItem(done: () => void) {
  await setUpNestedTreeItems();

  // By default item1 has 2 tabbable items.
  const item1 = getTreeItemById('item1');
  assertEquals(2, item1.tabbableItems.length);

  // Disable item1b.
  const item1b = getTreeItemById('item1b');
  item1b.disabled = true;
  await waitForElementUpdate(item1b);

  // aria-disabled should be true and expand icon should be hidden.
  const {root, expandIcon} = getTreeItemInnerElements(item1b);
  assertEquals('true', root.getAttribute('aria-disabled'));
  assertEquals('hidden', window.getComputedStyle(expandIcon).visibility);

  // item1b will be ignored in tabbable items.
  assertEquals(2, item1.items.length);
  assertEquals(1, item1.tabbableItems.length);
  assertEquals('item1a', item1.tabbableItems[0]!.id);

  done();
}

/** Tests tree item can be selected. */
export async function testSelectTreeItem(done: () => void) {
  await setUpNestedTreeItems();

  // Select item1bi.
  const item1bi = getTreeItemById('item1bi');
  item1bi.selected = true;
  await waitForElementUpdate(item1bi);

  const {root} = getTreeItemInnerElements(item1bi);
  assertEquals('true', root.getAttribute('aria-selected'));
  const tree = getTree();
  assertEquals('item1bi', tree.selectedItem!.id);

  // All its parent chain will be expanded.
  const item1b = getTreeItemById('item1b');
  const item1 = getTreeItemById('item1');
  assertTrue(item1b.expanded);
  assertTrue(item1.expanded);

  // Unselect item1bi.
  item1bi.selected = false;
  await waitForElementUpdate(item1bi);

  assertEquals('false', root.getAttribute('aria-selected'));
  assertEquals(null, tree.selectedItem);

  done();
}


/** Tests tree item can be expanded. */
export async function testExpandTreeItem(done: () => void) {
  await setUpNestedTreeItems();

  // By default children items are not displayed.
  const item1 = getTreeItemById('item1');
  const {treeChildren} = getTreeItemInnerElements(item1);
  assertEquals('none', window.getComputedStyle(treeChildren).display);

  // Expand item1.
  const itemExpandedEventPromise: Promise<TreeItemExpandedEvent> =
      eventToPromise(XfTreeItem.events.TREE_ITEM_EXPANDED, item1);
  item1.expanded = true;
  await waitForElementUpdate(item1);
  const {root} = getTreeItemInnerElements(item1);
  assertEquals('true', root.getAttribute('aria-expanded'));

  // Assert the event is triggered.
  const itemExpandedEvent = await itemExpandedEventPromise;
  assertEquals(item1, itemExpandedEvent.detail.item);

  // Assert the children items are shown.
  assertEquals('block', window.getComputedStyle(treeChildren).display);

  done();
}

/**
 * Tests tree item can be collapsed.
 */
export async function testCollapseTreeItem(done: () => void) {
  await setUpNestedTreeItems();

  // Select item1b.
  const item1b = getTreeItemById('item1b');
  item1b.selected = true;
  await waitForElementUpdate(item1b);

  const item1 = getTreeItemById('item1');
  const {treeChildren} = getTreeItemInnerElements(item1);
  await waitForElementUpdate(item1);
  assertTrue(item1.expanded);

  // Collapse item1.
  const itemCollapsedEventPromise: Promise<TreeItemCollapsedEvent> =
      eventToPromise(XfTreeItem.events.TREE_ITEM_COLLAPSED, item1);
  item1.expanded = false;
  await waitForElementUpdate(item1);
  const {root} = getTreeItemInnerElements(item1);
  assertEquals('false', root.getAttribute('aria-expanded'));

  // Assert the event is triggered.
  const itemCollapsedEvent = await itemCollapsedEventPromise;
  assertEquals(item1, itemCollapsedEvent.detail.item);

  // Assert the children items are hidden.
  assertEquals('none', window.getComputedStyle(treeChildren).display);

  done();
}

/** Tests adding/removing tree items. */
export async function testAddRemoveTreeItems(done: () => void) {
  await setUpSingleTreeItem();
  const item1 = getTreeItemById('item1');

  // Add item1a as a child to item1.
  const item1a = document.createElement('xf-tree-item');
  item1a.id = 'item1a';
  item1.appendChild(item1a);
  await waitForElementUpdate(item1);
  assertEquals(1, item1.items.length);
  assertEquals('item1a', item1.items[0]!.id);

  // Add item1b as a child to item1.
  const item1b = document.createElement('xf-tree-item');
  item1b.id = 'item1b';
  item1.appendChild(item1b);
  await waitForElementUpdate(item1);
  assertEquals(2, item1.items.length);
  assertEquals('item1b', item1.items[1]!.id);

  // Remove item1a.
  item1.removeChild(item1a);
  await waitForElementUpdate(item1);
  assertEquals(1, item1.items.length);
  assertEquals('item1b', item1.items[0]!.id);

  done();
}

/** Tests expanded item will become collapsed when last child is removed. */
export async function testRemoveChildForExpandedItem(done: () => void) {
  await setUpNestedTreeItems();

  // Expand item1.
  const item1 = getTreeItemById('item1');
  item1.expanded = true;
  await waitForElementUpdate(item1);

  // Remove item1a.
  const item1a = getTreeItemById('item1a');
  item1.removeChild(item1a);
  await waitForElementUpdate(item1);
  assertTrue(item1.expanded);

  // Remove item1b.
  const item1b = getTreeItemById('item1b');
  item1.removeChild(item1b);
  await waitForElementUpdate(item1);

  // item1 will be collapsed because all its children are removed.
  assertFalse(item1.expanded);

  done();
}

/** Tests removal of the selected item. */
export async function testRemoveSelectedItem(done: () => void) {
  await setUpNestedTreeItems();

  // Select item1a.
  const item1a = getTreeItemById('item1a');
  item1a.selected = true;
  await waitForElementUpdate(item1a);

  // Remove item1a.
  const item1 = getTreeItemById('item1');
  assertFalse(item1.selected);
  item1.removeChild(item1a);
  await waitForElementUpdate(item1);

  // The selected item should be null now.
  assertEquals(null, item1.tree?.selectedItem);

  done();
}

/** Tests removal of the focused item. */
export async function testRemoveFocusedItem(done: () => void) {
  await setUpNestedTreeItems();
  const tree = getTree();

  // Focus item1a.
  const item1a = getTreeItemById('item1a');
  tree.focusedItem = item1a;

  // Select item1b.
  const item1b = getTreeItemById('item1b');
  item1b.selected = true;
  await waitForElementUpdate(item1b);

  // Remove item1a.
  const item1 = getTreeItemById('item1');
  item1.removeChild(item1a);
  await waitForElementUpdate(item1);

  // The focused item should be the selected item now.
  assertEquals('item1b', tree.focusedItem.id);

  done();
}

/** Tests that iconSet has higher priority than icon property. */
export async function testIconSetIgnoreIcon(done: () => void) {
  await setUpSingleTreeItem();

  // Set both icon and iconSet.
  const item1 = getTreeItemById('item1');
  item1.icon = ICON_TYPES.ANDROID_FILES;
  item1.iconSet = {
    icon16x16Url: undefined,
    icon32x32Url: 'fake-base64-data',
  };
  await waitForElementUpdate(item1);

  // Check only iconSet property is set for the xf-icon.
  const {treeLabelIcon} = getTreeItemInnerElements(item1);
  assertEquals(null, treeLabelIcon.type);
  assertEquals('fake-base64-data', treeLabelIcon.iconSet!.icon32x32Url);

  done();
}

/** Tests the has-children attribute. */
export async function testHasChildrenAttribute(done: () => void) {
  await setUpSingleTreeItem();
  const item1 = getTreeItemById('item1');

  // Check has-children attribute is false because we have no children.
  assertEquals('false', item1.getAttribute('has-children'));

  // Add a child item for item1.
  const item1a = document.createElement('xf-tree-item');
  item1a.id = 'item1a';
  item1.appendChild(item1a);
  await waitForElementUpdate(item1);

  // Check has-children attribute is true now because we have 1 child now.
  assertEquals('true', item1.getAttribute('has-children'));

  done();
}