// 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