chromium/chrome/test/data/webui/chromeos/shortcut_customization/search_box_test.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 'chrome://shortcut-customization/js/search/search_box.js';
import 'chrome://webui-test/chromeos/mojo_webui_test_support.js';

import {strictQuery} from 'chrome://resources/ash/common/typescript_utils/strict_query.js';
import {getDeepActiveElement} from 'chrome://resources/ash/common/util.js';
import {CrToolbarSearchFieldElement} from 'chrome://resources/ash/common/cr_elements/cr_toolbar/cr_toolbar_search_field.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {IronDropdownElement} from 'chrome://resources/polymer/v3_0/iron-dropdown/iron-dropdown.js';
import {IronListElement} from 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import {flush} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {AcceleratorLookupManager} from 'chrome://shortcut-customization/js/accelerator_lookup_manager.js';
import {CycleTabsTextSearchResult, fakeAcceleratorConfig, fakeLayoutInfo, fakeSearchResults, TakeScreenshotSearchResult} from 'chrome://shortcut-customization/js/fake_data.js';
import {FakeShortcutSearchHandler} from 'chrome://shortcut-customization/js/search/fake_shortcut_search_handler.js';
import {SearchBoxElement} from 'chrome://shortcut-customization/js/search/search_box.js';
import {SearchResultRowElement} from 'chrome://shortcut-customization/js/search/search_result_row.js';
import {setShortcutSearchHandlerForTesting} from 'chrome://shortcut-customization/js/search/shortcut_search_handler.js';
import {AcceleratorState, MojoSearchResult} from 'chrome://shortcut-customization/js/shortcut_types.js';
import {assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {flushTasks, waitAfterNextRender} from 'chrome://webui-test/polymer_test_util.js';
import {eventToPromise} from 'chrome://webui-test/test_util.js';

suite('searchBoxTest', function() {
  let searchBoxElement: SearchBoxElement|null = null;
  let searchFieldElement: CrToolbarSearchFieldElement|null = null;
  let dropdownElement: IronDropdownElement|null = null;
  let resultsListElement: IronListElement|null = null;

  let handler: FakeShortcutSearchHandler;
  let manager: AcceleratorLookupManager|null = null;

  setup(() => {
    // Set up SearchHandler.
    handler = new FakeShortcutSearchHandler();
    handler.setFakeSearchResult(fakeSearchResults);
    setShortcutSearchHandlerForTesting(handler);

    // Set up manager.
    manager = AcceleratorLookupManager.getInstance();
    manager.setAcceleratorLookup(fakeAcceleratorConfig);
    manager.setAcceleratorLayoutLookup(fakeLayoutInfo);
  });

  teardown(() => {
    if (manager) {
      manager.reset();
    }
    if (searchBoxElement) {
      searchBoxElement.remove();
    }
    searchBoxElement = null;

    if (searchFieldElement) {
      searchFieldElement.remove();
    }
    searchFieldElement = null;

    if (dropdownElement) {
      dropdownElement.remove();
    }
    dropdownElement = null;

    if (resultsListElement) {
      resultsListElement.remove();
    }
    resultsListElement = null;
  });

  function initSearchBoxElement(): [
    SearchBoxElement, CrToolbarSearchFieldElement, IronDropdownElement,
    IronListElement
  ] {
    const searchBoxElement_ = document.createElement('search-box');
    document.body.appendChild(searchBoxElement_);
    flush();

    const searchFieldElement_ = strictQuery(
        '#search', searchBoxElement_.shadowRoot, CrToolbarSearchFieldElement);
    const dropdownElement_ = strictQuery(
                                 'iron-dropdown', searchBoxElement_.shadowRoot,
                                 HTMLElement) as IronDropdownElement;
    const resultsListElement_ =
        strictQuery('iron-list', searchBoxElement_.shadowRoot, HTMLElement) as
        IronListElement;

    return [
      searchBoxElement_,
      searchFieldElement_,
      dropdownElement_,
      resultsListElement_,
    ];
  }

  async function simulateSearch(query: string) {
    assertTrue(
        !!searchBoxElement,
        'SearchBoxElement was not initialized before simulating search.');
    assertTrue(
        !!searchFieldElement,
        'SearchFieldElement was not initialized before simulating search.');

    // Setting the value of the search field searches for the query after a
    // short period of time.
    searchFieldElement.$.searchInput.focus();
    searchFieldElement.setValue(query);

    if (query) {
      await waitForSearchResultsFetched();
      await waitForListUpdate();
    }
    flush();
  }

  async function waitForSearchResultsFetched() {
    assertTrue(!!searchBoxElement);
    await eventToPromise('search-results-fetched', searchBoxElement);
    flush();
  }

  async function waitForListUpdate() {
    assertTrue(!!resultsListElement);
    // Wait for iron-list to complete resizing.
    await eventToPromise('iron-resize', resultsListElement);
    flush();
  }

  function isSearchTextSelected() {
    assertTrue(!!searchFieldElement);
    const input = searchFieldElement.getSearchInput();
    return input.selectionStart === 0 &&
        input.selectionEnd === input.value.length;
  }

  test('SearchBoxLoaded', async () => {
    [searchBoxElement] = initSearchBoxElement();
    assertTrue(!!searchBoxElement);
  });

  test('Focus search input on open', async () => {
    [searchBoxElement, searchFieldElement] = initSearchBoxElement();

    waitAfterNextRender(searchBoxElement);
    await flushTasks();

    // The search input should be focused after the first render.
    assertEquals(searchFieldElement.getSearchInput(), getDeepActiveElement());
  });

  test('SearchResultsPopulated', async () => {
    [searchBoxElement, searchFieldElement, dropdownElement,
     resultsListElement] = initSearchBoxElement();

    // Before: No search results shown.
    assertEquals('', searchFieldElement.getValue());
    assertFalse(searchBoxElement.shouldShowDropdown);
    assertFalse(dropdownElement.opened);
    assertEquals(0, searchBoxElement.searchResults.length);

    await simulateSearch('query');

    // After: Fake search results shown.
    assertEquals('query', searchFieldElement.getValue());
    assertTrue(searchBoxElement.shouldShowDropdown);
    assertTrue(dropdownElement.opened);
    assertEquals(3, searchBoxElement.searchResults.length);

    // Click outside and the dropdown should be closed.
    searchBoxElement.dispatchEvent(new Event('blur'));
    assertFalse(searchBoxElement.shouldShowDropdown);
    assertFalse(dropdownElement.opened);
  });

  test('RestorePreviousExistingResults', async () => {
    [searchBoxElement, searchFieldElement, dropdownElement,
     resultsListElement] = initSearchBoxElement();

    await simulateSearch('query');

    assertEquals(3, searchBoxElement.searchResults.length);
    assertTrue(
        !!resultsListElement.items, 'iron-list element should have items.');
    assertEquals(3, resultsListElement.items.length);
    assertTrue(dropdownElement.opened);

    // Save the results for later.
    const [firstResult, secondResult, thirdResult] = resultsListElement.items;

    // User clicks outside the search box, closing the dropdown.
    searchBoxElement.blur();
    assertFalse(dropdownElement.opened, 'Expected dropdown to be closed.');

    // User clicks on input, restoring old results and opening dropdown.
    searchFieldElement.getSearchInput().focus();

    assertEquals('query', searchFieldElement.getValue());
    assertTrue(
        dropdownElement.opened,
        'Expected dropdown to be opened after focusing on the search field.');

    // The same result rows exist.
    assertEquals(firstResult, resultsListElement.items[0]);
    assertEquals(secondResult, resultsListElement.items[1]);
    assertEquals(thirdResult, resultsListElement.items[2]);

    // Search field is blurred, closing the dropdown.
    searchFieldElement.getSearchInput().blur();
    assertFalse(dropdownElement.opened, 'Expected dropdown to be closed.');

    // User clicks on input, restoring old results and opening dropdown.
    searchFieldElement.getSearchInput().focus();
    assertEquals('query', searchFieldElement.getValue());
    assertTrue(
        dropdownElement.opened,
        'Expected dropdown to be opened after focusing on the search field.');

    // The same result rows exist.
    assertEquals(firstResult, resultsListElement.items[0]);
    assertEquals(secondResult, resultsListElement.items[1]);
    assertEquals(thirdResult, resultsListElement.items[2]);
  });

  test('ArrowKeysChangeSelectedItem', async () => {
    [searchBoxElement, searchFieldElement, dropdownElement,
     resultsListElement] = initSearchBoxElement();

    await simulateSearch('query');

    assertEquals(3, searchBoxElement.searchResults.length);
    assertTrue(
        !!resultsListElement.items, 'iron-list element should have items.');
    assertEquals(3, resultsListElement.items.length);
    assertTrue(dropdownElement.opened);

    // The first row should be selected when results are fetched.
    assertEquals(resultsListElement.selectedItem, resultsListElement.items[0]);

    // Test ArrowUp and ArrowDown interaction with selecting.
    const arrowUpEvent = new KeyboardEvent(
        'keydown', {cancelable: true, key: 'ArrowUp', keyCode: 38});
    const arrowDownEvent = new KeyboardEvent(
        'keydown', {cancelable: true, key: 'ArrowDown', keyCode: 40});

    // ArrowDown event should select next row.
    searchBoxElement.dispatchEvent(arrowDownEvent);
    assertEquals(resultsListElement.selectedItem, resultsListElement.items[1]);

    // Move down to the last row
    searchBoxElement.dispatchEvent(arrowDownEvent);
    assertEquals(resultsListElement.selectedItem, resultsListElement.items[2]);

    // If last row selected, ArrowDown brings select back to first row.
    searchBoxElement.dispatchEvent(arrowDownEvent);
    assertEquals(resultsListElement.selectedItem, resultsListElement.items[0]);

    // If first row selected, ArrowUp brings select back to last row.
    searchBoxElement.dispatchEvent(arrowUpEvent);
    assertEquals(resultsListElement.selectedItem, resultsListElement.items[2]);

    // ArrowUp should bring select previous row.
    searchBoxElement.dispatchEvent(arrowUpEvent);
    assertEquals(resultsListElement.selectedItem, resultsListElement.items[1]);

    // Test that ArrowLeft and ArrowRight do nothing.
    const arrowLeftEvent = new KeyboardEvent(
        'keydown', {cancelable: true, key: 'ArrowLeft', keyCode: 37});
    const arrowRightEvent = new KeyboardEvent(
        'keydown', {cancelable: true, key: 'ArrowRight', keyCode: 39});

    // No change on ArrowLeft
    searchBoxElement.dispatchEvent(arrowLeftEvent);
    assertEquals(resultsListElement.selectedItem, resultsListElement.items[1]);

    // No change on ArrowRight
    searchBoxElement.dispatchEvent(arrowRightEvent);
    assertEquals(resultsListElement.selectedItem, resultsListElement.items[1]);
  });

  test('ClickingMagnifyingGlassOpensDropdownAndSelectsText', async () => {
    [searchBoxElement, searchFieldElement, dropdownElement,
     resultsListElement] = initSearchBoxElement();

    await simulateSearch('query');
    assertTrue(dropdownElement.opened);

    // Click away from the search box.
    searchBoxElement.blur();
    assertFalse(dropdownElement.opened);
    assertFalse(isSearchTextSelected());

    // Click the search icon and the text should be selected.
    strictQuery(
        'cr-icon-button#icon', searchFieldElement.shadowRoot, HTMLElement)
        .click();
    assertTrue(isSearchTextSelected());
    assertTrue(dropdownElement.opened);
  });

  test('Keypress Enter on selected row causes dropdown to close', async () => {
    [searchBoxElement, searchFieldElement, dropdownElement,
     resultsListElement] = initSearchBoxElement();

    await simulateSearch('query');
    assertTrue(dropdownElement.opened);
    assertTrue(searchBoxElement.shouldShowDropdown);

    const selectedRow = strictQuery(
        'search-result-row[selected]', dropdownElement, SearchResultRowElement);
    const selectedRowInnerElement = strictQuery(
        '#searchResultRowInner', selectedRow.shadowRoot, HTMLDivElement);

    const enterEvent = new KeyboardEvent(
        'keypress', {cancelable: true, key: 'Enter', keyCode: 13});
    selectedRowInnerElement.dispatchEvent(enterEvent);
    assertFalse(dropdownElement.opened);
  });

  test('Clicking on a row closes the dropdown', async () => {
    [searchBoxElement, searchFieldElement, dropdownElement,
     resultsListElement] = initSearchBoxElement();

    await simulateSearch('query');
    assertTrue(dropdownElement.opened);
    assertTrue(searchBoxElement.shouldShowDropdown);

    const selectedRow = strictQuery(
        'search-result-row[selected]', dropdownElement, SearchResultRowElement);
    const selectedRowInnerElement = strictQuery(
        '#searchResultRowInner', selectedRow.shadowRoot, HTMLDivElement);

    selectedRowInnerElement.click();
    assertFalse(dropdownElement.opened);
  });

  test('Search availability changed', async () => {
    [searchBoxElement, searchFieldElement, dropdownElement,
     resultsListElement] = initSearchBoxElement();

    await simulateSearch('query');

    assertTrue(dropdownElement.opened);
    assertEquals(3, searchBoxElement.searchResults.length);

    // Add a new search result, but don't trigger the observer callback.
    handler.setFakeSearchResult(
        [...fakeSearchResults, TakeScreenshotSearchResult]);
    assertTrue(dropdownElement.opened);
    assertEquals(3, searchBoxElement.searchResults.length);

    // Trigger the observer callback.
    searchBoxElement.onSearchResultsAvailabilityChanged();
    await waitForSearchResultsFetched();
    // Check that the list updates when the dropdown is open, and the dropdown
    // remains open.
    assertTrue(dropdownElement.opened);
    assertEquals(4, searchBoxElement.searchResults.length);

    // User clicks outside the search box, closing the dropdown.
    searchBoxElement.blur();
    assertFalse(dropdownElement.opened);

    // Reset the fake results.
    handler.setFakeSearchResult(fakeSearchResults);

    // Dropdown should still be closed, and the search results should not have
    // updated yet.
    assertFalse(dropdownElement.opened);
    assertEquals(4, searchBoxElement.searchResults.length);

    // Trigger the observer callback.
    searchBoxElement.onSearchResultsAvailabilityChanged();
    await waitForSearchResultsFetched();

    // Check that the list updates when the dropdown is closed, and the dropdown
    // remains closed.
    assertFalse(dropdownElement.opened);
    assertEquals(3, searchBoxElement.searchResults.length);

    // The first item should be selected immediately when the search results
    // change even if the change occurred while the dropdown was closed.
    searchFieldElement.getSearchInput().focus();
    await waitForListUpdate();
    assertTrue(dropdownElement.opened);
    const selectedRow = strictQuery(
        'search-result-row[selected]', dropdownElement, SearchResultRowElement);
    const selectedItem =
        resultsListElement.selectedItem as (MojoSearchResult | undefined);
    assertTrue(!!selectedItem);
    assertEquals(
        selectedRow.searchResult.acceleratorLayoutInfo.description,
        selectedItem.acceleratorLayoutInfo.description);
  });

  test(
      'Filter partially disabled search results', async () => {
        [searchBoxElement, searchFieldElement, dropdownElement,
         resultsListElement] = initSearchBoxElement();

        const disabledFirstAcceleratorInfo =
            TakeScreenshotSearchResult.acceleratorInfos[0]!;
        disabledFirstAcceleratorInfo.state =
            AcceleratorState.kDisabledByUnavailableKeys;

        // This SearchResult is of standard layout, and contains two
        // AcceleratorInfos. We disable one of them to verify that the disabled
        // AcceleratorInfo is not shown.
        const partiallyDisabledSearchResult: MojoSearchResult = {
          ...TakeScreenshotSearchResult,
          acceleratorInfos: [
            disabledFirstAcceleratorInfo,
            TakeScreenshotSearchResult.acceleratorInfos[1]!,
          ],
        };

        // Set search results: one is partially disabled, the other is enabled.
        handler.setFakeSearchResult(
            [partiallyDisabledSearchResult, CycleTabsTextSearchResult]);

        await simulateSearch('query');

        assertTrue(dropdownElement.opened);
        assertEquals(2, searchBoxElement.searchResults.length);

        // Check that the disabled AcceleratorInfo is not present in the
        // acceleratorInfos list of the partially disabled search result.
        assertEquals(
            1, searchBoxElement.searchResults[0]?.acceleratorInfos.length);
        assertEquals(
            TakeScreenshotSearchResult.acceleratorInfos[1],
            searchBoxElement.searchResults[0]?.acceleratorInfos[0]);
      });

  test(
      'Filter fully disabled search results when customization is allowed',
      async () => {
        loadTimeData.overrideValues({isCustomizationAllowed: true});

        [searchBoxElement, searchFieldElement, dropdownElement,
         resultsListElement] = initSearchBoxElement();

        const disabledFirstAcceleratorInfo =
            TakeScreenshotSearchResult.acceleratorInfos[0]!;
        const disabledSecondAcceleratorInfo =
            TakeScreenshotSearchResult.acceleratorInfos[1]!;
        disabledFirstAcceleratorInfo.state =
            AcceleratorState.kDisabledByUnavailableKeys;
        disabledSecondAcceleratorInfo.state = AcceleratorState.kDisabledByUser;

        // Create a SearchResult that doesn't have any enabled AcceleratorInfos,
        // one is disabled by unavailable keys, the other is disabled by user.
        const fullyDisabledSearchResult: MojoSearchResult = {
          ...TakeScreenshotSearchResult,
          acceleratorInfos:
              [disabledFirstAcceleratorInfo, disabledSecondAcceleratorInfo],
        };

        handler.setFakeSearchResult([fullyDisabledSearchResult]);

        searchBoxElement.onSearchResultsAvailabilityChanged();
        await simulateSearch('query');

        assertTrue(dropdownElement.opened);
        // Verify fully disabled search result is not filtered out.
        assertEquals(1, searchBoxElement.searchResults.length);

        const searchResultRowElement = strictQuery(
            'search-result-row', searchBoxElement.shadowRoot,
            SearchResultRowElement);
        assertTrue(!!searchResultRowElement);

        // Verify description is shown.
        assertEquals(
            'Take full screenshot or screen recording',
            strictQuery(
                '#description', searchResultRowElement.shadowRoot,
                HTMLDivElement)
                .innerText);

        // Verify 'No shortcut assigned' message is shown.
        assertEquals(
            'No shortcut assigned',
            strictQuery(
                '#noShortcutAssignedMessage', searchResultRowElement.shadowRoot,
                HTMLDivElement)
                .innerText);
      });

  test(
      'Filter fully disabled search results when customization is not allowed',
      async () => {
        loadTimeData.overrideValues({isCustomizationAllowed: false});
        [searchBoxElement, searchFieldElement, dropdownElement,
         resultsListElement] = initSearchBoxElement();

        const disabledFirstAcceleratorInfo =
            TakeScreenshotSearchResult.acceleratorInfos[0]!;
        const disabledSecondAcceleratorInfo =
            TakeScreenshotSearchResult.acceleratorInfos[1]!;
        disabledFirstAcceleratorInfo.state =
            AcceleratorState.kDisabledByUnavailableKeys;
        disabledSecondAcceleratorInfo.state = AcceleratorState.kDisabledByUser;

        // Create a SearchResult that doesn't have any enabled AcceleratorInfos,
        // one is disabled by unavailable keys, the other is disabled by user.
        const fullyDisabledSearchResult: MojoSearchResult = {
          ...TakeScreenshotSearchResult,
          acceleratorInfos:
              [disabledFirstAcceleratorInfo, disabledSecondAcceleratorInfo],
        };

        handler.setFakeSearchResult([fullyDisabledSearchResult]);

        searchBoxElement.onSearchResultsAvailabilityChanged();
        await simulateSearch('query');

        assertTrue(dropdownElement.opened);
        // Verify fully disabled search result is hidden when customization is
        // not allowed.
        assertEquals(0, searchBoxElement.searchResults.length);
      });

  test(
      'Filter disabled + ensure extra results are present when customization' +
          'is not allowed',
      async () => {
        loadTimeData.overrideValues({isCustomizationAllowed: false});
        [searchBoxElement, searchFieldElement, dropdownElement,
         resultsListElement] = initSearchBoxElement();
        // Create a SearchResult that doesn't have any enabled AcceleratorInfos.
        const disabledFirstAcceleratorInfo =
            TakeScreenshotSearchResult.acceleratorInfos[0]!;
        disabledFirstAcceleratorInfo.state =
            AcceleratorState.kDisabledByUnavailableKeys;
        const disabledSecondAcceleratorInfo =
            TakeScreenshotSearchResult.acceleratorInfos[1]!;
        disabledSecondAcceleratorInfo.state = AcceleratorState.kDisabledByUser;
        const fullyDisabledSearchResult: MojoSearchResult = {
          ...TakeScreenshotSearchResult,
          acceleratorInfos:
              [disabledFirstAcceleratorInfo, disabledSecondAcceleratorInfo],
        };

        handler.setFakeSearchResult([
          fullyDisabledSearchResult,
          CycleTabsTextSearchResult,
          CycleTabsTextSearchResult,
          CycleTabsTextSearchResult,
          CycleTabsTextSearchResult,
          CycleTabsTextSearchResult,
          CycleTabsTextSearchResult,
        ]);

        searchBoxElement.onSearchResultsAvailabilityChanged();
        await simulateSearch('query');

        assertTrue(dropdownElement.opened);
        // After filtering, at most 5 of the non-disabled elements should be
        // shown.
        assertEquals(5, searchBoxElement.searchResults.length);
        assertEquals(
            CycleTabsTextSearchResult.acceleratorLayoutInfo.description,
            searchBoxElement.searchResults[0]
                ?.acceleratorLayoutInfo.description);
      });

  test('Max query length has been set', async () => {
    [searchBoxElement, searchFieldElement, dropdownElement,
     resultsListElement] = initSearchBoxElement();

    assertTrue(!!searchFieldElement.getSearchInput().maxLength);
  });
});