chromium/components/omnibox/browser/most_visited_sites_provider_unittest.cc

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

#include "components/omnibox/browser/most_visited_sites_provider.h"

#include <list>
#include <memory>
#include <string>

#include "base/test/metrics/histogram_tester.h"
#include "base/test/scoped_feature_list.h"
#include "base/test/task_environment.h"
#include "components/history/core/browser/top_sites.h"
#include "components/omnibox/browser/autocomplete_provider_listener.h"
#include "components/omnibox/browser/fake_autocomplete_provider_client.h"
#include "components/omnibox/browser/test_scheme_classifier.h"
#include "components/omnibox/common/omnibox_features.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/metrics_proto/omnibox_event.pb.h"
#include "third_party/metrics_proto/omnibox_focus_type.pb.h"
#include "ui/base/device_form_factor.h"

namespace {
struct TestData {
  bool is_search;
  history::MostVisitedURL entry;
};

class FakeTopSites : public history::TopSites {
 public:
  FakeTopSites() = default;

  // history::TopSites:
  void GetMostVisitedURLs(GetMostVisitedURLsCallback callback) override {
    callbacks_.push_back(std::move(callback));
  }
  void SyncWithHistory() override {}

  bool HasBlockedUrls() const override { return !blocked_urls_.empty(); }
  void AddBlockedUrl(const GURL& url) override {
    blocked_urls_.insert(url.spec());
  }
  void RemoveBlockedUrl(const GURL& url) override {
    blocked_urls_.erase(url.spec());
  }
  bool IsBlocked(const GURL& url) override {
    return blocked_urls_.count(url.spec()) > 0;
  }
  void ClearBlockedUrls() override { blocked_urls_.clear(); }
  bool IsFull() override { return false; }
  bool loaded() const override { return false; }
  history::PrepopulatedPageList GetPrepopulatedPages() override {
    return history::PrepopulatedPageList();
  }
  void OnNavigationCommitted(const GURL& url) override {}

  // RefcountedKeyedService:
  void ShutdownOnUIThread() override {}

  // Only runs a single callback, so that the test can specify a different
  // set per call.
  // Returns true if there was a recipient to receive the URLs and the list was
  // emitted, otherwise returns false.
  bool EmitURLs(const std::vector<TestData>& data) {
    if (callbacks_.empty())
      return false;

    history::MostVisitedURLList urls;
    for (const auto& test_element : data) {
      urls.push_back(test_element.entry);
    }

    std::move(callbacks_.front()).Run(std::move(urls));
    callbacks_.pop_front();
    return true;
  }

  const std::set<std::string>& blocked_urls() const { return blocked_urls_; }

 protected:
  // A test-specific field for controlling when most visited callback is run
  // after top sites have been requested.
  std::list<GetMostVisitedURLsCallback> callbacks_;
  std::set<std::string> blocked_urls_;

  ~FakeTopSites() override = default;
};

constexpr const auto* WEB_URL = u"https://example.com/";
constexpr const auto* SRP_URL = u"https://www.google.com/?q=flowers";
constexpr const auto* FTP_URL = u"ftp://just.for.filtering.com";

enum class ExpectedUiType {
  kAggregateMatch,
  kIndividualTiles
};

const std::vector<TestData> DefaultTestData() {
  return {{false, {GURL("http://www.a.art/"), u"A art"}},
          {false, {GURL("http://www.b.biz/"), u"B biz"}},
          {false, {GURL("http://www.c.com/"), u"C com"}},
          {false, {GURL("http://www.d.de/"), u"D de"}},
          {true, {GURL("http://www.google.com/search?q=abc"), u"abc"}}};
}

}  // namespace

