// Copyright 2012 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/in_memory_url_index.h"

#include <stddef.h>
#include <stdint.h>

#include <algorithm>
#include <array>
#include <fstream>
#include <memory>
#include <numeric>
#include <string>
#include <utility>

#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/i18n/case_conversion.h"
#include "base/memory/raw_ptr.h"
#include "base/path_service.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_split.h"
#include "base/strings/string_util.h"
#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "base/test/bind.h"
#include "base/test/task_environment.h"
#include "base/time/time.h"
#include "components/history/core/browser/history_backend.h"
#include "components/history/core/browser/history_database.h"
#include "components/history/core/browser/history_service.h"
#include "components/history/core/browser/history_types.h"
#include "components/history/core/test/history_service_test_util.h"
#include "components/omnibox/browser/in_memory_url_index_types.h"
#include "components/omnibox/browser/omnibox_triggered_feature_service.h"
#include "components/omnibox/browser/url_index_private_data.h"
#include "components/search_engines/search_engines_test_environment.h"
#include "components/search_engines/template_url_service.h"
#include "components/url_formatter/url_formatter.h"
#include "testing/gtest/include/gtest/gtest.h"

// The test version of the history url database table ('url') is contained in
// a database file created from a text file('in_memory_url_index_test.db.txt').
// The only difference between this table and a live 'urls' table from a
// profile is that the last_visit_time column in the test table contains a
// number specifying the number of days relative to 'today' to which the
// absolute time should be set during the test setup stage.
//
// The format of the test database text file is of a SQLite .dump file.
// Note that only lines whose first character is an upper-case letter are
// processed when creating the test database.

namespace {
const size_t kInvalid = std::u16string::npos;
const size_t kProviderMaxMatches = 3;
const char kClientAllowlistedScheme[] = "xyz";

// TemplateURLs used to test filtering of search engine URLs.
const char16_t kDefaultTemplateURLKeyword[] = u"default-engine.com";
const char16_t kNonDefaultTemplateURLKeyword[] = u"non-default-engine.com";
const TemplateURLService::Initializer kTemplateURLData[] = {
    {"default-engine.com", "http://default-engine.com?q={searchTerms}",
     "Default"},
    {"non-default-engine.com", "http://non-default-engine.com?q={searchTerms}",
     "Not Default"},
};

// Helper function to set lower case |lower_string| and |lower_terms| (words
// list) based on supplied |search_string| and |cursor_position|. If
// |cursor_position| is set and useful (not at either end of the string), allow
// the |search_string| to be broken at |cursor_position|. We do this by
// pretending there's a space where the cursor is. |lower_terms| are obtained by
// splitting the |lower_string| on whitespace into tokens.
void StringToTerms(const char* search_string,
                   size_t cursor_position,
                   std::u16string* lower_string,
                   String16Vector* lower_terms) {
  *lower_string = base::i18n::ToLower(base::ASCIIToUTF16(search_string));
  if ((cursor_position != kInvalid) &&
      (cursor_position < lower_string->length()) && (cursor_position > 0)) {
    lower_string->insert(cursor_position, u" ");
  }

  *lower_terms =
      base::SplitString(*lower_string, base::kWhitespaceUTF16,
                        base::KEEP_WHITESPACE, base::SPLIT_WANT_NONEMPTY);
}

}  // namespace

// -----------------------------------------------------------------------------

class InMemoryURLIndexTest : public testing::Test {
 public:
  InMemoryURLIndexTest() = default;

 protected:
  // Test setup.
  void SetUp() override;
  void TearDown() override;

  // Allows the database containing the test data to be customized by
  // subclasses.
  virtual base::FilePath::StringType TestDBName() const;

  // Allows the test to control when the InMemoryURLIndex is initialized.
  virtual bool InitializeInMemoryURLIndexInSetUp() const;

  // Initialize the InMemoryURLIndex for the tests.
  void InitializeInMemoryURLIndex();

  // Validates that the given |term| is contained in |cache| and that it is
  // marked as in-use.
  void CheckTerm(const URLIndexPrivateData::SearchTermCacheMap& cache,
                 std::u16string term) const;

  // Pass-through function to simplify our friendship with HistoryService.
  sql::Database& GetDB();

  // Pass-through functions to simplify our friendship with InMemoryURLIndex.
  URLIndexPrivateData* GetPrivateData() const;
  base::CancelableTaskTracker* GetPrivateDataTracker() const;
  void ClearPrivateData();
  const SchemeSet& scheme_allowlist();

  // Pass-through functions to simplify our friendship with URLIndexPrivateData.
  bool UpdateURL(const history::URLRow& row);
  bool DeleteURL(const GURL& url);

  // Data verification helper functions.
  void ExpectPrivateDataNotEmpty(const URLIndexPrivateData& data);
  void ExpectPrivateDataEmpty(const URLIndexPrivateData& data);
  void ExpectPrivateDataEqual(const URLIndexPrivateData& expected,
                              const URLIndexPrivateData& actual);

  TemplateURLService* template_url_service();

  ScoredHistoryMatches HistoryItemsForTerms(const std::u16string& term_string,
                                            size_t cursor_position,
                                            size_t max_matches) {
    OmniboxTriggeredFeatureService x{};
    return url_index_->HistoryItemsForTerms(term_string, cursor_position,
                                            max_matches, &x);
  }

  base::ScopedTempDir history_dir_;
  base::ScopedTempDir temp_dir_;
  base::test::TaskEnvironment task_environment_;
  std::unique_ptr<history::HistoryService> history_service_;
  raw_ptr<history::HistoryDatabase> history_database_ = nullptr;
  search_engines::SearchEnginesTestEnvironment search_engines_test_environment_{
      {.template_url_service_initializer = kTemplateURLData}};
  std::unique_ptr<InMemoryURLIndex> url_index_;
};

TemplateURLService* InMemoryURLIndexTest::template_url_service() {
  return search_engines_test_environment_.template_url_service();
}

sql::Database& InMemoryURLIndexTest::GetDB() {
  return history_database_->GetDB();
}

URLIndexPrivateData* InMemoryURLIndexTest::GetPrivateData() const {
  DCHECK(url_index_->private_data());
  return url_index_->private_data();
}

base::CancelableTaskTracker* InMemoryURLIndexTest::GetPrivateDataTracker()
    const {
  DCHECK(url_index_->private_data_tracker());
  return url_index_->private_data_tracker();
}

void InMemoryURLIndexTest::ClearPrivateData() {
  return url_index_->ClearPrivateData();
}

const SchemeSet& InMemoryURLIndexTest::scheme_allowlist() {
  return url_index_->scheme_allowlist();
}

