chromium/ash/frame/multitask_menu_nudge_controller_unittest.cc

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

#include "chromeos/ui/frame/multitask_menu/multitask_menu_nudge_controller.h"

#include "ash/constants/ash_features.h"
#include "ash/constants/ash_pref_names.h"
#include "ash/constants/notifier_catalogs.h"
#include "ash/display/display_move_window_util.h"
#include "ash/frame/multitask_menu_nudge_delegate_ash.h"
#include "ash/frame/non_client_frame_view_ash.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shell.h"
#include "ash/test/ash_test_base.h"
#include "ash/test/ash_test_util.h"
#include "ash/wm/desks/desk.h"
#include "ash/wm/desks/desks_test_util.h"
#include "ash/wm/splitview/split_view_controller.h"
#include "ash/wm/tablet_mode/tablet_mode_controller_test_api.h"
#include "ash/wm/tablet_mode/tablet_mode_multitask_cue_controller.h"
#include "ash/wm/tablet_mode/tablet_mode_multitask_menu_controller.h"
#include "ash/wm/tablet_mode/tablet_mode_window_manager.h"
#include "ash/wm/window_state.h"
#include "ash/wm/wm_event.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/simple_test_clock.h"
#include "chromeos/ui/base/nudge_util.h"
#include "chromeos/ui/frame/caption_buttons/frame_caption_button_container_view.h"
#include "chromeos/ui/frame/immersive/immersive_fullscreen_controller.h"
#include "chromeos/ui/frame/immersive/immersive_fullscreen_controller_test_api.h"
#include "chromeos/ui/frame/multitask_menu/multitask_button.h"
#include "chromeos/ui/frame/multitask_menu/multitask_menu.h"
#include "chromeos/ui/frame/multitask_menu/multitask_menu_view_test_api.h"
#include "components/user_manager/fake_user_manager.h"
#include "components/user_manager/scoped_user_manager.h"
#include "ui/aura/window.h"
#include "ui/compositor/scoped_animation_duration_scale_mode.h"
#include "ui/display/screen.h"
#include "ui/gfx/geometry/size.h"
#include "ui/wm/core/window_util.h"

namespace ash {

namespace {

// Returns the nudge controller associated with `window`.
chromeos::MultitaskMenuNudgeController* GetNudgeControllerForWindow(
    aura::Window* window) {
  if (display::Screen::GetScreen()->InTabletMode()) {
    return TabletModeControllerTestApi()
        .tablet_mode_window_manager()
        ->tablet_mode_multitask_menu_controller()
        ->multitask_cue_controller()
        ->nudge_controller_for_testing();
  }

  if (auto* frame = NonClientFrameViewAsh::Get(window)) {
    return chromeos::FrameCaptionButtonContainerView::TestApi(
               frame->GetHeaderView()->caption_button_container())
        .nudge_controller();
  }

  return nullptr;
}

}  // namespace

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

  views::Widget* GetNudgeWidgetForWindow(aura::Window* window) {
    chromeos::MultitaskMenuNudgeController* controller =
        GetNudgeControllerForWindow(window);
    return controller ? controller->nudge_widget_.get() : nullptr;
  }

  void FireDismissNudgeTimer(aura::Window* window) {
    if (chromeos::MultitaskMenuNudgeController* controller =
            GetNudgeControllerForWindow(window)) {
      controller->clamshell_nudge_dismiss_timer_.FireNow();
    }
  }

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

    chromeos::MultitaskMenuNudgeController::SetSuppressNudgeForTesting(false);
    chromeos::MultitaskMenuNudgeController::SetOverrideClockForTesting(
        &test_clock_);

