chromium/chrome/browser/ui/ash/user_education/chrome_user_education_delegate_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 "chrome/browser/ui/ash/user_education/chrome_user_education_delegate.h"

#include <array>
#include <memory>
#include <optional>
#include <string>
#include <utility>

#include "ash/ash_element_identifiers.h"
#include "ash/session/test_session_controller_client.h"
#include "ash/test/ash_test_helper.h"
#include "ash/user_education/user_education_class_properties.h"
#include "ash/user_education/user_education_types.h"
#include "ash/user_education/user_education_util.h"
#include "base/functional/callback.h"
#include "base/functional/callback_helpers.h"
#include "base/memory/raw_ptr.h"
#include "base/one_shot_event.h"
#include "base/test/bind.h"
#include "base/test/gmock_callback_support.h"
#include "base/test/run_until.h"
#include "chrome/browser/ash/app_list/app_list_syncable_service.h"
#include "chrome/browser/ash/app_list/app_list_syncable_service_factory.h"
#include "chrome/browser/ash/login/users/fake_chrome_user_manager.h"
#include "chrome/browser/user_education/user_education_service.h"
#include "chrome/browser/user_education/user_education_service_factory.h"
#include "chrome/browser/web_applications/web_app_id_constants.h"
#include "chrome/test/base/browser_with_test_window_test.h"
#include "chrome/test/base/testing_profile.h"
#include "chrome/test/base/testing_profile_manager.h"
#include "chromeos/ash/components/browser_context_helper/browser_context_helper.h"
#include "components/strings/grit/components_strings.h"
#include "components/user_education/common/help_bubble_params.h"
#include "components/user_education/common/tutorial_description.h"
#include "components/user_education/common/tutorial_registry.h"
#include "components/user_education/common/tutorial_service.h"
#include "components/user_manager/scoped_user_manager.h"
#include "components/user_manager/user.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "ui/base/interaction/element_identifier.h"
#include "ui/base/interaction/element_test_util.h"
#include "ui/base/interaction/expect_call_in_scope.h"
#include "ui/base/interaction/interaction_sequence.h"
#include "ui/views/interaction/element_tracker_views.h"
#include "ui/views/view.h"
#include "ui/views/view_class_properties.h"
#include "ui/views/widget/widget.h"

namespace {

// Aliases.
using ::testing::Invoke;
using ::testing::NiceMock;

// Element identifiers.
DEFINE_LOCAL_ELEMENT_IDENTIFIER_VALUE(kElementId);

// Mocks -----------------------------------------------------------------------

class MockAppListSyncableService : public app_list::AppListSyncableService {
 public:
  explicit MockAppListSyncableService(Profile* profile)
      : app_list::AppListSyncableService(profile) {}

  // app_list::AppListSyncableService:
  MOCK_METHOD(void,
              OnFirstSync,
              (base::OnceCallback<void(bool was_first_sync_ever)> callback),
              (override));
};

}  // namespace

// ChromeUserEducationDelegateTest ---------------------------------------------

// Base class for tests of the `ChromeUserEducationDelegate`.
class ChromeUserEducationDelegateTest : public BrowserWithTestWindowTest {
 public:
  // Returns the `AccountId` for the primary `profile()`.
  const AccountId& account_id() const {
    return ash::BrowserContextHelper::Get()
        ->GetUserByBrowserContext(profile())
        ->GetAccountId();
  }

  // Returns a pointer to the `delegate_` instance under test.
  ash::UserEducationDelegate* delegate() { return delegate_.get(); }

 private:
  // BrowserWithTestWindowTest:
  void SetUp() override {
    BrowserWithTestWindowTest::SetUp();

    // Instantiate the `delegate_` after `BrowserWithTestWindowTest::SetUp()`
    // so that the browser process has fully initialized.
    delegate_ = std::make_unique<ChromeUserEducationDelegate>();
  }

  // The delegate instance under test.
  std::unique_ptr<ChromeUserEducationDelegate> delegate_;
};

// Tests -----------------------------------------------------------------------

// Verifies that `GetElementIdentifierForAppId()` is working as intended.
TEST_F(ChromeUserEducationDelegateTest, GetElementIdentifierForAppId) {
  using AppIdWithElementIdentifier =
      std::pair<const char*, std::optional<ui::ElementIdentifier>>;

  const std::array<AppIdWithElementIdentifier, 4u> kAppIdsWithElementIds = {
      {{web_app::kHelpAppId, ash::kExploreAppElementId},
       {web_app::kOsSettingsAppId, ash::kSettingsAppElementId},
       {"unknown", std::nullopt},
       {"", std::nullopt}}};

  for (const auto& [app_id, element_id] : kAppIdsWithElementIds) {
    EXPECT_EQ(delegate()->GetElementIdentifierForAppId(app_id), element_id);
  }
}