bool InMemoryURLIndexTest::UpdateURL(const history::URLRow& row) {
  return GetPrivateData()->UpdateURL(history_service_.get(), row,
                                     url_index_->scheme_allowlist_,
                                     GetPrivateDataTracker());
}

bool InMemoryURLIndexTest::DeleteURL(const GURL& url) {
  return GetPrivateData()->DeleteURL(url);
}

void InMemoryURLIndexTest::SetUp() {
  // We cannot access the database until the backend has been loaded.
  ASSERT_TRUE(history_dir_.CreateUniqueTempDir());
  history_service_ =
      history::CreateHistoryService(history_dir_.GetPath(), true);
  ASSERT_TRUE(history_service_);

  history::HistoryBackend* backend = history_service_->history_backend_.get();
  history_database_ = backend->db();

  // Mutating the History database should only happen on the backend sequence.
  history_service_->ScheduleTask(
      history::HistoryService::PRIORITY_NORMAL, base::BindLambdaForTesting([&] {
        // TODO(shess): If/when this code gets refactored, consider including
        // the schema in the golden file (as sqlite3 .dump would generate) and
        // using sql::test::CreateDatabaseFromSQL() to load it.  The code above
        // which creates the database can change in ways which may not reliably
        // represent user databases on disks in the fleet.

        // Execute the contents of a golden file to populate the [urls] and
        // [visits] tables.
        base::FilePath golden_path;
        base::PathService::Get(base::DIR_SRC_TEST_DATA_ROOT, &golden_path);
        golden_path = golden_path.AppendASCII("components/test/data/omnibox");
        golden_path = golden_path.Append(TestDBName());
        ASSERT_TRUE(base::PathExists(golden_path));
        std::string sql;
        ASSERT_TRUE(base::ReadFileToString(golden_path, &sql));
        sql::Database& db(GetDB());
        ASSERT_TRUE(db.is_open());
        ASSERT_TRUE(db.ExecuteScriptForTesting(sql));

        // Update [urls.last_visit_time] and [visits.visit_time] to represent a
        // time relative to 'now'.
        base::Time time_right_now = base::Time::NowFromSystemTime();
        base::TimeDelta day_delta = base::Days(1);
        {
          sql::Statement s(db.GetUniqueStatement(
              "UPDATE urls SET last_visit_time = ? - ? * last_visit_time"));
          s.BindTime(0, time_right_now);
          s.BindTimeDelta(1, day_delta);
          ASSERT_TRUE(s.Run());
        }
        {
          sql::Statement s(db.GetUniqueStatement(
              "UPDATE visits SET visit_time = ? - ? * visit_time"));
          s.BindTime(0, time_right_now);
          s.BindTimeDelta(1, day_delta);
          ASSERT_TRUE(s.Run());
        }
      }));
  // Block for the above lambda to run, otherwise the captured variables will
  // go out of scope. And moreover, subsequent parts of this function need to
  // have the test data already pre-populated.
  BlockUntilHistoryProcessesPendingRequests(history_service_.get());

  // Set up a simple template URL service with a default search engine.
  TemplateURL* template_url = template_url_service()->GetTemplateURLForKeyword(
      kDefaultTemplateURLKeyword);
  template_url_service()->SetUserSelectedDefaultSearchProvider(template_url);

  if (InitializeInMemoryURLIndexInSetUp())
    InitializeInMemoryURLIndex();
}

void InMemoryURLIndexTest::TearDown() {
  // Ensure that the InMemoryURLIndex no longer observes HistoryService before
  // it is destroyed in order to prevent HistoryService calling dead observer.
  if (url_index_)
    url_index_->Shutdown();
  task_environment_.RunUntilIdle();
}

base::FilePath::StringType InMemoryURLIndexTest::TestDBName() const {
  return FILE_PATH_LITERAL("in_memory_url_index_test.sql");
}

bool InMemoryURLIndexTest::InitializeInMemoryURLIndexInSetUp() const {
  return true;
}

void InMemoryURLIndexTest::InitializeInMemoryURLIndex() {
  DCHECK(!url_index_);

  SchemeSet client_schemes_to_allowlist;
  client_schemes_to_allowlist.insert(kClientAllowlistedScheme);
  url_index_ = std::make_unique<InMemoryURLIndex>(
      nullptr, history_service_.get(), template_url_service(), base::FilePath(),
      client_schemes_to_allowlist);
  url_index_->Init();

  BlockUntilHistoryProcessesPendingRequests(history_service_.get());
  ASSERT_TRUE(url_index_->restored());
}

void InMemoryURLIndexTest::CheckTerm(
    const URLIndexPrivateData::SearchTermCacheMap& cache,
    std::u16string term) const {
  auto cache_iter(cache.find(term));
  ASSERT_TRUE(cache.end() != cache_iter)
      << "Cache does not contain '" << term << "' but should.";
  URLIndexPrivateData::SearchTermCacheItem cache_item = cache_iter->second;
  EXPECT_TRUE(cache_item.used_)
      << "Cache item '" << term << "' should be marked as being in use.";
}

void InMemoryURLIndexTest::ExpectPrivateDataNotEmpty(
    const URLIndexPrivateData& data) {
  EXPECT_FALSE(data.word_list_.empty());
  // available_words_ will be empty since we have freshly built the
  // data set for these tests.
  EXPECT_TRUE(data.available_words_.empty());
  EXPECT_FALSE(data.word_map_.empty());
  EXPECT_FALSE(data.char_word_map_.empty());
  EXPECT_FALSE(data.word_id_history_map_.empty());
  EXPECT_FALSE(data.history_id_word_map_.empty());
  EXPECT_FALSE(data.history_info_map_.empty());
}

void InMemoryURLIndexTest::ExpectPrivateDataEmpty(
    const URLIndexPrivateData& data) {
  EXPECT_TRUE(data.word_list_.empty());
  EXPECT_TRUE(data.available_words_.empty());
  EXPECT_TRUE(data.word_map_.empty());
  EXPECT_TRUE(data.char_word_map_.empty());
  EXPECT_TRUE(data.word_id_history_map_.empty());
  EXPECT_TRUE(data.history_id_word_map_.empty());
  EXPECT_TRUE(data.history_info_map_.empty());
}

