chromium/ios/web/find_in_page/find_in_page_manager_impl_unittest.mm

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

#import "ios/web/find_in_page/find_in_page_manager_impl.h"

#import "base/test/ios/wait_util.h"
#import "base/test/metrics/user_action_tester.h"
#import "ios/web/public/test/fakes/crw_fake_find_interaction.h"
#import "ios/web/public/test/fakes/crw_fake_find_session.h"
#import "ios/web/public/test/fakes/fake_find_in_page_manager_delegate.h"
#import "ios/web/public/test/fakes/fake_web_client.h"
#import "ios/web/public/test/fakes/fake_web_state.h"
#import "ios/web/public/test/web_test.h"
#import "testing/gtest/include/gtest/gtest.h"

namespace {

// Timeout before failure if the FindInPageManager does not report to the
// delegate fast enough.
const base::TimeDelta kWaitForFindCompletionTimeout = base::Milliseconds(100);

}  // namespace

using base::test::ios::WaitUntilConditionOrTimeout;

namespace web {

// Tests FindInPageManagerImpl and verifies that the state of
// FindInPageManagerDelegate is correct depending on what the Find session
// returns. FindInPageManagerImplTest is part of an iOS 16+ feature so this
// test class only applies to iOS 16+. For the old Find in Page tests, please
// see java_script_find_in_page_manager_impl_unittest.mm
class FindInPageManagerImplTest : public WebTest {
 protected:
  FindInPageManagerImplTest() : WebTest(std::make_unique<FakeWebClient>()) {}

  void SetUp() override {
    WebTest::SetUp();

    // Skip setup if this is <iOS 16.
    if (@available(iOS 16, *)) {
      fake_web_state_ = std::make_unique<FakeWebState>();
      fake_web_state_->SetBrowserState(GetBrowserState());

      FindInPageManagerImpl::CreateForWebState(fake_web_state_.get());
      GetFindInPageManager()->SetDelegate(&fake_delegate_);
      // Sets a smaller delay between each manager's call to
      // `PollActiveFindSession()` so tests run faster.
      GetFindInPageManager()->poll_active_find_session_delay_ =
          base::Milliseconds(5);

        // Enable and set up fake Find interaction in the fake web state.
        fake_web_state_->SetFindInteractionEnabled(true);
        fake_web_state_->SetFindInteraction(
            [[CRWFakeFindInteraction alloc] init]);
    }
  }

  // Sets the fake Find session analyzed by the FindInPageManager.
  void SetFindSession(CRWFakeFindSession* find_session) API_AVAILABLE(ios(16)) {
    static_cast<CRWFakeFindInteraction*>(fake_web_state_->GetFindInteraction())
        .activeFindSession = find_session;
  }

  // Create a fake Find session which returns the appropriate match counts for
  // given queries in `result_counts_for_queries`.
  CRWFakeFindSession* CreateFindSessionWithResultCountsForQueries(
      ResultCountsForQueries* result_counts_for_queries)
      API_AVAILABLE(ios(16)) {
    CRWFakeFindSession* find_session = [[CRWFakeFindSession alloc] init];
    find_session.resultCountsForQueries = result_counts_for_queries;
    return find_session;
  }

  // Sets a fake Find session with given fake result counts for given queries in
  // `result_counts_for_queries`,
  // to be analyzed by the FindInPageManager.
  void SetFindSessionWithResultCountsForQueries(
      ResultCountsForQueries* result_counts_for_queries)
      API_AVAILABLE(ios(16)) {
    SetFindSession(
        CreateFindSessionWithResultCountsForQueries(result_counts_for_queries));
  }

  // Wait until the delegate state has been set or time is out.
  bool WaitForStateOrTimeout() API_AVAILABLE(ios(16)) {
    return WaitUntilConditionOrTimeout(kWaitForFindCompletionTimeout, ^bool {
      base::RunLoop().RunUntilIdle();
      return fake_delegate_.state();
    });
  }