class MostVisitedSitesProviderTest : public testing::Test,
                                     public AutocompleteProviderListener {
 public:
  void SetUp() override;

 protected:
  // Construct AutocompleteInput object a hypothetical Omnibox session context.
  // Does not run any validation on the supplied values, allowing any
  // combination (including invalid ones) to be used to create AutocompleteInput
  // context object.
  AutocompleteInput BuildAutocompleteInput(
      const std::u16string& input_url,
      const std::u16string& current_url,
      metrics::OmniboxEventProto::PageClassification page_class,
      metrics::OmniboxFocusType focus_type) {
    AutocompleteInput input(input_url, page_class, TestSchemeClassifier());
    input.set_focus_type(focus_type);
    input.set_current_url(GURL(current_url));
    return input;
  }

  // Helper method, constructing a valid AutocompleteInput object for a website
  // visit.
  AutocompleteInput BuildAutocompleteInputForWebOnFocus() {
    return BuildAutocompleteInput(WEB_URL, WEB_URL,
                                  metrics::OmniboxEventProto::OTHER,
                                  metrics::OmniboxFocusType::INTERACTION_FOCUS);
  }

  // Iterate over all matches offered by the Provider and verify these against
  // the supplied list of History URLs.
  void CheckMatchesEquivalentTo(const std::vector<TestData>& data,
                                ExpectedUiType ui_type);

  // Returns total number of all NAVSUGGEST and TILE_NAVSUGGEST elements.
  size_t NumMostVisitedMatches();

  // Returns the N-th match of a particular type, skipping over all matches of
  // other types. If match of that type does not exist, or there are not enough
  // elements of that type, this call returns null.
  const AutocompleteMatch* GetMatch(AutocompleteMatchType::Type type,
                                    size_t index);

  // AutocompleteProviderListener:
  void OnProviderUpdate(bool updated_matches,
                        const AutocompleteProvider* provider) override;

  base::HistogramTester histogram_;
  std::unique_ptr<base::test::SingleThreadTaskEnvironment> task_environment_;
  FakeAutocompleteProviderClient client_;
  scoped_refptr<FakeTopSites> top_sites_;
  scoped_refptr<MostVisitedSitesProvider> provider_;
  int provider_update_count_{};
};

size_t MostVisitedSitesProviderTest::NumMostVisitedMatches() {
  const auto& result = provider_->matches();
  size_t count = 0;
  for (const auto& match : result) {
    if ((match.type == AutocompleteMatchType::TILE_NAVSUGGEST) ||
        (match.type == AutocompleteMatchType::NAVSUGGEST) ||
        (match.type == AutocompleteMatchType::TILE_MOST_VISITED_SITE) ||
        (match.type == AutocompleteMatchType::TILE_REPEATABLE_QUERY)) {
      ++count;
    }
  }
  return count;
}

const AutocompleteMatch* MostVisitedSitesProviderTest::GetMatch(
    AutocompleteMatchType::Type type,
    size_t index) {
  const auto& result = provider_->matches();
  for (const auto& match : result) {
    if (match.type == type) {
      if (!index)
        return &match;
      --index;
    }
  }
  return nullptr;
}

void MostVisitedSitesProviderTest::CheckMatchesEquivalentTo(
    const std::vector<TestData>& data,
    ExpectedUiType ui_type) {
  // Compare the AutocompleteResult against a set of URLs that we expect to see.
  // Note that additional matches may be offered if other providers are also
  // registered in the same category as MostVisitedSitesProvider.
  // We ignore all matches that are not ours.
  const auto& result = provider_->matches();

  size_t match_index = 0;

  if (ui_type == ExpectedUiType::kAggregateMatch) {
    ASSERT_EQ(1ul, NumMostVisitedMatches())
        << "Expected only one TILE_NAVSUGGEST match";
    for (const auto& match : result) {
      if (match.type != AutocompleteMatchType::TILE_NAVSUGGEST)
        continue;
      EXPECT_TRUE(match.subtypes.contains(
          omnibox::SUBTYPE_ZERO_PREFIX_LOCAL_FREQUENT_URLS));
      EXPECT_TRUE(match.subtypes.contains(omnibox::SUBTYPE_URL_BASED));
      const auto& tiles = match.suggest_tiles;
      ASSERT_EQ(data.size(), tiles.size()) << "Wrong number of tiles reported";
      for (size_t index = 0u; index < data.size(); index++) {
        EXPECT_EQ(data[index].entry.url, tiles[index].url)
            << "Invalid Tile URL at position " << index;
        EXPECT_EQ(data[index].entry.title, tiles[index].title)
            << "Invalid Tile Title at position " << index;
      }
      break;
    }
  } else if (ui_type == ExpectedUiType::kIndividualTiles) {
    ASSERT_EQ(data.size(), NumMostVisitedMatches())
        << "Unexpected number of TILE matches";
    int expected_relevance = 1600;  // kMostVisitedTilesIndividualHighRelevance
    for (const auto& match : result) {
      if (data[match_index].is_search) {
        EXPECT_EQ(match.type, AutocompleteMatchType::TILE_REPEATABLE_QUERY);
        EXPECT_TRUE(match.subtypes.contains(
            omnibox::SUBTYPE_ZERO_PREFIX_LOCAL_FREQUENT_QUERIES));
      } else {
        EXPECT_EQ(match.type, AutocompleteMatchType::TILE_MOST_VISITED_SITE);
        EXPECT_TRUE(match.subtypes.contains(
            omnibox::SUBTYPE_ZERO_PREFIX_LOCAL_FREQUENT_URLS));
        EXPECT_TRUE(match.subtypes.contains(omnibox::SUBTYPE_URL_BASED));
      }

      EXPECT_EQ(data[match_index].entry.url, match.destination_url)
          << "Invalid Match URL at position " << match_index;
      EXPECT_EQ(data[match_index].entry.title, match.description)
          << "Invalid Match Title at position " << match_index;
      EXPECT_EQ(expected_relevance, match.relevance)
          << "Invalid Match Relevance at position " << match_index;
      ++match_index;
      // Degrade relevance of partially visible and invisible matches.
      if (match_index == 4 &&
          ui::GetDeviceFormFactor() ==
              ui::DeviceFormFactor::DEVICE_FORM_FACTOR_PHONE) {
        expected_relevance = 100;  // kMostVisitedTilesIndividualLowRelevance
      }
      --expected_relevance;
    }
  }
}