// Verifies `RegisterTutorial()` registers a tutorial with the browser registry
// and that `IsTutorialRegistered()` accurately reflects browser registry state.
TEST_F(ChromeUserEducationDelegateTest, RegisterTutorial) {
  static constexpr auto kTutorialIds =
      base::EnumSet<ash::TutorialId, ash::TutorialId::kMinValue,
                    ash::TutorialId::kMaxValue>::All();

  const ash::TutorialId tutorial_id = ash::TutorialId::kTest1;
  const auto tutorial_id_str = ash::user_education_util::ToString(tutorial_id);

  const user_education::TutorialRegistry& tutorial_registry =
      UserEducationServiceFactory::GetForBrowserContext(profile())
          ->tutorial_registry();

  // Initially there should be no tutorial registered.
  EXPECT_FALSE(tutorial_registry.IsTutorialRegistered(tutorial_id_str));
  for (ash::TutorialId candidate_tutorial_id : kTutorialIds) {
    EXPECT_FALSE(
        delegate()->IsTutorialRegistered(account_id(), candidate_tutorial_id));
  }

  // Attempt to register a tutorial.
  delegate()->RegisterTutorial(account_id(), tutorial_id,
                               user_education::TutorialDescription());

  // Confirm tutorial registration.
  EXPECT_TRUE(tutorial_registry.IsTutorialRegistered(tutorial_id_str));
  for (ash::TutorialId candidate_tutorial_id : kTutorialIds) {
    EXPECT_EQ(
        delegate()->IsTutorialRegistered(account_id(), candidate_tutorial_id),
        candidate_tutorial_id == tutorial_id);
  }
}

// Verifies `StartTutorial()` starts a tutorial with the browser service, and
// `AbortTutorial()` will abort the tutorial.
TEST_F(ChromeUserEducationDelegateTest, StartAndAbortTutorial) {
  // Create a test element.
  const ui::ElementContext element_context(1);
  ui::test::TestElement test_element(kElementId, element_context);

  // Create a tutorial description.
  user_education::TutorialDescription tutorial_description;
  tutorial_description.steps.emplace_back(
      user_education::TutorialDescription::BubbleStep(kElementId)
          .SetBubbleBodyText(IDS_OK));

  // Register the tutorial.
  delegate()->RegisterTutorial(account_id(), ash::TutorialId::kTest1,
                               std::move(tutorial_description));

  // Verify the tutorial is not running.
  user_education::TutorialService& tutorial_service =
      UserEducationServiceFactory::GetForBrowserContext(profile())
          ->tutorial_service();
  EXPECT_FALSE(delegate()->IsRunningTutorial(account_id()));

  // Attempt to start the tutorial.
  UNCALLED_MOCK_CALLBACK(base::OnceClosure, aborted_callback);
  delegate()->StartTutorial(
      account_id(), ash::TutorialId::kTest1, element_context,
      /*completed_callback=*/base::BindLambdaForTesting([]() { FAIL(); }),
      aborted_callback.Get());

  // Confirm the tutorial is running.
  EXPECT_TRUE(tutorial_service.IsRunningTutorial());

  // Verify the running tutorial's ID.
  EXPECT_TRUE(
      delegate()->IsRunningTutorial(account_id(), ash::TutorialId::kTest1));

  // Abort the tutorial and expect the callback to be called.
  EXPECT_CALL_IN_SCOPE(aborted_callback, Run,
                       delegate()->AbortTutorial(account_id()));
  EXPECT_FALSE(delegate()->IsRunningTutorial(account_id()));
}