    // Advance the test clock so we aren't at zero time.
    test_clock_.Advance(base::Hours(50));
  }

  void TearDown() override {
    chromeos::MultitaskMenuNudgeController::SetOverrideClockForTesting(nullptr);

    AshTestBase::TearDown();
  }

 protected:
  // Tests that the tablet mode nudge bounds in screen are correct.
  void ExpectCorrectTabletNudgeBounds(aura::Window* window) {
    const gfx::Size size =
        GetNudgeWidgetForWindow(window)->GetContentsView()->GetPreferredSize();
    const auto window_screen_bounds = window->GetBoundsInScreen();
    const int tablet_nudge_y_offset =
        MultitaskMenuNudgeDelegateAsh::kTabletNudgeAdditionalYOffset +
        TabletModeMultitaskCueController::kCueHeight +
        TabletModeMultitaskCueController::kCueYOffset;
    const gfx::Rect expected_bounds(
        (window_screen_bounds.width() - size.width()) / 2 +
            window_screen_bounds.x(),
        tablet_nudge_y_offset + window_screen_bounds.y(), size.width(),
        size.height());
    EXPECT_EQ(expected_bounds,
              GetNudgeWidgetForWindow(window)->GetWindowBoundsInScreen());
  }

  base::SimpleTestClock test_clock_;
};

// Tests that there is no crash after toggling fullscreen on and off. Regression
// test for https://crbug.com/1341142.
TEST_F(MultitaskMenuNudgeControllerTest, NoCrashAfterFullscreening) {
  auto window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));

  // Turn of animations for immersive mode, so we don't have to wait for the top
  // container to hide on fullscreen.
  auto* immersive_controller = chromeos::ImmersiveFullscreenController::Get(
      views::Widget::GetWidgetForNativeView(window.get()));
  chromeos::ImmersiveFullscreenControllerTestApi(immersive_controller)
      .SetupForTest();

  const WMEvent event(WM_EVENT_TOGGLE_FULLSCREEN);
  WindowState::Get(window.get())->OnWMEvent(&event);
  EXPECT_FALSE(GetNudgeWidgetForWindow(window.get()));

  // Window needs to be immersive enabled, but not revealed for the bug to
  // reproduce.
  ASSERT_TRUE(immersive_controller->IsEnabled());
  ASSERT_FALSE(immersive_controller->IsRevealed());

  WindowState::Get(window.get())->OnWMEvent(&event);
  EXPECT_FALSE(GetNudgeWidgetForWindow(window.get()));
}

// Tests that there is no crash after floating a window via the multitask menu.
// Regression test for http://b/265189622.
TEST_F(MultitaskMenuNudgeControllerTest,
       NoCrashAfterFloatingFromMultitaskMenu) {
  auto window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));

  // Float the window from the multitask menu. Floating the window using the
  // accelerator does not cause the crash mentioned in the bug because the
  // presence of the multitask menu causes an activation change which leads to
  // restacking that does not happen otherwise.
  chromeos::MultitaskMenu* multitask_menu =
      ShowAndWaitMultitaskMenuForWindow(window.get());

  // After floating the window from the multitask menu, there is no crash.
  LeftClickOn(
      chromeos::MultitaskMenuViewTestApi(multitask_menu->multitask_menu_view())
          .GetFloatButton());
  EXPECT_TRUE(WindowState::Get(window.get())->IsFloated());
}

// Tests that there is no crash after entering tablet mode with the multitask
// menu created on the secondary display. Regression test for
// http://b/278165707.
TEST_F(MultitaskMenuNudgeControllerTest,
       NoCrashAfterEnterTabletFromMultidisplay) {
  UpdateDisplay("800x600,801+0-800x600");

  auto window = CreateAppWindow(gfx::Rect(900, 0, 300, 300));
  ASSERT_EQ(Shell::GetAllRootWindows()[1], window->GetRootWindow());

  // Ensure that the clamshell nudge is closed and advance the clock so that the
  // tablet one will show.
  FireDismissNudgeTimer(window.get());
  test_clock_.Advance(base::Hours(26));

  // We use non zero duration since we want to mimic real behavior of stacking
  // order changed on `window` before tablet mode is entered.
  ui::ScopedAnimationDurationScaleMode scale_mode(
      ui::ScopedAnimationDurationScaleMode::NON_ZERO_DURATION);
  TabletModeControllerTestApi().EnterTabletMode();
}