void MostVisitedSitesProviderTest::SetUp() {
  task_environment_ =
      std::make_unique<base::test::SingleThreadTaskEnvironment>();
  top_sites_ = new FakeTopSites();

  client_.set_top_sites(top_sites_);

  // For tests requiring direct interaction with the Provider.
  provider_ = new MostVisitedSitesProvider(&client_, this);
}

void MostVisitedSitesProviderTest::OnProviderUpdate(
    bool updated_matches,
    const AutocompleteProvider* provider) {
  provider_update_count_++;
}

TEST_F(MostVisitedSitesProviderTest, TestMostVisitedCallback) {
  base::test::ScopedFeatureList features;
  features.InitAndDisableFeature(
      omnibox::kMostVisitedTilesHorizontalRenderGroup);
  auto input = BuildAutocompleteInputForWebOnFocus();
  provider_->Start(input, true);
  EXPECT_EQ(0u, NumMostVisitedMatches());
  auto test_data = DefaultTestData();
  EXPECT_TRUE(top_sites_->EmitURLs(test_data));
  CheckMatchesEquivalentTo(test_data, ExpectedUiType::kAggregateMatch);
  EXPECT_EQ(1, provider_update_count_);
  provider_->Stop(false, false);

  // Observe that subsequent request does not return stale data.
  provider_->Start(input, true);
  provider_->Stop(false, false);
  // Since this provider's async logic is still in-flight (`EmitURLs()` has not
  // been called yet), we should not be reporting anything from past runs.
  EXPECT_EQ(0ul, NumMostVisitedMatches());
  EXPECT_EQ(1, provider_update_count_);

  // Most visited results arriving after Stop() has been called, ensure they
  // are not displayed.
  std::vector<TestData> new_urls{{
      false,
      {GURL("http://www.g.gov/"), u"G gov"},
  }};
  EXPECT_TRUE(top_sites_->EmitURLs(new_urls));
  EXPECT_EQ(0ul, NumMostVisitedMatches());
  EXPECT_EQ(1, provider_update_count_);

  provider_->Start(input, true);
  provider_->Stop(false, false);
  provider_->Start(input, true);

  // Stale results (reported for the first of the two Start() requests) should
  // be rejected.
  EXPECT_TRUE(top_sites_->EmitURLs(DefaultTestData()));
  EXPECT_EQ(0ul, NumMostVisitedMatches());
  EXPECT_EQ(1, provider_update_count_);

  // Results for the second Start() action should be recorded.
  EXPECT_TRUE(top_sites_->EmitURLs(test_data));
  CheckMatchesEquivalentTo(test_data, ExpectedUiType::kAggregateMatch);
  EXPECT_EQ(2, provider_update_count_);
  provider_->Stop(false, false);
}

TEST_F(MostVisitedSitesProviderTest, TestMostVisitedNavigateToSearchPage) {
  provider_->Start(BuildAutocompleteInputForWebOnFocus(), true);
  EXPECT_EQ(0u, NumMostVisitedMatches());
  // Stop() doesn't always get called.

  auto srp_input = BuildAutocompleteInput(
      SRP_URL, SRP_URL,
      metrics::OmniboxEventProto::SEARCH_RESULT_PAGE_NO_SEARCH_TERM_REPLACEMENT,
      metrics::OmniboxFocusType::INTERACTION_FOCUS);

  provider_->Start(srp_input, true);
  EXPECT_EQ(0u, NumMostVisitedMatches());

  // Most visited results arriving after a new request has been started.
  EXPECT_TRUE(top_sites_->EmitURLs(DefaultTestData()));
  EXPECT_EQ(0u, NumMostVisitedMatches());
}

