[go: up one dir, main page]

[TabGroupModelFilter] Add mergeTabsToGroup API

This CL adds an API mergeTabsToGroup to TabGroupModelFilter. This API
allows users to create group from existed tabs or groups.

BUG=963692
(cherry picked from commit 9772e48e079107ab4931dae5cdafe1c82813c57a)

Change-Id: I04d2610e169d4c68041b9ad542038f384e9cf40e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1632838
Commit-Queue: Mei Liang <meiliang@chromium.org>
Reviewed-by: Yusuf Ozuysal <yusufo@chromium.org>
Cr-Original-Commit-Position: refs/heads/master@{#665351}
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1656711
Cr-Commit-Position: refs/branch-heads/3809@{#270}
Cr-Branched-From: d82dec1a818f378c464ba307ddd9c92133eac355-refs/heads/master@{#665002}
diff --git a/chrome/android/chrome_junit_test_java_sources.gni b/chrome/android/chrome_junit_test_java_sources.gni
index aa7afa4..aae8b87 100644
--- a/chrome/android/chrome_junit_test_java_sources.gni
+++ b/chrome/android/chrome_junit_test_java_sources.gni
@@ -184,6 +184,7 @@
   "junit/src/org/chromium/chrome/browser/tabstate/TabStateUnitTest.java",
   "junit/src/org/chromium/chrome/browser/tasks/EngagementTimeUtilTest.java",
   "junit/src/org/chromium/chrome/browser/tasks/JourneyManagerTest.java",
+  "junit/src/org/chromium/chrome/browser/tasks/tabgroup/TabGroupModelFilterUnitTest.java",
   "junit/src/org/chromium/chrome/browser/toolbar/ToolbarSecurityIconTest.java",
   "junit/src/org/chromium/chrome/browser/usage_stats/EventTrackerTest.java",
   "junit/src/org/chromium/chrome/browser/usage_stats/PageViewObserverTest.java",
diff --git a/chrome/android/features/tab_ui/java/src/org/chromium/chrome/browser/tasks/tab_management/TabListMediator.java b/chrome/android/features/tab_ui/java/src/org/chromium/chrome/browser/tasks/tab_management/TabListMediator.java
index 7e17b3e..7951ff3 100644
--- a/chrome/android/features/tab_ui/java/src/org/chromium/chrome/browser/tasks/tab_management/TabListMediator.java
+++ b/chrome/android/features/tab_ui/java/src/org/chromium/chrome/browser/tasks/tab_management/TabListMediator.java
@@ -340,6 +340,9 @@
                         instanceof TabGroupModelFilter) {
             mTabGroupObserver = new TabGroupModelFilter.Observer() {
                 @Override
+                public void didMergeTabToGroup(Tab movedTab, int selectedTabIdInGroup) {}
+
+                @Override
                 public void didMoveWithinGroup(
                         Tab movedTab, int tabModelOldIndex, int tabModelNewIndex) {
                     int curPosition = mModel.indexFromId(movedTab.getId());
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/tabmodel/EmptyTabModelFilter.java b/chrome/android/java/src/org/chromium/chrome/browser/tabmodel/EmptyTabModelFilter.java
index f2066924..3ed6e46 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/tabmodel/EmptyTabModelFilter.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/tabmodel/EmptyTabModelFilter.java
@@ -28,6 +28,9 @@
     @Override
     protected void reorder() {}
 
+    @Override
+    protected void resetFilterStateInternal() {}
+
     // TabList implementation.
     @Override
     public boolean isIncognito() {
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/tabmodel/TabModelFilter.java b/chrome/android/java/src/org/chromium/chrome/browser/tabmodel/TabModelFilter.java
index 16823987..cfacc71 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/tabmodel/TabModelFilter.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/tabmodel/TabModelFilter.java
@@ -110,6 +110,25 @@
      */
     protected abstract void reorder();
 
+    /**
+     * Concrete class requires to define what to clean up.
+     */
+    protected abstract void resetFilterStateInternal();
+
+    /**
+     * Calls {@code resetFilterStateInternal} method to clean up filter internal data, and resets
+     * the internal data based on the current {@link TabModel}.
+     */
+    protected void resetFilterState() {
+        resetFilterStateInternal();
+
+        TabModel tabModel = getTabModel();
+        for (int i = 0; i < tabModel.getCount(); i++) {
+            Tab tab = tabModel.getTabAt(i);
+            addTab(tab);
+        }
+    }
+
     // TODO(crbug.com/948518): This is a band-aid fix for not crashing when undo the last closed
     // tab, should remove later.
     /**
@@ -161,7 +180,6 @@
 
     @Override
     public void didMoveTab(Tab tab, int newIndex, int curIndex) {
-        reorder();
         for (TabModelObserver observer : mFilteredObservers) {
             observer.didMoveTab(tab, newIndex, curIndex);
         }
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/tasks/tabgroup/TabGroupModelFilter.java b/chrome/android/java/src/org/chromium/chrome/browser/tasks/tabgroup/TabGroupModelFilter.java
index bec2314..860b12a4 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/tasks/tabgroup/TabGroupModelFilter.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/tasks/tabgroup/TabGroupModelFilter.java
@@ -48,6 +48,15 @@
      */
     public interface Observer {
         /**
+         * This method is called after a tab is moved to form a group or moved into an existed
+         * group.
+         * @param movedTab The {@link Tab} which has been moved. If a group is merged to a tab or
+         *                 another group, this is the last tab of the merged group.
+         * @param selectedTabIdInGroup The id of the selected {@link Tab} in group.
+         */
+        void didMergeTabToGroup(Tab movedTab, int selectedTabIdInGroup);
+
+        /**
          * This method is called after a group is moved.
          *
          * @param movedTab The tab which has been moved. This is the last tab within the group.
@@ -139,6 +148,7 @@
     // The number of groups with at least 2 tabs.
     private int mActualGroupCount;
     private Tab mAbsentSelectedTab;
+    private boolean mShouldRecordUma = true;
 
     public TabGroupModelFilter(TabModel tabModel) {
         super(tabModel);
@@ -223,6 +233,71 @@
         }
     }
 
+    /**
+     * This method merges the source group that contains the {@code sourceTabId} to the destination
+     * group that contains the {@code destinationTabId}. This method only operates if two groups are
+     * in the same {@code TabModel}.
+     *
+     * @param sourceTabId The id of the {@link Tab} to get the source group.
+     * @param destinationTabId The id of a {@link Tab} to get the destination group.
+     */
+    public void mergeTabsToGroup(int sourceTabId, int destinationTabId) {
+        Tab sourceTab = TabModelUtils.getTabById(getTabModel(), sourceTabId);
+        Tab destinationTab = TabModelUtils.getTabById(getTabModel(), destinationTabId);
+
+        assert sourceTab != null && destinationTab != null
+                && sourceTab.isIncognito()
+                        == destinationTab.isIncognito()
+            : "Attempting to merge groups from different model";
+
+        int destinationGroupId = destinationTab.getRootId();
+        List<Tab> tabsToMerge = getRelatedTabList(sourceTabId);
+        int sourceTabIndexInTabModel =
+                TabModelUtils.getTabIndexById(getTabModel(), sourceTab.getId());
+        int destinationIndexInTabModel = getTabModelDestinationIndex(destinationTab);
+        boolean isMovingBackward = sourceTabIndexInTabModel < destinationIndexInTabModel;
+
+        if (!needToUpdateTabModel(tabsToMerge, destinationIndexInTabModel)) {
+            for (int i = 0; i < tabsToMerge.size(); i++) {
+                Tab tab = tabsToMerge.get(i);
+                tab.setRootId(destinationGroupId);
+            }
+            resetFilterState();
+
+            Tab lastMergedTab = tabsToMerge.get(tabsToMerge.size() - 1);
+            TabGroup group = mGroupIdToGroupMap.get(lastMergedTab.getRootId());
+            for (Observer observer : mGroupFilterObserver) {
+                observer.didMergeTabToGroup(
+                        tabsToMerge.get(tabsToMerge.size() - 1), group.getLastShownTabId());
+            }
+        } else {
+            for (int i = 0; i < tabsToMerge.size(); i++) {
+                Tab tab = tabsToMerge.get(i);
+                tab.setRootId(destinationGroupId);
+                getTabModel().moveTab(tab.getId(),
+                        isMovingBackward ? destinationIndexInTabModel
+                                         : destinationIndexInTabModel++);
+            }
+        }
+    }
+
+    private int getTabModelDestinationIndex(Tab destinationTab) {
+        List<Integer> destinationGroupedTabIds =
+                mGroupIdToGroupMap.get(destinationTab.getRootId()).getTabIdList();
+        int destinationTabIndex = TabModelUtils.getTabIndexById(
+                getTabModel(), destinationGroupedTabIds.get(destinationGroupedTabIds.size() - 1));
+
+        return destinationTabIndex + 1;
+    }
+
+    private boolean needToUpdateTabModel(List<Tab> tabsToMerge, int destinationIndexInTabModel) {
+        assert tabsToMerge.size() > 0;
+
+        int firstTabIndexInTabModel =
+                TabModelUtils.getTabIndexById(getTabModel(), tabsToMerge.get(0).getId());
+        return firstTabIndexInTabModel != destinationIndexInTabModel;
+    }
+
     // TODO(crbug.com/951608): follow up with sessions count histogram for TabGroups.
     private int updateAndGetSessionsCount(int groupId) {
         ThreadUtils.assertOnBackgroundThread();
@@ -276,7 +351,8 @@
         if (mGroupIdToGroupMap.containsKey(groupId)) {
             if (mGroupIdToGroupMap.get(groupId).size() == 1) {
                 mActualGroupCount++;
-                if (tab.getLaunchType() == TabLaunchType.FROM_LONGPRESS_BACKGROUND) {
+                if (mShouldRecordUma
+                        && tab.getLaunchType() == TabLaunchType.FROM_LONGPRESS_BACKGROUND) {
                     RecordUserAction.record("TabGroup.Created.OpenInNewTab");
                 }
             }
@@ -377,24 +453,77 @@
     }
 
     @Override
+    protected void resetFilterStateInternal() {
+        mGroupIdToGroupIndexMap.clear();
+        mGroupIdToGroupMap.clear();
+        mActualGroupCount = 0;
+    }
+
+    @Override
+    protected void resetFilterState() {
+        mShouldRecordUma = false;
+        super.resetFilterState();
+
+        TabModel tabModel = getTabModel();
+        selectTab(tabModel.getTabAt(tabModel.index()));
+        mShouldRecordUma = true;
+    }
+
+    @Override
     protected boolean shouldNotifyObserversOnSetIndex() {
         return mAbsentSelectedTab == null;
     }
 
     @Override
     public void didMoveTab(Tab tab, int newIndex, int curIndex) {
-        super.didMoveTab(tab, newIndex, curIndex);
+        // Need to cache the flag before resetting the internal data map.
+        boolean isMergeTabToGroup = isMergeTabToGroup(tab);
+        int groupIdBeforeMove = getGroupIdBeforeMove(tab, isMergeTabToGroup);
+        assert groupIdBeforeMove != TabGroup.INVALID_GROUP_ID;
+        TabGroup groupBeforeMove = mGroupIdToGroupMap.get(groupIdBeforeMove);
 
-        if (isMoveWithinGroup(tab, curIndex, newIndex)) {
+        if (isMergeTabToGroup) {
+            resetFilterState();
+            if (groupBeforeMove != null && groupBeforeMove.size() != 1) return;
+
+            TabGroup group = mGroupIdToGroupMap.get(tab.getRootId());
             for (Observer observer : mGroupFilterObserver) {
-                observer.didMoveWithinGroup(tab, curIndex, newIndex);
+                observer.didMergeTabToGroup(tab, group.getLastShownTabId());
             }
         } else {
-            if (!hasFinishedMovingGroup(tab, newIndex)) return;
-            for (Observer observer : mGroupFilterObserver) {
-                observer.didMoveTabGroup(tab, curIndex, newIndex);
+            reorder();
+
+            if (isMoveWithinGroup(tab, curIndex, newIndex)) {
+                for (Observer observer : mGroupFilterObserver) {
+                    observer.didMoveWithinGroup(tab, curIndex, newIndex);
+                }
+            } else {
+                if (!hasFinishedMovingGroup(tab, newIndex)) return;
+                for (Observer observer : mGroupFilterObserver) {
+                    observer.didMoveTabGroup(tab, curIndex, newIndex);
+                }
             }
         }
+
+        super.didMoveTab(tab, newIndex, curIndex);
+    }
+
+    private boolean isMergeTabToGroup(Tab tab) {
+        TabGroup tabGroup = mGroupIdToGroupMap.get(tab.getRootId());
+        return !tabGroup.contains(tab.getId());
+    }
+
+    private int getGroupIdBeforeMove(Tab tabToMove, boolean isMoveToDifferentGroup) {
+        if (!isMoveToDifferentGroup) return tabToMove.getRootId();
+
+        Set<Integer> groupIdSet = mGroupIdToGroupMap.keySet();
+        for (Integer groupIdKey : groupIdSet) {
+            if (mGroupIdToGroupMap.get(groupIdKey).contains(tabToMove.getId())) {
+                return groupIdKey;
+            }
+        }
+
+        return TabGroup.INVALID_GROUP_ID;
     }
 
     private boolean isMoveWithinGroup(
@@ -409,8 +538,13 @@
 
     private boolean hasFinishedMovingGroup(Tab movedTab, int newIndexInTabModel) {
         TabGroup tabGroup = mGroupIdToGroupMap.get(movedTab.getRootId());
-        int offsetIndex = Math.abs(newIndexInTabModel - tabGroup.size() + 1);
-        return tabGroup.contains(getTabModel().getTabAt(offsetIndex).getId());
+        int offsetIndex = newIndexInTabModel - tabGroup.size() + 1;
+        if (offsetIndex < 0) return false;
+
+        for (int i = newIndexInTabModel; i >= offsetIndex; i--) {
+            if (getTabModel().getTabAt(i).getRootId() != movedTab.getRootId()) return false;
+        }
+        return true;
     }
 
     // TabList implementation.
diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/tasks/tabgroup/TabGroupModelFilterUnitTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/tasks/tabgroup/TabGroupModelFilterUnitTest.java
new file mode 100644
index 0000000..631f40e
--- /dev/null
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/tasks/tabgroup/TabGroupModelFilterUnitTest.java
@@ -0,0 +1,344 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package org.chromium.chrome.browser.tasks.tabgroup;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+import org.robolectric.annotation.Config;
+
+import org.chromium.base.metrics.RecordHistogram;
+import org.chromium.base.metrics.RecordUserAction;
+import org.chromium.chrome.browser.tab.Tab;
+import org.chromium.chrome.browser.tabmodel.TabLaunchType;
+import org.chromium.chrome.browser.tabmodel.TabModel;
+import org.chromium.chrome.browser.tabmodel.TabModelObserver;
+import org.chromium.chrome.browser.tabmodel.TabModelUtils;
+import org.chromium.testing.local.LocalRobolectricTestRunner;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Tests for {@link TabGroupModelFilter}.
+ */
+@RunWith(LocalRobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class TabGroupModelFilterUnitTest {
+    private static final int TAB1_ID = 456;
+    private static final int TAB2_ID = 789;
+    private static final int TAB3_ID = 123;
+    private static final int TAB4_ID = 147;
+    private static final int TAB5_ID = 258;
+    private static final int TAB6_ID = 369;
+    private static final int TAB1_ROOT_ID = TAB1_ID;
+    private static final int TAB2_ROOT_ID = TAB2_ID;
+    private static final int TAB3_ROOT_ID = TAB2_ID;
+    private static final int TAB4_ROOT_ID = TAB4_ID;
+    private static final int TAB5_ROOT_ID = TAB5_ID;
+    private static final int TAB6_ROOT_ID = TAB5_ID;
+    private static final int POSITION1 = 0;
+    private static final int POSITION2 = 1;
+    private static final int POSITION3 = 2;
+    private static final int POSITION4 = 3;
+    private static final int POSITION5 = 4;
+    private static final int POSITION6 = 5;
+
+    @Mock
+    TabModel mTabModel;
+
+    @Mock
+    TabGroupModelFilter.Observer mTabGroupModelFilterObserver;
+
+    @Captor
+    ArgumentCaptor<TabModelObserver> mTabModelObserverCaptor;
+
+    private Tab mTab1;
+    private Tab mTab2;
+    private Tab mTab3;
+    private Tab mTab4;
+    private Tab mTab5;
+    private Tab mTab6;
+    private List<Tab> mTabs = new ArrayList<>();
+
+    private TabGroupModelFilter mTabGroupModelFilter;
+
+    private Tab prepareTab(int tabId, int rootId) {
+        Tab tab = mock(Tab.class);
+
+        doAnswer(new Answer() {
+            @Override
+            public Object answer(InvocationOnMock invocation) throws Throwable {
+                int newRootId = invocation.getArgument(0);
+                doReturn(newRootId).when(tab).getRootId();
+                return null;
+            }
+        }).when(tab).setRootId(anyInt());
+
+        doReturn(tabId).when(tab).getId();
+        tab.setRootId(rootId);
+
+        return tab;
+    }
+
+    private void setRootId(Tab tab, int rootId) {
+        doAnswer(new Answer() {
+            @Override
+            public Object answer(InvocationOnMock invocation) throws Throwable {
+                int newRootId = invocation.getArgument(0);
+                doReturn(newRootId).when(tab).getRootId();
+                return null;
+            }
+        }).when(tab).setRootId(rootId);
+    }
+
+    private void setUpTab() {
+        mTab1 = prepareTab(TAB1_ID, TAB1_ROOT_ID);
+        mTab2 = prepareTab(TAB2_ID, TAB2_ROOT_ID);
+        mTab3 = prepareTab(TAB3_ID, TAB3_ROOT_ID);
+        mTab4 = prepareTab(TAB4_ID, TAB4_ROOT_ID);
+        mTab5 = prepareTab(TAB5_ID, TAB5_ROOT_ID);
+        mTab6 = prepareTab(TAB6_ID, TAB6_ROOT_ID);
+    }
+
+    private void setUpTabModel() {
+        doAnswer(new Answer() {
+            @Override
+            public Object answer(InvocationOnMock invocation) throws Throwable {
+                Tab tab = invocation.getArgument(0);
+                mTabs.add(tab);
+                return null;
+            }
+        }).when(mTabModel).addTab(any(Tab.class), anyInt(), anyInt());
+
+        doAnswer(new Answer() {
+            @Override
+            public Object answer(InvocationOnMock invocation) throws Throwable {
+                int movedTabId = invocation.getArgument(0);
+                int newIndex = invocation.getArgument(1);
+
+                int oldIndex = TabModelUtils.getTabIndexById(mTabModel, movedTabId);
+                Tab tab = TabModelUtils.getTabById(mTabModel, movedTabId);
+
+                mTabs.remove(tab);
+                if (oldIndex < newIndex) --newIndex;
+                mTabs.add(newIndex, tab);
+                mTabModelObserverCaptor.getValue().didMoveTab(tab, newIndex, oldIndex);
+                return null;
+            }
+        }).when(mTabModel).moveTab(anyInt(), anyInt());
+
+        doAnswer(new Answer() {
+            @Override
+            public Tab answer(InvocationOnMock invocation) throws Throwable {
+                int index = invocation.getArgument(0);
+                return mTabs.get(index);
+            }
+        }).when(mTabModel).getTabAt(anyInt());
+
+        doAnswer(new Answer() {
+            @Override
+            public Integer answer(InvocationOnMock invocation) throws Throwable {
+                Tab tab = invocation.getArgument(0);
+                return mTabs.indexOf(tab);
+            }
+        }).when(mTabModel).indexOf(any(Tab.class));
+
+        doAnswer(new Answer() {
+            @Override
+            public Integer answer(InvocationOnMock invocation) throws Throwable {
+                return mTabs.size();
+            }
+        }).when(mTabModel).getCount();
+
+        doReturn(0).when(mTabModel).index();
+    }
+
+    @Before
+    public void setUp() {
+        RecordUserAction.setDisabledForTests(true);
+        RecordHistogram.setDisabledForTests(true);
+
+        MockitoAnnotations.initMocks(this);
+
+        setUpTab();
+        setUpTabModel();
+
+        doNothing().when(mTabModel).addObserver(mTabModelObserverCaptor.capture());
+
+        mTabGroupModelFilter = new TabGroupModelFilter(mTabModel);
+        mTabGroupModelFilter.addTabGroupObserver(mTabGroupModelFilterObserver);
+
+        mTabModel.addTab(mTab1, -1, TabLaunchType.FROM_CHROME_UI);
+        mTabModelObserverCaptor.getValue().didAddTab(mTab1, TabLaunchType.FROM_CHROME_UI);
+
+        mTabModel.addTab(mTab2, -1, TabLaunchType.FROM_CHROME_UI);
+        mTabModelObserverCaptor.getValue().didAddTab(mTab2, TabLaunchType.FROM_CHROME_UI);
+
+        mTabModel.addTab(mTab3, -1, TabLaunchType.FROM_CHROME_UI);
+        mTabModelObserverCaptor.getValue().didAddTab(mTab3, TabLaunchType.FROM_CHROME_UI);
+
+        mTabModel.addTab(mTab4, -1, TabLaunchType.FROM_CHROME_UI);
+        mTabModelObserverCaptor.getValue().didAddTab(mTab4, TabLaunchType.FROM_CHROME_UI);
+
+        mTabModel.addTab(mTab5, -1, TabLaunchType.FROM_CHROME_UI);
+        mTabModelObserverCaptor.getValue().didAddTab(mTab5, TabLaunchType.FROM_CHROME_UI);
+
+        mTabModel.addTab(mTab6, -1, TabLaunchType.FROM_CHROME_UI);
+        mTabModelObserverCaptor.getValue().didAddTab(mTab6, TabLaunchType.FROM_CHROME_UI);
+    }
+
+    @After
+    public void tearDown() {
+        RecordUserAction.setDisabledForTests(false);
+        RecordHistogram.setDisabledForTests(false);
+    }
+
+    @Test
+    public void mergeTabToGroup_No_Update_TabModel() {
+        List<Tab> expectedGroup = new ArrayList<>(Arrays.asList(mTab2, mTab3, mTab4));
+
+        mTabGroupModelFilter.mergeTabsToGroup(mTab4.getId(), mTab2.getId());
+
+        verify(mTabModel, never()).moveTab(anyInt(), anyInt());
+        assertArrayEquals(mTabGroupModelFilter.getRelatedTabList(mTab4.getId()).toArray(),
+                expectedGroup.toArray());
+    }
+
+    @Test
+    public void mergeTabToGroup_Update_TabModel() {
+        mTabGroupModelFilter.mergeTabsToGroup(mTab5.getId(), mTab2.getId());
+        verify(mTabModel).moveTab(mTab5.getId(), POSITION3 + 1);
+    }
+
+    @Test
+    public void mergeOneTabToTab_Forward() {
+        List<Tab> expectedGroup = new ArrayList<>(Arrays.asList(mTab1, mTab4));
+        List<Tab> expectedTabModel =
+                new ArrayList<>(Arrays.asList(mTab1, mTab4, mTab2, mTab3, mTab5, mTab6));
+        int startIndex = POSITION1;
+
+        mTabGroupModelFilter.mergeTabsToGroup(mTab4.getId(), mTab1.getId());
+
+        verify(mTabModel).moveTab(mTab4.getId(), ++startIndex);
+        verify(mTabGroupModelFilterObserver).didMergeTabToGroup(mTab4, mTab1.getId());
+        assertArrayEquals(mTabGroupModelFilter.getRelatedTabList(mTab4.getId()).toArray(),
+                expectedGroup.toArray());
+        assertArrayEquals(mTabs.toArray(), expectedTabModel.toArray());
+    }
+
+    @Test
+    public void mergeGroupToTab_Forward() {
+        List<Tab> expectedGroup = new ArrayList<>(Arrays.asList(mTab1, mTab5, mTab6));
+        List<Tab> expectedTabModel =
+                new ArrayList<>(Arrays.asList(mTab1, mTab5, mTab6, mTab2, mTab3, mTab4));
+        int startIndex = POSITION1;
+
+        mTabGroupModelFilter.mergeTabsToGroup(mTab5.getId(), mTab1.getId());
+
+        verify(mTabModel).moveTab(mTab5.getId(), ++startIndex);
+        verify(mTabModel).moveTab(mTab6.getId(), ++startIndex);
+        verify(mTabGroupModelFilterObserver).didMergeTabToGroup(mTab6, mTab1.getId());
+        assertArrayEquals(mTabGroupModelFilter.getRelatedTabList(mTab5.getId()).toArray(),
+                expectedGroup.toArray());
+        assertArrayEquals(mTabs.toArray(), expectedTabModel.toArray());
+    }
+
+    @Test
+    public void mergeGroupToGroup_Forward() {
+        List<Tab> expectedGroup = new ArrayList<>(Arrays.asList(mTab2, mTab3, mTab5, mTab6));
+        List<Tab> expectedTabModel =
+                new ArrayList<>(Arrays.asList(mTab1, mTab2, mTab3, mTab5, mTab6, mTab4));
+        int startIndex = POSITION3;
+
+        mTabGroupModelFilter.mergeTabsToGroup(mTab5.getId(), mTab2.getId());
+
+        verify(mTabModel).moveTab(mTab5.getId(), ++startIndex);
+        verify(mTabModel).moveTab(mTab6.getId(), ++startIndex);
+        verify(mTabGroupModelFilterObserver).didMergeTabToGroup(mTab6, mTab2.getId());
+        assertArrayEquals(mTabGroupModelFilter.getRelatedTabList(mTab5.getId()).toArray(),
+                expectedGroup.toArray());
+        assertArrayEquals(mTabs.toArray(), expectedTabModel.toArray());
+    }
+
+    @Test
+    public void mergeOneTabToTab_Backward() {
+        List<Tab> expectedGroup = new ArrayList<>(Arrays.asList(mTab4, mTab1));
+        List<Tab> expectedTabModel =
+                new ArrayList<>(Arrays.asList(mTab2, mTab3, mTab4, mTab1, mTab5, mTab6));
+        int startIndex = POSITION4;
+
+        mTabGroupModelFilter.mergeTabsToGroup(mTab1.getId(), mTab4.getId());
+
+        verify(mTabModel).moveTab(mTab1.getId(), startIndex + 1);
+        verify(mTabGroupModelFilterObserver).didMergeTabToGroup(mTab1, mTab4.getId());
+        assertArrayEquals(mTabGroupModelFilter.getRelatedTabList(mTab1.getId()).toArray(),
+                expectedGroup.toArray());
+        assertArrayEquals(mTabs.toArray(), expectedTabModel.toArray());
+    }
+
+    @Test
+    public void mergeGroupToTab_Backward() {
+        List<Tab> expectedGroup = new ArrayList<>(Arrays.asList(mTab4, mTab2, mTab3));
+        List<Tab> expectedTabModel =
+                new ArrayList<>(Arrays.asList(mTab1, mTab4, mTab2, mTab3, mTab5, mTab6));
+        int startIndex = POSITION4;
+
+        mTabGroupModelFilter.mergeTabsToGroup(mTab2.getId(), mTab4.getId());
+
+        verify(mTabModel).moveTab(mTab2.getId(), startIndex + 1);
+        verify(mTabModel).moveTab(mTab3.getId(), startIndex + 1);
+        verify(mTabGroupModelFilterObserver).didMergeTabToGroup(mTab3, mTab4.getId());
+        assertArrayEquals(mTabGroupModelFilter.getRelatedTabList(mTab2.getId()).toArray(),
+                expectedGroup.toArray());
+        assertArrayEquals(mTabs.toArray(), expectedTabModel.toArray());
+    }
+
+    @Test
+    public void moveGroup_Backward() {
+        List<Tab> expectedTabModel =
+                new ArrayList<>(Arrays.asList(mTab1, mTab4, mTab2, mTab3, mTab5, mTab6));
+        int startIndex = POSITION4;
+
+        mTabGroupModelFilter.moveRelatedTabs(mTab2.getId(), startIndex + 1);
+
+        verify(mTabModel).moveTab(mTab2.getId(), startIndex + 1);
+        verify(mTabModel).moveTab(mTab3.getId(), startIndex + 1);
+        verify(mTabGroupModelFilterObserver).didMoveTabGroup(mTab3, POSITION3 - 1, startIndex);
+        assertArrayEquals(mTabs.toArray(), expectedTabModel.toArray());
+    }
+
+    @Test
+    public void moveGroup_Forward() {
+        List<Tab> expectedTabModel =
+                new ArrayList<>(Arrays.asList(mTab1, mTab2, mTab3, mTab5, mTab6, mTab4));
+        int startIndex = POSITION3;
+
+        mTabGroupModelFilter.moveRelatedTabs(mTab5.getId(), startIndex + 1);
+
+        verify(mTabModel).moveTab(mTab5.getId(), startIndex + 1);
+        verify(mTabModel).moveTab(mTab6.getId(), startIndex + 2);
+        verify(mTabGroupModelFilterObserver).didMoveTabGroup(mTab6, POSITION6, startIndex + 2);
+        assertArrayEquals(mTabs.toArray(), expectedTabModel.toArray());
+    }
+}