  // Wait until the index verifies `index_predicate` in the delegate state.
  bool WaitForIndexOrTimeout(bool (^index_predicate)(int))
      API_AVAILABLE(ios(16)) {
    return WaitUntilConditionOrTimeout(kWaitForFindCompletionTimeout, ^bool {
      base::RunLoop().RunUntilIdle();
      return fake_delegate_.state() &&
             index_predicate(fake_delegate_.state()->index);
    });
  }

  // Wait until the index is different from -1 in the delegate state.
  bool WaitForValidIndexOrTimeout() API_AVAILABLE(ios(16)) {
    return WaitForIndexOrTimeout(^(int index) {
      return index != -1;
    });
  }

  // Returns the FindInPageManager associated with `fake_web_state_`.
  FindInPageManagerImpl* GetFindInPageManager() API_AVAILABLE(ios(16)) {
    return static_cast<FindInPageManagerImpl*>(
        FindInPageManager::FromWebState(fake_web_state_.get()));
  }

  // Get the active Find session in the FindInPageManager.
  id<CRWFindSession> GetActiveFindSession() API_AVAILABLE(ios(16)) {
    return GetFindInPageManager()->GetActiveFindSession();
  }

  std::unique_ptr<FakeWebState> fake_web_state_ API_AVAILABLE(ios(16));
  FakeFindInPageManagerDelegate fake_delegate_ API_AVAILABLE(ios(16));
  base::UserActionTester user_action_tester_ API_AVAILABLE(ios(16));
};

// Tests that Find In Page responds with a total match count of three when it
// calls Find on a query with three matches.
TEST_F(FindInPageManagerImplTest, FindThreeMatches) {
  if (@available(iOS 16, *)) {
    SetFindSessionWithResultCountsForQueries(@{@"foo" : @3});

    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageSearch);

    ASSERT_TRUE(WaitForStateOrTimeout());
    EXPECT_EQ(3, fake_delegate_.state()->match_count);
  }
}

// Tests that Find In Page returns a total match count matching the latest find
// if two finds are called.
TEST_F(FindInPageManagerImplTest, ReturnLatestFind) {
  if (@available(iOS 16, *)) {
    SetFindSessionWithResultCountsForQueries(@{@"foo" : @3, @"bar" : @2});

    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageSearch);
    ASSERT_TRUE(WaitForStateOrTimeout());
    fake_delegate_.Reset();

    GetFindInPageManager()->Find(@"bar", FindInPageOptions::FindInPageSearch);

    ASSERT_TRUE(WaitForStateOrTimeout());
    EXPECT_EQ(2, fake_delegate_.state()->match_count);
  }
}

// Tests that the Find In Page manager does not report to the delegate if the
// web state is destroyed during a Find operation.
TEST_F(FindInPageManagerImplTest, DestroyWebStateDuringFind) {
  if (@available(iOS 16, *)) {
    SetFindSessionWithResultCountsForQueries(@{@"foo" : @3});
    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageSearch);

    fake_web_state_.reset();
    base::RunLoop().RunUntilIdle();
    EXPECT_FALSE(fake_delegate_.state());
  }
}

// Tests that Find In Page doesn't fail when delegate is not set.
TEST_F(FindInPageManagerImplTest, DelegateNotSet) {
  if (@available(iOS 16, *)) {
    GetFindInPageManager()->SetDelegate(nullptr);
    SetFindSessionWithResultCountsForQueries(@{@"foo" : @3});
    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageSearch);

    ASSERT_TRUE(WaitUntilConditionOrTimeout(kWaitForFindCompletionTimeout, ^{
      base::RunLoop().RunUntilIdle();
      return GetActiveFindSession().resultCount == 3;
    }));
  }
}

// Tests that Find in Page manager responds with a total match count of zero
// when there are no matches in the web page. Tests that Find in Page also did
// not respond with a valid selected match index value.
TEST_F(FindInPageManagerImplTest, PageWithNoMatchNoHighlight) {
  if (@available(iOS 16, *)) {
    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageSearch);

    ASSERT_TRUE(WaitForStateOrTimeout());
    EXPECT_EQ(0, fake_delegate_.state()->match_count);
    EXPECT_EQ(-1, fake_delegate_.state()->index);
  }
}

