chromium/ash/system/accessibility/dictation_button_tray_unittest.cc

// Copyright 2018 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/system/accessibility/dictation_button_tray.h"
#include <memory>

#include "ash/accessibility/accessibility_controller.h"
#include "ash/accessibility/test_accessibility_controller_client.h"
#include "ash/constants/ash_features.h"
#include "ash/display/window_tree_host_manager.h"
#include "ash/login_status.h"
#include "ash/public/cpp/shelf_types.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/root_window_controller.h"
#include "ash/rotator/screen_rotation_animator.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shell.h"
#include "ash/style/ash_color_id.h"
#include "ash/system/progress_indicator/progress_indicator.h"
#include "ash/system/status_area_widget.h"
#include "ash/system/status_area_widget_test_helper.h"
#include "ash/test/ash_test_base.h"
#include "ash/test/ash_test_helper.h"
#include "ash/wm/overview/overview_controller.h"
#include "ash/wm/splitview/split_view_controller.h"
#include "ash/wm/tablet_mode/tablet_mode_controller.h"
#include "ash/wm/window_state.h"
#include "ash/wm/window_util.h"
#include "base/command_line.h"
#include "base/strings/utf_string_conversions.h"
#include "base/test/bind.h"
#include "base/test/metrics/user_action_tester.h"
#include "base/test/scoped_feature_list.h"
#include "base/time/time.h"
#include "chromeos/constants/chromeos_features.h"
#include "components/soda/soda_installer.h"
#include "components/soda/soda_installer_impl_chromeos.h"
#include "ui/accessibility/accessibility_features.h"
#include "ui/aura/client/aura_constants.h"
#include "ui/aura/window.h"
#include "ui/base/ime/ash/ime_bridge.h"
#include "ui/base/ime/ash/input_method_ash.h"
#include "ui/base/ime/fake_text_input_client.h"
#include "ui/base/ime/text_input_type.h"
#include "ui/compositor/scoped_animation_duration_scale_mode.h"
#include "ui/display/display_switches.h"
#include "ui/display/manager/display_manager.h"
#include "ui/events/event.h"
#include "ui/events/gestures/gesture_types.h"
#include "ui/events/types/event_type.h"
#include "ui/gfx/color_palette.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/wm/core/window_util.h"

namespace ash {

namespace {

const std::string kEnabledTooltip = "Dictation";
const std::string kDisabledTooltip = "Downloading speech files";

DictationButtonTray* GetTray() {
  return StatusAreaWidgetTestHelper::GetStatusAreaWidget()
      ->dictation_button_tray();
}

// ProgressIndicatorWaiter -----------------------------------------------------

// A class which supports waiting for a progress indicator to reach a desired
// state of progress.
class ProgressIndicatorWaiter {
 public:
  ProgressIndicatorWaiter() = default;
  ProgressIndicatorWaiter(const ProgressIndicatorWaiter&) = delete;
  ProgressIndicatorWaiter& operator=(const ProgressIndicatorWaiter&) = delete;
  ~ProgressIndicatorWaiter() = default;

  // Waits for `progress_indicator` to reach the specified `progress`. If the
  // `progress_indicator` is already at `progress`, this method no-ops.
  void WaitForProgress(ProgressIndicator* progress_indicator,
                       const std::optional<float>& progress) {
    if (progress_indicator->progress() == progress)
      return;
    base::RunLoop run_loop;
    auto subscription = progress_indicator->AddProgressChangedCallback(
        base::BindLambdaForTesting([&]() {
          if (progress_indicator->progress() == progress)
            run_loop.Quit();
        }));
    run_loop.Run();
  }
};

}  // namespace

// DictationButtonTrayTest -----------------------------------------------------

class DictationButtonTrayTest : public AshTestBase {
 public:
  DictationButtonTrayTest() = default;
  ~DictationButtonTrayTest() override = default;
  DictationButtonTrayTest(const DictationButtonTrayTest&) = delete;
  DictationButtonTrayTest& operator=(const DictationButtonTrayTest&) = delete;

