chromium/ash/assistant/ui/main_stage/assistant_onboarding_view_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.

#ifdef UNSAFE_BUFFERS_BUILD
// TODO(crbug.com/40285824): Remove this and convert code to safer constructs.
#pragma allow_unsafe_buffers
#endif

#include "ash/assistant/ui/main_stage/assistant_onboarding_view.h"

#include <memory>
#include <queue>
#include <string>
#include <utility>
#include <vector>

#include "ash/assistant/model/assistant_suggestions_model.h"
#include "ash/assistant/model/assistant_ui_model.h"
#include "ash/assistant/test/assistant_ash_test_base.h"
#include "ash/assistant/ui/assistant_ui_constants.h"
#include "ash/assistant/ui/main_stage/assistant_onboarding_suggestion_view.h"
#include "ash/assistant/ui/test_support/mock_assistant_view_delegate.h"
#include "ash/assistant/util/test_support/macros.h"
#include "ash/constants/ash_pref_names.h"
#include "ash/public/cpp/assistant/controller/assistant_suggestions_controller.h"
#include "ash/public/cpp/assistant/controller/assistant_ui_controller.h"
#include "ash/public/cpp/session/session_types.h"
#include "ash/public/cpp/session/user_info.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shell.h"
#include "ash/style/ash_color_provider.h"
#include "ash/style/dark_light_mode_controller_impl.h"
#include "base/memory/raw_ref.h"
#include "base/strings/utf_string_conversions.h"
#include "base/test/icu_test_util.h"
#include "base/unguessable_token.h"
#include "chromeos/ash/services/assistant/public/cpp/assistant_service.h"
#include "chromeos/ui/vector_icons/vector_icons.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "ui/color/color_provider_manager.h"
#include "ui/gfx/image/image_unittest_util.h"
#include "ui/gfx/paint_vector_icon.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/controls/label.h"
#include "ui/views/view_utils.h"
#include "ui/views/widget/widget.h"

namespace ash {

namespace {

using assistant::AssistantInteractionMetadata;
using assistant::AssistantInteractionType;
using assistant::AssistantQuerySource;
using assistant::AssistantSuggestion;
using assistant::AssistantSuggestionType;

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

AssistantSuggestion CreateSuggestionWithIconUrl(const std::string& icon_url) {
  AssistantSuggestion suggestion;
  suggestion.icon_url = GURL(icon_url);
  return suggestion;
}

template <typename T>
void FindDescendentByClassName(views::View* parent, T** result) {
  DCHECK_EQ(nullptr, *result);
  std::queue<views::View*> children({parent});
  while (!children.empty()) {
    auto* candidate = children.front();
    children.pop();

    if (views::IsViewClass<T>(candidate)) {
      *result = static_cast<T*>(candidate);
      return;
    }

    for (views::View* child : candidate->children()) {
      children.push(child);
    }
  }
}

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

class MockAssistantInteractionSubscriber
    : public testing::NiceMock<assistant::AssistantInteractionSubscriber> {
 public:
  explicit MockAssistantInteractionSubscriber(assistant::Assistant* service) {
    scoped_subscriber_.Observe(service);
  }

  ~MockAssistantInteractionSubscriber() override = default;

  MOCK_METHOD(void,
              OnInteractionStarted,
              (const AssistantInteractionMetadata&),
              (override));

 private:
  assistant::ScopedAssistantInteractionSubscriber scoped_subscriber_{this};
};

// ScopedShowUi ----------------------------------------------------------------

class ScopedShowUi {
 public:
  ScopedShowUi()
      : original_visibility_(
            AssistantUiController::Get()->GetModel()->visibility()) {
    AssistantUiController::Get()->ShowUi(
        assistant::AssistantEntryPoint::kUnspecified);
  }

  ScopedShowUi(const ScopedShowUi&) = delete;
  ScopedShowUi& operator=(const ScopedShowUi&) = delete;

  ~ScopedShowUi() {
    switch (original_visibility_) {
      case AssistantVisibility::kClosed:
        AssistantUiController::Get()->CloseUi(
            assistant::AssistantExitPoint::kUnspecified);
        return;
      case AssistantVisibility::kVisible:
        // No action necessary.
        return;
      case AssistantVisibility::kClosing:
        // No action necessary.
        return;
    }
  }