// Tests that Find in Page responds with index zero after a find when there are
// three matches in a page.
TEST_F(FindInPageManagerImplTest, DidHighlightFirstIndex) {
  if (@available(iOS 16, *)) {
    SetFindSessionWithResultCountsForQueries(@{@"foo" : @3});
    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageSearch);

    ASSERT_TRUE(WaitForValidIndexOrTimeout());
    EXPECT_EQ(0, fake_delegate_.state()->index);
  }
}

// Tests that Find in Page responds with index one to a FindInPageNext find
// after a FindInPageSearch find finishes when there are two matches in a page.
TEST_F(FindInPageManagerImplTest, FindDidHighlightSecondIndexAfterNextCall) {
  if (@available(iOS 16, *)) {
    SetFindSessionWithResultCountsForQueries(@{@"foo" : @2});
    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageSearch);
    ASSERT_TRUE(WaitForStateOrTimeout());

    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageNext);

    ASSERT_TRUE(WaitForValidIndexOrTimeout());
    EXPECT_EQ(1, fake_delegate_.state()->index);
  }
}

// Tests that Find in Page selects all matches in a page with three matches and
// wraps when making successive FindInPageNext calls.
TEST_F(FindInPageManagerImplTest, FindDidSelectAllMatchesWithNextCall) {
  if (@available(iOS 16, *)) {
    SetFindSessionWithResultCountsForQueries(@{@"foo" : @3});
    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageSearch);

    ASSERT_TRUE(WaitForValidIndexOrTimeout());
    EXPECT_EQ(0, fake_delegate_.state()->index);

    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageNext);
    fake_delegate_.Reset();
    ASSERT_TRUE(WaitForValidIndexOrTimeout());
    EXPECT_EQ(1, fake_delegate_.state()->index);

    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageNext);
    fake_delegate_.Reset();
    ASSERT_TRUE(WaitForValidIndexOrTimeout());
    EXPECT_EQ(2, fake_delegate_.state()->index);

    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageNext);
    fake_delegate_.Reset();
    ASSERT_TRUE(WaitForValidIndexOrTimeout());
    EXPECT_EQ(0, fake_delegate_.state()->index);
  }
}

// Tests that Find in Page selects all matches in a page with three matches and
// wraps when making successive FindInPagePrevious calls.
TEST_F(FindInPageManagerImplTest,
       FindDidLoopThroughAllMatchesWithPreviousCall) {
  if (@available(iOS 16, *)) {
    SetFindSessionWithResultCountsForQueries(@{@"foo" : @3});
    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageSearch);

    ASSERT_TRUE(WaitForValidIndexOrTimeout());
    EXPECT_EQ(0, fake_delegate_.state()->index);

    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPagePrevious);

    fake_delegate_.Reset();
    ASSERT_TRUE(WaitForStateOrTimeout());
    EXPECT_EQ(2, fake_delegate_.state()->index);

    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPagePrevious);

    fake_delegate_.Reset();
    ASSERT_TRUE(WaitForStateOrTimeout());
    EXPECT_EQ(1, fake_delegate_.state()->index);

    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPagePrevious);

    fake_delegate_.Reset();
    ASSERT_TRUE(WaitForStateOrTimeout());
    EXPECT_EQ(0, fake_delegate_.state()->index);
  }
}

// Tests that Find in Page does not respond to a FindInPageNext or a
// FindInPagePrevious call if no FindInPageSearch find was executed beforehand.
TEST_F(FindInPageManagerImplTest, FindDidNotRepondToNextOrPrevIfNoSearch) {
  if (@available(iOS 16, *)) {
    SetFindSessionWithResultCountsForQueries(@{@"foo" : @3});

    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageNext);
    base::RunLoop().RunUntilIdle();

    EXPECT_FALSE(fake_delegate_.state());

    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPagePrevious);
    base::RunLoop().RunUntilIdle();

    EXPECT_FALSE(fake_delegate_.state());
  }
}