  // AshTestBase:
  void SetUp() override {
    // Focus some input text so the Dictation button will be enabled.
    fake_text_input_client_ =
        std::make_unique<ui::FakeTextInputClient>(ui::TEXT_INPUT_TYPE_TEXT);
    InputMethodAsh ime(nullptr);
    IMEBridge::Get()->SetInputContextHandler(&ime);
    AshTestBase::SetUp();
    FocusTextInputClient();
  }

 protected:
  views::ImageView* GetImageView(DictationButtonTray* tray) {
    return tray->icon_;
  }
  void CheckDictationStatusAndUpdateIcon(DictationButtonTray* tray) {
    tray->CheckDictationStatusAndUpdateIcon();
  }
  void FocusTextInputClient() {
    Shell::Get()
        ->window_tree_host_manager()
        ->input_method()
        ->SetFocusedTextInputClient(fake_text_input_client_.get());
  }
  void DetachTextInputClient() {
    Shell::Get()
        ->window_tree_host_manager()
        ->input_method()
        ->SetFocusedTextInputClient(nullptr);
  }

  std::unique_ptr<ui::FakeTextInputClient> fake_text_input_client_;
};

// Ensures that creation doesn't cause any crashes and adds the image icon.
// Also checks that the tray is visible.
TEST_F(DictationButtonTrayTest, BasicConstruction) {
  AccessibilityController* controller =
      Shell::Get()->accessibility_controller();
  controller->dictation().SetEnabled(true);
  EXPECT_TRUE(GetImageView(GetTray()));
  EXPECT_TRUE(GetTray()->GetVisible());
}

// Test that clicking the button activates dictation.
TEST_F(DictationButtonTrayTest, ButtonActivatesDictation) {
  AccessibilityController* controller =
      Shell::Get()->accessibility_controller();
  TestAccessibilityControllerClient client;
  controller->dictation().SetEnabled(true);
  EXPECT_FALSE(controller->dictation_active());

  GestureTapOn(GetTray());
  EXPECT_TRUE(controller->dictation_active());

  GestureTapOn(GetTray());
  EXPECT_FALSE(controller->dictation_active());
}

// Test that activating dictation causes the button to activate.
TEST_F(DictationButtonTrayTest, ActivatingDictationActivatesButton) {
  AccessibilityController* controller =
      Shell::Get()->accessibility_controller();
  controller->dictation().SetEnabled(true);
  Shell::Get()->OnDictationStarted();
  EXPECT_TRUE(GetTray()->is_active());

  Shell::Get()->OnDictationEnded();
  EXPECT_FALSE(GetTray()->is_active());
}

// Tests that the tray only renders as active while dictation is listening. Any
// termination of dictation clears the active state.
TEST_F(DictationButtonTrayTest, ActiveStateOnlyDuringDictation) {
  AccessibilityController* controller =
      Shell::Get()->accessibility_controller();
  TestAccessibilityControllerClient client;
  controller->dictation().SetEnabled(true);

  ASSERT_FALSE(controller->dictation_active());
  ASSERT_FALSE(GetTray()->is_active());
  // In an input text area by default.
  EXPECT_TRUE(GetTray()->GetEnabled());

  Shell::Get()->accelerator_controller()->PerformActionIfEnabled(
      AcceleratorAction::kEnableOrToggleDictation, {});
  EXPECT_TRUE(controller->dictation_active());
  EXPECT_TRUE(GetTray()->is_active());

  Shell::Get()->accelerator_controller()->PerformActionIfEnabled(
      AcceleratorAction::kEnableOrToggleDictation, {});
  EXPECT_FALSE(controller->dictation_active());
  EXPECT_FALSE(GetTray()->is_active());
}

TEST_F(DictationButtonTrayTest, ImageIcons) {
  AccessibilityController* controller =
      Shell::Get()->accessibility_controller();
  TestAccessibilityControllerClient client;
  controller->dictation().SetEnabled(true);

  const bool is_jelly_enabled = chromeos::features::IsJellyEnabled();
  const auto* color_provider = GetTray()->GetColorProvider();
  const auto off_icon_color = color_provider->GetColor(
      is_jelly_enabled
          ? static_cast<ui::ColorId>(cros_tokens::kCrosSysOnSurface)
          : kColorAshIconColorPrimary);
  const auto on_icon_color = color_provider->GetColor(
      is_jelly_enabled ? static_cast<ui::ColorId>(
                             cros_tokens::kCrosSysSystemOnPrimaryContainer)
                       : kColorAshIconColorPrimary);

  gfx::ImageSkia off_icon =
      gfx::CreateVectorIcon(kDictationOffNewuiIcon, off_icon_color);
  gfx::ImageSkia on_icon =
      gfx::CreateVectorIcon(kDictationOnNewuiIcon, on_icon_color);

  views::ImageView* view = GetImageView(GetTray());
  EXPECT_TRUE(gfx::test::AreBitmapsEqual(*view->GetImage().bitmap(),
                                         *off_icon.bitmap()));

  Shell::Get()->accelerator_controller()->PerformActionIfEnabled(
      AcceleratorAction::kEnableOrToggleDictation, {});

  EXPECT_TRUE(gfx::test::AreBitmapsEqual(*view->GetImage().bitmap(),
                                         *on_icon.bitmap()));
}

TEST_F(DictationButtonTrayTest, DisabledWhenNoInputFocused) {
  DetachTextInputClient();

  AccessibilityController* controller =
      Shell::Get()->accessibility_controller();
  controller->dictation().SetEnabled(true);
  DictationButtonTray* tray = GetTray();
  EXPECT_FALSE(tray->GetEnabled());

  // Action doesn't work because disabled.
  Shell::Get()->accelerator_controller()->PerformActionIfEnabled(
      AcceleratorAction::kEnableOrToggleDictation, {});
  EXPECT_FALSE(controller->dictation_active());
  EXPECT_FALSE(tray->GetEnabled());

  FocusTextInputClient();
  EXPECT_TRUE(tray->GetEnabled());

  DetachTextInputClient();
  EXPECT_FALSE(tray->GetEnabled());
}

// Base class for SODA tests of the dictation button tray.
class DictationButtonTraySodaTest : public DictationButtonTrayTest {
 public:
  DictationButtonTraySodaTest() = default;
  ~DictationButtonTraySodaTest() override = default;
  DictationButtonTraySodaTest(const DictationButtonTraySodaTest&) = delete;
  DictationButtonTraySodaTest& operator=(const DictationButtonTraySodaTest&) =
      delete;

