chromium/ash/app_list/views/app_list_item_view_unittest.cc

// Copyright 2021 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/app_list/views/app_list_item_view.h"

#include "ash/app_list/model/app_list_test_model.h"
#include "ash/app_list/test/app_list_test_helper.h"
#include "ash/app_list/views/apps_grid_view_test_api.h"
#include "ash/app_list/views/paged_apps_grid_view.h"
#include "ash/app_list/views/scrollable_apps_grid_view.h"
#include "ash/constants/ash_features.h"
#include "ash/drag_drop/drag_drop_controller.h"
#include "ash/public/cpp/app_list/app_list_features.h"
#include "ash/public/cpp/test/shell_test_api.h"
#include "ash/shelf/shelf.h"
#include "ash/shell.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/system/progress_indicator/progress_indicator.h"
#include "ash/test/ash_test_base.h"
#include "base/memory/raw_ptr.h"
#include "base/test/bind.h"
#include "base/test/scoped_feature_list.h"
#include "ui/accessibility/ax_enums.mojom-shared.h"
#include "ui/accessibility/ax_node_data.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/events/event_utils.h"
#include "ui/events/keycodes/keyboard_codes_posix.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/controls/label.h"

namespace ash {

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

class AppListItemViewTest : public AshTestBase,
                            public testing::WithParamInterface<bool> {
 public:
  AppListItemViewTest() = default;
  ~AppListItemViewTest() override = default;

  // testing::Test:
  void SetUp() override {
    scoped_feature_list_.InitWithFeatureStates(
        {{app_list_features::kDragAndDropRefactor, IsUsingDragDropController()},
         {features::kPromiseIcons, true}});

    AshTestBase::SetUp();

    ShellTestApi().drag_drop_controller()->SetLoopClosureForTesting(
        base::BindLambdaForTesting([&]() {
          drag_started_on_controller_++;
          ASSERT_TRUE(drag_view_);
          EXPECT_EQ(GetDragState(drag_view_),
                    AppListItemView::DragState::kStarted);
          GetEventGenerator()->ReleaseTouch();
        }),
        base::DoNothing());
  }

  static views::View* GetNewInstallDot(AppListItemView* view) {
    return view->new_install_dot_;
  }

  AppListItem* CreateAppListItem(const std::string& name) {
    AppListItem* item =
        GetAppListTestHelper()->model()->CreateAndAddItem(name + "_id");
    item->SetName(name);
    return item;
  }

  AppListItem* CreatePromiseAppListItem(const std::string& name) {
    AppListItem* item =
        GetAppListTestHelper()->model()->CreateAndAddPromiseItem(name + "_id");
    item->SetName(name);
    return item;
  }

  AppListItem* CreateFolderItem(const int num_apps) {
    AppListItem* item =
        GetAppListTestHelper()->model()->CreateAndPopulateFolderWithApps(
            num_apps);
    return item;
  }

  AppListItemView::DragState GetDragState(AppListItemView* view) {
    return view->drag_state_;
  }

  bool IsIconScaled(AppListItemView* view) { return view->icon_scale_ != 1.0f; }

  void SetAppListItemViewForTest(AppListItemView* view) { drag_view_ = view; }

  void MaybeCheckDragStartedOnControllerCount(int count) {
    if (IsUsingDragDropController()) {
      EXPECT_EQ(count, drag_started_on_controller_);
    }
  }

  bool IsUsingDragDropController() { return GetParam(); }

  int drag_started_on_controller_ = 0;
  raw_ptr<AppListItemView, DanglingUntriaged> drag_view_;
  base::test::ScopedFeatureList scoped_feature_list_;
};
INSTANTIATE_TEST_SUITE_P(All, AppListItemViewTest, testing::Bool());

using AppListItemViewTestWithDragDropController = AppListItemViewTest;

INSTANTIATE_TEST_SUITE_P(All,
                         AppListItemViewTestWithDragDropController,
                         testing::Values(true));

TEST_P(AppListItemViewTest, NewInstallDot) {
  AppListItem* item = CreateAppListItem("Google Buzz");
  ASSERT_FALSE(item->is_new_install());

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();
  ui::AXNodeData node_data;

  // By default, the new install dot is not visible.
  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* item_view = apps_grid_view->GetItemViewAt(0);
  views::View* new_install_dot = GetNewInstallDot(item_view);
  ASSERT_TRUE(new_install_dot);
  EXPECT_FALSE(new_install_dot->GetVisible());
  EXPECT_EQ(item_view->GetTooltipText({}), u"Google Buzz");
  EXPECT_EQ(item_view->GetViewAccessibility().GetCachedDescription(), u"");

  // When the app is a new install the dot is visible and the tooltip changes.
  item->SetIsNewInstall(true);
  EXPECT_TRUE(new_install_dot->GetVisible());
  EXPECT_EQ(item_view->GetTooltipText({}), u"Google Buzz\nNew install");

  EXPECT_EQ(item_view->GetViewAccessibility().GetCachedDescription(),
            l10n_util::GetStringUTF16(
                IDS_APP_LIST_NEW_INSTALL_ACCESSIBILE_DESCRIPTION));
}

TEST_P(AppListItemViewTest, LabelInsetWithNewInstallDot) {
  AppListItem* long_item = CreateAppListItem("Very very very very long name");
  long_item->SetIsNewInstall(true);
  AppListItem* short_item = CreateAppListItem("Short");
  short_item->SetIsNewInstall(true);

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();

  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* long_item_view = apps_grid_view->GetItemViewAt(0);
  AppListItemView* short_item_view = apps_grid_view->GetItemViewAt(1);

  // The item with the long name has its title bounds left edge inset to make
  // space for the blue dot.
  EXPECT_LT(long_item_view->GetDefaultTitleBoundsForTest().x(),
            long_item_view->title()->x());

  // The item with the short name does not have the title bounds inset, because
  // there is enough space for the blue dot as-is.
  EXPECT_EQ(short_item_view->GetDefaultTitleBoundsForTest(),
            short_item_view->title()->bounds());
}

TEST_P(AppListItemViewTest, AppItemReleaseTouchBeforeTimerFires) {
  CreateAppListItem("TestItem");

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();

  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* view = apps_grid_view->GetItemViewAt(0);
  auto* generator = GetEventGenerator();
  ASSERT_EQ(GetDragState(view), AppListItemView::DragState::kNone);

  SetAppListItemViewForTest(view);

  gfx::Point from = view->GetBoundsInScreen().CenterPoint();
  generator->MoveTouch(from);
  generator->PressTouch();
  EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kNone);