// Tests that Find in Page resets the match count to 0 and the query to nil
// after calling StopFinding().
TEST_F(FindInPageManagerImplTest, FindInPageCanStopFind) {
  if (@available(iOS 16, *)) {
    SetFindSessionWithResultCountsForQueries(@{@"foo" : @3});
    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageSearch);
    ASSERT_TRUE(WaitForStateOrTimeout());

    fake_delegate_.Reset();
    GetFindInPageManager()->StopFinding();
    ASSERT_TRUE(WaitForStateOrTimeout());
    EXPECT_FALSE(fake_delegate_.state()->query);
    EXPECT_EQ(0, fake_delegate_.state()->match_count);
  }
}

// Tests that Find in Page logs correct UserActions for given API calls.
TEST_F(FindInPageManagerImplTest, FindUserActions) {
  if (@available(iOS 16, *)) {
    SetFindSessionWithResultCountsForQueries(@{@"foo" : @3});
    ASSERT_EQ(
        0, user_action_tester_.GetActionCount("IOS.FindInPage.SearchStarted"));
    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageSearch);
    ASSERT_TRUE(WaitForValidIndexOrTimeout());

    ASSERT_EQ(0, user_action_tester_.GetActionCount("IOS.FindInPage.FindNext"));
    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageNext);
    fake_delegate_.Reset();
    ASSERT_TRUE(WaitForIndexOrTimeout(^(int index) {
      return index == 1;
    }));
    EXPECT_EQ(1, user_action_tester_.GetActionCount("IOS.FindInPage.FindNext"));

    ASSERT_EQ(
        0, user_action_tester_.GetActionCount("IOS.FindInPage.FindPrevious"));
    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPagePrevious);
    fake_delegate_.Reset();
    ASSERT_TRUE(WaitForIndexOrTimeout(^(int index) {
      return index == 0;
    }));
    EXPECT_EQ(
        1, user_action_tester_.GetActionCount("IOS.FindInPage.FindPrevious"));
  }
}

// Tests that the Find navigator is presented when the Find session starts and
// dismissed when the Find session stops, if a Find interaction is used.
TEST_F(FindInPageManagerImplTest, FindNavigatorPresentedAndDismissed) {
  if (@available(iOS 16, *)) {
    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageSearch);
    ASSERT_TRUE(WaitUntilConditionOrTimeout(kWaitForFindCompletionTimeout, ^{
      base::RunLoop().RunUntilIdle();
      return fake_web_state_->GetFindInteraction().findNavigatorVisible;
    }));

    GetFindInPageManager()->StopFinding();
    ASSERT_TRUE(WaitUntilConditionOrTimeout(kWaitForFindCompletionTimeout, ^{
      base::RunLoop().RunUntilIdle();
      return !fake_web_state_->GetFindInteraction().findNavigatorVisible;
    }));
  }
}

// Tests that the manager reports to its delegate when it detects the Find
// navigator has been dismissed by the user, and set the query back to nil and
// the match count to 0.
TEST_F(FindInPageManagerImplTest,
       UserDismissesFindNavigatorDetectedAndStopsSearch) {
  if (@available(iOS 16, *)) {
    SetFindSessionWithResultCountsForQueries(@{@"foo" : @3});
    GetFindInPageManager()->Find(@"foo", FindInPageOptions::FindInPageSearch);
    ASSERT_TRUE(WaitForValidIndexOrTimeout());
    ASSERT_FALSE(fake_delegate_.state()->user_dismissed_find_navigator);
    ASSERT_EQ(3, fake_delegate_.state()->match_count);

    [fake_web_state_->GetFindInteraction() dismissFindNavigator];
    fake_delegate_.Reset();
    ASSERT_TRUE(WaitForStateOrTimeout());
    EXPECT_TRUE(fake_delegate_.state()->user_dismissed_find_navigator);
    EXPECT_EQ(0, fake_delegate_.state()->match_count);
    EXPECT_FALSE(fake_delegate_.state()->query);
  }
}

}  // namespace web