chromium/ui/file_manager/file_manager/widgets/xf_breadcrumb_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 {assert} from 'chrome://resources/js/assert.js';
import {getTrustedHTML} from 'chrome://resources/js/static_types.js';
import {assertEquals, assertFalse, assertGT, assertNotEquals, assertTrue} from 'chrome://webui-test/chromeos/chai_assert.js';

import {hasOverflowEllipsis} from '../common/js/dom_utils.js';
import {waitUntil} from '../common/js/test_error_reporting.js';

import {type BreadcrumbClickedEvent, XfBreadcrumb} from './xf_breadcrumb.js';

/**
 * Creates new <xf-breadcrumb> element for each test. Asserts it has no initial
 * path using the element.path getter.
 */
export function setUp() {
  document.body.innerHTML = getTrustedHTML`
    <xf-breadcrumb></xf-breadcrumb>
  `;
  const breadcrumb = document.querySelector('xf-breadcrumb');
  assertEquals('', breadcrumb!.path);
}

/** Returns the <xf-breadcrumb> element. */
function getBreadcrumb(): XfBreadcrumb {
  const element = document.querySelector('xf-breadcrumb');
  assertNotEquals('none', window.getComputedStyle(element!).display);
  assertFalse(element!.hasAttribute('hidden'));
  return element!;
}

/**
 * Returns the <xf-breadcrumb> child button elements.
 */
function getAllBreadcrumbButtons(): HTMLButtonElement[] {
  const buttons = getBreadcrumb().shadowRoot!.querySelectorAll('button');
  return Array.from(buttons) as HTMLButtonElement[];
}

/**
 * Returns the not-hidden <xf-breadcrumb> main button elements. The breadcrumb
 * main buttons have an id, all other breadcrumb buttons do not.
 */
function getVisibleBreadcrumbMainButtons(): HTMLButtonElement[] {
  const notHiddenMain = 'button[id]';
  const buttons = getBreadcrumb().shadowRoot!.querySelectorAll(notHiddenMain);
  return Array.from(buttons) as HTMLButtonElement[];
}

/** Returns the last not-hidden <xf-breadcrumb> main button element. */
function getLastVisibleBreadcrumbMainButton(): HTMLButtonElement {
  return getVisibleBreadcrumbMainButtons().pop() as HTMLButtonElement;
}

/** Returns the <xf-breadcrumb> elider button element. */
function getBreadcrumbEliderButton(): HTMLButtonElement|null {
  const elider = 'button[elider]';
  const button = getBreadcrumb().shadowRoot!.querySelectorAll(elider);
  if (button.length > 0) {
    return button[0] as HTMLButtonElement;
  }
  return null;
}

/** Returns the <xf-breadcrumb> drop-down menu button elements. */
function getBreadcrumbMenuButtons(): HTMLButtonElement[] {
  const menuButton = 'cr-action-menu button';
  const buttons = getBreadcrumb().shadowRoot!.querySelectorAll(menuButton);
  return Array.from(buttons) as HTMLButtonElement[];
}

/**
 * Returns <xf-breadcrumb> main button visual state.
 * @param button Main button (these have an id).
 * @param i Number to assign to the button.
 */
function getMainButtonState(button: HTMLButtonElement, i: number): string {
  const display = window.getComputedStyle(button).display;

  const result = i + ': display:' + display + ' id=' + button.id + ' text=[' +
      button.textContent + ']';

  assertTrue(!!(button.id));
  return result;
}

/**
 * Returns <xf-breadcrumb> elider button visual state.
 * @param button Elider button.
 * @param i Number to assign to the button.
 */