  generator->ReleaseTouch();
  EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kNone);
  EXPECT_FALSE(view->FireTouchDragTimerForTest());
  EXPECT_FALSE(IsIconScaled(view));
  MaybeCheckDragStartedOnControllerCount(0);
}

TEST_P(AppListItemViewTest, AppItemDragStateChange) {
  CreateAppListItem("TestItem");

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();

  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* view = apps_grid_view->GetItemViewAt(0);
  auto* generator = GetEventGenerator();
  ASSERT_EQ(GetDragState(view), AppListItemView::DragState::kNone);

  SetAppListItemViewForTest(view);

  gfx::Point from = view->GetBoundsInScreen().CenterPoint();
  generator->MoveTouch(from);
  generator->PressTouch();
  view->FireTouchDragTimerForTest();
  EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kInitialized);

  generator->MoveTouchBy(10, 10);

  if (!IsUsingDragDropController()) {
    EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kStarted);
    generator->ReleaseTouch();
  }

  EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kNone);
  EXPECT_FALSE(view->FireTouchDragTimerForTest());
  EXPECT_FALSE(IsIconScaled(view));
  MaybeCheckDragStartedOnControllerCount(1);
}

TEST_P(AppListItemViewTest, AppItemDragStateAfterLongPress) {
  CreateAppListItem("TestItem");

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();

  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* view = apps_grid_view->GetItemViewAt(0);
  auto* generator = GetEventGenerator();
  ASSERT_EQ(GetDragState(view), AppListItemView::DragState::kNone);

  SetAppListItemViewForTest(view);

  gfx::Point from = view->GetBoundsInScreen().CenterPoint();
  generator->MoveTouch(from);
  generator->PressTouch();
  view->FireTouchDragTimerForTest();
  EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kInitialized);

  // Verify that actual drag state is not started until the item is moved.
  ui::GestureEvent long_press(
      from.x(), from.y(), 0, ui::EventTimeForNow(),
      ui::GestureEventDetails(ui::EventType::kGestureLongPress));
  generator->Dispatch(&long_press);
  EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kInitialized);

  // After a long press, the first event type is EventType::kGestureScrollBegin
  // but drag does not start until EventType::kGestureScrollUpdate, so do the
  // movement in two steps.
  generator->MoveTouchBy(5, 5);
  generator->MoveTouchBy(5, 5);

  if (!IsUsingDragDropController()) {
    EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kStarted);
    generator->ReleaseTouch();
  }

  EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kNone);
  EXPECT_FALSE(view->FireTouchDragTimerForTest());
  EXPECT_FALSE(IsIconScaled(view));
  MaybeCheckDragStartedOnControllerCount(1);
}