  // DictationButtonTrayTest:
  void SetUp() override {
    DictationButtonTrayTest::SetUp();

    scoped_feature_list_.InitAndEnableFeature(
        features::kOnDeviceSpeechRecognition);

    // Since this test suite is part of ash unit tests, the
    // SodaInstallerImplChromeOS is never created (it's normally created when
    // `ChromeBrowserMainPartsAsh` initializes). Create it here so that
    // calling speech::SodaInstaller::GetInstance) returns a valid instance.
    soda_installer_impl_ =
        std::make_unique<speech::SodaInstallerImplChromeOS>();
  }

  void TearDown() override {
    soda_installer_impl_.reset();
    AshTestBase::TearDown();
  }

  ProgressIndicator* GetProgressIndicator() {
    return GetTray()->progress_indicator_.get();
  }

  float GetProgressIndicatorProgress() const {
    DCHECK(GetTray()->progress_indicator_);
    std::optional<float> progress = GetTray()->progress_indicator_->progress();
    DCHECK(progress.has_value());
    return progress.value();
  }

  bool IsImageVisible() {
    DCHECK(GetTray()->icon_);

    ui::Layer* const layer = GetTray()->icon_->layer();
    if (!layer)
      return true;

    return layer->GetTargetOpacity() == 1.f &&
           layer->GetTargetTransform() == gfx::Transform();
  }