// Tests that there is no crash after a window is placed such that the nudge
// widget should be offscreen. Regression test for http://b/282994793.
TEST_F(MultitaskMenuNudgeControllerTest,
       NoCrashAfterActivatingMostlyOffscreenWindowMultidisplay) {
  // Crash is multidisplay related since it involves switching root windows.
  UpdateDisplay("1600x1000,1601+0-1200x1000");

  // Create two windows so we can reactivate `window2` to simulate the crash
  // because the window manager will shift `window2` onscreen if we try to
  // create it offscreen directly.
  auto window1 = CreateAppWindow(gfx::Rect(300, 300));
  auto window2 = CreateAppWindow(gfx::Rect(1000, 300));

  // Place `window2` mostly offscreen on primary display, such that on
  // activation, the nudge widget should not be seen.
  window2->SetBounds(gfx::Rect(1400, 0, 1000, 300));
  ASSERT_EQ(Shell::GetAllRootWindows()[0], window2->GetRootWindow());

  // The nudge widget was shown on `window1` since it was created first. Dismiss
  // it and advance the clock so it will show on the next window activation.
  ASSERT_TRUE(GetNudgeWidgetForWindow(window1.get()));
  FireDismissNudgeTimer(window1.get());
  wm::ActivateWindow(window1.get());
  test_clock_.Advance(base::Hours(26));

  // Activate `window2`. Verify that the nudge widget is not created since the
  // anchor is invisible.
  wm::ActivateWindow(window2.get());
  EXPECT_FALSE(GetNudgeWidgetForWindow(window2.get()));
}

TEST_F(MultitaskMenuNudgeControllerTest, NudgeTimeout) {
  auto window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));

  FireDismissNudgeTimer(window.get());
  EXPECT_FALSE(GetNudgeWidgetForWindow(window.get()));
}

TEST_F(MultitaskMenuNudgeControllerTest, NoNudgeForNewUser) {
  chromeos::MultitaskMenuNudgeController::SetSuppressNudgeForTesting(false);

  user_manager::TypedScopedUserManager<user_manager::FakeUserManager>
      fake_user_manager{std::make_unique<user_manager::FakeUserManager>()};
  fake_user_manager->SetIsCurrentUserNew(true);

  auto window = CreateAppWindow(gfx::Rect(300, 300));
  EXPECT_FALSE(GetNudgeWidgetForWindow(window.get()));
}

TEST_F(MultitaskMenuNudgeControllerTest, Metrics) {
  base::HistogramTester histogram_tester;

  // Create and activate a window. Test the histogram is recorded.
  auto window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));
  EXPECT_EQ(1, histogram_tester.GetBucketCount(
                   chromeos::kNotifierFrameworkNudgeShownCountHistogram,
                   NudgeCatalogName::kMultitaskMenuClamshell));

  // Simulate opening the multitask menu within 1 minute. Test that the
  // "Within1m" histogram is recorded.
  test_clock_.Advance(base::Seconds(50));
  GetNudgeControllerForWindow(window.get())
      ->OnMenuOpened(/*tablet_mode=*/false);

  const std::string kHistogramPrefix =
      "Ash.NotifierFramework.Nudge.TimeToAction.";
  base::HistogramTester::CountsMap expected_counts;
  expected_counts[kHistogramPrefix + "Within1m"] = 1;
  EXPECT_THAT(histogram_tester.GetTotalCountsForPrefix(kHistogramPrefix),
              testing::ContainerEq(expected_counts));

  // Once the user opens the multitask menu, the nudge is no longer shown.
  // Forcefully reset it so we can proceed to the next test. Also advance the
  // clock as the nudge only shows after 24 hours have elapsed.
  Shell::Get()->session_controller()->GetActivePrefService()->SetInteger(
      prefs::kMultitaskMenuNudgeClamshellShownCount, 0);
  test_clock_.Advance(base::Days(2));

  // Destroy the window and recreate and activate it.
  window.reset();
  window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));
  EXPECT_EQ(2, histogram_tester.GetBucketCount(
                   chromeos::kNotifierFrameworkNudgeShownCountHistogram,
                   NudgeCatalogName::kMultitaskMenuClamshell));

  // Simulate opening the multitask menu within 1 hour. Test that the "Within1h"
  // histogram is recorded.
  test_clock_.Advance(base::Minutes(50));
  GetNudgeControllerForWindow(window.get())
      ->OnMenuOpened(/*tablet_mode=*/false);
  expected_counts[kHistogramPrefix + "Within1h"] = 1;
  EXPECT_THAT(histogram_tester.GetTotalCountsForPrefix(kHistogramPrefix),
              testing::ContainerEq(expected_counts));

  Shell::Get()->session_controller()->GetActivePrefService()->SetInteger(
      prefs::kMultitaskMenuNudgeClamshellShownCount, 0);
  test_clock_.Advance(base::Days(2));

  window.reset();
  window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));
  EXPECT_EQ(3, histogram_tester.GetBucketCount(
                   chromeos::kNotifierFrameworkNudgeShownCountHistogram,
                   NudgeCatalogName::kMultitaskMenuClamshell));

  // Simulate opening the multitask menu after a long time. Test that the
  // "WithinSession" histogram is recorded.
  test_clock_.Advance(base::Hours(50));
  GetNudgeControllerForWindow(window.get())
      ->OnMenuOpened(/*tablet_mode=*/false);
  expected_counts[kHistogramPrefix + "WithinSession"] = 1;
  EXPECT_THAT(histogram_tester.GetTotalCountsForPrefix(kHistogramPrefix),
              testing::ContainerEq(expected_counts));
}

