chromium/ash/assistant/assistant_interaction_controller_impl_unittest.cc

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

#include "ash/assistant/assistant_interaction_controller_impl.h"

#include <algorithm>
#include <map>

#include "ash/assistant/assistant_suggestions_controller_impl.h"
#include "ash/assistant/model/assistant_interaction_model.h"
#include "ash/assistant/model/assistant_interaction_model_observer.h"
#include "ash/assistant/model/assistant_response.h"
#include "ash/assistant/model/assistant_response_observer.h"
#include "ash/assistant/model/ui/assistant_card_element.h"
#include "ash/assistant/model/ui/assistant_error_element.h"
#include "ash/assistant/model/ui/assistant_ui_element.h"
#include "ash/assistant/test/assistant_ash_test_base.h"
#include "ash/assistant/ui/assistant_view_ids.h"
#include "ash/assistant/ui/main_stage/assistant_error_element_view.h"
#include "ash/constants/ash_features.h"
#include "ash/public/cpp/app_list/app_list_features.h"
#include "ash/public/cpp/ash_web_view.h"
#include "ash/public/cpp/assistant/controller/assistant_interaction_controller.h"
#include "ash/public/cpp/assistant/controller/assistant_suggestions_controller.h"
#include "ash/test/fake_android_intent_helper.h"
#include "ash/test/test_ash_web_view.h"
#include "base/functional/bind.h"
#include "base/run_loop.h"
#include "base/test/scoped_feature_list.h"
#include "base/time/time.h"
#include "chromeos/ash/services/assistant/public/cpp/assistant_service.h"
#include "chromeos/ash/services/assistant/public/cpp/features.h"
#include "chromeos/ash/services/assistant/test_support/mock_assistant_interaction_subscriber.h"
#include "testing/gmock/include/gmock/gmock.h"

namespace ash {

namespace {

using assistant::AndroidAppInfo;
using assistant::Assistant;
using assistant::AssistantInteractionMetadata;
using assistant::AssistantInteractionSubscriber;
using assistant::AssistantInteractionType;
using assistant::AssistantQuerySource;
using assistant::AssistantSuggestion;
using assistant::AssistantSuggestionType;
using assistant::MockAssistantInteractionSubscriber;
using assistant::ScopedAssistantInteractionSubscriber;

using ::testing::Invoke;
using ::testing::Mock;
using ::testing::Return;
using ::testing::StrictMock;

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

class AssistantInteractionSubscriberMock
    : public AssistantInteractionSubscriber {
 public:
  explicit AssistantInteractionSubscriberMock(Assistant* service) {
    scoped_subscriber_.Observe(service);
  }

  ~AssistantInteractionSubscriberMock() override = default;

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

 private:
  ScopedAssistantInteractionSubscriber scoped_subscriber_{this};
};

// AssistantInteractionControllerImplTest --------------------------------------

class AssistantInteractionControllerImplTest : public AssistantAshTestBase {
 public:
  AssistantInteractionControllerImplTest() = default;

  AssistantInteractionControllerImpl* interaction_controller() {
    return static_cast<AssistantInteractionControllerImpl*>(
        AssistantInteractionController::Get());
  }

  AssistantSuggestionsControllerImpl* suggestion_controller() {
    return static_cast<AssistantSuggestionsControllerImpl*>(
        AssistantSuggestionsController::Get());
  }

  const AssistantInteractionModel* interaction_model() {
    return interaction_controller()->GetModel();
  }

  void StartInteraction() {
    interaction_controller()->OnInteractionStarted(
        AssistantInteractionMetadata());
  }