  bool IsProgressIndicatorVisible() const {
    const float progress = GetProgressIndicatorProgress();
    return progress > 0.f && progress < 1.f;
  }

 private:
  base::test::ScopedFeatureList scoped_feature_list_;
  std::unique_ptr<speech::SodaInstallerImplChromeOS> soda_installer_impl_;
};

// Tests the behavior of the UpdateOnSpeechRecognitionDownloadChanged() method.
TEST_F(DictationButtonTraySodaTest, UpdateOnSpeechRecognitionDownloadChanged) {
  AccessibilityController* controller =
      Shell::Get()->accessibility_controller();
  controller->dictation().SetEnabled(true);
  DictationButtonTray* tray = GetTray();
  views::ImageView* image = GetImageView(tray);
  EXPECT_TRUE(IsImageVisible());

  // Download progress of 0 indicates that download is not in-progress.
  tray->UpdateOnSpeechRecognitionDownloadChanged(/*download_progress=*/0);
  EXPECT_EQ(0, tray->download_progress());
  EXPECT_TRUE(tray->GetEnabled());
  EXPECT_EQ(base::UTF8ToUTF16(kEnabledTooltip), image->GetTooltipText());

  // The tray icon should be visible when the download is not in-progress.
  ProgressIndicator* progress_indicator = GetProgressIndicator();
  ProgressIndicatorWaiter().WaitForProgress(
      progress_indicator, ProgressIndicator::kProgressComplete);
  EXPECT_FALSE(IsProgressIndicatorVisible());
  EXPECT_TRUE(IsImageVisible());

  // Any number 0 < number < 100 means that download is in-progress.
  tray->UpdateOnSpeechRecognitionDownloadChanged(/*download_progress=*/50);
  EXPECT_EQ(50, tray->download_progress());
  EXPECT_FALSE(tray->GetEnabled());
  EXPECT_EQ(base::UTF8ToUTF16(kDisabledTooltip), image->GetTooltipText());

  // Enabled state doesn't change even if text input is focused.
  DetachTextInputClient();
  EXPECT_FALSE(tray->GetEnabled());
  FocusTextInputClient();
  EXPECT_FALSE(tray->GetEnabled());

  // The tray icon should still be visible when the download is in progress.
  ProgressIndicatorWaiter().WaitForProgress(progress_indicator, 0.5f);
  EXPECT_TRUE(IsProgressIndicatorVisible());
  EXPECT_FALSE(progress_indicator->inner_icon_visible());
  EXPECT_TRUE(IsImageVisible());

  tray->UpdateOnSpeechRecognitionDownloadChanged(/*download_progress=*/70);
  EXPECT_EQ(70, tray->download_progress());
  EXPECT_FALSE(tray->GetEnabled());
  EXPECT_EQ(base::UTF8ToUTF16(kDisabledTooltip), image->GetTooltipText());

  // The tray icon should be visible when the download is in progress.
  ProgressIndicatorWaiter().WaitForProgress(progress_indicator, 0.7f);
  EXPECT_TRUE(IsProgressIndicatorVisible());
  EXPECT_FALSE(progress_indicator->inner_icon_visible());
  EXPECT_TRUE(IsImageVisible());

  // Similar to 0, a value of 100 means that download is not in-progress.
  tray->UpdateOnSpeechRecognitionDownloadChanged(/*download_progress=*/100);
  EXPECT_EQ(100, tray->download_progress());
  EXPECT_TRUE(tray->GetEnabled());
  EXPECT_EQ(base::UTF8ToUTF16(kEnabledTooltip), image->GetTooltipText());

  // The tray icon should be visible when the download is not in-progress.
  ProgressIndicatorWaiter().WaitForProgress(
      progress_indicator, ProgressIndicator::kProgressComplete);
  EXPECT_FALSE(IsProgressIndicatorVisible());
  EXPECT_TRUE(IsImageVisible());
}

}  // namespace ash