TEST_F(MostVisitedSitesProviderTest, AllowMostVisitedSitesSuggestions) {
  using OEP = metrics::OmniboxEventProto;
  using OFT = metrics::OmniboxFocusType;

  // MostVisited should never deal with prefix suggestions.
  EXPECT_FALSE(
      provider_->AllowMostVisitedSitesSuggestions(BuildAutocompleteInput(
          WEB_URL, WEB_URL, OEP::OTHER, OFT::INTERACTION_DEFAULT)));

  // This should always be true, as otherwise we will break MostVisited.
  EXPECT_TRUE(
      provider_->AllowMostVisitedSitesSuggestions(BuildAutocompleteInput(
          WEB_URL, WEB_URL, OEP::OTHER, OFT::INTERACTION_FOCUS)));

  // Verifies that non-permitted schemes are rejected.
  EXPECT_FALSE(
      provider_->AllowMostVisitedSitesSuggestions(BuildAutocompleteInput(
          FTP_URL, FTP_URL, OEP::OTHER, OFT::INTERACTION_FOCUS)));

  // Offer MV sites when the User is visiting a website and deletes text.
  EXPECT_TRUE(
      provider_->AllowMostVisitedSitesSuggestions(BuildAutocompleteInput(
          WEB_URL, WEB_URL, OEP::OTHER, OFT::INTERACTION_CLOBBER)));
}

TEST_F(MostVisitedSitesProviderTest, NoSRPCoverage) {
  using OEP = metrics::OmniboxEventProto;
  using OFT = metrics::OmniboxFocusType;

  EXPECT_FALSE(
      provider_->AllowMostVisitedSitesSuggestions(BuildAutocompleteInput(
          WEB_URL, WEB_URL, OEP::SEARCH_RESULT_PAGE_NO_SEARCH_TERM_REPLACEMENT,
          OFT::INTERACTION_FOCUS)));
}

TEST_F(MostVisitedSitesProviderTest, TestCreateMostVisitedMatch) {
  base::test::ScopedFeatureList features;
  features.InitAndDisableFeature(
      omnibox::kMostVisitedTilesHorizontalRenderGroup);
  provider_->Start(BuildAutocompleteInputForWebOnFocus(), true);
  EXPECT_EQ(0u, NumMostVisitedMatches());
  // Accept only direct TopSites data.
  auto test_data = DefaultTestData();
  EXPECT_TRUE(top_sites_->EmitURLs(test_data));
  CheckMatchesEquivalentTo(test_data, ExpectedUiType::kAggregateMatch);
}

TEST_F(MostVisitedSitesProviderTest, NoMatchesWhenNoMostVisitedSites) {
  // Start with no URLs.
  provider_->Start(BuildAutocompleteInputForWebOnFocus(), true);
  EXPECT_EQ(0u, NumMostVisitedMatches());
  // Accept only direct TopSites data, confirm no matches are built.
  EXPECT_TRUE(top_sites_->EmitURLs({}));
  EXPECT_EQ(0u, NumMostVisitedMatches());
}

TEST_F(MostVisitedSitesProviderTest,
       NoMatchesWhenTopSitesNotLoadedAndWantAsyncMatchesFalse) {
  // Assume that top sites list has not been loaded yet from the DB.
  ASSERT_FALSE(top_sites_->loaded());
  auto input = BuildAutocompleteInputForWebOnFocus();
  input.set_focus_type(metrics::OmniboxFocusType::INTERACTION_DEFAULT);
  input.set_omit_asynchronous_matches(true);
  provider_->Start(input, true);
  EXPECT_TRUE(provider_->done());
  EXPECT_EQ(0u, NumMostVisitedMatches());
  // No callbacks should have been added due to early return.
  EXPECT_FALSE(top_sites_->EmitURLs(DefaultTestData()));
  EXPECT_EQ(0u, NumMostVisitedMatches());
}

