| // Copyright 2012 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "chrome/browser/ui/views/tabs/tab_strip.h" |
| |
| #include <memory> |
| #include <string> |
| |
| #include "base/feature_list.h" |
| #include "base/functional/bind.h" |
| #include "base/memory/raw_ptr.h" |
| #include "base/test/bind.h" |
| #include "base/test/scoped_feature_list.h" |
| #include "base/test/task_environment.h" |
| #include "base/timer/timer.h" |
| #include "chrome/browser/ui/layout_constants.h" |
| #include "chrome/browser/ui/tabs/features.h" |
| #include "chrome/browser/ui/tabs/tab_renderer_data.h" |
| #include "chrome/browser/ui/tabs/tab_style.h" |
| #include "chrome/browser/ui/ui_features.h" |
| #include "chrome/browser/ui/views/frame/browser_root_view.h" |
| #include "chrome/browser/ui/views/tabs/fake_base_tab_strip_controller.h" |
| #include "chrome/browser/ui/views/tabs/tab.h" |
| #include "chrome/browser/ui/views/tabs/tab_group_header.h" |
| #include "chrome/browser/ui/views/tabs/tab_group_highlight.h" |
| #include "chrome/browser/ui/views/tabs/tab_group_underline.h" |
| #include "chrome/browser/ui/views/tabs/tab_group_views.h" |
| #include "chrome/browser/ui/views/tabs/tab_icon.h" |
| #include "chrome/browser/ui/views/tabs/tab_strip_controller.h" |
| #include "chrome/browser/ui/views/tabs/tab_strip_observer.h" |
| #include "chrome/browser/ui/views/tabs/tab_strip_types.h" |
| #include "chrome/browser/ui/views/tabs/tab_style_views.h" |
| #include "chrome/test/base/testing_profile.h" |
| #include "chrome/test/views/chrome_views_test_base.h" |
| #include "components/data_sharing/public/features.h" |
| #include "components/saved_tab_groups/public/features.h" |
| #include "components/tab_groups/tab_group_id.h" |
| #include "testing/gtest/include/gtest/gtest.h" |
| #include "ui/base/pointer/touch_ui_controller.h" |
| #include "ui/base/ui_base_features.h" |
| #include "ui/events/base_event_utils.h" |
| #include "ui/gfx/animation/animation_test_api.h" |
| #include "ui/gfx/canvas.h" |
| #include "ui/gfx/geometry/rect_conversions.h" |
| #include "ui/gfx/geometry/skia_conversions.h" |
| #include "ui/views/accessibility/view_accessibility.h" |
| #include "ui/views/controls/label.h" |
| #include "ui/views/layout/flex_layout.h" |
| #include "ui/views/test/ax_event_counter.h" |
| #include "ui/views/test/views_test_utils.h" |
| #include "ui/views/view.h" |
| #include "ui/views/view_class_properties.h" |
| #include "ui/views/view_observer.h" |
| #include "ui/views/view_targeter.h" |
| #include "ui/views/view_utils.h" |
| #include "ui/views/widget/widget.h" |
| |
| namespace { |
| |
| struct TabStripUnittestParams { |
| const bool touch_ui; |
| const bool scrolling_enabled; |
| }; |
| |
| constexpr TabStripUnittestParams kTabStripUnittestParams[] = { |
| {false, true}, |
| {false, false}, |
| {true, false}, |
| {true, true}, |
| }; |
| } // namespace |
| |
| class TestTabStripObserver : public TabStripObserver { |
| public: |
| explicit TestTabStripObserver(TabStrip* tab_strip) : tab_strip_(tab_strip) { |
| tab_strip_->SetTabStripObserver(this); |
| } |
| TestTabStripObserver(const TestTabStripObserver&) = delete; |
| TestTabStripObserver& operator=(const TestTabStripObserver&) = delete; |
| ~TestTabStripObserver() override { tab_strip_->SetTabStripObserver(nullptr); } |
| |
| int last_tab_added() const { return last_tab_added_; } |
| int last_tab_removed() const { return last_tab_removed_; } |
| int last_tab_moved_from() const { return last_tab_moved_from_; } |
| int last_tab_moved_to() const { return last_tab_moved_to_; } |
| |
| private: |
| // TabStripObserver overrides. |
| void OnTabAdded(int index) override { last_tab_added_ = index; } |
| |
| void OnTabMoved(int from_index, int to_index) override { |
| last_tab_moved_from_ = from_index; |
| last_tab_moved_to_ = to_index; |
| } |
| |
| void OnTabRemoved(int index) override { last_tab_removed_ = index; } |
| |
| raw_ptr<TabStrip> tab_strip_; |
| int last_tab_added_ = -1; |
| int last_tab_removed_ = -1; |
| int last_tab_moved_from_ = -1; |
| int last_tab_moved_to_ = -1; |
| }; |
| |
| // TabStripTestBase contains no test cases. |
| class TabStripTestBase : public ChromeViewsTestBase { |
| public: |
| TabStripTestBase(bool touch_ui, bool scrolling_enabled) |
| : touch_ui_scoper_(touch_ui), |
| animation_mode_reset_(gfx::AnimationTestApi::SetRichAnimationRenderMode( |
| gfx::Animation::RichAnimationRenderMode::FORCE_ENABLED)) { |
| if (scrolling_enabled) { |
| scoped_feature_list_.InitWithFeatures({tabs::kScrollableTabStrip}, {}); |
| } else { |
| scoped_feature_list_.InitWithFeatures({}, {tabs::kScrollableTabStrip}); |
| } |
| } |
| TabStripTestBase(const TabStripTestBase&) = delete; |
| TabStripTestBase& operator=(const TabStripTestBase&) = delete; |
| ~TabStripTestBase() override = default; |
| |
| void SetUp() override { |
| ChromeViewsTestBase::SetUp(); |
| |
| controller_ = new FakeBaseTabStripController; |
| tab_strip_ = new TabStrip(std::unique_ptr<TabStripController>(controller_)); |
| controller_->set_tab_strip(tab_strip_); |
| // Do this to force TabStrip to create the buttons. |
| auto tab_strip_parent = std::make_unique<views::View>(); |
| views::FlexLayout* layout_manager = tab_strip_parent->SetLayoutManager( |
| std::make_unique<views::FlexLayout>()); |
| // Scale the tabstrip between zero and its preferred width to match the |
| // context it operates in in TabStripRegionView (with tab scrolling off). |
| layout_manager->SetOrientation(views::LayoutOrientation::kHorizontal) |
| .SetDefault( |
| views::kFlexBehaviorKey, |
| views::FlexSpecification(views::MinimumFlexSizeRule::kScaleToZero, |
| views::MaximumFlexSizeRule::kPreferred)); |
| tab_strip_parent->AddChildViewRaw(tab_strip_.get()); |
| // The tab strip is free to use all of the space in its parent view, since |
| // there are no sibling controls such as the NTB in the test context. |
| tab_strip_->SetAvailableWidthCallback(base::BindRepeating( |
| [](views::View* tab_strip_parent) { |
| return tab_strip_parent->size().width(); |
| }, |
| tab_strip_parent.get())); |
| |
| widget_ = |
| CreateTestWidget(views::Widget::InitParams::WIDGET_OWNS_NATIVE_WIDGET); |
| tab_strip_parent_ = widget_->SetContentsView(std::move(tab_strip_parent)); |
| |
| // Prevent hover cards from appearing when the mouse is over the tab. Tests |
| // don't typically account for this possibly, so it can cause unrelated |
| // tests to fail due to tab data not being set. See crbug.com/1050012. |
| Tab::SetShowHoverCardOnMouseHoverForTesting(false); |
| } |
| |
| void TearDown() override { |
| widget_.reset(); |
| ChromeViewsTestBase::TearDown(); |
| } |
| |
| protected: |
| void SetMaxTabStripWidth(int max_width) { |
| tab_strip_parent_->SetBounds(0, 0, max_width, |
| GetLayoutConstant(TAB_STRIP_HEIGHT)); |
| // Layout is handled from the Widget, so make sure it is also the correct |
| // size. |
| widget_->SetSize(tab_strip_parent_->bounds().size()); |
| } |
| |
| bool IsShowingAttentionIndicator(Tab* tab) { |
| return tab->icon_->GetShowingAttentionIndicator(); |
| } |
| |
| bool IsShowingAttentionIndicator(const tab_groups::TabGroupId& id) { |
| return tab_strip_->group_header(id)->GetShowingAttentionIndicator(); |
| } |
| |
| void CompleteAnimationAndLayout() { |
| // Complete animations and lay out *within the current tabstrip width*. |
| tab_strip_->StopAnimating(); |
| // Resize the tabstrip based on the current tab states. |
| views::test::RunScheduledLayout(tab_strip_parent_.get()); |
| } |
| |
| // End any outstanding drag and animate tabs back to their ideal bounds. |
| void StopDragging() { tab_strip_->GetDragContext()->StoppedDragging(); } |
| |
| size_t NumTabSlotViews() { |
| base::RepeatingCallback<size_t(views::View*)> num_tab_slot_views = |
| base::BindLambdaForTesting([&num_tab_slot_views](views::View* parent) { |
| if (views::IsViewClass<TabSlotView>(parent)) { |
| return size_t(1); |
| } else { |
| size_t sum = 0; |
| for (views::View* child : parent->children()) { |
| sum += num_tab_slot_views.Run(child); |
| } |
| return sum; |
| } |
| }); |
| |
| return num_tab_slot_views.Run(tab_strip_); |
| } |
| |
| std::vector<TabGroupViews*> ListGroupViews() const { |
| std::vector<TabGroupViews*> result; |
| for (auto const& group_view_pair : |
| tab_strip_->tab_container_->get_group_views_for_testing()) { |
| result.push_back(group_view_pair.second.get()); |
| } |
| return result; |
| } |
| |
| // Owned by TabStrip. |
| raw_ptr<FakeBaseTabStripController, DanglingUntriaged> controller_ = nullptr; |
| raw_ptr<TabStrip, DanglingUntriaged> tab_strip_ = nullptr; |
| raw_ptr<views::View, DanglingUntriaged> tab_strip_parent_ = nullptr; |
| std::unique_ptr<views::Widget> widget_; |
| |
| ui::MouseEvent dummy_event_ = ui::MouseEvent(ui::EventType::kMousePressed, |
| gfx::PointF(), |
| gfx::PointF(), |
| base::TimeTicks::Now(), |
| 0, |
| 0); |
| |
| private: |
| ui::TouchUiController::TouchUiScoperForTesting touch_ui_scoper_; |
| gfx::AnimationTestApi::RenderModeResetter animation_mode_reset_; |
| base::test::ScopedFeatureList scoped_feature_list_; |
| }; |
| |
| // TabStripTest contains tests that will run with all permutations of touch ui |
| // and scrolling enabled and disabled. |
| class TabStripTest |
| : public TabStripTestBase, |
| public testing::WithParamInterface<TabStripUnittestParams> { |
| public: |
| TabStripTest() |
| : TabStripTestBase(GetParam().touch_ui, GetParam().scrolling_enabled) {} |
| TabStripTest(const TabStripTest&) = delete; |
| TabStripTest& operator=(const TabStripTest&) = delete; |
| ~TabStripTest() override = default; |
| }; |
| |
| TEST_P(TabStripTest, GetModelCount) { |
| EXPECT_EQ(0, tab_strip_->GetModelCount()); |
| } |
| |
| TEST_P(TabStripTest, AccessibilityEvents) { |
| views::test::AXEventCounter ax_counter(views::AXUpdateNotifier::Get()); |
| |
| controller_->AddTab(0, TabActive::kInactive); |
| controller_->AddTab(1, TabActive::kActive); |
| Tab* tab = tab_strip_->tab_at(1); |
| EXPECT_EQ(0, ax_counter.GetCount(ax::mojom::Event::kSelectionAdd)); |
| EXPECT_EQ(1, ax_counter.GetCount(ax::mojom::Event::kSelection)); |
| ui::AXNodeData node_data; |
| tab->GetViewAccessibility().GetAccessibleNodeData(&node_data); |
| EXPECT_TRUE(node_data.GetBoolAttribute(ax::mojom::BoolAttribute::kSelected)); |
| EXPECT_EQ(0, ax_counter.GetCount(ax::mojom::Event::kSelectionRemove)); |
| |
| tab = tab_strip_->tab_at(0); |
| controller_->RemoveTab(1); |
| node_data = ui::AXNodeData(); |
| tab->GetViewAccessibility().GetAccessibleNodeData(&node_data); |
| EXPECT_TRUE(node_data.GetBoolAttribute(ax::mojom::BoolAttribute::kSelected)); |
| EXPECT_EQ(0, ax_counter.GetCount(ax::mojom::Event::kSelectionAdd)); |
| EXPECT_EQ(2, ax_counter.GetCount(ax::mojom::Event::kSelection)); |
| EXPECT_EQ(0, ax_counter.GetCount(ax::mojom::Event::kSelectionRemove)); |
| |
| // Before the Widget actcivation changes to true, it must be deactivated |
| // first. |
| widget_->OnNativeWidgetActivationChanged(false); |
| node_data = ui::AXNodeData(); |
| tab->GetViewAccessibility().GetAccessibleNodeData(&node_data); |
| EXPECT_TRUE(node_data.GetBoolAttribute(ax::mojom::BoolAttribute::kSelected)); |
| EXPECT_EQ(0, ax_counter.GetCount(ax::mojom::Event::kSelectionAdd)); |
| EXPECT_EQ(2, ax_counter.GetCount(ax::mojom::Event::kSelection)); |
| EXPECT_EQ(0, ax_counter.GetCount(ax::mojom::Event::kSelectionRemove)); |
| |
| // When activating widget, refire selection event on tab. |
| widget_->OnNativeWidgetActivationChanged(true); |
| node_data = ui::AXNodeData(); |
| tab->GetViewAccessibility().GetAccessibleNodeData(&node_data); |
| EXPECT_TRUE(node_data.GetBoolAttribute(ax::mojom::BoolAttribute::kSelected)); |
| EXPECT_EQ(0, ax_counter.GetCount(ax::mojom::Event::kSelectionAdd)); |
| EXPECT_EQ(3, ax_counter.GetCount(ax::mojom::Event::kSelection)); |
| EXPECT_EQ(0, ax_counter.GetCount(ax::mojom::Event::kSelectionRemove)); |
| } |
| |
| TEST_P(TabStripTest, IsValidModelIndex) { |
| EXPECT_FALSE(tab_strip_->IsValidModelIndex(0)); |
| } |
| |
| TEST_P(TabStripTest, tab_count) { |
| EXPECT_EQ(0, tab_strip_->GetTabCount()); |
| } |
| |
| TEST_P(TabStripTest, AddTabAt) { |
| TestTabStripObserver observer(tab_strip_); |
| controller_->AddTab(0, TabActive::kInactive); |
| ASSERT_EQ(1, tab_strip_->GetTabCount()); |
| EXPECT_EQ(0, observer.last_tab_added()); |
| Tab* tab = tab_strip_->tab_at(0); |
| EXPECT_FALSE(tab == nullptr); |
| } |
| |
| TEST_P(TabStripTest, MoveTab) { |
| TestTabStripObserver observer(tab_strip_); |
| controller_->AddTab(0, TabActive::kInactive); |
| controller_->AddTab(1, TabActive::kInactive); |
| controller_->AddTab(2, TabActive::kInactive); |
| ASSERT_EQ(3, tab_strip_->GetTabCount()); |
| EXPECT_EQ(2, observer.last_tab_added()); |
| Tab* tab = tab_strip_->tab_at(0); |
| controller_->MoveTab(0, 1); |
| EXPECT_EQ(0, observer.last_tab_moved_from()); |
| EXPECT_EQ(1, observer.last_tab_moved_to()); |
| EXPECT_EQ(tab, tab_strip_->tab_at(1)); |
| } |
| |
| // Verifies child views are deleted after an animation completes. |
| TEST_P(TabStripTest, RemoveTab) { |
| TestTabStripObserver observer(tab_strip_); |
| controller_->AddTab(0, TabActive::kInactive); |
| controller_->AddTab(1, TabActive::kInactive); |
| const size_t num_children = NumTabSlotViews(); |
| EXPECT_EQ(2, tab_strip_->GetTabCount()); |
| controller_->RemoveTab(0); |
| EXPECT_EQ(0, observer.last_tab_removed()); |
| // When removing a tab the tabcount should immediately decrement. |
| EXPECT_EQ(1, tab_strip_->GetTabCount()); |
| // But the number of views should remain the same (it's animatining closed). |
| EXPECT_EQ(num_children, NumTabSlotViews()); |
| |
| CompleteAnimationAndLayout(); |
| |
| EXPECT_EQ(num_children - 1, NumTabSlotViews()); |
| |
| // Remove the last tab to make sure things are cleaned up correctly when |
| // the TabStrip is destroyed and an animation is ongoing. |
| controller_->RemoveTab(0); |
| EXPECT_EQ(0, observer.last_tab_removed()); |
| } |
| |
| // Tests that the tab close buttons of non-active tabs are hidden when |
| // the tab sizes are shrunk into small sizes. |
| TEST_P(TabStripTest, TabCloseButtonVisibility) { |
| // Set the tab strip width to be wide enough for three tabs to show all |
| // three icons, but not enough for five tabs to show all three icons. |
| // Touch-optimized UI requires a larger width for tabs to show close buttons. |
| const bool touch_ui = ui::TouchUiController::Get()->touch_ui(); |
| SetMaxTabStripWidth(touch_ui ? 442 : 346); |
| |
| controller_->AddTab(0, TabActive::kInactive); |
| controller_->AddTab(1, TabActive::kActive); |
| controller_->AddTab(2, TabActive::kInactive); |
| ASSERT_EQ(3, tab_strip_->GetTabCount()); |
| |
| Tab* tab0 = tab_strip_->tab_at(0); |
| ASSERT_FALSE(tab0->IsActive()); |
| Tab* tab1 = tab_strip_->tab_at(1); |
| ASSERT_TRUE(tab1->IsActive()); |
| Tab* tab2 = tab_strip_->tab_at(2); |
| ASSERT_FALSE(tab2->IsActive()); |
| |
| // Ensure that all tab close buttons are initially visible. |
| EXPECT_TRUE(tab0->showing_close_button_); |
| EXPECT_TRUE(tab1->showing_close_button_); |
| EXPECT_TRUE(tab2->showing_close_button_); |
| |
| // Shrink the tab sizes by adding more tabs. |
| // An inactive tab added to the tabstrip, now each tab size is not |
| // big enough to accomodate 3 icons, so it should not show its |
| // tab close button. |
| controller_->AddTab(3, TabActive::kInactive); |
| Tab* tab3 = tab_strip_->tab_at(3); |
| EXPECT_FALSE(tab3->showing_close_button_); |
| |
| // This inactive tab doesn't have alert button, but its favicon and |
| // title would be shown. |
| EXPECT_TRUE(tab3->showing_icon_); |
| EXPECT_FALSE(tab3->showing_alert_indicator_); |
| EXPECT_TRUE(tab3->title_->GetVisible()); |
| |
| // The active tab's close button still shows. |
| EXPECT_TRUE(tab1->showing_close_button_); |
| |
| // An active tab added to the tabstrip should show its tab close |
| // button. |
| controller_->AddTab(4, TabActive::kActive); |
| Tab* tab4 = tab_strip_->tab_at(4); |
| ASSERT_TRUE(tab4->IsActive()); |
| EXPECT_TRUE(tab4->showing_close_button_); |
| |
| // The previous active button is now inactive so its close |
| // button should not show. |
| EXPECT_FALSE(tab1->showing_close_button_); |
| |
| // After switching tabs, the previously-active tab should have its |
| // tab close button hidden and the newly-active tab should show |
| // its tab close button. |
| tab_strip_->SelectTab(tab2, dummy_event_); |
| ASSERT_FALSE(tab4->IsActive()); |
| ASSERT_TRUE(tab2->IsActive()); |
| EXPECT_FALSE(tab0->showing_close_button_); |
| EXPECT_FALSE(tab1->showing_close_button_); |
| EXPECT_TRUE(tab2->showing_close_button_); |
| EXPECT_FALSE(tab3->showing_close_button_); |
| EXPECT_FALSE(tab4->showing_close_button_); |
| |
| // After closing the active tab, the tab which becomes active should |
| // show its tab close button. |
| tab_strip_->CloseTab(tab2, CloseTabSource::kFromTouch); |
| tab2 = nullptr; |
| ASSERT_TRUE(tab3->IsActive()); |
| CompleteAnimationAndLayout(); |
| EXPECT_FALSE(tab0->showing_close_button_); |
| EXPECT_FALSE(tab1->showing_close_button_); |
| EXPECT_TRUE(tab3->showing_close_button_); |
| EXPECT_FALSE(tab4->showing_close_button_); |
| } |
| |
| #if BUILDFLAG(IS_CHROMEOS) |
| TEST_P(TabStripTest, CloseButtonHiddenWhenLockedForOnTask) { |
| controller_->SetLockedForOnTask(true); |
| |
| controller_->AddTab(0, TabActive::kInactive); |
| controller_->AddTab(1, TabActive::kActive); |
| controller_->AddTab(2, TabActive::kInactive); |
| ASSERT_EQ(3, tab_strip_->GetTabCount()); |
| |
| Tab* const tab0 = tab_strip_->tab_at(0); |
| ASSERT_FALSE(tab0->IsActive()); |
| EXPECT_FALSE(tab0->showing_close_button_); |
| |
| Tab* const tab1 = tab_strip_->tab_at(1); |
| ASSERT_TRUE(tab1->IsActive()); |
| EXPECT_FALSE(tab1->showing_close_button_); |
| |
| Tab* tab2 = tab_strip_->tab_at(2); |
| ASSERT_FALSE(tab2->IsActive()); |
| EXPECT_FALSE(tab2->showing_close_button_); |
| |
| // Switch tabs and confirm close button remains hidden for all opened tabs. |
| tab_strip_->SelectTab(tab2, dummy_event_); |
| ASSERT_TRUE(tab2->IsActive()); |
| EXPECT_FALSE(tab0->showing_close_button_); |
| EXPECT_FALSE(tab1->showing_close_button_); |
| EXPECT_FALSE(tab2->showing_close_button_); |
| |
| // Closing a tab should not alter tab close button visibility either. |
| tab_strip_->CloseTab(tab2, CloseTabSource::kFromMouse); |
| tab2 = nullptr; |
| EXPECT_FALSE(tab0->showing_close_button_); |
| EXPECT_FALSE(tab1->showing_close_button_); |
| } |
| #endif |
| |
| // The active tab should always be at least as wide as its minimum width. |
| // http://crbug.com/587688 |
| TEST_P(TabStripTest, ActiveTabWidthWhenTabsAreTiny) { |
| // The bug was caused when it's animating. Therefore we should make widget |
| // visible so that animation can be triggered. |
| tab_strip_->GetWidget()->Show(); |
| SetMaxTabStripWidth(400); |
| |
| // Create a lot of tabs in order to make inactive tabs tiny. |
| const int min_inactive_width = TabStyle::Get()->GetMinimumInactiveWidth(); |
| while (tab_strip_->GetTabCount() == 0 || |
| tab_strip_->tab_at(0)->width() != min_inactive_width) { |
| controller_->CreateNewTab(NewTabTypes::kNewTabCommand); |
| CompleteAnimationAndLayout(); |
| } |
| |
| EXPECT_GT(tab_strip_->GetTabCount(), 1); |
| |
| int active_index = tab_strip_->GetActiveIndex().value(); |
| EXPECT_EQ(tab_strip_->GetTabCount() - 1, active_index); |
| EXPECT_LT(tab_strip_->tab_at(0)->bounds().width(), |
| tab_strip_->tab_at(active_index)->bounds().width()); |
| |
| // During mouse-based tab closure, the active tab should remain at least as |
| // wide as it's minimum width. |
| controller_->SelectTab(0, dummy_event_); |
| while (tab_strip_->GetTabCount() > 0) { |
| active_index = tab_strip_->GetActiveIndex().value(); |
| EXPECT_GE(tab_strip_->tab_at(active_index)->bounds().width(), |
| TabStyle::Get()->GetMinimumActiveWidth(/*is_split*/ false)); |
| tab_strip_->CloseTab(tab_strip_->tab_at(active_index), |
| CloseTabSource::kFromMouse); |
| CompleteAnimationAndLayout(); |
| } |
| } |
| |
| // Inactive tabs shouldn't shrink during mouse-based tab closure. |
| // http://crbug.com/850190 |
| TEST_P(TabStripTest, InactiveTabWidthWhenTabsAreTiny) { |
| SetMaxTabStripWidth(200); |
| |
| // Create a lot of tabs in order to make inactive tabs smaller than active |
| // tab but not the minimum. |
| const int min_inactive_width = TabStyle::Get()->GetMinimumInactiveWidth(); |
| const int min_active_width = |
| TabStyle::Get()->GetMinimumActiveWidth(/*is_split*/ false); |
| while (tab_strip_->GetTabCount() == 0 || |
| tab_strip_->tab_at(0)->width() >= |
| (min_inactive_width + min_active_width) / 2) { |
| controller_->CreateNewTab(NewTabTypes::kNewTabCommand); |
| CompleteAnimationAndLayout(); |
| } |
| |
| // During mouse-based tab closure, inactive tabs shouldn't shrink |
| // so that users can close tabs continuously without moving mouse. |
| controller_->SelectTab(0, dummy_event_); |
| // If there are only two tabs in the strip, then after closing one the |
| // remaining one will be active and there will be no inactive tabs, |
| // so we stop at 2. |
| while (tab_strip_->GetTabCount() > 2) { |
| const int last_inactive_width = tab_strip_->tab_at(0)->width(); |
| tab_strip_->CloseTab( |
| tab_strip_->tab_at(tab_strip_->GetActiveIndex().value()), |
| CloseTabSource::kFromMouse); |
| CompleteAnimationAndLayout(); |
| EXPECT_GE(tab_strip_->tab_at(0)->width(), last_inactive_width); |
| } |
| } |
| |
| // When dragged tabs are moving back to their position, changes to ideal bounds |
| // should be respected. http://crbug.com/848016 |
| TEST_P(TabStripTest, ResetBoundsForDraggedTabs) { |
| SetMaxTabStripWidth(200); |
| |
| // Create a lot of tabs in order to make inactive tabs tiny. |
| const int min_inactive_width = TabStyle::Get()->GetMinimumInactiveWidth(); |
| while (tab_strip_->GetTabCount() == 0 || |
| tab_strip_->tab_at(0)->width() != min_inactive_width) { |
| controller_->CreateNewTab(NewTabTypes::kNewTabCommand); |
| CompleteAnimationAndLayout(); |
| } |
| |
| const int min_active_width = |
| TabStyle::Get()->GetMinimumActiveWidth(/*is_split*/ false); |
| |
| int dragged_tab_index = tab_strip_->GetActiveIndex().value(); |
| ASSERT_GE(tab_strip_->tab_at(dragged_tab_index)->bounds().width(), |
| min_active_width); |
| |
| // Mark the active tab as being dragged. |
| Tab* dragged_tab = tab_strip_->tab_at(dragged_tab_index); |
| tab_strip_->GetDragContext()->StartedDragging({dragged_tab}); |
| |
| // Ending the drag triggers the tabstrip to begin animating this tab back |
| // to its ideal bounds. |
| ASSERT_FALSE(tab_strip_->IsAnimatingInTabStrip()); |
| StopDragging(); |
| EXPECT_TRUE(tab_strip_->IsAnimatingInTabStrip()); |
| |
| // Change the ideal bounds of the tabs mid-animation by selecting a |
| // different tab. |
| controller_->SelectTab(0, dummy_event_); |
| |
| // Once the animation completes, the dragged tab should have animated to |
| // the new ideal bounds (computed with this as an inactive tab) rather |
| // than the original ones (where it's an active tab). |
| tab_strip_->StopAnimating(); |
| |
| EXPECT_FALSE(dragged_tab->dragging()); |
| EXPECT_LT(dragged_tab->bounds().width(), min_active_width); |
| } |
| |
| // The "blocked" attention indicator should only show for background tabs. |
| TEST_P(TabStripTest, TabNeedsAttentionBlocked) { |
| controller_->AddTab(0, TabActive::kInactive); |
| controller_->AddTab(1, TabActive::kActive); |
| |
| Tab* tab1 = tab_strip_->tab_at(1); |
| |
| // Block tab1. |
| TabRendererData data; |
| data.blocked = true; |
| tab1->SetData(data); |
| |
| EXPECT_FALSE(IsShowingAttentionIndicator(tab1)); |
| controller_->SelectTab(0, dummy_event_); |
| EXPECT_TRUE(IsShowingAttentionIndicator(tab1)); |
| controller_->SelectTab(1, dummy_event_); |
| EXPECT_FALSE(IsShowingAttentionIndicator(tab1)); |
| } |
| |
| // The generic "wants attention" version should always show. |
| TEST_P(TabStripTest, TabNeedsAttentionGeneric) { |
| controller_->AddTab(0, TabActive::kInactive); |
| controller_->AddTab(1, TabActive::kActive); |
| |
| Tab* tab1 = tab_strip_->tab_at(1); |
| |
| tab1->SetTabNeedsAttention(true); |
| |
| EXPECT_TRUE(IsShowingAttentionIndicator(tab1)); |
| controller_->SelectTab(0, dummy_event_); |
| EXPECT_TRUE(IsShowingAttentionIndicator(tab1)); |
| controller_->SelectTab(1, dummy_event_); |
| EXPECT_TRUE(IsShowingAttentionIndicator(tab1)); |
| } |
| |
| // The tab group header can display an attention indicator. |
| TEST_P(TabStripTest, TabGroupNeedsAttention) { |
| base::test::ScopedFeatureList scoped_feature_list; |
| scoped_feature_list.InitWithFeatures( |
| {data_sharing::features::kDataSharingFeature}, {}); |
| controller_->AddTab(0, TabActive::kInactive); |
| controller_->AddTab(1, TabActive::kActive); |
| |
| auto group_id = tab_groups::TabGroupId::GenerateNew(); |
| controller_->MoveTabIntoGroup(0, group_id); |
| |
| // Collapse the group so it can accept attention state. |
| controller_->ToggleTabGroupCollapsedState( |
| group_id, ToggleTabGroupCollapsedStateOrigin::kMenuAction); |
| tab_strip_->group_header(group_id)->VisualsChanged(); |
| EXPECT_TRUE(controller_->IsGroupCollapsed(group_id)); |
| |
| tab_strip_->SetTabGroupNeedsAttention(group_id, true); |
| EXPECT_TRUE(IsShowingAttentionIndicator(group_id)); |
| |
| tab_strip_->SetTabGroupNeedsAttention(group_id, false); |
| EXPECT_FALSE(IsShowingAttentionIndicator(group_id)); |
| } |
| |
| // Closing tab should be targeted during event dispatching. |
| TEST_P(TabStripTest, EventsOnClosingTab) { |
| SetMaxTabStripWidth(200); |
| |
| controller_->AddTab(0, TabActive::kInactive); |
| controller_->AddTab(1, TabActive::kActive); |
| CompleteAnimationAndLayout(); |
| |
| Tab* first_tab = tab_strip_->tab_at(0); |
| Tab* second_tab = tab_strip_->tab_at(1); |
| gfx::Point tab_center = first_tab->bounds().CenterPoint(); |
| |
| EXPECT_EQ(first_tab, tab_strip_->GetEventHandlerForPoint(tab_center)); |
| tab_strip_->CloseTab(first_tab, CloseTabSource::kFromMouse); |
| EXPECT_EQ(first_tab, tab_strip_->GetEventHandlerForPoint(tab_center)); |
| |
| // Closing `first_tab` again should forward to `second_tab` instead. |
| tab_strip_->CloseTab(first_tab, CloseTabSource::kFromMouse); |
| EXPECT_TRUE(second_tab->closing()); |
| } |
| |
| // TODO (crbug.com/1520595): Disabled for now due to test failing when CR2023 |
| // enabled. |
| TEST_P(TabStripTest, DISABLED_ChangingLayoutTypeResizesTabs) { |
| SetMaxTabStripWidth(1000); |
| |
| controller_->AddTab(0, TabActive::kInactive); |
| Tab* tab = tab_strip_->tab_at(0); |
| const int initial_height = tab->height(); |
| |
| ui::TouchUiController::TouchUiScoperForTesting other_layout( |
| !GetParam().touch_ui); |
| |
| CompleteAnimationAndLayout(); |
| if (GetParam().touch_ui) { |
| // Touch -> normal. |
| EXPECT_LT(tab->height(), initial_height); |
| } else { |
| // Normal -> touch. |
| EXPECT_GT(tab->height(), initial_height); |
| } |
| } |
| |
| // Regression test for a crash when closing a tab under certain conditions. If |
| // the first tab in a group was animating closed, attempting to close the next |
| // tab could result in a crash. This was due to TabStripLayoutHelper mistakenly |
| // mapping the next tab's model index to the closing tab's slot. See |
| // https://crbug.com/1138748 for a related crash. |
| TEST_P(TabStripTest, CloseTabInGroupWhilePreviousTabAnimatingClosed) { |
| controller_->AddTab(0, TabActive::kActive); |
| controller_->AddTab(1, TabActive::kInactive); |
| controller_->AddTab(2, TabActive::kInactive); |
| |
| auto group_id = tab_groups::TabGroupId::GenerateNew(); |
| controller_->MoveTabIntoGroup(1, group_id); |
| controller_->MoveTabIntoGroup(2, group_id); |
| |
| CompleteAnimationAndLayout(); |
| ASSERT_EQ(3, tab_strip_->GetTabCount()); |
| ASSERT_EQ(3, tab_strip_->GetModelCount()); |
| EXPECT_EQ(std::nullopt, tab_strip_->tab_at(0)->group()); |
| EXPECT_EQ(group_id, tab_strip_->tab_at(1)->group()); |
| EXPECT_EQ(group_id, tab_strip_->tab_at(2)->group()); |
| |
| // We have the following tabs: |
| // 1. An ungrouped tab with model index 0 |
| // 2. A tab in `group_id` with model index 1 |
| // 3. A tab in `group_id` with model index 2 |
| controller_->RemoveTab(1); |
| |
| // After closing the first tab, we now have: |
| // 1. An ungrouped tab with model index 0 |
| // 2. A closing tab in `group_id` with no model index |
| // 3. A tab in `group_id` with model index 1. |
| // |
| // Closing the tab at model index 1 should result in (3) above being |
| // closed. |
| controller_->RemoveTab(1); |
| |
| // We should now have: |
| // 1. An ungrouped tab with model index 0 |
| // 2. A closing tab in `group_id` with no model index |
| // 3. A closing tab in `group_id` with no model index. |
| |
| CompleteAnimationAndLayout(); |
| |
| // After finishing animations, there should be exactly 1 tab in no |
| // group. |
| EXPECT_EQ(1, tab_strip_->GetTabCount()); |
| EXPECT_EQ(std::nullopt, tab_strip_->tab_at(0)->group()); |
| EXPECT_EQ(1, tab_strip_->GetModelCount()); |
| } |
| |
| TEST_P(TabStripTest, HeaderOnCollapseChangeAccessibilityProperties) { |
| controller_->AddTab(0, TabActive::kActive); |
| |
| std::optional<tab_groups::TabGroupId> group = |
| tab_groups::TabGroupId::GenerateNew(); |
| controller_->MoveTabIntoGroup(0, group); |
| CompleteAnimationAndLayout(); |
| |
| ASSERT_FALSE(controller_->IsGroupCollapsed(group.value())); |
| EXPECT_TRUE(tab_strip_->group_header(group.value())->GetVisible()); |
| |
| // Initially the tab group is expanded |
| ui::AXNodeData node_data; |
| tab_strip_->group_header(group.value()) |
| ->GetViewAccessibility() |
| .GetAccessibleNodeData(&node_data); |
| EXPECT_TRUE(node_data.HasState(ax::mojom::State::kExpanded)); |
| EXPECT_FALSE(node_data.HasState(ax::mojom::State::kCollapsed)); |
| |
| // Using controller to change the collapsed state of the tab group . |
| controller_->ToggleTabGroupCollapsedState( |
| group.value(), ToggleTabGroupCollapsedStateOrigin::kMenuAction); |
| tab_strip_->group_header(group.value())->VisualsChanged(); |
| |
| node_data = ui::AXNodeData(); |
| tab_strip_->group_header(group.value()) |
| ->GetViewAccessibility() |
| .GetAccessibleNodeData(&node_data); |
| EXPECT_FALSE(node_data.HasState(ax::mojom::State::kExpanded)); |
| EXPECT_TRUE(node_data.HasState(ax::mojom::State::kCollapsed)); |
| |
| controller_->ToggleTabGroupCollapsedState( |
| group.value(), ToggleTabGroupCollapsedStateOrigin::kMenuAction); |
| tab_strip_->group_header(group.value())->VisualsChanged(); |
| |
| node_data = ui::AXNodeData(); |
| tab_strip_->group_header(group.value()) |
| ->GetViewAccessibility() |
| .GetAccessibleNodeData(&node_data); |
| EXPECT_TRUE(node_data.HasState(ax::mojom::State::kExpanded)); |
| EXPECT_FALSE(node_data.HasState(ax::mojom::State::kCollapsed)); |
| } |
| |
| namespace { |
| |
| struct SizeChangeObserver : public views::ViewObserver { |
| explicit SizeChangeObserver(views::View* observed_view) |
| : view(observed_view) { |
| view->AddObserver(this); |
| } |
| ~SizeChangeObserver() override { view->RemoveObserver(this); } |
| |
| void OnViewPreferredSizeChanged(views::View* observed_view) override { |
| size_change_count++; |
| } |
| |
| const raw_ptr<views::View> view; |
| int size_change_count = 0; |
| }; |
| |
| } // namespace |
| |
| // When dragged tabs' bounds are modified through TabDragContext, both tab strip |
| // and its parent view must get re-laid out http://crbug.com/1151092. |
| TEST_P(TabStripTest, RelayoutAfterDraggedTabBoundsUpdate) { |
| SetMaxTabStripWidth(400); |
| |
| // Creates a single tab. |
| controller_->CreateNewTab(NewTabTypes::kNewTabCommand); |
| CompleteAnimationAndLayout(); |
| |
| int dragged_tab_index = tab_strip_->GetActiveIndex().value(); |
| Tab* dragged_tab = tab_strip_->tab_at(dragged_tab_index); |
| ASSERT_TRUE(dragged_tab); |
| |
| // Mark the active tab as being dragged. |
| dragged_tab->set_dragging(true); |
| |
| constexpr int kXOffset = 20; |
| std::vector<TabSlotView*> tabs{dragged_tab}; |
| std::vector<gfx::Rect> bounds{gfx::Rect({kXOffset, 0}, dragged_tab->size())}; |
| SizeChangeObserver view_observer(tab_strip_); |
| tab_strip_->GetDragContext()->SetBoundsForDrag(tabs, bounds); |
| EXPECT_EQ(1, view_observer.size_change_count); |
| } |
| |
| TEST_P(TabStripTest, PreferredWidthDuringDrag) { |
| // Start with two full-width tabs. |
| controller_->AddTab(0, TabActive::kInactive); |
| controller_->AddTab(1, TabActive::kActive); |
| SetMaxTabStripWidth(1000); |
| CompleteAnimationAndLayout(); |
| |
| Tab* const dragged_tab = tab_strip_->tab_at(1); |
| gfx::Rect dragged_tab_bounds = dragged_tab->bounds(); |
| const int original_preferred_width = tab_strip_->GetPreferredSize().width(); |
| |
| // Drag the second tab Y to the right. |
| tab_strip_->GetDragContext()->StartedDragging({dragged_tab}); |
| constexpr int kXOffset = 10; |
| dragged_tab_bounds.Offset(kXOffset, 0); |
| tab_strip_->GetDragContext()->SetBoundsForDrag({dragged_tab}, |
| {dragged_tab_bounds}); |
| |
| // Preferred width should be larger by Y. |
| EXPECT_EQ(original_preferred_width + kXOffset, |
| tab_strip_->GetPreferredSize().width()); |
| } |
| |
| TEST_P(TabStripTest, TabIconActiveState) { |
| controller_->AddTab(0, TabActive::kActive); |
| ASSERT_EQ(1, tab_strip_->GetTabCount()); |
| Tab* tab0 = tab_strip_->tab_at(0); |
| EXPECT_TRUE(tab0->GetTabIconForTesting()->GetActiveStateForTesting()); |
| |
| controller_->AddTab(1, TabActive::kActive); |
| ASSERT_EQ(2, tab_strip_->GetTabCount()); |
| EXPECT_FALSE(tab0->GetTabIconForTesting()->GetActiveStateForTesting()); |
| |
| controller_->SelectTab(0, dummy_event_); |
| ASSERT_EQ(2, tab_strip_->GetTabCount()); |
| EXPECT_TRUE(tab0->GetTabIconForTesting()->GetActiveStateForTesting()); |
| } |
| |
| // TabStripTestWithScrollingDisabled contains tests that will run with scrolling |
| // disabled. |
| // TODO(http://crbug.com/951078) Remove these tests as well as tests in |
| // TabStripTest with scrolling disabled once tab scrolling is fully launched. |
| class TabStripTestWithScrollingDisabled |
| : public TabStripTestBase, |
| public testing::WithParamInterface<bool> { |
| public: |
| TabStripTestWithScrollingDisabled() : TabStripTestBase(GetParam(), false) {} |
| TabStripTestWithScrollingDisabled(const TabStripTestWithScrollingDisabled&) = |
| delete; |
| TabStripTestWithScrollingDisabled& operator=( |
| const TabStripTestWithScrollingDisabled&) = delete; |
| ~TabStripTestWithScrollingDisabled() override = default; |
| }; |
| |
| TEST_P(TabStripTestWithScrollingDisabled, VisibilityInOverflow) { |
| constexpr int kInitialWidth = 250; |
| SetMaxTabStripWidth(kInitialWidth); |
| |
| // The first tab added to a reasonable-width strip should be visible. If we |
| // add enough additional tabs, eventually one should be invisible due to |
| // overflow. |
| int invisible_tab_index = 0; |
| for (; invisible_tab_index < 100; ++invisible_tab_index) { |
| controller_->AddTab(invisible_tab_index, TabActive::kInactive); |
| CompleteAnimationAndLayout(); |
| if (!tab_strip_->tab_at(invisible_tab_index)->GetVisible()) { |
| break; |
| } |
| } |
| EXPECT_GT(invisible_tab_index, 0); |
| EXPECT_LT(invisible_tab_index, 100); |
| |
| // The tabs before the invisible tab should still be visible. |
| for (int i = 0; i < invisible_tab_index; ++i) { |
| EXPECT_TRUE(tab_strip_->tab_at(i)->GetVisible()); |
| } |
| |
| // Enlarging the strip should result in the last tab becoming visible. |
| SetMaxTabStripWidth(kInitialWidth * 2); |
| EXPECT_TRUE(tab_strip_->tab_at(invisible_tab_index)->GetVisible()); |
| |
| // Shrinking it again should re-hide the last tab. |
| SetMaxTabStripWidth(kInitialWidth); |
| EXPECT_FALSE(tab_strip_->tab_at(invisible_tab_index)->GetVisible()); |
| |
| // Shrinking it still more should make more tabs invisible, though not all. |
| // All the invisible tabs should be at the end of the strip. |
| SetMaxTabStripWidth(kInitialWidth / 2); |
| int i = 0; |
| for (; i < invisible_tab_index; ++i) { |
| if (!tab_strip_->tab_at(i)->GetVisible()) { |
| break; |
| } |
| } |
| ASSERT_GT(i, 0); |
| EXPECT_LT(i, invisible_tab_index); |
| invisible_tab_index = i; |
| for (int j = invisible_tab_index + 1; j < tab_strip_->GetTabCount(); ++j) { |
| EXPECT_FALSE(tab_strip_->tab_at(j)->GetVisible()); |
| } |
| |
| // When we're already in overflow, adding tabs at the beginning or end of |
| // the strip should not change how many tabs are visible. |
| controller_->AddTab(tab_strip_->GetTabCount(), TabActive::kInactive); |
| CompleteAnimationAndLayout(); |
| EXPECT_TRUE(tab_strip_->tab_at(invisible_tab_index - 1)->GetVisible()); |
| EXPECT_FALSE(tab_strip_->tab_at(invisible_tab_index)->GetVisible()); |
| controller_->AddTab(0, TabActive::kInactive); |
| CompleteAnimationAndLayout(); |
| EXPECT_TRUE(tab_strip_->tab_at(invisible_tab_index - 1)->GetVisible()); |
| EXPECT_FALSE(tab_strip_->tab_at(invisible_tab_index)->GetVisible()); |
| |
| // If we remove enough tabs, all the tabs should be visible. |
| for (int j = tab_strip_->GetTabCount() - 1; j >= invisible_tab_index; --j) { |
| controller_->RemoveTab(j); |
| } |
| CompleteAnimationAndLayout(); |
| EXPECT_TRUE(tab_strip_->tab_at(tab_strip_->GetTabCount() - 1)->GetVisible()); |
| } |
| |
| TEST_P(TabStripTestWithScrollingDisabled, GroupedTabSlotOverflowVisibility) { |
| constexpr int kInitialWidth = 250; |
| SetMaxTabStripWidth(kInitialWidth); |
| |
| // The first tab added to a reasonable-width strip should be visible. If we |
| // add enough additional tabs, eventually one should be invisible due to |
| // overflow. |
| int invisible_tab_index = 0; |
| for (; invisible_tab_index < 100; ++invisible_tab_index) { |
| controller_->AddTab(invisible_tab_index, TabActive::kInactive); |
| CompleteAnimationAndLayout(); |
| if (!tab_strip_->tab_at(invisible_tab_index)->GetVisible()) { |
| break; |
| } |
| } |
| ASSERT_GT(invisible_tab_index, 0); |
| ASSERT_LT(invisible_tab_index, 100); |
| |
| // The tabs before the invisible tab should still be visible. |
| for (int i = 0; i < invisible_tab_index; ++i) { |
| ASSERT_TRUE(tab_strip_->tab_at(i)->GetVisible()); |
| } |
| |
| // The group header of an invisible tab should not be visible. |
| std::optional<tab_groups::TabGroupId> group1 = |
| tab_groups::TabGroupId::GenerateNew(); |
| controller_->MoveTabIntoGroup(invisible_tab_index, group1); |
| CompleteAnimationAndLayout(); |
| ASSERT_FALSE(tab_strip_->tab_at(invisible_tab_index)->GetVisible()); |
| EXPECT_FALSE(tab_strip_->group_header(group1.value())->GetVisible()); |
| |
| // The group header of a visible tab should be visible when the group is |
| // expanded and collapsed. |
| std::optional<tab_groups::TabGroupId> group2 = |
| tab_groups::TabGroupId::GenerateNew(); |
| controller_->MoveTabIntoGroup(0, group2); |
| CompleteAnimationAndLayout(); |
| ASSERT_FALSE(controller_->IsGroupCollapsed(group2.value())); |
| EXPECT_TRUE(tab_strip_->group_header(group2.value())->GetVisible()); |
| controller_->ToggleTabGroupCollapsedState( |
| group2.value(), ToggleTabGroupCollapsedStateOrigin::kMenuAction); |
| ASSERT_TRUE(controller_->IsGroupCollapsed(group2.value())); |
| EXPECT_TRUE(tab_strip_->group_header(group2.value())->GetVisible()); |
| } |
| |
| INSTANTIATE_TEST_SUITE_P(All, |
| TabStripTest, |
| ::testing::ValuesIn(kTabStripUnittestParams)); |
| |
| INSTANTIATE_TEST_SUITE_P(All, |
| TabStripTestWithScrollingDisabled, |
| ::testing::Values(false, true)); |