function getEliderButtonState(button: HTMLButtonElement, i: number): string {
  const display = window.getComputedStyle(button).display;

  const result = i + ': display:' + display;
  const attributes: string[] = [];
  for (const value of button.getAttributeNames().values()) {
    if (value === 'aria-expanded') {  // drop-down menu: opened || closed
      attributes.push(value + '=' + button.getAttribute('aria-expanded'));
    } else if (value !== 'elider') {
      attributes.push(value);
    }
  }

  assertFalse(!!button.id, 'elider button should not have an id');
  assertTrue(button.hasAttribute('elider'));
  return result + ' elider[' + attributes.sort() + ']';
}

/**
 * Returns <xf-breadcrumb> drop-down menu button visual state.
 * @param button Drop-down menu button.
 */
function getDropDownMenuButtonState(button: HTMLButtonElement): string {
  const display = window.getComputedStyle(button).display;

  const result = `${button.classList.toString()}: display:` + display +
      ' text=[' + button.textContent + ']';

  assertFalse(!!button.id, 'drop-down buttons should not have an id');
  assertTrue(button.classList.contains('dropdown-item'));
  return result;
}

/** Returns the <xf-breadcrumb> buttons visual state. */
function getBreadcrumbButtonState(): string {
  const parts: string[] = [];
  const menus: string[] = [];

  const buttons = getAllBreadcrumbButtons();
  let number = 0;
  buttons.forEach((button) => {
    if (button.id) {  // Main buttons have an id.
      parts.push(getMainButtonState(button, ++number));
    } else if (button.hasAttribute('elider')) {  // Elider button.
      parts.push(getEliderButtonState(button, ++number));
    } else {  // A drop-down menu button.
      menus.push(getDropDownMenuButtonState(button));
    }
  });

  // Elider should only display for paths with more than 4 parts.
  if (getBreadcrumbEliderButton()) {
    assertGT(getBreadcrumb().parts.length, 4);
  }

  // The 'last' main button displayed should always be [disabled].
  const last = getLastVisibleBreadcrumbMainButton();
  if (getBreadcrumb().path !== '') {
    assertTrue(last.hasAttribute('disabled'));
  }

  if (menus.length) {
    return [parts[0], parts[1]].concat(menus, parts.slice(2)).join(' ');
  }

  return parts.join(' ');
}

/** Sets and Waits for the path to updated in the DOM. */
async function setAndWaitPath(path: string): Promise<void> {
  const element = getBreadcrumb();
  element.path = path;
  await element.updateComplete;
}

function simulateMouseEnter(element: HTMLElement) {
  const ev = new MouseEvent('mouseenter', {
    view: window,
    bubbles: true,
    cancelable: true,
  });

  element.dispatchEvent(ev);
}

/** Returns the visible buttons rendered with CSS overflow ellipsis.  */
function getEllipsisButtons(breadcrumb: XfBreadcrumb): HTMLButtonElement[] {
  const pathButtons = Array.from(
      breadcrumb.shadowRoot!.querySelectorAll<HTMLButtonElement>('button[id]')!,
  );
  if (breadcrumb.parts.length <= 4) {
    return pathButtons.filter(hasOverflowEllipsis);
  }

  const elidedButtons =
      Array.from(breadcrumb.shadowRoot!.querySelectorAll<HTMLButtonElement>(
          'cr-action-menu button')!);
  const allButtons =
      [pathButtons[0]].concat(elidedButtons, pathButtons.slice(1)) as
      HTMLButtonElement[];
  return allButtons.filter(hasOverflowEllipsis);
}

/**
 * Tests rendering an empty path.
 */
export async function testBreadcrumbEmptyPath(done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('');

  const path = element.path;
  assertEquals('', path);
  // An empty string is rendered if the path is empty.
  assertEquals('', getBreadcrumbButtonState());

  done();
}

/**
 * Tests rendering a one element path.
 */
export async function testBreadcrumbOnePartPath(done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('A');

  // clang-format off
  const expect = element.path +
    ' 1: display:block id=first text=[A]';
  // clang-format on

  const path = element.path;
  assertEquals(expect, path + ' ' + getBreadcrumbButtonState());

  done();
}