TEST_F(MostVisitedSitesProviderTest, TestDeleteMostVisitedElement) {
  base::test::ScopedFeatureList features;
  features.InitAndDisableFeature(
      omnibox::kMostVisitedTilesHorizontalRenderGroup);
  // Make a copy (intentional - we'll modify this later)
  provider_->Start(BuildAutocompleteInputForWebOnFocus(), true);
  // Accept only direct TopSites data.
  auto test_data = DefaultTestData();
  EXPECT_TRUE(top_sites_->EmitURLs(test_data));
  CheckMatchesEquivalentTo(test_data, ExpectedUiType::kAggregateMatch);

  // Commence delete.
  histogram_.ExpectTotalCount("Omnibox.SuggestTiles.TileTypeCount.Search", 1);
  histogram_.ExpectBucketCount("Omnibox.SuggestTiles.TileTypeCount.Search", 1,
                               1);
  histogram_.ExpectTotalCount("Omnibox.SuggestTiles.TileTypeCount.URL", 1);
  histogram_.ExpectBucketCount("Omnibox.SuggestTiles.TileTypeCount.URL", 4, 1);
  histogram_.ExpectTotalCount("Omnibox.SuggestTiles.DeletedTileIndex", 0);
  auto* match = GetMatch(AutocompleteMatchType::TILE_NAVSUGGEST, 0);
  ASSERT_NE(nullptr, match) << "No TILE_NAVSUGGEST Match found";
  provider_->DeleteMatchElement(*match, 1);
  histogram_.ExpectTotalCount("Omnibox.SuggestTiles.DeletedTileIndex", 1);
  histogram_.ExpectBucketCount("Omnibox.SuggestTiles.DeletedTileIndex", 1, 1);
  // Note: TileTypeCounts are not emitted after deletion.

  // Observe that the URL is now blocked and removed from suggestion.
  auto deleted_url = test_data[1].entry.url;
  test_data.erase(test_data.begin() + 1);
  CheckMatchesEquivalentTo(test_data, ExpectedUiType::kAggregateMatch);
  EXPECT_TRUE(top_sites_->IsBlocked(deleted_url));
}

TEST_F(MostVisitedSitesProviderTest, NoMatchesWhenLastURLIsDeleted) {
  base::test::ScopedFeatureList features;
  features.InitAndDisableFeature(
      omnibox::kMostVisitedTilesHorizontalRenderGroup);

  // Start with just one URL.
  std::vector<TestData> urls{{
      {false, {GURL("http://www.a.art/"), u"A art"}},
  }};

  provider_->Start(BuildAutocompleteInputForWebOnFocus(), true);
  EXPECT_TRUE(top_sites_->EmitURLs(urls));
  CheckMatchesEquivalentTo(urls, ExpectedUiType::kAggregateMatch);

  // Commence delete of the only item that we have.
  histogram_.ExpectTotalCount("Omnibox.SuggestTiles.TileTypeCount.Search", 1);
  histogram_.ExpectBucketCount("Omnibox.SuggestTiles.TileTypeCount.Search", 0,
                               1);
  histogram_.ExpectTotalCount("Omnibox.SuggestTiles.TileTypeCount.URL", 1);
  histogram_.ExpectBucketCount("Omnibox.SuggestTiles.TileTypeCount.URL", 1, 1);
  histogram_.ExpectTotalCount("Omnibox.SuggestTiles.DeletedTileIndex", 0);
  auto* match = GetMatch(AutocompleteMatchType::TILE_NAVSUGGEST, 0);
  ASSERT_NE(nullptr, match) << "No TILE_NAVSUGGEST Match found";
  provider_->DeleteMatchElement(*match, 0);
  histogram_.ExpectTotalCount("Omnibox.SuggestTiles.DeletedTileIndex", 1);
  histogram_.ExpectBucketCount("Omnibox.SuggestTiles.DeletedTileIndex", 0, 1);
  // Note: TileTypeCounts are not emitted after deletion.

  // Confirm no more NAVSUGGEST matches are offered.
  EXPECT_EQ(0u, NumMostVisitedMatches());
}

TEST_F(MostVisitedSitesProviderTest,
       TestCreateMostVisitedHorizontalGroupTiles) {
  base::test::ScopedFeatureList features;
  features.InitWithFeatures({omnibox::kMostVisitedTilesHorizontalRenderGroup},
                            {});

  provider_->Start(BuildAutocompleteInputForWebOnFocus(), true);
  EXPECT_EQ(0u, NumMostVisitedMatches());
  // Accept only direct TopSites data.
  auto test_data = DefaultTestData();
  EXPECT_TRUE(top_sites_->EmitURLs(test_data));
  CheckMatchesEquivalentTo(test_data, ExpectedUiType::kIndividualTiles);
}