// Helper function which compares two maps for equivalence. The maps' values
// are associative containers and their contents are compared as well.
template <typename T>
void ExpectMapOfContainersIdentical(const T& expected, const T& actual) {
  ASSERT_EQ(expected.size(), actual.size());
  for (auto expected_iter = expected.begin(); expected_iter != expected.end();
       ++expected_iter) {
    auto actual_iter = actual.find(expected_iter->first);
    ASSERT_TRUE(actual.end() != actual_iter);
    typename T::mapped_type const& expected_values(expected_iter->second);
    typename T::mapped_type const& actual_values(actual_iter->second);
    ASSERT_EQ(expected_values.size(), actual_values.size());
    for (auto set_iter = expected_values.begin();
         set_iter != expected_values.end(); ++set_iter)
      EXPECT_EQ(actual_values.count(*set_iter),
                expected_values.count(*set_iter));
  }
}

void InMemoryURLIndexTest::ExpectPrivateDataEqual(
    const URLIndexPrivateData& expected,
    const URLIndexPrivateData& actual) {
  EXPECT_EQ(expected.word_list_.size(), actual.word_list_.size());
  EXPECT_EQ(expected.word_map_.size(), actual.word_map_.size());
  EXPECT_EQ(expected.char_word_map_.size(), actual.char_word_map_.size());
  EXPECT_EQ(expected.word_id_history_map_.size(),
            actual.word_id_history_map_.size());
  EXPECT_EQ(expected.history_id_word_map_.size(),
            actual.history_id_word_map_.size());
  EXPECT_EQ(expected.history_info_map_.size(), actual.history_info_map_.size());
  EXPECT_EQ(expected.word_starts_map_.size(), actual.word_starts_map_.size());
  // WordList must be index-by-index equal.
  size_t count = expected.word_list_.size();
  for (size_t i = 0; i < count; ++i)
    EXPECT_EQ(expected.word_list_[i], actual.word_list_[i]);

  ExpectMapOfContainersIdentical(expected.char_word_map_,
                                 actual.char_word_map_);
  ExpectMapOfContainersIdentical(expected.word_id_history_map_,
                                 actual.word_id_history_map_);
  ExpectMapOfContainersIdentical(expected.history_id_word_map_,
                                 actual.history_id_word_map_);

  for (const auto& expected_info : expected.history_info_map_) {
    auto actual_info = actual.history_info_map_.find(expected_info.first);
    // NOTE(yfriedman): ASSERT_NE can't be used due to incompatibility between
    // gtest and STLPort in the Android build. See
    // http://code.google.com/p/googletest/issues/detail?id=359
    ASSERT_TRUE(actual_info != actual.history_info_map_.end());
    const history::URLRow& expected_row(expected_info.second.url_row);
    const history::URLRow& actual_row(actual_info->second.url_row);
    EXPECT_EQ(expected_row.visit_count(), actual_row.visit_count());
    EXPECT_EQ(expected_row.typed_count(), actual_row.typed_count());
    EXPECT_EQ(expected_row.last_visit(), actual_row.last_visit());
    EXPECT_EQ(expected_row.url(), actual_row.url());
    const VisitInfoVector& expected_visits(expected_info.second.visits);
    const VisitInfoVector& actual_visits(actual_info->second.visits);
    EXPECT_EQ(expected_visits.size(), actual_visits.size());
    for (size_t i = 0;
         i < std::min(expected_visits.size(), actual_visits.size()); ++i) {
      EXPECT_EQ(expected_visits[i].first, actual_visits[i].first);
      EXPECT_TRUE(ui::PageTransitionTypeIncludingQualifiersIs(
          actual_visits[i].second, expected_visits[i].second));
    }
  }

  for (const auto& expected_starts : expected.word_starts_map_) {
    auto actual_starts = actual.word_starts_map_.find(expected_starts.first);
    // NOTE(yfriedman): ASSERT_NE can't be used due to incompatibility between
    // gtest and STLPort in the Android build. See
    // http://code.google.com/p/googletest/issues/detail?id=359
    ASSERT_TRUE(actual_starts != actual.word_starts_map_.end());
    const RowWordStarts& expected_word_starts(expected_starts.second);
    const RowWordStarts& actual_word_starts(actual_starts->second);
    EXPECT_TRUE(std::ranges::equal(expected_word_starts.url_word_starts_,
                                   actual_word_starts.url_word_starts_));
    EXPECT_TRUE(std::ranges::equal(expected_word_starts.title_word_starts_,
                                   actual_word_starts.title_word_starts_));
  }
}

//------------------------------------------------------------------------------

class LimitedInMemoryURLIndexTest : public InMemoryURLIndexTest {
 protected:
  base::FilePath::StringType TestDBName() const override;
  bool InitializeInMemoryURLIndexInSetUp() const override;
};

base::FilePath::StringType LimitedInMemoryURLIndexTest::TestDBName() const {
  return FILE_PATH_LITERAL("in_memory_url_index_test_limited.sql");
}

bool LimitedInMemoryURLIndexTest::InitializeInMemoryURLIndexInSetUp() const {
  return false;
}

TEST_F(LimitedInMemoryURLIndexTest, Initialization) {
  // Verify that the database contains the expected number of items, which is
  // the pre-filtered count, i.e. all the items.
  uint64_t row_count = 0;
  history_service_->ScheduleDBTaskForUI(base::BindLambdaForTesting(
      [&](history::HistoryBackend* backend, history::URLDatabase* url_db) {
        if (!url_db) {
          return;
        }
        history::URLDatabase::URLEnumerator enumerator;
        if (url_db->InitURLEnumeratorForEverything(&enumerator)) {
          history::URLRow row;
          while (enumerator.GetNextURL(&row)) {
            ++row_count;
          }
        }
      }));
  BlockUntilHistoryProcessesPendingRequests(history_service_.get());
  EXPECT_EQ(1U, row_count);

  InitializeInMemoryURLIndex();
  URLIndexPrivateData& private_data(*GetPrivateData());

  // history_info_map_ should have the same number of items as were filtered.
  EXPECT_EQ(1U, private_data.history_info_map_.size());
  EXPECT_EQ(35U, private_data.char_word_map_.size());
  EXPECT_EQ(19U, private_data.word_map_.size());
}

TEST_F(InMemoryURLIndexTest, HiddenURLRowsAreIgnored) {
  history::URLID new_row_id = 87654321;  // Arbitrarily chosen large new row id.
  history::URLRow new_row =
      history::URLRow(GURL("http://hidden.com/"), new_row_id++);
  new_row.set_last_visit(base::Time::Now());
  new_row.set_hidden(true);

  EXPECT_FALSE(UpdateURL(new_row));
  EXPECT_EQ(0U, HistoryItemsForTerms(u"hidden", std::u16string::npos,
                                     kProviderMaxMatches)
                    .size());
}