 private:
  const AssistantVisibility original_visibility_;
};

// DISABLED_AssistantOnboardingViewTest
// -------------------------------------------------

class DISABLED_AssistantOnboardingViewTest : public AssistantAshTestBase {
 public:
  DISABLED_AssistantOnboardingViewTest()
      : AssistantAshTestBase(
            base::test::TaskEnvironment::TimeSource::MOCK_TIME) {}

  ~DISABLED_AssistantOnboardingViewTest() override = default;

  void AdvanceClock(base::TimeDelta time_delta) {
    task_environment()->AdvanceClock(time_delta);
  }

  void SetOnboardingSuggestions(
      std::vector<AssistantSuggestion> onboarding_suggestions) {
    const_cast<AssistantSuggestionsModel*>(
        AssistantSuggestionsController::Get()->GetModel())
        ->SetOnboardingSuggestions(std::move(onboarding_suggestions));
  }

  views::Label* greeting_label() {
    return static_cast<views::Label*>(onboarding_view()->children().at(0));
  }

  views::Label* intro_label() {
    return static_cast<views::Label*>(onboarding_view()->children().at(1));
  }

 private:
  base::test::ScopedRestoreICUDefaultLocale locale_{"en_US"};
};

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

TEST_F(DISABLED_AssistantOnboardingViewTest, ShouldHaveExpectedGreeting) {
  struct ExpectedGreeting {
    std::u16string for_morning;
    std::u16string for_afternoon;
    std::u16string for_evening;
    std::u16string for_night;
  };

  struct TestCase {
    std::string display_email;
    std::string given_name;
    ExpectedGreeting expected_greeting;
  };

  const std::vector<TestCase> test_cases = {
      TestCase{/*display_email=*/"empty@test",
               /*given_name=*/std::string(),
               ExpectedGreeting{
                   /*for_morning=*/u"Good morning,",
                   /*for_afternoon=*/u"Good afternoon,",
                   /*for_evening=*/u"Good evening,",
                   /*for_night=*/u"Good night,",
               }},
      TestCase{/*display_email=*/"david@test",
               /*given_name=*/"David",
               ExpectedGreeting{
                   /*for_morning=*/u"Good morning David,",
                   /*for_afternoon=*/u"Good afternoon David,",
                   /*for_evening=*/u"Good evening David,",
                   /*for_night=*/u"Good night David,",
               }}};

  for (const auto& test_case : test_cases) {
    CreateAndSwitchActiveUser(test_case.display_email, test_case.given_name);

    // Advance clock to midnight tomorrow.
    AdvanceClock(base::Time::Now().LocalMidnight() + base::Hours(24) -
                 base::Time::Now());

    {
      // Verify 4:59 AM.
      AdvanceClock(base::Hours(4) + base::Minutes(59));
      ScopedShowUi scoped_show_ui;
      EXPECT_EQ(greeting_label()->GetText(),
                test_case.expected_greeting.for_night);
    }

    {
      // Verify 5:00 AM.
      AdvanceClock(base::Minutes(1));
      ScopedShowUi scoped_show_ui;
      EXPECT_EQ(greeting_label()->GetText(),
                test_case.expected_greeting.for_morning);
    }

    {
      // Verify 11:59 AM.
      AdvanceClock(base::Hours(6) + base::Minutes(59));
      ScopedShowUi scoped_show_ui;
      EXPECT_EQ(greeting_label()->GetText(),
                test_case.expected_greeting.for_morning);
    }

    {
      // Verify 12:00 PM.
      AdvanceClock(base::Minutes(1));
      ScopedShowUi scoped_show_ui;
      EXPECT_EQ(greeting_label()->GetText(),
                test_case.expected_greeting.for_afternoon);
    }

    {
      // Verify 4:59 PM.
      AdvanceClock(base::Hours(4) + base::Minutes(59));
      ScopedShowUi scoped_show_ui;
      EXPECT_EQ(greeting_label()->GetText(),
                test_case.expected_greeting.for_afternoon);
    }

    {
      // Verify 5:00 PM.
      AdvanceClock(base::Minutes(1));
      ScopedShowUi scoped_show_ui;
      EXPECT_EQ(greeting_label()->GetText(),
                test_case.expected_greeting.for_evening);
    }

    {
      // Verify 10:59 PM.
      AdvanceClock(base::Hours(5) + base::Minutes(59));
      ScopedShowUi scoped_show_ui;
      EXPECT_EQ(greeting_label()->GetText(),
                test_case.expected_greeting.for_evening);
    }

    {
      // Verify 11:00 PM.
      AdvanceClock(base::Minutes(1));
      ScopedShowUi scoped_show_ui;
      EXPECT_EQ(greeting_label()->GetText(),
                test_case.expected_greeting.for_night);
    }
  }
}

TEST_F(DISABLED_AssistantOnboardingViewTest, ShouldHaveExpectedIntro) {
  ShowAssistantUi();
  EXPECT_EQ(intro_label()->GetText(),
            u"I'm your Google Assistant, here to help you throughout your day!"
            u"\nHere are some things you can try to get started.");
}

TEST_F(DISABLED_AssistantOnboardingViewTest, ShouldHaveExpectedSuggestions) {
  struct VectorIconWithColor {
    VectorIconWithColor(const gfx::VectorIcon& icon, SkColor color)
        : icon(icon), color(color) {}

    const raw_ref<const gfx::VectorIcon> icon;
    SkColor color;
  };

  struct ExpectedSuggestion {
    std::u16string message;
    std::unique_ptr<VectorIconWithColor> icon_with_color;
  };

  auto get_color = [](int index) {
    constexpr SkColor kForegroundColors[6][3] = {
        // Colors of dark/light mode is disabled, dark mode, light mode.
        {gfx::kGoogleBlue800, gfx::kGoogleBlue200, gfx::kGoogleBlue800},
        {gfx::kGoogleRed800, gfx::kGoogleRed200, gfx::kGoogleRed800},
        {SkColorSetRGB(0xBF, 0x50, 0x00), gfx::kGoogleYellow200,
         SkColorSetRGB(0xBF, 0x50, 0x00)},
        {gfx::kGoogleGreen800, gfx::kGoogleGreen200, gfx::kGoogleGreen800},
        {SkColorSetRGB(0x8A, 0x0E, 0x9E), SkColorSetRGB(0xf8, 0x82, 0xff),
         SkColorSetRGB(0xaa, 0x00, 0xb8)},
        {gfx::kGoogleBlue800, gfx::kGoogleBlue200, gfx::kGoogleBlue800}};
    const int color_index =
        DarkLightModeControllerImpl::Get()->IsDarkModeEnabled() ? 1 : 2;
    return kForegroundColors[index][color_index];
  };

  // Iterate over each onboarding mode.
  for (int mode = 0;
       mode <= static_cast<int>(AssistantOnboardingMode::kMaxValue); ++mode) {
    auto onboarding_mode = static_cast<AssistantOnboardingMode>(mode);
    SetOnboardingMode(onboarding_mode);

    // Determine expected suggestions based on onboarding mode.
    std::vector<ExpectedSuggestion> expected_suggestions;
    switch (onboarding_mode) {
      case AssistantOnboardingMode::kEducation:
        expected_suggestions.push_back(
            {/*message=*/u"Square root of 71",
             /*icon_with_color=*/std::make_unique<VectorIconWithColor>(
                 chromeos::kCalculateIcon, get_color(0))});
        expected_suggestions.push_back(
            {/*message=*/u"How far is Venus",
             /*icon_with_color=*/std::make_unique<VectorIconWithColor>(
                 chromeos::kStraightenIcon, get_color(1))});
        expected_suggestions.push_back(
            {/*message=*/u"Set timer",
             /*icon_with_color=*/std::make_unique<VectorIconWithColor>(
                 chromeos::kTimerIcon, get_color(2))});
        expected_suggestions.push_back(
            {/*message=*/u"Tell me a joke",
             /*icon_with_color=*/std::make_unique<VectorIconWithColor>(
                 chromeos::kSentimentVerySatisfiedIcon, get_color(3))});
        expected_suggestions.push_back(
            {/*message=*/u"\"Hello\" in Chinese",
             /*icon_with_color=*/std::make_unique<VectorIconWithColor>(
                 chromeos::kTranslateIcon, get_color(4))});
        expected_suggestions.push_back(
            {/*message=*/u"Take a screenshot",
             /*icon_with_color=*/std::make_unique<VectorIconWithColor>(
                 chromeos::kScreenshotIcon, get_color(5))});
        break;
      case AssistantOnboardingMode::kDefault:
        expected_suggestions.push_back(
            {/*message=*/u"5K in miles",
             /*icon_with_color=*/std::make_unique<VectorIconWithColor>(
                 chromeos::kConversionPathIcon, get_color(0))});
        expected_suggestions.push_back(
            {/*message=*/u"Population in Nigeria",
             /*icon_with_color=*/std::make_unique<VectorIconWithColor>(
                 chromeos::kPersonPinCircleIcon, get_color(1))});
        expected_suggestions.push_back(
            {/*message=*/u"Set timer",
             /*icon_with_color=*/std::make_unique<VectorIconWithColor>(
                 chromeos::kTimerIcon, get_color(2))});
        expected_suggestions.push_back(
            {/*message=*/u"Tell me a joke",
             /*icon_with_color=*/std::make_unique<VectorIconWithColor>(
                 chromeos::kSentimentVerySatisfiedIcon, get_color(3))});
        expected_suggestions.push_back(
            {/*message=*/u"\"Hello\" in Chinese",
             /*icon_with_color=*/std::make_unique<VectorIconWithColor>(
                 chromeos::kTranslateIcon, get_color(4))});
        expected_suggestions.push_back(
            {/*message=*/u"Take a screenshot",
             /*icon_with_color=*/std::make_unique<VectorIconWithColor>(
                 chromeos::kScreenshotIcon, get_color(5))});
        break;
    }

    ShowAssistantUi();

    // Verify the expected number of suggestion views.
    auto suggestion_views = GetOnboardingSuggestionViews();
    ASSERT_EQ(suggestion_views.size(), expected_suggestions.size());

    // Verify that each suggestion view has the expected message and icon.
    for (size_t i = 0; i < expected_suggestions.size(); ++i) {
      const auto* suggestion_view = suggestion_views.at(i);
      const auto& expected_suggestion = expected_suggestions.at(i);

      EXPECT_EQ(suggestion_view->GetText(), expected_suggestion.message);

      ASSERT_PIXELS_EQ(
          suggestion_view->GetIcon(),
          gfx::CreateVectorIcon(*expected_suggestion.icon_with_color->icon,
                                /*size=*/24,
                                expected_suggestion.icon_with_color->color));
    }
  }
}

TEST_F(DISABLED_AssistantOnboardingViewTest, ShouldHandleSuggestionPresses) {
  ShowAssistantUi();

  // Verify onboarding suggestions exist.
  auto suggestion_views = GetOnboardingSuggestionViews();
  ASSERT_FALSE(suggestion_views.empty());

  // Expect a text interaction originating from the onboarding feature...
  MockAssistantInteractionSubscriber subscriber(assistant_service());
  EXPECT_CALL(subscriber, OnInteractionStarted)
      .WillOnce(
          testing::Invoke([](const AssistantInteractionMetadata& metadata) {
            EXPECT_EQ(AssistantInteractionType::kText, metadata.type);
            EXPECT_EQ(AssistantQuerySource::kBetterOnboarding, metadata.source);
          }));

  // ...when an onboarding suggestion is pressed.
  TapOnAndWait(suggestion_views.at(0));
}

TEST_F(DISABLED_AssistantOnboardingViewTest, ShouldHandleSuggestionUpdates) {
  // Show Assistant UI and verify suggestions exist.
  ShowAssistantUi();
  ASSERT_FALSE(GetOnboardingSuggestionViews().empty());

  // Manually create a suggestion.
  AssistantSuggestion suggestion;
  suggestion.id = base::UnguessableToken();
  suggestion.type = AssistantSuggestionType::kBetterOnboarding;
  suggestion.text = "Forced suggestion";

  // Force a model update.
  std::vector<AssistantSuggestion> suggestions;
  suggestions.push_back(std::move(suggestion));
  SetOnboardingSuggestions(std::move(suggestions));

  // Verify view state is updated to reflect model state.
  auto suggestion_views = GetOnboardingSuggestionViews();
  ASSERT_EQ(suggestion_views.size(), 1u);
  EXPECT_EQ(suggestion_views.at(0)->GetText(), u"Forced suggestion");
}

TEST_F(DISABLED_AssistantOnboardingViewTest, ShouldHandleLocalIcons) {
  SetOnboardingSuggestions({CreateSuggestionWithIconUrl(
      "googleassistant://resource?type=icon&name=assistant")});

  ShowAssistantUi();
  auto suggestion_views = GetOnboardingSuggestionViews();
  ASSERT_EQ(suggestion_views.size(), 1u);

  const auto& actual = suggestion_views.at(0)->GetIcon();
  gfx::ImageSkia expected = gfx::CreateVectorIcon(
      gfx::IconDescription(chromeos::kAssistantIcon, /*size=*/24));

  ASSERT_PIXELS_EQ(actual, expected);
}

TEST_F(DISABLED_AssistantOnboardingViewTest, ShouldHandleRemoteIcons) {
  const gfx::ImageSkia expected =
      gfx::test::CreateImageSkia(/*width=*/10, /*height=*/10);

  MockAssistantViewDelegate delegate;
  EXPECT_CALL(delegate, GetPrimaryUserGivenName)
      .WillOnce(testing::Return("Primary User Given Name"));

  auto widget = CreateFramelessTestWidget();
  auto* onboarding_view = widget->SetContentsView(
      std::make_unique<AssistantOnboardingView>(&delegate));
  EXPECT_CALL(delegate, DownloadImage)
      .WillOnce(testing::Invoke(
          [&](const GURL& url, ImageDownloader::DownloadCallback callback) {
            std::move(callback).Run(expected);
          }));

  SetOnboardingSuggestions({CreateSuggestionWithIconUrl(
      "https://www.gstatic.com/images/branding/product/2x/googleg_48dp.png")});

  AssistantOnboardingSuggestionView* suggestion_view = nullptr;
  FindDescendentByClassName(onboarding_view, &suggestion_view);
  ASSERT_NE(nullptr, suggestion_view);

  const auto& actual = suggestion_view->GetIcon();
  EXPECT_TRUE(actual.BackedBySameObjectAs(expected));
}

TEST_F(DISABLED_AssistantOnboardingViewTest, DarkAndLightTheme) {
  AshColorProvider* color_provider = AshColorProvider::Get();
  auto* dark_light_mode_controller = DarkLightModeControllerImpl::Get();
  dark_light_mode_controller->OnActiveUserPrefServiceChanged(
      Shell::Get()->session_controller()->GetActivePrefService());

  ShowAssistantUi();

  const bool initial_dark_mode_status =
      dark_light_mode_controller->IsDarkModeEnabled();
  const SkColor initial_greeting_label_color =
      greeting_label()->GetEnabledColor();
  const SkColor initial_intro_label_color = intro_label()->GetEnabledColor();
  const SkColor intial_text_primary_color =
      color_provider->GetContentLayerColor(
          ColorProvider::ContentLayerType::kTextColorPrimary);
  EXPECT_EQ(initial_greeting_label_color, intial_text_primary_color);
  EXPECT_EQ(initial_intro_label_color, intial_text_primary_color);

  // Switch the color mode.
  dark_light_mode_controller->ToggleColorMode();
  ASSERT_NE(initial_dark_mode_status,
            dark_light_mode_controller->IsDarkModeEnabled());
  const SkColor text_primary_color = color_provider->GetContentLayerColor(
      ColorProvider::ContentLayerType::kTextColorPrimary);
  EXPECT_NE(intial_text_primary_color, text_primary_color);

  // Check that both label colors are updated to the text primary color,
  // calculated based on the new color mode.
  const SkColor greeting_label_color = greeting_label()->GetEnabledColor();
  const SkColor intro_label_color = intro_label()->GetEnabledColor();
  EXPECT_EQ(greeting_label_color, text_primary_color);
  EXPECT_EQ(intro_label_color, text_primary_color);
}

}  // namespace
}  // namespace ash