chromium/ash/webui/shortcut_customization_ui/backend/search/search_handler_unittest.cc

// 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.

#include "ash/webui/shortcut_customization_ui/backend/search/search_handler.h"
#include <string>

#include "ash/webui/shortcut_customization_ui/backend/search/fake_search_data.h"
#include "ash/webui/shortcut_customization_ui/backend/search/search_concept_registry.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/task_environment.h"
#include "base/test/test_future.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "testing/gtest/include/gtest/gtest.h"

namespace ash::shortcut_ui {

namespace {

class FakeObserver
    : public shortcut_customization::mojom::SearchResultsAvailabilityObserver {
 public:
  FakeObserver() = default;
  ~FakeObserver() override = default;

  mojo::PendingRemote<
      shortcut_customization::mojom::SearchResultsAvailabilityObserver>
  GenerateRemote() {
    mojo::PendingRemote<
        shortcut_customization::mojom::SearchResultsAvailabilityObserver>
        remote;
    receiver_.Bind(remote.InitWithNewPipeAndPassReceiver());
    return remote;
  }

  size_t num_calls() const { return num_calls_; }

 private:
  // shortcut_customization::mojom::SearchResultsAvailabilityObserver:
  void OnSearchResultsAvailabilityChanged() override { ++num_calls_; }

  size_t num_calls_ = 0;
  mojo::Receiver<
      shortcut_customization::mojom::SearchResultsAvailabilityObserver>
      receiver_{this};
};

std::vector<shortcut_ui::SearchConcept> GetTestSearchConcepts() {
  std::vector<shortcut_ui::SearchConcept> concepts;

  {
    std::vector<ash::mojom::AcceleratorInfoPtr> accelerator_info_list;
    accelerator_info_list.emplace_back(ash::mojom::AcceleratorInfo::New(
        /*type=*/ash::mojom::AcceleratorType::kDefault,
        /*state=*/ash::mojom::AcceleratorState::kEnabled,
        /*locked=*/true,
        /*accelerator_locked=*/false,
        /*layout_properties=*/
        ash::mojom::LayoutStyleProperties::NewStandardAccelerator(
            ash::mojom::StandardAcceleratorProperties::New(
                ui::Accelerator(
                    /*key_code=*/ui::KeyboardCode::VKEY_SPACE,
                    /*modifiers=*/ui::EF_CONTROL_DOWN),
                u"Space", std::nullopt))));
    concepts.emplace_back(
        fake_search_data::CreateFakeAcceleratorLayoutInfo(
            /*description=*/u"Open launcher",
            /*source=*/ash::mojom::AcceleratorSource::kAsh,
            /*action=*/fake_search_data::FakeActionIds::kAction1,
            /*style=*/ash::mojom::AcceleratorLayoutStyle::kDefault),
        std::move(accelerator_info_list));
  }

  {
    std::vector<ash::mojom::AcceleratorInfoPtr> accelerator_info_list;
    accelerator_info_list.emplace_back(ash::mojom::AcceleratorInfo::New(
        /*type=*/ash::mojom::AcceleratorType::kDefault,
        /*state=*/ash::mojom::AcceleratorState::kEnabled,
        /*locked=*/true,
        /*accelerator_locked=*/false,
        /*layout_properties=*/
        ash::mojom::LayoutStyleProperties::NewStandardAccelerator(
            ash::mojom::StandardAcceleratorProperties::New(
                ui::Accelerator(
                    /*key_code=*/ui::KeyboardCode::VKEY_T,
                    /*modifiers=*/ui::EF_CONTROL_DOWN),
                u"T", std::nullopt))));
    concepts.emplace_back(
        fake_search_data::CreateFakeAcceleratorLayoutInfo(
            /*description=*/u"Open new tab",
            /*source=*/ash::mojom::AcceleratorSource::kBrowser,
            /*action=*/fake_search_data::FakeActionIds::kAction2,
            /*style=*/ash::mojom::AcceleratorLayoutStyle::kDefault),
        std::move(accelerator_info_list));
  }

  {
    std::vector<ash::mojom::AcceleratorInfoPtr> accelerator_info_list;
    accelerator_info_list.emplace_back(ash::mojom::AcceleratorInfo::New(
        /*type=*/ash::mojom::AcceleratorType::kDefault,
        /*state=*/ash::mojom::AcceleratorState::kEnabled,
        /*locked=*/true,
        /*accelerator_locked=*/false,
        /*layout_properties=*/
        ash::mojom::LayoutStyleProperties::NewStandardAccelerator(
            ash::mojom::StandardAcceleratorProperties::New(
                ui::Accelerator(
                    /*key_code=*/ui::KeyboardCode::VKEY_A,
                    /*modifiers=*/ui::EF_CONTROL_DOWN | ui::EF_SHIFT_DOWN),
                u"A", std::nullopt))));
    accelerator_info_list.emplace_back(ash::mojom::AcceleratorInfo::New(
        /*type=*/ash::mojom::AcceleratorType::kDefault,
        /*state=*/ash::mojom::AcceleratorState::kEnabled,
        /*locked=*/true,
        /*accelerator_locked=*/false,
        /*layout_properties=*/
        ash::mojom::LayoutStyleProperties::NewStandardAccelerator(
            ash::mojom::StandardAcceleratorProperties::New(
                ui::Accelerator(
                    /*key_code=*/ui::KeyboardCode::VKEY_BRIGHTNESS_DOWN,
                    /*modifiers=*/ui::EF_ALT_DOWN),
                u"BrightnessDown", std::nullopt))));

    concepts.emplace_back(
        fake_search_data::CreateFakeAcceleratorLayoutInfo(
            /*description=*/u"Open the Foo app",
            /*source=*/ash::mojom::AcceleratorSource::kAsh,
            /*action=*/fake_search_data::kAction3,
            /*style=*/ash::mojom::AcceleratorLayoutStyle::kDefault),
        std::move(accelerator_info_list));
  }

  {
    // Create a TextAccelerator.
    std::vector<ash::mojom::TextAcceleratorPartPtr> text_parts;
    text_parts.push_back(ash::mojom::TextAcceleratorPart::New(
        u"Press ", ash::mojom::TextAcceleratorPartType::kPlainText));
    text_parts.push_back(ash::mojom::TextAcceleratorPart::New(
        u"Ctrl", ash::mojom::TextAcceleratorPartType::kModifier));
    text_parts.push_back(ash::mojom::TextAcceleratorPart::New(
        u"+", ash::mojom::TextAcceleratorPartType::kDelimiter));
    text_parts.push_back(ash::mojom::TextAcceleratorPart::New(
        u"A", ash::mojom::TextAcceleratorPartType::kKey));

    std::vector<ash::mojom::AcceleratorInfoPtr> text_accelerator_info_list;
    text_accelerator_info_list.emplace_back(ash::mojom::AcceleratorInfo::New(
        /*type=*/ash::mojom::AcceleratorType::kDefault,
        /*state=*/ash::mojom::AcceleratorState::kEnabled,
        /*locked=*/true,
        /*accelerator_locked=*/false,
        /*layout_properties=*/
        ash::mojom::LayoutStyleProperties::NewTextAccelerator(
            ash::mojom::TextAcceleratorProperties::New(
                std::move(text_parts)))));

    // Add that TextAccelerator to the list of SearchConcepts.
    concepts.emplace_back(
        fake_search_data::CreateFakeAcceleratorLayoutInfo(
            /*description=*/u"Select all text content",
            /*source=*/ash::mojom::AcceleratorSource::kAsh,
            /*action=*/fake_search_data::FakeActionIds::kAction4,
            /*style=*/ash::mojom::AcceleratorLayoutStyle::kText),
        std::move(text_accelerator_info_list));
  }

  return concepts;
}

// Creates a search result with some default values.
shortcut_customization::mojom::SearchResultPtr CreateFakeSearchResult() {
  return shortcut_customization::mojom::SearchResult::New(
      /*accelerator_layout_info=*/fake_search_data::
          CreateFakeAcceleratorLayoutInfo(
              u"Open launcher", ash::mojom::AcceleratorSource::kAsh,
              fake_search_data::FakeActionIds::kAction1,
              ash::mojom::AcceleratorLayoutStyle::kDefault),
      /*accelerator_infos=*/fake_search_data::CreateFakeAcceleratorInfoList(),
      /*relevance_score=*/0.5);
}

}  // namespace

class SearchHandlerTest : public testing::Test {
 protected:
  SearchHandlerTest()
      : search_concept_registry_(*local_search_service_proxy_.get()),
        handler_(&search_concept_registry_, local_search_service_proxy_.get()) {
  }
  ~SearchHandlerTest() override = default;