// Verifies that `AbortTutorial()` will only abort the tutorial associated with
// the given id, when it is given.
TEST_F(ChromeUserEducationDelegateTest, AbortSpecificTutorial) {
  const auto kTestTutorialIdString =
      ash::user_education_util::ToString(ash::TutorialId::kTest1);

  // Create a test element.
  const ui::ElementContext element_context(1);
  ui::test::TestElement test_element(kElementId, element_context);

  // Create a tutorial description.
  user_education::TutorialDescription tutorial_description;
  tutorial_description.steps.emplace_back(
      user_education::TutorialDescription::BubbleStep(kElementId)
          .SetBubbleBodyText(IDS_OK));

  // Register the tutorial.
  delegate()->RegisterTutorial(account_id(), ash::TutorialId::kTest1,
                               std::move(tutorial_description));

  // Verify the tutorial is not running.
  user_education::TutorialService& tutorial_service =
      UserEducationServiceFactory::GetForBrowserContext(profile())
          ->tutorial_service();
  EXPECT_FALSE(tutorial_service.IsRunningTutorial(kTestTutorialIdString));

  // Attempt to start the tutorial.
  delegate()->StartTutorial(account_id(), ash::TutorialId::kTest1,
                            element_context,
                            /*completed_callback=*/base::DoNothing(),
                            /*aborted_callback=*/base::DoNothing());

  // Confirm the tutorial is running.
  EXPECT_TRUE(tutorial_service.IsRunningTutorial(kTestTutorialIdString));

  // Abort the tutorial with the incorrect id, and expect the tutorial to still
  // be running.
  delegate()->AbortTutorial(account_id(), ash::TutorialId::kTest2);
  EXPECT_TRUE(tutorial_service.IsRunningTutorial(kTestTutorialIdString));

  // Abort the tutorial with the correct id, and expect no tutorial to be
  // running.
  delegate()->AbortTutorial(account_id(), ash::TutorialId::kTest1);
  EXPECT_FALSE(tutorial_service.IsRunningTutorial());
}

// ChromeUserEducationDelegateNewUserTest --------------------------------------

// Base class for tests of the `ChromeUserEducationDelegate` concerned with
// new users, parameterized by whether the first app list sync in the session
// was the first sync ever across all ChromeOS devices and sessions for the
// given user.
class ChromeUserEducationDelegateNewUserTest
    : public ChromeUserEducationDelegateTest,
      public ::testing::WithParamInterface</*was_first_sync_ever=*/bool> {
 public:
  // Returns the event to signal when the first app list sync in the session has
  // been completed.
  base::OneShotEvent& on_first_sync() { return on_first_sync_; }

  // Returns whether the first app list sync in the session was the first sync
  // ever across all ChromeOS devices and sessions for the given user, based on
  // test parameterization.
  bool was_first_sync_ever() const { return GetParam(); }

 private:
  // ChromeUserEducationDelegateTest:
  TestingProfile::TestingFactories GetTestingFactories() override {
    return {TestingProfile::TestingFactory{
        app_list::AppListSyncableServiceFactory::GetInstance(),
        base::BindLambdaForTesting([&](content::BrowserContext* context)
                                       -> std::unique_ptr<KeyedService> {
          auto app_list_syncable_service =
              std::make_unique<NiceMock<MockAppListSyncableService>>(
                  Profile::FromBrowserContext(context));

          // Mock `app_list::AppListSyncableService::OnFirstSync()` so that
          // it runs callbacks to inform them if the first app list sync in
          // the session was the first sync ever across all ChromeOS
          // devices and sessions for the given user, based on test
          // parameterization. Callbacks should only run once signaled that
          // the first app list sync in the session has been completed.
          ON_CALL(*app_list_syncable_service, OnFirstSync)
              .WillByDefault(
                  Invoke([&](base::OnceCallback<void(bool was_first_sync_ever)>
                                 callback) {
                    on_first_sync_.Post(FROM_HERE,
                                        base::BindOnce(std::move(callback),
                                                       was_first_sync_ever()));
                  }));

          return app_list_syncable_service;
        })}};
  }

  // The event to signal when the first app list sync in the session has been
  // completed.
  base::OneShotEvent on_first_sync_;
};

INSTANTIATE_TEST_SUITE_P(All,
                         ChromeUserEducationDelegateNewUserTest,
                         /*was_first_sync_ever=*/::testing::Bool());

// Tests -----------------------------------------------------------------------

// Verifies that `IsNewUser()` is working as intended.
TEST_P(ChromeUserEducationDelegateNewUserTest, IsNewUser) {
  // Until the first app list sync in the session has been completed, it is
  // not known whether a given user can be considered new.
  EXPECT_EQ(delegate()->IsNewUser(account_id()), std::nullopt);

  // Signal that the first app list sync in the session has been completed.
  on_first_sync().Signal();

  // Once the first app list sync in the session has been completed, a task will
  // be posted to the `delegate()` which will cache whether the given user can
  // be considered new.
  EXPECT_TRUE(base::test::RunUntil([&]() {
    return delegate()->IsNewUser(account_id()) == was_first_sync_ever();
  }));
}