// Copyright 2023 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/tray/tray_item_view.h"
#include "ash/system/tray/tray_constants.h"
#include "ash/test/ash_test_base.h"
#include "ash/test/ash_test_util.h"
#include "base/memory/raw_ptr.h"
#include "base/run_loop.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/task_environment.h"
#include "third_party/skia/include/core/SkColor.h"
#include "ui/compositor/layer.h"
#include "ui/compositor/scoped_animation_duration_scale_mode.h"
#include "ui/compositor/test/test_utils.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/widget/widget.h"
namespace ash {
namespace {
constexpr char kShowAnimationSmoothnessHistogramName[] =
"Ash.StatusArea.TrayItemView.Show";
constexpr char kHideAnimationSmoothnessHistogramName[] =
"Ash.StatusArea.TrayItemView.Hide";
} // namespace
// A class that can be used to wait for the given `TrayItemView`'s visibility
// animation to finish (`TrayItemView` does not currently use layer animations,
// so we can't just use `ui::LayerAnimationStoppedWaiter`).
class TrayItemViewAnimationWaiter {
public:
explicit TrayItemViewAnimationWaiter(TrayItemView* tray_item)
: tray_item_(tray_item) {}
TrayItemViewAnimationWaiter(const TrayItemViewAnimationWaiter&) = delete;
TrayItemViewAnimationWaiter& operator=(const TrayItemViewAnimationWaiter&) =
delete;
~TrayItemViewAnimationWaiter() = default;
// Waits for `tray_item_`'s visibility animation to finish, or no-op if it is
// not currently animating.
void Wait() {
if (tray_item_->IsAnimating()) {
tray_item_->SetAnimationIdleClosureForTest(base::BindOnce(
&TrayItemViewAnimationWaiter::OnTrayItemAnimationFinished,
weak_ptr_factory_.GetWeakPtr()));
run_loop_.Run();
}
}
private:
// Called when `tray_item_`'s visibility animation finishes.
void OnTrayItemAnimationFinished() { run_loop_.Quit(); }
// The tray item whose animation is being waited for. Owned by the views
// hierarchy.
raw_ptr<TrayItemView> tray_item_ = nullptr;
base::RunLoop run_loop_;
base::WeakPtrFactory<TrayItemViewAnimationWaiter> weak_ptr_factory_{this};
};
class TestTrayItemView : public TrayItemView {
public:
explicit TestTrayItemView(Shelf* shelf) : TrayItemView(shelf) {}
TestTrayItemView(const TestTrayItemView&) = delete;
TestTrayItemView& operator=(const TestTrayItemView&) = delete;
~TestTrayItemView() override = default;
// TrayItemView:
void HandleLocaleChange() override {}
};
class TrayItemViewTest : public AshTestBase {
public:
TrayItemViewTest()
: AshTestBase(base::test::TaskEnvironment::TimeSource::MOCK_TIME) {}
TrayItemViewTest(const TrayItemViewTest&) = delete;
TrayItemViewTest& operator=(const TrayItemViewTest&) = delete;
~TrayItemViewTest() override = default;
void SetUp() override {
AshTestBase::SetUp();
// Create a hosting widget with non empty bounds so that it actually draws.
widget_ = CreateFramelessTestWidget();
widget_->SetBounds(gfx::Rect(0, 0, 100, 80));
widget_->Show();
tray_item_ = widget_->SetContentsView(
std::make_unique<TestTrayItemView>(GetPrimaryShelf()));
tray_item_->CreateImageView();
tray_item_->SetVisible(true);
// Warms up the compositor so that UI changes are picked up in time before
// throughput tracker is stopped.
ui::Compositor* const compositor =
tray_item()->GetWidget()->GetCompositor();
compositor->ScheduleFullRedraw();
ASSERT_TRUE(ui::WaitForNextFrameToBePresented(compositor));
}
void TearDown() override {
widget_.reset();
AshTestBase::TearDown();
}
// Helper function that waits not only for `tray_item()`'s animation to finish
// but also for any animation throughput data to be passed from cc to ui.
void WaitForAnimation() {
TrayItemViewAnimationWaiter waiter(tray_item());
waiter.Wait();
// Force frames and wait for all throughput trackers to be gone to allow
// animation throughput data to be passed from cc to ui.
ui::Compositor* const compositor =
tray_item()->GetWidget()->GetCompositor();
while (compositor->has_throughput_trackers_for_testing()) {
compositor->ScheduleFullRedraw();
std::ignore = ui::WaitForNextFrameToBePresented(compositor,
base::Milliseconds(500));
}
}
// Helper function that waits for `tray_item()` opacity to change to a value
// different from `opacity`.
void WaitForAnimationChangeOpacityFrom(float opacity) {
ASSERT_TRUE(tray_item()->IsAnimating());
ui::Compositor* const compositor =
tray_item()->GetWidget()->GetCompositor();
while (tray_item()->layer()->opacity() == opacity) {
ASSERT_TRUE(ui::WaitForNextFrameToBePresented(compositor));
}
}
views::Widget* widget() { return widget_.get(); }
TrayItemView* tray_item() { return tray_item_; }
protected:
std::unique_ptr<views::Widget> widget_;
// Owned by `widget`:
raw_ptr<TrayItemView, DanglingUntriaged> tray_item_ = nullptr;
};
// Tests that scheduling a `TrayItemView`'s show animation while its hide
// animation is running will stop the hide animation in favor of the show
// animation.
TEST_F(TrayItemViewTest, ShowInterruptsHide) {
ui::ScopedAnimationDurationScaleMode scoped_animation_duration_scale_mode(
ui::ScopedAnimationDurationScaleMode::NON_ZERO_DURATION);
ASSERT_FALSE(tray_item()->IsAnimating());
ASSERT_TRUE(tray_item()->GetVisible());
// Start the tray item's hide animation.
tray_item()->SetVisible(false);
// The tray item should be animating to its hide state.
EXPECT_TRUE(tray_item()->IsAnimating());
EXPECT_FALSE(tray_item()->target_visible_for_testing());
// Interrupt the hide animation with the show animation.
tray_item()->SetVisible(true);
// The tray item should be animating to its show state.
EXPECT_TRUE(tray_item()->IsAnimating());
EXPECT_TRUE(tray_item()->target_visible_for_testing());
}
// Tests that scheduling a `TrayItemView`'s hide animation while its show
// animation is running will stop the show animation in favor of the hide
// animation.
TEST_F(TrayItemViewTest, HideInterruptsShow) {
// Hide the tray item. Note that at this point in the test animations still
// complete immediately.
tray_item()->SetVisible(false);
ASSERT_FALSE(tray_item()->IsAnimating());
ASSERT_FALSE(tray_item()->GetVisible());
// Set the animation duration scale to a non-zero value for the rest of the
// test.
ui::ScopedAnimationDurationScaleMode scoped_animation_duration_scale_mode(
ui::ScopedAnimationDurationScaleMode::NON_ZERO_DURATION);
// Start the tray item's show animation.
tray_item()->SetVisible(true);
// The tray item should be animating to its show state.
EXPECT_TRUE(tray_item()->IsAnimating());
EXPECT_TRUE(tray_item()->target_visible_for_testing());
// Interrupt the show animation with the hide animation.
tray_item()->SetVisible(false);
// The tray item should be animating to its hide state.
EXPECT_TRUE(tray_item()->IsAnimating());
EXPECT_FALSE(tray_item()->target_visible_for_testing());
}
// Regression test for http://b/283494045
TEST_F(TrayItemViewTest, ShowDuringZeroDurationAnimation) {
ui::ScopedAnimationDurationScaleMode duration_scale1(
ui::ScopedAnimationDurationScaleMode::NON_ZERO_DURATION);
// Hide the tray item and wait for animation to complete.
base::RunLoop run_loop1;
tray_item()->SetAnimationIdleClosureForTest(run_loop1.QuitClosure());
tray_item()->SetVisible(false);
run_loop1.Run();
ASSERT_FALSE(tray_item()->IsAnimating());
ASSERT_FALSE(tray_item()->GetVisible());
ASSERT_EQ(tray_item()->layer()->opacity(), 0.0f);
{
// Set animation duration to zero. The screen rotation animation does this,
// but it's hard to get that animation into the correct state in a test.
ui::ScopedAnimationDurationScaleMode duration_scale2(
ui::ScopedAnimationDurationScaleMode::ZERO_DURATION);
// While animations are zero duration, show the item.
base::RunLoop run_loop2;
tray_item()->SetAnimationIdleClosureForTest(run_loop2.QuitClosure());
tray_item()->SetVisible(true);
run_loop2.Run();
}
// The item should be visible and opaque.
EXPECT_TRUE(tray_item()->GetVisible());
EXPECT_EQ(tray_item()->layer()->opacity(), 1.0f);
}
TEST_F(TrayItemViewTest, LargeImageIcon) {
// Use a size that is larger than the default tray icon size.
const int kLargeSize = 24;
static_assert(kLargeSize > kUnifiedTrayIconSize);
// Set the image to a large image.
gfx::Size kLargeImageSize(kLargeSize, kLargeSize);
tray_item()->image_view()->SetImage(
CreateSolidColorTestImage(kLargeImageSize, SK_ColorRED));
// The preferred size is the size of the larger image (which is not the
// default tray icon size, see static_assert above).
EXPECT_EQ(tray_item()->CalculatePreferredSize({}), kLargeImageSize);
}
// Tests that a smoothness metric is recorded for the "show" animation.
TEST_F(TrayItemViewTest, SmoothnessMetricRecordedForShowAnimation) {
// Start with the tray item hidden. Note that animations still complete
// immediately in this part of the test, so no smoothness metrics are emitted.
tray_item()->SetVisible(false);
base::HistogramTester histogram_tester;
histogram_tester.ExpectTotalCount(kHideAnimationSmoothnessHistogramName, 0);
// Set the animation duration scale to a non-zero value for the rest of the
// test. Smoothness metrics should be emitted from this point onward.
ui::ScopedAnimationDurationScaleMode scoped_animation_duration_scale_mode(
ui::ScopedAnimationDurationScaleMode::NON_ZERO_DURATION);
// Start the tray item's "show" animation and wait for it to finish.
tray_item()->SetVisible(true);
WaitForAnimation();
// Verify that the "show" animation's smoothness metric was recorded.
histogram_tester.ExpectTotalCount(kShowAnimationSmoothnessHistogramName, 1);
}
// Tests that a smoothness metric is recorded for the "hide" animation.
TEST_F(TrayItemViewTest, SmoothnessMetricRecordedForHideAnimation) {
base::HistogramTester histogram_tester;
histogram_tester.ExpectTotalCount(kHideAnimationSmoothnessHistogramName, 0);
// Set the animation duration scale to a non-zero value for the rest of the
// test. Smoothness metrics should be emitted from this point onward.
ui::ScopedAnimationDurationScaleMode scoped_animation_duration_scale_mode(
ui::ScopedAnimationDurationScaleMode::NON_ZERO_DURATION);
// Start the tray item's "hide" animation and wait for it to finish.
tray_item()->SetVisible(false);
WaitForAnimation();
// Verify that the "hide" animation's smoothness metric was recorded.
histogram_tester.ExpectTotalCount(kHideAnimationSmoothnessHistogramName, 1);
}
// Tests that the smoothness metric for the "hide" animation is still recorded
// even when the "hide" animation interrupts the "show" animation.
TEST_F(TrayItemViewTest, HideSmoothnessMetricRecordedWhenHideInterruptsShow) {
// Start with the tray item hidden. Note that animations still complete
// immediately in this part of the test, so no smoothness metrics are emitted.
tray_item()->SetVisible(false);
base::HistogramTester histogram_tester;
histogram_tester.ExpectTotalCount(kHideAnimationSmoothnessHistogramName, 0);
// Set the animation duration scale to a non-zero value for the rest of the
// test. Smoothness metrics should be emitted from this point onward.
ui::ScopedAnimationDurationScaleMode scoped_animation_duration_scale_mode(
ui::ScopedAnimationDurationScaleMode::NON_ZERO_DURATION);
// Start the tray item's "show" animation, but interrupt it with the "hide"
// animation. Wait for the "hide" animation to complete.
tray_item()->SetVisible(true);
// Wait for animation to change opacity to actually draw on screen. Otherwise,
// the interrupted animation may end up as a no-op.
WaitForAnimationChangeOpacityFrom(0.0f);
tray_item()->SetVisible(false);
WaitForAnimation();
// Verify that the "hide" animation's smoothness metric was recorded.
histogram_tester.ExpectTotalCount(kHideAnimationSmoothnessHistogramName, 1);
}
// Tests that the smoothness metric for the "show" animation is still recorded
// even when the "show" animation interrupts the "hide" animation.
TEST_F(TrayItemViewTest, ShowSmoothnessMetricRecordedWhenShowInterruptsHide) {
base::HistogramTester histogram_tester;
histogram_tester.ExpectTotalCount(kHideAnimationSmoothnessHistogramName, 0);
// Set the animation duration scale to a non-zero value for the rest of the
// test. Smoothness metrics should be emitted from this point onward.
ui::ScopedAnimationDurationScaleMode scoped_animation_duration_scale_mode(
ui::ScopedAnimationDurationScaleMode::NON_ZERO_DURATION);
// Start the tray item's "hide" animation, but interrupt it with the "show"
// animation. Wait for the "show" animation to complete.
tray_item()->SetVisible(false);
// Wait for animation to change opacity to actually draw on screen. Otherwise,
// the interrupted animation may end up as a no-op.
WaitForAnimationChangeOpacityFrom(1.0f);
tray_item()->SetVisible(true);
WaitForAnimation();
// Verify that the "show" animation's smoothness metric was recorded.
histogram_tester.ExpectTotalCount(kShowAnimationSmoothnessHistogramName, 1);
}
TEST_F(TrayItemViewTest, IconizedLabelAccessibleProperties) {
tray_item()->CreateLabel();
IconizedLabel* label = tray_item()->label();
ui::AXNodeData data;
// Test when custom accessible name is empty.
label->GetViewAccessibility().GetAccessibleNodeData(&data);
EXPECT_EQ(label->GetTextContext(), views::style::CONTEXT_LABEL);
EXPECT_EQ(data.role, ax::mojom::Role::kStaticText);
EXPECT_FALSE(data.HasStringAttribute(ax::mojom::StringAttribute::kName));
label->SetText(u"Sample text");
label->SetTextContext(views::style::CONTEXT_DIALOG_TITLE);
data = ui::AXNodeData();
label->GetViewAccessibility().GetAccessibleNodeData(&data);
EXPECT_EQ(data.role, ax::mojom::Role::kTitleBar);
EXPECT_EQ(data.GetString16Attribute(ax::mojom::StringAttribute::kName),
u"Sample text");
// Test when custom accessible name is not empty.
label->SetCustomAccessibleName(u"Sample name");
label->SetTextContext(views::style::CONTEXT_LABEL);
data = ui::AXNodeData();
label->GetViewAccessibility().GetAccessibleNodeData(&data);
EXPECT_EQ(data.role, ax::mojom::Role::kStaticText);
EXPECT_EQ(data.GetString16Attribute(ax::mojom::StringAttribute::kName),
u"Sample name");
label->SetTextContext(views::style::CONTEXT_DIALOG_TITLE);
label->SetText(u"New sample text");
data = ui::AXNodeData();
label->GetViewAccessibility().GetAccessibleNodeData(&data);
EXPECT_EQ(data.role, ax::mojom::Role::kStaticText);
EXPECT_EQ(data.GetString16Attribute(ax::mojom::StringAttribute::kName),
u"Sample name");
// Test when custom accessible name is again set to empty.
label->SetCustomAccessibleName(u"");
data = ui::AXNodeData();
label->GetViewAccessibility().GetAccessibleNodeData(&data);
EXPECT_EQ(data.role, ax::mojom::Role::kTitleBar);
EXPECT_EQ(data.GetString16Attribute(ax::mojom::StringAttribute::kName),
u"New sample text");
}
} // namespace ash