/** Tests rendering a two element path.  */
export async function testBreadcrumbTwoPartPath(done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('A/B');

  // clang-format off
  const expect = element.path +
    ' 1: display:block id=first text=[A]' +
    ' 2: display:block id=second text=[B]';
  // clang-format on

  const path = element.path;
  assertEquals(expect, path + ' ' + getBreadcrumbButtonState());

  done();
}

/** Tests rendering a three element path.  */
export async function testBreadcrumbThreePartPath(done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('A/B/C');

  // clang-format off
  const expect = element.path +
    ' 1: display:block id=first text=[A]' +
    ' 2: display:block id=second text=[B]' +
    ' 3: display:block id=third text=[C]';
  // clang-format on

  const path = element.path;
  assertEquals(expect, path + ' ' + getBreadcrumbButtonState());

  done();
}

/**
 * Tests rendering a four element path.
 */
export async function testBreadcrumbFourPartPath(done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('A/B/C/D');

  // clang-format off
  const expect = element.path +
    ' 1: display:block id=first text=[A]' +
    ' 2: display:block id=second text=[B]' +
    ' 3: display:block id=third text=[C]' +
    ' 4: display:block id=fourth text=[D]';
  // clang-format on

  const path = element.path;
  assertEquals(expect, path + ' ' + getBreadcrumbButtonState());

  done();
}

/**
 * Tests rendering a path of more than four parts. The elider button should be
 * visible (not hidden and have display).
 *
 * The drop-down menu button should contain the elided path parts and can have
 * display, but are invisible because the elider drop-down menu is closed.
 */
export async function testBreadcrumbMoreThanFourElementPathsElide(
    done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('A/B/C/D/E/F');

  // Elider button drop-down menu should be in the 'closed' state.
  const elider = getBreadcrumbEliderButton()!;
  assertEquals('false', elider.getAttribute('aria-expanded'));

  // clang-format off
  const expect = element.path +
     ' 1: display:block id=first text=[A]' +
     ' 2: display:flex elider[aria-expanded=false,aria-haspopup,aria-label]' +
     ' dropdown-item: display:block text=[B]' +
     ' dropdown-item: display:block text=[C]' +
     ' dropdown-item: display:block text=[D]' +
     ' 3: display:block id=second text=[E]' +
     ' 4: display:block id=third text=[F]';
  // clang-format on

  const path = element.path;
  assertEquals(expect, path + ' ' + getBreadcrumbButtonState());

  done();
}

/**
 * Tests rendering a path where the path parts have escaped characters. Again,
 * the elider should be visible (not hidden and have display) because the path
 * has more than four parts.
 *
 * The drop-down menu button should contain the elided path parts and can have
 * display, but are invisible because the elider drop-down menu is closed.
 */
export async function testBreadcrumbRendersEscapedPathParts(done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath(
      'A%2FA/B%2FB/C %2F/%2FD /%2F%2FE/Nexus%2FPixel %28MTP%29');

  // Elider button drop-down menu should be in the 'closed' state.
  const elider = getBreadcrumbEliderButton()!;
  assertEquals('false', elider.getAttribute('aria-expanded'));

  // clang-format off
  const expect = element.path +
     ' 1: display:block id=first text=[A/A]' +
     ' 2: display:flex elider[aria-expanded=false,aria-haspopup,aria-label]' +
     ' dropdown-item: display:block text=[B/B]' +
     ' dropdown-item: display:block text=[C /]' +
     ' dropdown-item: display:block text=[/D ]' +
     ' 3: display:block id=second text=[//E]' +
     ' 4: display:block id=third text=[Nexus/Pixel (MTP)]';
  // clang-format on

  const path = element.path;
  assertEquals(expect, path + ' ' + getBreadcrumbButtonState());

  done();
}

/**
 * Tests rendering a path of more than four parts. The elider button should be
 * visible and clicking it should 'open' and 'close' its drop-down menu.
 */