// Tests that the nudge bounds is within display bounds when the associated
// window is maximized.
TEST_F(MultitaskMenuNudgeControllerTest, ClamshellNudgeBounds) {
  auto window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));

  WindowState::Get(window.get())->Maximize();
  auto* nudge_widget = GetNudgeWidgetForWindow(window.get());
  ASSERT_TRUE(nudge_widget);
  EXPECT_TRUE(display::Screen::GetScreen()
                  ->GetDisplayNearestView(window.get())
                  .work_area()
                  .Contains(nudge_widget->GetWindowBoundsInScreen()));

  // Cleanup some state for the next test.
  FireDismissNudgeTimer(window.get());
  window.reset();
  test_clock_.Advance(base::Hours(26));

  // Test the same thing in RTL.
  base::i18n::SetRTLForTesting(true);
  window = CreateAppWindow(gfx::Rect(300, 300));
  WindowState::Get(window.get())->Maximize();
  nudge_widget = GetNudgeWidgetForWindow(window.get());
  ASSERT_TRUE(nudge_widget);
  EXPECT_TRUE(display::Screen::GetScreen()
                  ->GetDisplayNearestView(window.get())
                  .work_area()
                  .Contains(nudge_widget->GetWindowBoundsInScreen()));
}

TEST_F(MultitaskMenuNudgeControllerTest, NudgeMultiDisplay) {
  UpdateDisplay("800x700,801+0-800x700");
  ASSERT_EQ(2u, Shell::GetAllRootWindows().size());

  auto window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));

  // Move the window using the shortcut. Test that the nudge is on the correct
  // display.
  display_move_window_util::HandleMoveActiveWindowBetweenDisplays();
  EXPECT_EQ(Shell::GetAllRootWindows()[1], GetNudgeWidgetForWindow(window.get())
                                               ->GetNativeWindow()
                                               ->GetRootWindow());

  // Drag from the caption the window to the other display. The nudge should be
  // gone, but there is no crash.
  display_move_window_util::HandleMoveActiveWindowBetweenDisplays();
  auto* event_generator = GetEventGenerator();
  event_generator->set_current_screen_location(gfx::Point(150, 10));
  event_generator->PressLeftButton();
  event_generator->MoveMouseTo(gfx::Point(1200, 0));
  EXPECT_FALSE(GetNudgeWidgetForWindow(window.get()));
}