TEST_P(AppListItemViewTest, AppItemReleaseTouchBeforeDragStart) {
  CreateAppListItem("TestItem");

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();

  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* view = apps_grid_view->GetItemViewAt(0);
  auto* generator = GetEventGenerator();
  ASSERT_EQ(GetDragState(view), AppListItemView::DragState::kNone);

  SetAppListItemViewForTest(view);

  gfx::Point from = view->GetBoundsInScreen().CenterPoint();
  generator->MoveTouch(from);
  generator->PressTouch();
  view->FireTouchDragTimerForTest();

  generator->ReleaseTouch();
  EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kNone);
  EXPECT_FALSE(view->FireTouchDragTimerForTest());
  EXPECT_FALSE(IsIconScaled(view));
  MaybeCheckDragStartedOnControllerCount(0);
}

TEST_P(AppListItemViewTest, AppItemReleaseTouchBeforeDragStartWithLongPress) {
  CreateAppListItem("TestItem");

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();

  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* view = apps_grid_view->GetItemViewAt(0);
  auto* generator = GetEventGenerator();
  ASSERT_EQ(GetDragState(view), AppListItemView::DragState::kNone);

  SetAppListItemViewForTest(view);

  gfx::Point from = view->GetBoundsInScreen().CenterPoint();
  generator->MoveTouch(from);
  generator->PressTouch();
  view->FireTouchDragTimerForTest();
  EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kInitialized);

  ui::GestureEvent long_press(
      from.x(), from.y(), 0, ui::EventTimeForNow(),
      ui::GestureEventDetails(ui::EventType::kGestureLongPress));
  generator->Dispatch(&long_press);

  generator->ReleaseTouch();
  EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kNone);
  EXPECT_EQ(drag_started_on_controller_, 0);
  EXPECT_FALSE(view->FireTouchDragTimerForTest());
  EXPECT_FALSE(IsIconScaled(view));
  MaybeCheckDragStartedOnControllerCount(0);
}

TEST_P(AppListItemViewTestWithDragDropController,
       TouchDragAppRemovedDoesNotCrash) {
  CreateAppListItem("TestItem 1");
  CreateAppListItem("TestItem 2");

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();

  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* view = apps_grid_view->GetItemViewAt(0);
  const std::string kDraggedItemId = view->item()->id();
  auto* generator = GetEventGenerator();
  ASSERT_EQ(GetDragState(view), AppListItemView::DragState::kNone);

  const views::ViewModelT<AppListItemView>* view_model =
      apps_grid_view->view_model();
  EXPECT_EQ(view_model->view_size(), 2u);

  gfx::Point from = view->GetBoundsInScreen().CenterPoint();
  generator->MoveTouch(from);
  generator->PressTouch();
  view->FireTouchDragTimerForTest();
  EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kInitialized);

  // Make sure that the item view is deleted after releasing the drag. This
  // should occur within the same thread as the events to force the crash.
  ShellTestApi().drag_drop_controller()->SetLoopClosureForTesting(
      base::BindLambdaForTesting([&]() {
        drag_started_on_controller_++;
        GetEventGenerator()->ReleaseTouch();
        GetAppListTestHelper()->model()->DeleteItem(kDraggedItemId);
      }),
      base::DoNothing());

  generator->MoveTouch(
      apps_grid_view->GetItemViewAt(1)->GetIconBoundsInScreen().CenterPoint());

  EXPECT_EQ(view_model->view_size(), 1u);
  EXPECT_FALSE(GetAppListTestHelper()->model()->FindItem(kDraggedItemId));
  MaybeCheckDragStartedOnControllerCount(1);
}