  // testing::Test:
  void SetUp() override {
    handler_.BindInterface(handler_remote_.BindNewPipeAndPassReceiver());
    handler_remote_->AddSearchResultsAvailabilityObserver(
        results_availability_observer_.GenerateRemote());
    handler_remote_.FlushForTesting();
  }

  void VerifySearchResultIsPresent(
      const std::u16string description,
      const std::vector<shortcut_customization::mojom::SearchResultPtr>&
          search_results) const {
    auto description_iterator = find_if(
        search_results.begin(), search_results.end(),
        [&description](
            const shortcut_customization::mojom::SearchResultPtr& result) {
          return result->accelerator_layout_info->description == description;
        });
    // The description should be present in the list of search results.
    EXPECT_NE(description_iterator, search_results.end());
  }

  std::vector<shortcut_customization::mojom::SearchResultPtr> Search(
      const std::u16string& query,
      int32_t max_num_results) {
    base::test::TestFuture<
        std::vector<shortcut_customization::mojom::SearchResultPtr>>
        future;
    handler_remote_->Search(query, max_num_results, future.GetCallback());
    return future.Take();
  }

  base::test::TaskEnvironment task_environment_;
  std::unique_ptr<local_search_service::LocalSearchServiceProxy>
      local_search_service_proxy_ =
          std::make_unique<local_search_service::LocalSearchServiceProxy>(
              /*for_testing=*/true);
  shortcut_ui::SearchConceptRegistry search_concept_registry_;
  mojo::Remote<shortcut_customization::mojom::SearchHandler> handler_remote_;
  shortcut_ui::SearchHandler handler_;
  base::test::ScopedFeatureList scoped_feature_list_;
  FakeObserver results_availability_observer_;
};

TEST_F(SearchHandlerTest, SearchResultsNormalUsage) {
  search_concept_registry_.SetSearchConcepts(GetTestSearchConcepts());
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  // SearchHandler observer should be called after the registry is updated.
  EXPECT_EQ(1u, results_availability_observer_.num_calls());

  // A search with no matches should return no results.
  std::vector<shortcut_customization::mojom::SearchResultPtr> search_results =
      Search(u"this search matches nothing!",
             /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 0u);

  // The number of observer calls should not have changed, even though a search
  // was completed. The observer is only called when the availability of results
  // changes, i.e. when the index is updated.
  EXPECT_EQ(1u, results_availability_observer_.num_calls());

  // The descriptions for the fake shortcuts are "Open launcher", "Open new
  // tab", "Open the Foo app", and "Select all text content".
  // The query "Open" matches the first three shortcuts because they contain the
  // word "open".
  search_results = Search(u"Open",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 3u);
  VerifySearchResultIsPresent(/*description=*/u"Open launcher",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Open new tab",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Open the Foo app",
                              /*search_results=*/search_results);

  // Checking again that the observer was not called after the previous search.
  EXPECT_EQ(1u, results_availability_observer_.num_calls());

  // The query "open" should also match the same concepts (query case doesn't
  // matter).
  search_results = Search(u"open",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 3u);
  VerifySearchResultIsPresent(/*description=*/u"Open launcher",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Open new tab",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Open the Foo app",
                              /*search_results=*/search_results);

  // For completeness, the query "OpEn" should also match the same concepts.
  search_results = Search(u"OpEn",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 3u);
  VerifySearchResultIsPresent(/*description=*/u"Open launcher",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Open new tab",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Open the Foo app",
                              /*search_results=*/search_results);

  // Searching for a specific shortcut matches only that concept.
  search_results = Search(u"Open new tab",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 1u);
  VerifySearchResultIsPresent(/*description=*/u"Open new tab",
                              /*search_results=*/search_results);

  // Searching for a specific shortcut should work even if the query is a
  // "fuzzy" match.
  search_results = Search(u"Open tab",
                          /*max_num_results=*/5u);
  // In this case, the search service also returns the other results, but with
  // lower relevance scores.
  EXPECT_EQ(search_results.size(), 3u);
  EXPECT_EQ(search_results.at(0)->accelerator_layout_info->description,
            u"Open new tab");
  EXPECT_EQ(search_results.at(1)->accelerator_layout_info->description,
            u"Open the Foo app");
  EXPECT_EQ(search_results.at(2)->accelerator_layout_info->description,
            u"Open launcher");
  // Expect that earlier search results have a higher relevance score.
  EXPECT_GT(search_results.at(0)->relevance_score,
            search_results.at(1)->relevance_score);
  EXPECT_GT(search_results.at(1)->relevance_score,
            search_results.at(2)->relevance_score);

  // Clear the index and verify that searches return no results, and that the
  // observer was called an additional time.
  std::vector<SearchConcept> empty_search_concepts;
  search_concept_registry_.SetSearchConcepts(std::move(empty_search_concepts));
  task_environment_.RunUntilIdle();
  search_results = Search(u"Open",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 0u);
  EXPECT_EQ(results_availability_observer_.num_calls(), 2u);
}

TEST_F(SearchHandlerTest, SearchResultsEdgeCases) {
  search_concept_registry_.SetSearchConcepts(GetTestSearchConcepts());
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  // A search with no matches should return no results.
  std::vector<shortcut_customization::mojom::SearchResultPtr> search_results =
      Search(u"this search matches nothing!",
             /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 0u);
}

TEST_F(SearchHandlerTest, SearchResultsSingleCharacter) {
  search_concept_registry_.SetSearchConcepts(GetTestSearchConcepts());
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  // Searching for "o" returns all results since they each contain an "o".
  std::vector<shortcut_customization::mojom::SearchResultPtr> search_results =
      Search(u"o",
             /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 4u);
  VerifySearchResultIsPresent(/*description=*/u"Open launcher",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Open new tab",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Open the Foo app",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Select all text content",
                              /*search_results=*/search_results);

  // Searching for "O" returns all results since they each contain an "o",
  // regardless of capitalization.
  search_results = Search(u"O",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 4u);
  VerifySearchResultIsPresent(/*description=*/u"Open launcher",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Open new tab",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Open the Foo app",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Select all text content",
                              /*search_results=*/search_results);

  // Searching for "p" returns all results that contain the letter "p".
  // In this case, "Select all text content" is included because its text
  // accelerator is "Press Ctrl+A".
  search_results = Search(u"p",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 4u);
  VerifySearchResultIsPresent(/*description=*/u"Open launcher",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Open new tab",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Open the Foo app",
                              /*search_results=*/search_results);
  VerifySearchResultIsPresent(/*description=*/u"Select all text content",
                              /*search_results=*/search_results);

  // Searching for "l" returns all results that contain the letter "l".
  search_results = Search(u"l",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 1u);
  VerifySearchResultIsPresent(/*description=*/u"Open launcher",
                              /*search_results=*/search_results);

  // Searching for "z" should return no results.
  search_results = Search(u"z",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 0u);
}

TEST_F(SearchHandlerTest, SearchResultsSearchByKeys) {
  search_concept_registry_.SetSearchConcepts(GetTestSearchConcepts());
  handler_remote_.FlushForTesting();
  task_environment_.RunUntilIdle();

  // Searching for the keys/modifiers of a shortcut should only work for text
  // accelerators. In this case, all shortcuts contain "Ctrl" as one of their
  // modifiers, but only one of them is a text accelerator, so only that one
  // should be returned.
  std::vector<shortcut_customization::mojom::SearchResultPtr> search_results =
      Search(u"ctrl",
             /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 1u);
  VerifySearchResultIsPresent(/*description=*/u"Select all text content",
                              /*search_results=*/search_results);

  // Verify that different various combinations of uppercase and lowercase work
  // when querying by modifier.
  search_results = Search(u"CTRL",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 1u);
  VerifySearchResultIsPresent(/*description=*/u"Select all text content",
                              /*search_results=*/search_results);
  search_results = Search(u"CtRl",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 1u);
  VerifySearchResultIsPresent(/*description=*/u"Select all text content",
                              /*search_results=*/search_results);

  // The test concept "Open the Foo app" has "Alt + BrightnessDown" as one of
  // its key combinations. Searching based on "BrightnessDown" should not return
  // that shortcut as a SearchResult because that shortcut is a standard
  // accelerator.
  search_results = Search(u"BrightnessDown",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 0u);

  // Searching for text-based shortcuts should work. In this case, the query
  // should match the shortcut "Select all text content" which has a shortcut
  // "Press Ctrl+A".
  search_results = Search(u"Press Ctrl+A",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 1u);
  VerifySearchResultIsPresent(/*description=*/u"Select all text content",
                              /*search_results=*/search_results);

  // Searching for text-based shortcuts should work with an inexact query.
  search_results = Search(u"Press",
                          /*max_num_results=*/5u);
  EXPECT_EQ(search_results.size(), 1u);
  VerifySearchResultIsPresent(/*description=*/u"Select all text content",
                              /*search_results=*/search_results);
}

TEST_F(SearchHandlerTest, CompareSearchResults) {
  // Create two equal fake search results.
  shortcut_customization::mojom::SearchResultPtr a = CreateFakeSearchResult();
  shortcut_customization::mojom::SearchResultPtr b = CreateFakeSearchResult();

  // CompareSearchResults() returns true if the first parameter should be ranked
  // higher than the second parameter. On a tie, this method should return
  // false. Since the two fake search results are equal, it should return false
  // regardless of the order of parameters.
  EXPECT_FALSE(SearchHandler::CompareSearchResults(a, b));
  EXPECT_FALSE(SearchHandler::CompareSearchResults(b, a));

  // Differ only on relevance score.
  a->relevance_score = 0;
  b->relevance_score = 1;

  // Comparison value should differ now that the relevance scores are different.
  EXPECT_NE(SearchHandler::CompareSearchResults(b, a),
            SearchHandler::CompareSearchResults(a, b));
  // CompareSearchResults() returns whether the first parameter should be higher
  // ranked than the second parameter.
  EXPECT_FALSE(SearchHandler::CompareSearchResults(a, b));
  EXPECT_TRUE(SearchHandler::CompareSearchResults(b, a));

  // Differ only on relevance score, this time using less extreme values.
  a->relevance_score = 0.123;
  b->relevance_score = 0.789;

  // Comparison value should differ now that the relevance scores are different.
  EXPECT_NE(SearchHandler::CompareSearchResults(b, a),
            SearchHandler::CompareSearchResults(a, b));
  // CompareSearchResults() returns whether the first parameter should be higher
  // ranked than the second parameter.
  EXPECT_FALSE(SearchHandler::CompareSearchResults(a, b));
  EXPECT_TRUE(SearchHandler::CompareSearchResults(b, a));
}

}  // namespace ash::shortcut_ui