export async function
testBreadcrumbElidedPathEliderButtonClicksOpenDropDownMenu(done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('A/B/C/D/E');

  // Elider button drop-down menu should be in the 'closed' state.
  const elider = getBreadcrumbEliderButton()!;
  assertEquals('false', elider.getAttribute('aria-expanded'));

  // Clicking the elider button should 'open' its drop-down menu.
  elider.click();
  await element.updateComplete;
  assertEquals('true', elider.getAttribute('aria-expanded'));

  // clang-format off
  const opened = element.path +
     ' 1: display:block id=first text=[A]' +
     ' 2: display:flex elider[aria-expanded=true,aria-haspopup,aria-label]' +
     ' dropdown-item: display:block text=[B]' +
     ' dropdown-item: display:block text=[C]' +
     ' 3: display:block id=second text=[D]' +
     ' 4: display:block id=third text=[E]';
  // clang-format on

  const path = element.path;
  assertEquals(opened, path + ' ' + getBreadcrumbButtonState());

  // Clicking the elider again should 'close' the drop-down menu.
  elider.click();
  await element.updateComplete;
  assertEquals('false', elider.getAttribute('aria-expanded'));

  // clang-format off
  const closed = element.path +
     ' 1: display:block id=first text=[A]' +
     ' 2: display:flex elider[aria-expanded=false,aria-haspopup,aria-label]' +
     ' dropdown-item: display:block text=[B]' +
     ' dropdown-item: display:block text=[C]' +
     ' 3: display:block id=second text=[D]' +
     ' 4: display:block id=third text=[E]';
  // clang-format on

  assertEquals(closed, path + ' ' + getBreadcrumbButtonState());

  done();
}

/**
 * Tests that clicking on the main buttons emits a signal that indicates which
 * part of the breadcrumb path was clicked.
 */
export async function testBreadcrumbMainButtonClicksEmitNumberSignal(
    done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('A/B/C/D/E/F');

  // clang-format off
  const expect = element.path +
     ' 1: display:block id=first text=[A]' +  // 1st main button
     ' 2: display:flex elider[aria-expanded=false,aria-haspopup,aria-label]' +
     ' dropdown-item: display:block text=[B]' +
     ' dropdown-item: display:block text=[C]' +
     ' dropdown-item: display:block text=[D]' +
     ' 3: display:block id=second text=[E]' +  // 2nd main button
     ' 4: display:block id=third text=[F]';  // 3rd main button
  // clang-format on

  const path = element.path;
  assertEquals(expect, path + ' ' + getBreadcrumbButtonState());

  let signal: number|null = null;
  element.addEventListener(
      XfBreadcrumb.events.BREADCRUMB_CLICKED,
      (event: BreadcrumbClickedEvent) => {
        const index = Number(event.detail.partIndex);
        assertEquals(typeof index, 'number');
        signal = index;
      });

  const buttons = getVisibleBreadcrumbMainButtons();
  assertEquals(3, buttons.length, 'three main buttons should be visible');

  assert(buttons[0]);
  assert(buttons[1]);
  assert(buttons[2]);

  signal = null;
  assertEquals('A', buttons[0].textContent);
  assertFalse(buttons[0].hasAttribute('disabled'));
  buttons[0].click();
  assertEquals(element.parts.indexOf('A'), signal);

  signal = null;
  assertEquals('E', buttons[1].textContent);
  assertFalse(buttons[1].hasAttribute('disabled'));
  buttons[1].click();
  assertEquals(element.parts.indexOf('E'), signal);

  signal = null;
  assertEquals('F', buttons[2].textContent);
  assertTrue(buttons[2].hasAttribute('disabled'));
  buttons[2].click();  // Ignored: the last main button is always disabled.
  assertEquals(null, signal);

  done();
}

/**
 * Tests that clicking on the menu buttons emits a signal that indicates which
 * part of the breadcrumb path was clicked.
 */