TEST_P(AppListItemViewTestWithDragDropController,
       AppListFolderLabelShowsAfterMouseClick) {
  CreateFolderItem(2);

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();

  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* view = apps_grid_view->GetItemViewAt(0);
  EXPECT_TRUE(view->title()->GetVisible());

  auto* generator = GetEventGenerator();

  // Press folder icon to open.
  gfx::Point folder_center = view->GetBoundsInScreen().CenterPoint();
  generator->MoveMouseTo(folder_center);
  generator->ClickLeftButton();
  EXPECT_TRUE(helper->IsInFolderView());

  // Attempt to fire the mouse drag timer. The view should've stopped after
  // releasing the click.
  EXPECT_FALSE(view->FireMouseDragTimerForTest());

  // Press ESC key to close the folder grid.
  generator->PressKey(ui::VKEY_ESCAPE, 0);
  EXPECT_FALSE(helper->IsInFolderView());

  EXPECT_TRUE(view->title()->GetVisible());
}

TEST_P(AppListItemViewTestWithDragDropController,
       AppItemDragStateResetsAfterDrag) {
  CreateAppListItem("TestItem 1");

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();

  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* view = apps_grid_view->GetItemViewAt(0);
  auto* generator = GetEventGenerator();
  ASSERT_EQ(GetDragState(view), AppListItemView::DragState::kNone);

  SetAppListItemViewForTest(view);

  gfx::Point from = view->GetBoundsInScreen().CenterPoint();
  generator->MoveTouch(from);
  generator->PressTouch();
  view->FireTouchDragTimerForTest();
  EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kInitialized);

  // Make sure that the item view has a started drag state during drag.
  ShellTestApi().drag_drop_controller()->SetLoopClosureForTesting(
      base::BindLambdaForTesting([&]() {
        drag_started_on_controller_++;
        generator->MoveTouchBy(10, 10);
        EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kStarted);
        generator->MoveMouseTo(apps_grid_view->GetBoundsInScreen().top_right());
        generator->MoveTouchBy(10, 10);
        EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kStarted);
        helper->Dismiss();
        EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kStarted);
        generator->ReleaseTouch();
      }),
      base::DoNothing());

  generator->MoveTouchBy(10, 10);

  EXPECT_EQ(GetDragState(view), AppListItemView::DragState::kNone);
  EXPECT_FALSE(view->FireTouchDragTimerForTest());
  EXPECT_FALSE(IsIconScaled(view));
  MaybeCheckDragStartedOnControllerCount(1);
}