  AndroidAppInfo CreateAndroidAppInfo(const std::string& app_name = "unknown") {
    AndroidAppInfo result;
    result.localized_app_name = app_name;
    return result;
  }
};

AssistantCardElement* GetAssistantCardElement(
    const std::vector<std::unique_ptr<AssistantUiElement>>& ui_elements) {
  if (ui_elements.size() != 1lu ||
      ui_elements.front()->type() != AssistantUiElementType::kCard) {
    return nullptr;
  }

  return static_cast<AssistantCardElement*>(ui_elements.front().get());
}

}  // namespace

TEST_F(AssistantInteractionControllerImplTest,
       ShouldBecomeActiveWhenInteractionStarts) {
  EXPECT_EQ(interaction_model()->interaction_state(),
            InteractionState::kInactive);

  interaction_controller()->OnInteractionStarted(
      AssistantInteractionMetadata());

  EXPECT_EQ(interaction_model()->interaction_state(),
            InteractionState::kActive);
}

TEST_F(AssistantInteractionControllerImplTest,
       ShouldBeNoOpWhenOpenAppIsCalledWhileInactive) {
  EXPECT_EQ(interaction_model()->interaction_state(),
            InteractionState::kInactive);

  FakeAndroidIntentHelper fake_helper;
  fake_helper.AddApp("app-name", "app-intent");
  interaction_controller()->OnOpenAppResponse(CreateAndroidAppInfo("app-name"));

  EXPECT_FALSE(fake_helper.last_launched_android_intent().has_value());
}

TEST_F(AssistantInteractionControllerImplTest,
       ShouldBeNoOpWhenOpenAppIsCalledForUnknownAndroidApp) {
  StartInteraction();
  FakeAndroidIntentHelper fake_helper;
  interaction_controller()->OnOpenAppResponse(
      CreateAndroidAppInfo("unknown-app-name"));

  EXPECT_FALSE(fake_helper.last_launched_android_intent().has_value());
}

TEST_F(AssistantInteractionControllerImplTest,
       ShouldLaunchAppAndReturnSuccessWhenOpenAppIsCalled) {
  const std::string app_name = "AppName";
  const std::string intent = "intent://AppName";

  StartInteraction();
  FakeAndroidIntentHelper fake_helper;
  fake_helper.AddApp(app_name, intent);

  interaction_controller()->OnOpenAppResponse(CreateAndroidAppInfo(app_name));

  EXPECT_EQ(intent, fake_helper.last_launched_android_intent());
}

TEST_F(AssistantInteractionControllerImplTest,
       ShouldAddSchemeToIntentWhenLaunchingAndroidApp) {
  const std::string app_name = "AppName";
  const std::string intent = "#Intent-without-a-scheme";
  const std::string intent_with_scheme = "intent://" + intent;

  StartInteraction();
  FakeAndroidIntentHelper fake_helper;
  fake_helper.AddApp(app_name, intent);

  interaction_controller()->OnOpenAppResponse(CreateAndroidAppInfo(app_name));

  EXPECT_EQ(intent_with_scheme, fake_helper.last_launched_android_intent());
}

TEST_F(AssistantInteractionControllerImplTest,
       ShouldCorrectlyMapSuggestionTypeToQuerySource) {
  // Mock Assistant interaction subscriber.
  StrictMock<AssistantInteractionSubscriberMock> mock(assistant_service());

  // Configure the expected mappings between suggestion type and query source.
  const std::map<AssistantSuggestionType, AssistantQuerySource>
      types_to_sources = {{AssistantSuggestionType::kConversationStarter,
                           AssistantQuerySource::kConversationStarter},
                          {AssistantSuggestionType::kBetterOnboarding,
                           AssistantQuerySource::kBetterOnboarding},
                          {AssistantSuggestionType::kUnspecified,
                           AssistantQuerySource::kSuggestionChip}};

  // Iterate over all expected mappings.
  for (const auto& type_to_source : types_to_sources) {
    base::RunLoop run_loop;

    // Confirm subscribers are delivered the expected query source...
    EXPECT_CALL(mock, OnInteractionStarted)
        .WillOnce(Invoke([&](const AssistantInteractionMetadata& metadata) {
          EXPECT_EQ(type_to_source.second, metadata.source);
          run_loop.QuitClosure().Run();
        }));

    AssistantSuggestion suggestion{/*id=*/base::UnguessableToken::Create(),
                                   /*type=*/type_to_source.first,
                                   /*text=*/""};
    const_cast<AssistantSuggestionsModel*>(suggestion_controller()->GetModel())
        ->SetConversationStarters({suggestion});

    // ...when an Assistant suggestion of a given type is pressed.
    interaction_controller()->OnSuggestionPressed(suggestion.id);

    run_loop.Run();
  }
}

TEST_F(AssistantInteractionControllerImplTest, ShouldDisplayGenericErrorOnce) {
  StartInteraction();

  // Call OnTtsStarted twice to mimic the behavior of libassistant when network
  // is disconnected.
  interaction_controller()->OnTtsStarted(/*due_to_error=*/true);
  interaction_controller()->OnTtsStarted(/*due_to_error=*/true);

  base::RunLoop().RunUntilIdle();

  auto& ui_elements =
      interaction_controller()->GetModel()->response()->GetUiElements();

  EXPECT_EQ(ui_elements.size(), 1ul);
  EXPECT_EQ(ui_elements.front()->type(), AssistantUiElementType::kError);

  base::RunLoop().RunUntilIdle();

  interaction_controller()->OnInteractionFinished(
      assistant::AssistantInteractionResolution::kError);

  base::RunLoop().RunUntilIdle();

  EXPECT_EQ(ui_elements.size(), 1ul);
  EXPECT_EQ(ui_elements.front()->type(), AssistantUiElementType::kError);
}

TEST_F(AssistantInteractionControllerImplTest,
       ShouldUpdateTimeOfLastInteraction) {
  MockAssistantInteractionSubscriber mock_subscriber;
  ScopedAssistantInteractionSubscriber scoped_subscriber{&mock_subscriber};
  scoped_subscriber.Observe(assistant_service());

  base::RunLoop run_loop;
  base::Time actual_time_of_last_interaction;
  EXPECT_CALL(mock_subscriber, OnInteractionStarted)
      .WillOnce(Invoke([&](const AssistantInteractionMetadata& metadata) {
        actual_time_of_last_interaction = base::Time::Now();
        run_loop.QuitClosure().Run();
      }));

  ShowAssistantUi();
  MockTextInteraction().WithTextResponse("<Any-Text-Response>");
  run_loop.Run();

  auto actual = interaction_controller()->GetTimeDeltaSinceLastInteraction();
  auto expected = base::Time::Now() - actual_time_of_last_interaction;

  EXPECT_NEAR(actual.InSeconds(), expected.InSeconds(), 1);
}

TEST_F(AssistantInteractionControllerImplTest, CompactBubbleLauncher) {
  static constexpr int kStandardLayoutAshWebViewWidth = 592;
  static constexpr int kNarrowLayoutAshWebViewWidth = 496;

  UpdateDisplay("1200x800");
  ShowAssistantUi();
  StartInteraction();

  interaction_controller()->OnHtmlResponse("<html></html>", "fallback");

  base::RunLoop().RunUntilIdle();

  AssistantCardElement* card_element = GetAssistantCardElement(
      interaction_controller()->GetModel()->response()->GetUiElements());
  ASSERT_TRUE(card_element);
  EXPECT_EQ(card_element->viewport_width(), 638);
  EXPECT_EQ(
      page_view()->GetViewByID(AssistantViewID::kAshWebView)->size().width(),
      kStandardLayoutAshWebViewWidth);

  ASSERT_TRUE(page_view()->GetViewByID(AssistantViewID::kAshWebView) !=
              nullptr);
  TestAshWebView* ash_web_view = static_cast<TestAshWebView*>(
      page_view()->GetViewByID(AssistantViewID::kAshWebView));
  // max_size and min_size in AshWebView::InitParams are different from the view
  // size. min_size affects to the size of rendered content, i.e. renderer will
  // try to render the content to the size. But View::Size() doesn't.
  ASSERT_TRUE(ash_web_view->init_params_for_testing().max_size);
  ASSERT_TRUE(ash_web_view->init_params_for_testing().min_size);
  EXPECT_EQ(ash_web_view->init_params_for_testing().max_size.value().width(),
            kStandardLayoutAshWebViewWidth);
  EXPECT_EQ(ash_web_view->init_params_for_testing().min_size.value().width(),
            kStandardLayoutAshWebViewWidth);

  CloseAssistantUi();

  // Change work area width < 1200 and confirm that the viewport width gets
  // updated to narrow layout one.
  UpdateDisplay("1199x800");
  ShowAssistantUi();
  StartInteraction();

  interaction_controller()->OnHtmlResponse("<html></html>", "fallback");

  base::RunLoop().RunUntilIdle();

  card_element = GetAssistantCardElement(
      interaction_controller()->GetModel()->response()->GetUiElements());
  ASSERT_TRUE(card_element);
  ASSERT_TRUE(page_view()->GetViewByID(AssistantViewID::kAshWebView) !=
              nullptr);
  EXPECT_EQ(card_element->viewport_width(), 542);
  EXPECT_EQ(
      page_view()->GetViewByID(AssistantViewID::kAshWebView)->size().width(),
      kNarrowLayoutAshWebViewWidth);

  ASSERT_TRUE(page_view()->GetViewByID(AssistantViewID::kAshWebView) !=
              nullptr);
  ash_web_view = static_cast<TestAshWebView*>(
      page_view()->GetViewByID(AssistantViewID::kAshWebView));
  ASSERT_TRUE(ash_web_view->init_params_for_testing().max_size);
  ASSERT_TRUE(ash_web_view->init_params_for_testing().min_size);
  EXPECT_EQ(ash_web_view->init_params_for_testing().max_size.value().width(),
            kNarrowLayoutAshWebViewWidth);
  EXPECT_EQ(ash_web_view->init_params_for_testing().min_size.value().width(),
            kNarrowLayoutAshWebViewWidth);
}

TEST_F(AssistantInteractionControllerImplTest, FixedZoomLevel) {
  ShowAssistantUi();
  StartInteraction();

  interaction_controller()->OnHtmlResponse("<html></html>", "fallback");

  base::RunLoop().RunUntilIdle();

  TestAshWebView* ash_web_view = static_cast<TestAshWebView*>(
      page_view()->GetViewByID(AssistantViewID::kAshWebView));
  EXPECT_TRUE(ash_web_view->init_params_for_testing().fix_zoom_level_to_one);
}
}  // namespace ash