export async function testBreadcrumbMenuButtonClicksEmitNumberSignal(
    done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('A/B/C/D/E');

  // Elider button drop-down menu should be in the 'closed' state.
  const elider = getBreadcrumbEliderButton()!;
  assertEquals('false', elider.getAttribute('aria-expanded'));

  // Clicking the elider button should 'open' its drop-down menu.
  elider.click();
  await element.updateComplete;
  assertEquals('true', elider.getAttribute('aria-expanded'));

  // clang-format off
  const opened = element.path +
     ' 1: display:block id=first text=[A]' +
     ' 2: display:flex elider[aria-expanded=true,aria-haspopup,aria-label]' +
     ' dropdown-item: display:block text=[B]' +
     ' dropdown-item: display:block text=[C]' +
     ' 3: display:block id=second text=[D]' +
     ' 4: display:block id=third text=[E]';
  // clang-format on

  const path = element.path;
  assertEquals(opened, path + ' ' + getBreadcrumbButtonState());

  let signal: number|null = null;
  element.addEventListener(
      XfBreadcrumb.events.BREADCRUMB_CLICKED,
      (event: BreadcrumbClickedEvent) => {
        const index = Number(event.detail.partIndex);
        assertEquals(typeof index, 'number');
        signal = index;
      });

  const buttons = getBreadcrumbMenuButtons();
  assertEquals(2, buttons.length, 'there should be two drop-down items');

  assert(buttons[0]);
  assert(buttons[1]);

  signal = null;
  assertEquals('B', buttons[0].textContent);
  assertFalse(buttons[0].hasAttribute('disabled'));
  buttons[0].click();
  assertEquals(element.parts.indexOf('B'), signal);

  signal = null;
  assertEquals('C', buttons[1].textContent);
  assertFalse(buttons[1].hasAttribute('disabled'));
  buttons[1].click();
  assertEquals(element.parts.indexOf('C'), signal);

  done();
}

/**
 * Tests that setting the path closes the the drop-down menu.
 */
export async function testBreadcrumbSetPathClosesEliderButtonDropDownMenu(
    done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('A/B/C/D/E');

  // Elider button drop-down menu should be in the 'closed' state.
  const elider = getBreadcrumbEliderButton()!;
  assertEquals('false', elider.getAttribute('aria-expanded'));

  // Clicking the elider button should 'open' its drop-down menu.
  elider.click();
  await element.updateComplete;
  assertEquals('true', elider.getAttribute('aria-expanded'));

  // clang-format off
  const opened = element.path +
     ' 1: display:block id=first text=[A]' +
     ' 2: display:flex elider[aria-expanded=true,aria-haspopup,aria-label]' +
     ' dropdown-item: display:block text=[B]' +
     ' dropdown-item: display:block text=[C]' +
     ' 3: display:block id=second text=[D]' +
     ' 4: display:block id=third text=[E]';
  // clang-format on

  const first = element.path;
  assertEquals(opened, first + ' ' + getBreadcrumbButtonState());

  // Changing the path should remove the drop-down menu.
  await setAndWaitPath('F/G/H');

  assertEquals(null, getBreadcrumbEliderButton());

  // clang-format off
  const closed = element.path +
    ' 1: display:block id=first text=[F]' +
    ' 2: display:block id=second text=[G]' +
    ' 3: display:block id=third text=[H]';
  // clang-format on

  const second = element.path;
  assertEquals(closed, second + ' ' + getBreadcrumbButtonState());

  done();
}

/**
 * Tests that setting the path updates the <xf-breadcrumb path> attribute.
 */
export async function testBreadcrumbSetPathChangesElementPath(
    done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('A/B/C/D/E/F');
  assertEquals(element.path, element.getAttribute('path'));

  // Change path.
  await setAndWaitPath('G/H/I');
  assertEquals(element.path, element.getAttribute('path'));

  done();
}