// Tests that based on preferences (shown count, and last shown time), the nudge
// may or may not be shown.
TEST_F(MultitaskMenuNudgeControllerTest, NudgePreferences) {
  auto window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));
  FireDismissNudgeTimer(window.get());
  ASSERT_FALSE(GetNudgeWidgetForWindow(window.get()));

  // Create the window. This does not show the nudge as 24 hours have not
  // elapsed since the nudge was shown.
  window.reset();
  window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_FALSE(GetNudgeWidgetForWindow(window.get()));

  // Create the window again after waiting 25 hours. The nudge should now show
  // for the second time.
  test_clock_.Advance(base::Hours(25));
  window.reset();
  window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));
  FireDismissNudgeTimer(window.get());
  ASSERT_FALSE(GetNudgeWidgetForWindow(window.get()));

  // Show the nudge for a third time. This will be the last time it is shown.
  test_clock_.Advance(base::Hours(25));
  window.reset();
  window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));
  FireDismissNudgeTimer(window.get());
  ASSERT_FALSE(GetNudgeWidgetForWindow(window.get()));

  // Advance the clock and attempt to show the nudge for a fourth time. Verify
  // that it will not show.
  test_clock_.Advance(base::Hours(25));
  window.reset();
  window = CreateAppWindow(gfx::Rect(300, 300));
  EXPECT_FALSE(GetNudgeWidgetForWindow(window.get()));
}

// Tests that after the multitask menu is shown, the nudge does not show
// anymore.
TEST_F(MultitaskMenuNudgeControllerTest, MenuShown) {
  // Create a window, the nudge is shown on new window activation.
  auto window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));

  // When opening the multitask menu, the nudge should dismiss immediately.
  std::ignore = ShowAndWaitMultitaskMenuForWindow(window.get());
  EXPECT_FALSE(GetNudgeWidgetForWindow(window.get()));

  // Advance the clock and then destroy the window and create a new window.
  // Test that the nudge does not show up.
  test_clock_.Advance(base::Hours(25));
  window.reset();
  window = CreateAppWindow(gfx::Rect(300, 300));
  EXPECT_FALSE(GetNudgeWidgetForWindow(window.get()));
}

// Tests that the nudge gets properly hidden after switching desks with a
// floated window. Regression test for http://b/276786909.
TEST_F(MultitaskMenuNudgeControllerTest, FloatedWindowNudge) {
  // Create a new desk.
  NewDesk();
  ASSERT_TRUE(DesksController::Get()->desks()[0]->is_active());

  // Create a floated window, the nudge is shown on new window activation.
  auto window = CreateAppWindow(gfx::Rect(300, 300));
  PressAndReleaseKey(ui::VKEY_F, ui::EF_ALT_DOWN | ui::EF_COMMAND_DOWN);
  ASSERT_TRUE(WindowState::Get(window.get())->IsFloated());
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));

  ActivateDesk(DesksController::Get()->desks()[1].get());
  EXPECT_FALSE(GetNudgeWidgetForWindow(window.get()));
}

// Tests that the nudge works in tablet mode, and that its bounds in screen are
// correct.
TEST_F(MultitaskMenuNudgeControllerTest, TabletNudgeBounds) {
  TabletModeControllerTestApi().EnterTabletMode();

  // The widget should appear the first time a window is activated.
  auto window = CreateAppWindow();
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));

  // Test that the widget is shown at the correct bounds when the window is
  // first created.
  ExpectCorrectTabletNudgeBounds(window.get());

  auto* split_view_controller =
      SplitViewController::Get(Shell::GetPrimaryRootWindow());

  // Tests that the widget is shown at the correct bounds when the window is
  // snapped in the primary position.
  split_view_controller->SnapWindow(window.get(), SnapPosition::kPrimary);
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));
  ExpectCorrectTabletNudgeBounds(window.get());

  // Tests that the widget is shown at the correct bounds when the window is
  // snapped in the secondary position.
  split_view_controller->SnapWindow(window.get(), SnapPosition::kSecondary);
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));
  ExpectCorrectTabletNudgeBounds(window.get());
}

// Tests that if a window gets destroyed while the nduge is showing in tablet
// mode, the nudge disappears and there is no crash.
TEST_F(MultitaskMenuNudgeControllerTest, TabletWindowDestroyedWhileNudgeShown) {
  TabletModeControllerTestApi().EnterTabletMode();

  auto window = CreateAppWindow(gfx::Rect(300, 300));
  ASSERT_TRUE(GetNudgeWidgetForWindow(window.get()));

  window.reset();
  EXPECT_FALSE(GetNudgeWidgetForWindow(window.get()));
}

}  // namespace ash