TEST_P(AppListItemViewTest, AppStatusReflectsOnProgressIndicator) {
  AppListItem* item = CreatePromiseAppListItem("TestItem 1");

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();

  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* view = apps_grid_view->GetItemViewAt(0);

  // Promise apps are created with app_status kPending.
  ProgressIndicator* progress_indicator = view->GetProgressIndicatorForTest();

  EXPECT_EQ(view->item()->progress(), -1.0f);
  ProgressIndicatorWaiter().WaitForProgress(progress_indicator, 0.0f);

  // Change app status to installing and send a progress update. Verify that the
  // progress indicator correctly reflects the progress.
  item->SetAppStatus(AppStatus::kInstalling);
  item->SetProgress(0.3f);
  EXPECT_EQ(view->item()->progress(), 0.3f);
  ProgressIndicatorWaiter().WaitForProgress(progress_indicator, 0.3f);

  // Change app status back to pending state. Verify that even if the item had
  // progress previously associated to it, the progress indicator reflects as
  // 0 progress since it is pending.
  item->SetAppStatus(AppStatus::kPending);
  EXPECT_EQ(view->item()->progress(), 0.3f);
  ProgressIndicatorWaiter().WaitForProgress(progress_indicator, 0.0f);

  // Send another progress update. Since the app status is still pending, the
  // progress indicator still be 0
  item->SetProgress(0.8f);
  EXPECT_EQ(view->item()->progress(), 0.8f);
  ProgressIndicatorWaiter().WaitForProgress(progress_indicator, 0.0f);

  // Set the last status update to kInstallSuccess as if the app had finished
  // installing.
  item->SetAppStatus(AppStatus::kInstallSuccess);

  // No crash.
}

TEST_P(AppListItemViewTest, AccessibleDescription) {
  AppListItem* item = CreatePromiseAppListItem("TestItem 1");

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();

  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* view = apps_grid_view->GetItemViewAt(0);

  EXPECT_EQ(view->GetViewAccessibility().GetCachedDescription(), u"");

  // Promise apps are created with app_status kPending.
  ProgressIndicator* progress_indicator = view->GetProgressIndicatorForTest();
  ProgressIndicatorWaiter().WaitForProgress(progress_indicator, 0.0f);

  item->SetAppStatus(AppStatus::kBlocked);
  EXPECT_EQ(view->GetViewAccessibility().GetCachedDescription(),
            l10n_util::GetStringUTF16(IDS_APP_LIST_BLOCKED_APP));

  item->SetAppStatus(AppStatus::kPaused);
  EXPECT_EQ(view->GetViewAccessibility().GetCachedDescription(),
            l10n_util::GetStringUTF16(IDS_APP_LIST_PAUSED_APP));

  item->SetAppStatus(AppStatus::kInstalling);
  EXPECT_EQ(view->GetViewAccessibility().GetCachedDescription(), u"");
}

TEST_P(AppListItemViewTest, FolderItemAccessibleDescription) {
  AppListItem* item = CreateFolderItem(2);

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();

  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* view = apps_grid_view->GetItemViewAt(0);

  item->SetAppStatus(AppStatus::kInstalling);
  item->SetProgress(0.3f);
  EXPECT_EQ(view->GetViewAccessibility().GetCachedDescription(),
            l10n_util::GetPluralStringFUTF16(
                IDS_APP_LIST_FOLDER_NUMBER_OF_APPS_ACCESSIBILE_DESCRIPTION, 2));
}

TEST_P(AppListItemViewTest, UpdateProgressOnPromiseIcon) {
  AppListItem* item = CreatePromiseAppListItem("TestItem 1");

  auto* helper = GetAppListTestHelper();
  helper->ShowAppList();

  auto* apps_grid_view = helper->GetScrollableAppsGridView();
  AppListItemView* view = apps_grid_view->GetItemViewAt(0);

  // Start install progress bar.
  item->SetAppStatus(AppStatus::kInstalling);
  item->SetProgress(0.f);
  ProgressIndicator* progress_indicator = view->GetProgressIndicatorForTest();

  EXPECT_EQ(view->item()->progress(), 0.f);
  ProgressIndicatorWaiter().WaitForProgress(progress_indicator, 0.0f);

  item->SetProgress(0.3f);
  EXPECT_EQ(view->item()->progress(), 0.3f);
  ProgressIndicatorWaiter().WaitForProgress(progress_indicator, 0.3f);

  item->SetProgress(0.7f);
  EXPECT_EQ(view->item()->progress(), 0.7f);
  ProgressIndicatorWaiter().WaitForProgress(progress_indicator, 0.7f);

  item->SetProgress(1.5f);
  EXPECT_EQ(view->item()->progress(), 1.5f);
  ProgressIndicatorWaiter().WaitForProgress(progress_indicator, 1.0f);
}

}  // namespace ash