/**
 * Tests that opening and closing the elider button drop-down menu adds and
 * removes global <html> element state.
 */
export async function testBreadcrumbEliderButtonOpenCloseChangesGlobalState(
    done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('A/B/C/D/E/F');

  // Elider button drop-down menu should be in the 'closed' state.
  const elider = getBreadcrumbEliderButton()!;
  assertEquals('false', elider.getAttribute('aria-expanded'));

  // Clicking the elider button should 'open' its drop-down menu.
  elider.click();
  await element.updateComplete;
  assertEquals('true', elider.getAttribute('aria-expanded'));

  // And also change the global element state.
  const root = document.documentElement;
  assertTrue(root.classList.contains('breadcrumb-elider-expanded'));

  // Change path.
  await setAndWaitPath('G/H/I/J/K');

  // Changing the path should 'close' the drop-down menu.
  assertEquals('false', elider.getAttribute('aria-expanded'));

  // And clear the global element state.
  assertFalse(root.classList.contains('breadcrumb-elider-expanded'));

  done();
}

/**
 * Tests that wide text path components are rendered elided with ellipsis ...
 * and hovering over the button sets the `title` attribute which is used by the
 * browser to render the native tooltip.
 */
export async function testBreadcrumbPartPartsEllipsisElide(done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('VERYVERYVERYVERYWIDEPATHPART/A');

  // clang-format off
  const expect = element.path +
      ' 1: display:block id=first text=[VERYVERYVERYVERYWIDEPATHPART]' +
      ' 2: display:block id=second text=[A]';
  // clang-format on

  const path = element.parts.join('/');
  assertEquals(expect, path + ' ' + getBreadcrumbButtonState());

  // The wide part should render its text with ellipsis.
  const ellipsis = getEllipsisButtons(element);
  const parts = element.parts;
  assert(ellipsis[0]);
  assert(parts[0]);

  assertEquals(1, ellipsis.length);
  const button = ellipsis[0];
  assertEquals(element.parts[0], button.textContent);

  // Simulate the mouseenter that sets the title.
  simulateMouseEnter(button);
  await waitUntil(() => button.getAttribute('title')! === button.innerText);

  done();
}

/**
 * Tests that wide text path components in the drop-down menu are rendered
 * elided with ellipsis ... and hovering over the button sets the `title`
 * attribute which is used by the browser to render the native tooltip.
 */
export async function testBreadcrumbDropDownMenuPathPartsEllipsisElide(
    done: () => void) {
  const element = getBreadcrumb();

  // Set path.
  await setAndWaitPath('A/B/VERYVERYVERYVERYWIDEPATHPARTINDEED/C/D');

  // clang-format off
  const expect = element.path +
      ' 1: display:block id=first text=[A]' +
      ' 2: display:flex elider[aria-expanded=false,aria-haspopup,aria-label]' +
      ' dropdown-item: display:block text=[B]' +
      ' dropdown-item: display:block' +
      ' text=[VERYVERYVERYVERYWIDEPATHPARTINDEED]' +
      ' 3: display:block id=second text=[C]' +
      ' 4: display:block id=third text=[D]';
  // clang-format on
  const path = element.parts.join('/');
  assertEquals(expect, path + ' ' + getBreadcrumbButtonState());

  // Display the dropdown menu.
  const elider = getBreadcrumbEliderButton()!;
  elider.click();
  await element.updateComplete;

  const parts = element.parts;
  assert(parts[2]);

  // The wide part button should render its text with ellipsis.
  const ellipsis = getEllipsisButtons(element);
  assertEquals(1, ellipsis.length);
  assert(ellipsis[0]);
  assertEquals(parts[2], ellipsis[0].textContent);

  // Simulate the mouseenter that sets the title.
  const button = ellipsis[0];
  simulateMouseEnter(button);
  await waitUntil(() => button.getAttribute('title')! === button.innerText);

  done();
}