TEST_F(InMemoryURLIndexTest, Retrieval) {
  // See if a very specific term gives a single result.
  ScoredHistoryMatches matches = HistoryItemsForTerms(
      u"DrudgeReport", std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());

  // Verify that we got back the result we expected.
  EXPECT_EQ(5, matches[0].url_info.id());
  EXPECT_EQ("http://drudgereport.com/", matches[0].url_info.url().spec());
  EXPECT_EQ(u"DRUDGE REPORT 2010", matches[0].url_info.title());

  // Make sure a trailing space still results in the expected result.
  matches = HistoryItemsForTerms(u"DrudgeReport ", std::u16string::npos,
                                 kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ(5, matches[0].url_info.id());
  EXPECT_EQ("http://drudgereport.com/", matches[0].url_info.url().spec());
  EXPECT_EQ(u"DRUDGE REPORT 2010", matches[0].url_info.title());

  // Search which should result in multiple results.
  matches = HistoryItemsForTerms(u"drudge", std::u16string::npos,
                                 kProviderMaxMatches);
  ASSERT_EQ(2U, matches.size());
  // The results should be in descending score order.
  EXPECT_GE(matches[0].raw_score, matches[1].raw_score);

  // Search which should result in nearly perfect result.
  matches = HistoryItemsForTerms(u"Nearly Perfect Result", std::u16string::npos,
                                 kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  // The results should have a very high score.
  EXPECT_GT(matches[0].raw_score, 900);
  EXPECT_EQ(32, matches[0].url_info.id());
  EXPECT_EQ("https://nearlyperfectresult.com/",
            matches[0].url_info.url().spec());  // Note: URL gets lowercased.
  EXPECT_EQ(u"Practically Perfect Search Result", matches[0].url_info.title());

  // Search which should result in very poor result.  (It's a mid-word match
  // in a hostname.)  No results since it will be suppressed by default scoring.
  matches = HistoryItemsForTerms(u"heinqui", std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(0U, matches.size());
  // But if the user adds a term that matches well against the same result,
  // the result should be returned.
  matches = HistoryItemsForTerms(u"heinqui microprocessor",
                                 std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ(18, matches[0].url_info.id());
  EXPECT_EQ("http://www.theinquirer.net/", matches[0].url_info.url().spec());
  EXPECT_EQ(
      u"THE INQUIRER - Microprocessor, Server, Memory, PCS, "
      u"Graphics, Networking, Storage",
      matches[0].url_info.title());

  // A URL that comes from the default search engine should not be returned.
  matches =
      HistoryItemsForTerms(u"query", std::u16string::npos, kProviderMaxMatches);
  EXPECT_EQ(0U, matches.size());

  // But if it's not from the default search engine, it should be returned.
  TemplateURL* template_url = template_url_service()->GetTemplateURLForKeyword(
      kNonDefaultTemplateURLKeyword);
  template_url_service()->SetUserSelectedDefaultSearchProvider(template_url);
  matches =
      HistoryItemsForTerms(u"query", std::u16string::npos, kProviderMaxMatches);
  EXPECT_EQ(1U, matches.size());

  // Search which will match at the end of a URL with encoded characters.
  matches =
      HistoryItemsForTerms(u"Mice", std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ(30, matches[0].url_info.id());

  // Check that URLs are not escaped an extra time.
  matches = HistoryItemsForTerms(u"1% wikipedia", std::u16string::npos,
                                 kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ(35, matches[0].url_info.id());
  EXPECT_EQ("http://en.wikipedia.org/wiki/1%25_rule_(Internet_culture)",
            matches[0].url_info.url().spec());

  // Verify that a single term can appear multiple times in the URL.
  matches =
      HistoryItemsForTerms(u"fubar", std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ(34, matches[0].url_info.id());
  EXPECT_EQ("http://fubarfubarandfubar.com/", matches[0].url_info.url().spec());
  EXPECT_EQ(u"Situation Normal -- FUBARED", matches[0].url_info.title());
}

TEST_F(InMemoryURLIndexTest, CursorPositionRetrieval) {
  // See if a very specific term with no cursor gives an empty result.
  ScoredHistoryMatches matches = HistoryItemsForTerms(
      u"DrudReport", std::u16string::npos, kProviderMaxMatches);
  EXPECT_EQ(0U, matches.size());

  // The same test with the cursor at the end should give an empty result.
  matches = HistoryItemsForTerms(u"DrudReport", 10u, kProviderMaxMatches);
  EXPECT_EQ(0U, matches.size());

  // If the cursor is between Drud and Report, we should find the desired
  // result.
  matches = HistoryItemsForTerms(u"DrudReport", 4u, kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ("http://drudgereport.com/", matches[0].url_info.url().spec());
  EXPECT_EQ(u"DRUDGE REPORT 2010", matches[0].url_info.title());

  // Now check multi-word inputs.  No cursor should fail to find a
  // result on this input.
  matches = HistoryItemsForTerms(u"MORTGAGERATE DROPS", std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(0U, matches.size());

  // Ditto with cursor at end.
  matches =
      HistoryItemsForTerms(u"MORTGAGERATE DROPS", 18u, kProviderMaxMatches);
  EXPECT_EQ(0U, matches.size());

  // If the cursor is between MORTAGE And RATE, we should find the
  // desired result.
  matches =
      HistoryItemsForTerms(u"MORTGAGERATE DROPS", 8u, kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ("http://www.reuters.com/article/idUSN0839880620100708",
            matches[0].url_info.url().spec());
  EXPECT_EQ(
      u"UPDATE 1-US 30-yr mortgage rate drops to new record low | Reuters",
      matches[0].url_info.title());
}

TEST_F(InMemoryURLIndexTest, URLPrefixMatching) {
  // "drudgere" - found
  ScoredHistoryMatches matches = HistoryItemsForTerms(
      u"drudgere", std::u16string::npos, kProviderMaxMatches);
  EXPECT_EQ(1U, matches.size());

  // "www.atdmt" - not found
  matches = HistoryItemsForTerms(u"www.atdmt", std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(0U, matches.size());

  // "atdmt" - found
  matches =
      HistoryItemsForTerms(u"atdmt", std::u16string::npos, kProviderMaxMatches);
  EXPECT_EQ(1U, matches.size());

  // "view.atdmt" - found
  matches = HistoryItemsForTerms(u"view.atdmt", std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(1U, matches.size());

  // "view.atdmt" - found
  matches = HistoryItemsForTerms(u"view.atdmt", std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(1U, matches.size());

  // "cnn.com" - found
  matches = HistoryItemsForTerms(u"cnn.com", std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(2U, matches.size());

  // "www.cnn.com" - found
  matches = HistoryItemsForTerms(u"www.cnn.com", std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(1U, matches.size());

  // "ww.cnn.com" - found because we suppress mid-term matches.
  matches = HistoryItemsForTerms(u"ww.cnn.com", std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(0U, matches.size());

  // "www.cnn.com" - found
  matches = HistoryItemsForTerms(u"www.cnn.com", std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(1U, matches.size());

  // "tp://www.cnn.com" - not found because we don't allow tp as a mid-term
  // match
  matches = HistoryItemsForTerms(u"tp://www.cnn.com", std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(0U, matches.size());
}

TEST_F(InMemoryURLIndexTest, ProperStringMatching) {
  // Search for the following with the expected results:
  // "atdmt view" - found
  // "atdmt.view" - not found
  // "view.atdmt" - found
  ScoredHistoryMatches matches = HistoryItemsForTerms(
      u"atdmt view", std::u16string::npos, kProviderMaxMatches);
  EXPECT_EQ(1U, matches.size());
  matches = HistoryItemsForTerms(u"atdmt.view", std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(0U, matches.size());
  matches = HistoryItemsForTerms(u"view.atdmt", std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(1U, matches.size());
}

TEST_F(InMemoryURLIndexTest, TrimHistoryIds) {
  // Constants ---------------------------------------------------------------

  constexpr size_t kItemsToScoreLimit = 500;
  constexpr size_t kAlmostLimit = kItemsToScoreLimit - 5;

  constexpr int kLowTypedCount = 2;
  constexpr int kHighTypedCount = 100;

  constexpr int kLowVisitCount = 20;
  constexpr int kHighVisitCount = 200;

  constexpr base::TimeDelta kOld = base::Days(15);
  constexpr base::TimeDelta kNew = base::Days(2);

  constexpr int kMinRowId = 5000;

  // Helpers -----------------------------------------------------------------

  struct ItemGroup {
    history::URLID min_id;
    history::URLID max_id;
    int typed_count;
    int visit_count;
    base::TimeDelta days_ago;
  };

  auto GetHistoryIdsUpTo = [&](HistoryID max) {
    HistoryIDVector res(max - kMinRowId);
    std::iota(res.begin(), res.end(), kMinRowId);
    return res;
  };

  auto CountGroupElementsInIds = [](const ItemGroup& group,
                                    const HistoryIDVector& ids) {
    return std::ranges::count_if(ids, [&](history::URLID id) {
      return group.min_id <= id && id < group.max_id;
    });
  };

  // Test body --------------------------------------------------------------

  // Groups of items ordered by increasing priority.
  ItemGroup item_groups[] = {
      {0, 0, kLowTypedCount, kLowVisitCount, kOld},
      {0, 0, kLowTypedCount, kLowVisitCount, kNew},
      {0, 0, kLowTypedCount, kHighVisitCount, kOld},
      {0, 0, kLowTypedCount, kHighVisitCount, kNew},
      {0, 0, kHighTypedCount, kLowVisitCount, kOld},
      {0, 0, kHighTypedCount, kLowVisitCount, kNew},
      {0, 0, kHighTypedCount, kHighVisitCount, kOld},
      {0, 0, kHighTypedCount, kHighVisitCount, kNew},
  };

  // Initialize groups.
  history::URLID row_id = kMinRowId;
  for (auto& group : item_groups) {
    group.min_id = row_id;
    for (size_t i = 0; i < kAlmostLimit; ++i) {
      history::URLRow new_row(
          GURL("http://www.fake_url" + base::NumberToString(row_id) + ".com"),
          row_id);
      new_row.set_typed_count(group.typed_count);
      new_row.set_visit_count(group.visit_count);
      new_row.set_last_visit(base::Time::Now() - group.days_ago);
      UpdateURL(std::move(new_row));
      ++row_id;
    }
    group.max_id = row_id;
  }

  // First group. Because number of entries is small enough no trimming occurs.
  {
    auto ids = GetHistoryIdsUpTo(item_groups[0].max_id);
    EXPECT_FALSE(GetPrivateData()->TrimHistoryIdsPool(&ids));
    EXPECT_EQ(kAlmostLimit, ids.size());
  }

  // Each next group should fill almost everything, while the previous group
  // should occupy what's left.
  auto* error_position = std::ranges::adjacent_find(
      item_groups, [&](const ItemGroup& previous, const ItemGroup& current) {
        auto ids = GetHistoryIdsUpTo(current.max_id);
        EXPECT_TRUE(GetPrivateData()->TrimHistoryIdsPool(&ids));

        size_t current_count = CountGroupElementsInIds(current, ids);
        EXPECT_EQ(kAlmostLimit, current_count);
        if (current_count != kAlmostLimit)
          return true;

        size_t previous_count = CountGroupElementsInIds(previous, ids);
        EXPECT_EQ(kItemsToScoreLimit - kAlmostLimit, previous_count);
        return previous_count != kItemsToScoreLimit - kAlmostLimit;
      });

  EXPECT_TRUE(error_position == std::end(item_groups))
      << "broken after: " << error_position - std::begin(item_groups);
}

TEST_F(InMemoryURLIndexTest, HugeResultSet) {
  // Create a huge set of qualifying history items.
  for (history::URLID row_id = 5000; row_id < 6000; ++row_id) {
    history::URLRow new_row(GURL("http://www.brokeandaloneinmanitoba.com/"),
                            row_id);
    new_row.set_last_visit(base::Time::Now());
    EXPECT_TRUE(UpdateURL(new_row));
  }

  ScoredHistoryMatches matches =
      HistoryItemsForTerms(u"b", std::u16string::npos, kProviderMaxMatches);
  EXPECT_EQ(kProviderMaxMatches, matches.size());
}

TEST_F(InMemoryURLIndexTest, TitleSearch) {
  // Signal if someone has changed the test DB.
  EXPECT_EQ(30U, GetPrivateData()->history_info_map_.size());

  // Ensure title is being searched.
  ScoredHistoryMatches matches = HistoryItemsForTerms(
      u"MORTGAGE RATE DROPS", std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());

  // Verify that we got back the result we expected.
  EXPECT_EQ(1, matches[0].url_info.id());
  EXPECT_EQ("http://www.reuters.com/article/idUSN0839880620100708",
            matches[0].url_info.url().spec());
  EXPECT_EQ(
      u"UPDATE 1-US 30-yr mortgage rate drops to new record low | Reuters",
      matches[0].url_info.title());
}

TEST_F(InMemoryURLIndexTest, TitleChange) {
  // Verify current title terms retrieves desired item.
  std::u16string original_terms = u"lebronomics could high taxes influence";
  ScoredHistoryMatches matches = HistoryItemsForTerms(
      original_terms, std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());

  // Verify that we got back the result we expected.
  const history::URLID expected_id = 3;
  EXPECT_EQ(expected_id, matches[0].url_info.id());
  EXPECT_EQ("http://www.businessandmedia.org/articles/2010/20100708120415.aspx",
            matches[0].url_info.url().spec());
  EXPECT_EQ(u"LeBronomics: Could High Taxes Influence James' Team Decision?",
            matches[0].url_info.title());
  history::URLRow old_row(matches[0].url_info);

  // Verify new title terms retrieves nothing.
  std::u16string new_terms = u"does eat oats little lambs ivy";
  matches = HistoryItemsForTerms(new_terms, std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(0U, matches.size());

  // Update the row.
  old_row.set_title(u"Does eat oats and little lambs eat ivy");
  EXPECT_TRUE(UpdateURL(old_row));

  // Verify we get the row using the new terms but not the original terms.
  matches = HistoryItemsForTerms(new_terms, std::u16string::npos,
                                 kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ(expected_id, matches[0].url_info.id());
  matches = HistoryItemsForTerms(original_terms, std::u16string::npos,
                                 kProviderMaxMatches);
  EXPECT_EQ(0U, matches.size());
}

TEST_F(InMemoryURLIndexTest, NonUniqueTermCharacterSets) {
  // The presence of duplicate characters should succeed. Exercise by cycling
  // through a string with several duplicate characters.
  ScoredHistoryMatches matches =
      HistoryItemsForTerms(u"ABRA", std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ(28, matches[0].url_info.id());
  EXPECT_EQ("http://www.ddj.com/windows/184416623",
            matches[0].url_info.url().spec());

  matches = HistoryItemsForTerms(u"ABRACAD", std::u16string::npos,
                                 kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ(28, matches[0].url_info.id());

  matches = HistoryItemsForTerms(u"ABRACADABRA", std::u16string::npos,
                                 kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ(28, matches[0].url_info.id());

  matches = HistoryItemsForTerms(u"ABRACADABR", std::u16string::npos,
                                 kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ(28, matches[0].url_info.id());

  matches = HistoryItemsForTerms(u"ABRACA", std::u16string::npos,
                                 kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ(28, matches[0].url_info.id());
}

TEST_F(InMemoryURLIndexTest, TypedCharacterCaching) {
  // Verify that match results for previously typed characters are retained
  // (in the term_char_word_set_cache_) and reused, if possible, in future
  // autocompletes.

  URLIndexPrivateData::SearchTermCacheMap& cache(
      GetPrivateData()->search_term_cache_);

  // The cache should be empty at this point.
  EXPECT_EQ(0U, cache.size());

  // Now simulate typing search terms into the omnibox and check the state of
  // the cache as each item is 'typed'.

  // Simulate typing "r" giving "r" in the simulated omnibox. The results for
  // 'r' will be not cached because it is only 1 character long.
  HistoryItemsForTerms(u"r", std::u16string::npos, kProviderMaxMatches);
  EXPECT_EQ(0U, cache.size());

  // Simulate typing "re" giving "r re" in the simulated omnibox.
  // 're' should be cached at this point but not 'r' as it is a single
  // character.
  HistoryItemsForTerms(u"r re", std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(1U, cache.size());
  CheckTerm(cache, u"re");

  // Simulate typing "reco" giving "r re reco" in the simulated omnibox.
  // 're' and 'reco' should be cached at this point but not 'r' as it is a
  // single character.
  HistoryItemsForTerms(u"r re reco", std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(2U, cache.size());
  CheckTerm(cache, u"re");
  CheckTerm(cache, u"reco");

  // Simulate typing "mort".
  // Since we now have only one search term, the cached results for 're' and
  // 'reco' should be purged, giving us only 1 item in the cache (for 'mort').
  HistoryItemsForTerms(u"mort", std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(1U, cache.size());
  CheckTerm(cache, u"mort");

  // Simulate typing "reco" giving "mort reco" in the simulated omnibox.
  HistoryItemsForTerms(u"mort reco", std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(2U, cache.size());
  CheckTerm(cache, u"mort");
  CheckTerm(cache, u"reco");

  // Simulate a <DELETE> by removing the 'reco' and adding back the 'rec'.
  HistoryItemsForTerms(u"mort rec", std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(2U, cache.size());
  CheckTerm(cache, u"mort");
  CheckTerm(cache, u"rec");
}

TEST_F(InMemoryURLIndexTest, AddNewRows) {
  // Verify that the row we're going to add does not already exist.
  history::URLID new_row_id = 87654321;
  // Newly created history::URLRows get a last_visit time of 'right now' so it
  // should
  // qualify as a quick result candidate.
  EXPECT_TRUE(HistoryItemsForTerms(u"brokeandalone", std::u16string::npos,
                                   kProviderMaxMatches)
                  .empty());

  // Add a new row.
  history::URLRow new_row(GURL("http://www.brokeandaloneinmanitoba.com/"),
                          new_row_id++);
  new_row.set_last_visit(base::Time::Now());
  EXPECT_TRUE(UpdateURL(new_row));

  // Verify that we can retrieve it.
  EXPECT_EQ(1U, HistoryItemsForTerms(u"brokeandalone", std::u16string::npos,
                                     kProviderMaxMatches)
                    .size());

  // Add it again just to be sure that is harmless and that it does not update
  // the index.
  EXPECT_FALSE(UpdateURL(new_row));
  EXPECT_EQ(1U, HistoryItemsForTerms(u"brokeandalone", std::u16string::npos,
                                     kProviderMaxMatches)
                    .size());

  // Make up a URL that does not qualify and try to add it.
  history::URLRow unqualified_row(
      GURL("http://www.brokeandaloneinmanitoba.com/"), new_row_id++);
  EXPECT_FALSE(UpdateURL(new_row));
}

TEST_F(InMemoryURLIndexTest, DeleteRows) {
  ScoredHistoryMatches matches = HistoryItemsForTerms(
      u"DrudgeReport", std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());

  // Delete the URL then search again.
  EXPECT_TRUE(DeleteURL(matches[0].url_info.url()));
  EXPECT_TRUE(HistoryItemsForTerms(u"DrudgeReport", std::u16string::npos,
                                   kProviderMaxMatches)
                  .empty());

  // Make up a URL that does not exist in the database and delete it.
  GURL url("http://www.hokeypokey.com/putyourrightfootin.html");
  EXPECT_FALSE(DeleteURL(url));
}

TEST_F(InMemoryURLIndexTest, OnURLVisited) {
  history::URLID new_row_id = 87654321;  // Arbitrarily chosen large new row id.
  history::URLRow new_row(GURL("http://www.404url.com/"), new_row_id++);
  new_row.set_last_visit(base::Time::Now());

  history::VisitRow visit_row;
  visit_row.visit_time = base::Time::Now();
  visit_row.transition = ui::PAGE_TRANSITION_TYPED;

  // Confirm row is initially not in index.
  EXPECT_TRUE(
      HistoryItemsForTerms(u"404url", std::u16string::npos, kProviderMaxMatches)
          .empty());

  // A 404 visit should not be added to the index.
  url_index_->OnURLVisited(
      history_service_.get(),
      history::VisitedURLInfo(new_row, visit_row,
                              history::VisitResponseCodeCategory::k404));

  EXPECT_TRUE(
      HistoryItemsForTerms(u"404url", std::u16string::npos, kProviderMaxMatches)
          .empty());

  // A non 404 visit should successfully be added to the index.
  url_index_->OnURLVisited(
      history_service_.get(),
      history::VisitedURLInfo(new_row, visit_row,
                              history::VisitResponseCodeCategory::kNot404));

  ScoredHistoryMatches matches = HistoryItemsForTerms(
      u"404url", std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());
  EXPECT_EQ(new_row.url(), matches[0].url_info.url());
}

TEST_F(InMemoryURLIndexTest, ExpireRow) {
  ScoredHistoryMatches matches = HistoryItemsForTerms(
      u"DrudgeReport", std::u16string::npos, kProviderMaxMatches);
  ASSERT_EQ(1U, matches.size());

  // Determine the row ID for the result, remember that ID, broadcast a delete
  // notification, then ensure that the row has been deleted.
  history::URLRows deleted_rows;
  deleted_rows.push_back(matches[0].url_info);
  url_index_->OnHistoryDeletions(
      nullptr, history::DeletionInfo::ForUrls(deleted_rows, std::set<GURL>()));
  EXPECT_TRUE(HistoryItemsForTerms(u"DrudgeReport", std::u16string::npos,
                                   kProviderMaxMatches)
                  .empty());
}

TEST_F(InMemoryURLIndexTest, AllowlistedURLs) {
  std::string client_allowlisted_url =
      base::StringPrintf("%s://foo", kClientAllowlistedScheme);
  struct TestData {
    const std::string url_spec;
    const bool expected_is_allowlisted;
  } data[] = {
      // URLs with allowlisted schemes.
      {"about:histograms", true},
      {"file://localhost/Users/joeschmoe/sekrets", true},
      {"ftp://public.mycompany.com/myfile.txt", true},
      {"http://www.google.com/translate", true},
      {"https://www.gmail.com/", true},
      {"mailto:support@google.com", true},
      {client_allowlisted_url, true},
      // URLs with unacceptable schemes.
      {"aaa://www.dummyhost.com;frammy", false},
      {"aaas://www.dummyhost.com;frammy", false},
      {"acap://suzie@somebody.com", false},
      {"cap://cal.example.com/Company/Holidays", false},
      {"cid:foo4*foo1@bar.net", false},
      {"crid://example.com/foobar", false},
      {"data:image/png;base64,iVBORw0KGgoAAAANSUhE=", false},
      {"dict://dict.org/d:shortcake:", false},
      {"dns://192.168.1.1/ftp.example.org?type=A", false},
      {"fax:+358.555.1234567", false},
      {"geo:13.4125,103.8667", false},
      {"go:Mercedes%20Benz", false},
      {"gopher://farnsworth.ca:666/gopher", false},
      {"h323:farmer-john;sixpence", false},
      {"iax:johnQ@example.com/12022561414", false},
      {"icap://icap.net/service?mode=translate&lang=french", false},
      {"im:fred@example.com", false},
      {"imap://michael@minbari.org/users.*", false},
      {"info:ddc/22/eng//004.678", false},
      {"ipp://example.com/printer/fox", false},
      {"iris:dreg1//example.com/local/myhosts", false},
      {"iris.beep:dreg1//example.com/local/myhosts", false},
      {"iris.lws:dreg1//example.com/local/myhosts", false},
      {"iris.xpc:dreg1//example.com/local/myhosts", false},
      {"iris.xpcs:dreg1//example.com/local/myhosts", false},
      {"ldap://ldap.itd.umich.edu/o=University%20of%20Michigan,c=US", false},
      {"mid:foo4%25foo1@bar.net", false},
      {"modem:+3585551234567;type=v32b?7e1;type=v110", false},
      {"msrp://atlanta.example.com:7654/jshA7weztas;tcp", false},
      {"msrps://atlanta.example.com:7654/jshA7weztas;tcp", false},
      {"news:colorectal.info.banned", false},
      {"nfs://server/d/e/f", false},
      {"nntp://www.example.com:6543/info.comp.lies/1234", false},
      {"pop://rg;AUTH=+APOP@mail.mycompany.com:8110", false},
      {"pres:fred@example.com", false},
      {"prospero://host.dom//pros/name", false},
      {"rsync://syler@lost.com/Source", false},
      {"rtsp://media.example.com:554/twister/audiotrack", false},
      {"acap://some.where.net;authentication=KERBEROSV4", false},
      {"shttp://www.terces.com/secret", false},
      {"sieve://example.com//script", false},
      {"sip:+1-212-555-1212:1234@gateway.com;user=phone", false},
      {"sips:+1-212-555-1212:1234@gateway.com;user=phone", false},
      {"sms:+15105551212?body=hello%20there", false},
      {"snmp://tester5@example.com:8161/bridge1;800002b804616263", false},
      {"soap.beep://stockquoteserver.example.com/StockQuote", false},
      {"soap.beeps://stockquoteserver.example.com/StockQuote", false},
      {"tag:blogger.com,1999:blog-555", false},
      {"tel:+358-555-1234567;postd=pp22", false},
      {"telnet://mayor_margie:one2rule4All@www.mycity.com:6789/", false},
      {"tftp://example.com/mystartupfile", false},
      {"tip://123.123.123.123/?urn:xopen:xid", false},
      {"tv:nbc.com", false},
      {"urn:foo:A123,456", false},
      {"vemmi://zeus.mctel.fr/demo", false},
      {"wais://www.mydomain.net:8765/mydatabase", false},
      {"xmpp:node@example.com", false},
      {"xmpp://guest@example.com", false},
  };

  const SchemeSet& allowlist(scheme_allowlist());
  for (const auto& d : data) {
    GURL url(d.url_spec);
    EXPECT_EQ(d.expected_is_allowlisted,
              URLIndexPrivateData::URLSchemeIsAllowlisted(url, allowlist));
  }
}

TEST_F(InMemoryURLIndexTest, ReadVisitsFromHistory) {
  const HistoryInfoMap& history_info_map = GetPrivateData()->history_info_map_;

  // Check (for URL with id 1) that the number of visits and their
  // transition types are what we expect.  We don't bother checking
  // the timestamps because it's too much trouble.  (The timestamps go
  // through a transformation in InMemoryURLIndexTest::SetUp().  We
  // assume that if the count and transitions show up with the right
  // information, we're getting the right information from the history
  // database file.)
  auto entry = history_info_map.find(1);
  ASSERT_TRUE(entry != history_info_map.end());
  {
    const VisitInfoVector& visits = entry->second.visits;
    ASSERT_EQ(3u, visits.size());
    EXPECT_EQ(0, static_cast<int32_t>(visits[0].second));
    EXPECT_EQ(1, static_cast<int32_t>(visits[1].second));
    EXPECT_EQ(0, static_cast<int32_t>(visits[2].second));
  }

  // Ditto but for URL with id 35.
  entry = history_info_map.find(35);
  ASSERT_TRUE(entry != history_info_map.end());
  {
    const VisitInfoVector& visits = entry->second.visits;
    ASSERT_EQ(2, static_cast<int32_t>(visits.size()));
    EXPECT_EQ(1, static_cast<int32_t>(visits[0].second));
    EXPECT_EQ(1, static_cast<int32_t>(visits[1].second));
  }

  // The URL with id 32 has many visits listed in the database, but we
  // should only read the most recent 10 (which are all transition type 0).
  entry = history_info_map.find(32);
  ASSERT_TRUE(entry != history_info_map.end());
  {
    const VisitInfoVector& visits = entry->second.visits;
    EXPECT_EQ(10u, visits.size());
    for (const auto& visit : visits)
      EXPECT_EQ(0, static_cast<int32_t>(visit.second));
  }
}

TEST_F(InMemoryURLIndexTest, CalculateWordStartsOffsets) {
  const struct {
    const char* search_string;
    size_t cursor_position;
    const size_t expected_word_starts_offsets_size;
    const std::array<size_t, 3> expected_word_starts_offsets;
  } test_cases[] = {
      /* No punctuations, only cursor position change. */
      {"ABCD", kInvalid, 1, {0, kInvalid, kInvalid}},
      {"abcd", 0, 1, {0, kInvalid, kInvalid}},
      {"AbcD", 1, 2, {0, 0, kInvalid}},
      {"abcd", 4, 1, {0, kInvalid, kInvalid}},

      /* Starting with punctuation. */
      {".abcd", kInvalid, 1, {1, kInvalid, kInvalid}},
      {".abcd", 0, 1, {1, kInvalid, kInvalid}},
      {"!abcd", 1, 2, {1, 0, kInvalid}},
      {"::abcd", 1, 2, {1, 1, kInvalid}},
      {":abcd", 5, 1, {1, kInvalid, kInvalid}},

      /* Ending with punctuation. */
      {"abcd://", kInvalid, 1, {0, kInvalid, kInvalid}},
      {"ABCD://", 0, 1, {0, kInvalid, kInvalid}},
      {"abcd://", 1, 2, {0, 0, kInvalid}},
      {"abcd://", 4, 2, {0, 3, kInvalid}},
      {"abcd://", 7, 1, {0, kInvalid, kInvalid}},

      /* Punctuation in the middle. */
      {"ab.cd", kInvalid, 1, {0, kInvalid, kInvalid}},
      {"ab.cd", 0, 1, {0, kInvalid, kInvalid}},
      {"ab!cd", 1, 2, {0, 0, kInvalid}},
      {"AB.cd", 2, 2, {0, 1, kInvalid}},
      {"AB.cd", 3, 2, {0, 0, kInvalid}},
      {"ab:cd", 5, 1, {0, kInvalid, kInvalid}},

      /* Hyphenation */
      {"Ab-cd", kInvalid, 1, {0, kInvalid, kInvalid}},
      {"ab-cd", 0, 1, {0, kInvalid, kInvalid}},
      {"-abcd", 0, 1, {1, kInvalid, kInvalid}},
      {"-abcd", 1, 2, {1, 0, kInvalid}},
      {"abcd-", 2, 2, {0, 0, kInvalid}},
      {"abcd-", 4, 2, {0, 1, kInvalid}},
      {"ab-cd", 5, 1, {0, kInvalid, kInvalid}},

      /* Whitespace */
      {"Ab cd", kInvalid, 2, {0, 0, kInvalid}},
      {"ab cd", 0, 2, {0, 0, kInvalid}},
      {" abcd", 0, 1, {0, kInvalid, kInvalid}},
      {" abcd", 1, 1, {0, kInvalid, kInvalid}},
      {"abcd ", 2, 2, {0, 0, kInvalid}},
      {"abcd :", 4, 2, {0, 1, kInvalid}},
      {"abcd :", 5, 2, {0, 1, kInvalid}},
      {"abcd :", 2, 3, {0, 0, 1}},

      /* Underscore */
      {"Ab_cd", kInvalid, 1, {0, kInvalid, kInvalid}},
      {"ab_cd", 0, 1, {0, kInvalid, kInvalid}},
      {"_abcd", 0, 1, {1, kInvalid, kInvalid}},
      {"_abcd", 1, 2, {1, 0, kInvalid}},
      {"abcd_", 2, 2, {0, 0, kInvalid}},
      {"abcd_", 4, 2, {0, 1, kInvalid}},
      {"ab_cd", 5, 1, {0, kInvalid, kInvalid}},
  };

  for (const auto& test_case : test_cases) {
    SCOPED_TRACE(testing::Message()
                 << "search_string = " << test_case.search_string
                 << ", cursor_position = " << test_case.cursor_position);

    std::u16string lower_string;
    String16Vector lower_terms;
    StringToTerms(test_case.search_string, test_case.cursor_position,
                  &lower_string, &lower_terms);
    WordStarts lower_terms_to_word_starts_offsets;
    URLIndexPrivateData::CalculateWordStartsOffsets(
        lower_terms, &lower_terms_to_word_starts_offsets);

    // Verify against expectations.
    EXPECT_EQ(test_case.expected_word_starts_offsets_size,
              lower_terms_to_word_starts_offsets.size());
    for (size_t j = 0; j < test_case.expected_word_starts_offsets_size; ++j) {
      EXPECT_EQ(test_case.expected_word_starts_offsets[j],
                lower_terms_to_word_starts_offsets[j]);
    }
  }
}
