chromium/chrome/browser/tab_group_sync/android/java/src/org/chromium/chrome/browser/tab_group_sync/TabGroupSyncLocalObserverUnitTest.java

// Copyright 2024 The Chromium Authors
// 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.tab_group_sync;

import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.robolectric.annotation.Config;

import org.chromium.base.Token;
import org.chromium.base.supplier.LazyOneshotSupplier;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.UserActionTester;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabCreationState;
import org.chromium.chrome.browser.tab.TabLaunchType;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tabmodel.TabModelObserver;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilter;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilterObserver;
import org.chromium.chrome.browser.tasks.tab_groups.TabGroupModelFilterObserver.DidRemoveTabGroupReason;
import org.chromium.chrome.test.util.browser.tabmodel.MockTabModel;
import org.chromium.components.tab_group_sync.ClosingSource;
import org.chromium.components.tab_group_sync.EventDetails;
import org.chromium.components.tab_group_sync.LocalTabGroupId;
import org.chromium.components.tab_group_sync.SavedTabGroup;
import org.chromium.components.tab_group_sync.SavedTabGroupTab;
import org.chromium.components.tab_group_sync.TabGroupEvent;
import org.chromium.components.tab_group_sync.TabGroupSyncService;
import org.chromium.components.tab_groups.TabGroupColorId;
import org.chromium.url.GURL;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/** Unit tests for the {@link TabGroupSyncLocalObserver}. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class TabGroupSyncLocalObserverUnitTest {
    private static final int TAB_ID_1 = 1;
    private static final int TAB_ID_2 = 2;
    private static final int ROOT_ID_1 = 1;
    private static final int ROOT_ID_2 = 2;
    private static final Token TOKEN_1 = new Token(2, 3);
    private static final LocalTabGroupId LOCAL_TAB_GROUP_ID_1 = new LocalTabGroupId(TOKEN_1);
    private static final String TITLE_1 = "Group Title";
    private static final String TAB_TITLE_1 = "Tab Title";
    private static final GURL TAB_URL_1 = new GURL("https://google.com");

    @Rule public MockitoRule mMockitoRule = MockitoJUnit.rule();
    private @Mock TabModelSelector mTabModelSelector;
    @Mock private Profile mProfile;
    private MockTabModel mTabModel;
    private @Mock TabGroupModelFilter mTabGroupModelFilter;
    private TabGroupSyncService mTabGroupSyncService;
    private NavigationTracker mNavigationTracker;
    private RemoteTabGroupMutationHelper mRemoteMutationHelper;
    private TabGroupSyncLocalObserver mLocalObserver;

    private @Captor ArgumentCaptor<TabModelObserver> mTabModelObserverCaptor;
    private @Captor ArgumentCaptor<TabGroupModelFilterObserver> mTabGroupModelFilterObserverCaptor;
    private @Captor ArgumentCaptor<EventDetails> mEventDetailsCaptor;

    private Tab mTab1;
    private Tab mTab2;

    private UserActionTester mActionTester;

    private static Tab prepareTab(int tabId, int rootId) {
        Tab tab = Mockito.mock(Tab.class);
        Mockito.doReturn(tabId).when(tab).getId();
        Mockito.doReturn(rootId).when(tab).getRootId();
        Mockito.doReturn(TAB_URL_1).when(tab).getUrl();
        Mockito.doReturn(TAB_TITLE_1).when(tab).getTitle();
        return tab;
    }

    @Before
    public void setUp() {
        mActionTester = new UserActionTester();
        mTabGroupSyncService = spy(new TestTabGroupSyncService());
        mTab1 = prepareTab(TAB_ID_1, ROOT_ID_1);
        mTab2 = prepareTab(TAB_ID_2, ROOT_ID_2);
        Mockito.doReturn(TOKEN_1).when(mTab1).getTabGroupId();
        Mockito.doReturn(TOKEN_1).when(mTab2).getTabGroupId();

        when(mTabGroupModelFilter.isTabInTabGroup(mTab1)).thenReturn(true);
        when(mTabGroupModelFilter.isTabInTabGroup(mTab2)).thenReturn(true);
        when(mTabGroupModelFilter.getRootIdFromStableId(eq(TOKEN_1))).thenReturn(ROOT_ID_1);
        when(mTabGroupModelFilter.getStableIdFromRootId(eq(ROOT_ID_1))).thenReturn(TOKEN_1);

        mTabModel = spy(new MockTabModel(mProfile, null));
        when(mTabGroupModelFilter.getTabModel()).thenReturn(mTabModel);

        Mockito.doNothing()
                .when(mTabGroupModelFilter)
                .addObserver(mTabModelObserverCaptor.capture());
        Mockito.doNothing()
                .when(mTabGroupModelFilter)
                .addTabGroupObserver(mTabGroupModelFilterObserverCaptor.capture());
        Mockito.doNothing()
                .when(mTabGroupSyncService)
                .recordTabGroupEvent(mEventDetailsCaptor.capture());
        mNavigationTracker = new NavigationTracker();
        mRemoteMutationHelper =
                new RemoteTabGroupMutationHelper(mTabGroupModelFilter, mTabGroupSyncService);
        mLocalObserver =
                new TabGroupSyncLocalObserver(
                        mTabModelSelector,
                        mTabGroupModelFilter,
                        mTabGroupSyncService,
                        mRemoteMutationHelper,
                        mNavigationTracker);
        mLocalObserver.enableObservers(true);
    }

    @After
    public void tearDown() {
        mLocalObserver.destroy();
        mActionTester.tearDown();
    }

    @Test
    public void testDidSelectTabRemote() {
        // Stub the bare minimum.
        SavedTabGroup savedGroup = new SavedTabGroup();
        String remoteGuid = "remote_device";
        savedGroup.creatorCacheGuid = remoteGuid;
        savedGroup.lastUpdaterCacheGuid = remoteGuid;
        SavedTabGroupTab savedTab1 = new SavedTabGroupTab();
        SavedTabGroupTab savedTab2 = new SavedTabGroupTab();
        savedTab2.localId = TAB_ID_2;
        savedGroup.savedTabs.add(savedTab1);
        savedGroup.savedTabs.add(savedTab2);
        when(mTabGroupSyncService.getGroup(LOCAL_TAB_GROUP_ID_1)).thenReturn(savedGroup);
        when(mTabGroupSyncService.isRemoteDevice(remoteGuid)).thenReturn(true);

        List<String> actions =
                List.of(
                        "TabGroups.Sync.SelectedTabInRemotelyCreatedGroup",
                        "MobileCrossDeviceTabJourney",
                        "TabGroups.Sync.SelectedRemotelyUpdatedTabInSession");
        for (String action : actions) {
            assertEquals(
                    "Expected no actions for " + action, 0, mActionTester.getActionCount(action));
        }
        mTabModelObserverCaptor
                .getValue()
                .didSelectTab(mTab2, TabSelectionType.FROM_USER, Tab.INVALID_TAB_ID);
        verify(mTabGroupSyncService).onTabSelected(eq(LOCAL_TAB_GROUP_ID_1), eq(TAB_ID_2));
        for (String action : actions) {
            assertEquals(
                    "Expected one action for " + action, 1, mActionTester.getActionCount(action));
        }
    }

    @Test
    public void testDidSelectTabLocal() {
        // Stub the bare minimum.
        SavedTabGroup savedGroup = new SavedTabGroup();
        savedGroup.creatorCacheGuid = TestTabGroupSyncService.LOCAL_DEVICE_CACHE_GUID;
        SavedTabGroupTab savedTab1 = new SavedTabGroupTab();
        SavedTabGroupTab savedTab2 = new SavedTabGroupTab();
        savedTab2.localId = TAB_ID_2;
        savedGroup.savedTabs.add(savedTab1);
        savedGroup.savedTabs.add(savedTab2);
        when(mTabGroupSyncService.getGroup(LOCAL_TAB_GROUP_ID_1)).thenReturn(savedGroup);

        String action = "TabGroups.Sync.SelectedTabInLocallyCreatedGroup";
        assertEquals(0, mActionTester.getActionCount(action));
        mTabModelObserverCaptor
                .getValue()
                .didSelectTab(mTab2, TabSelectionType.FROM_USER, Tab.INVALID_TAB_ID);
        assertEquals(1, mActionTester.getActionCount(action));
    }

    @Test
    public void testTabAddedLocally() {
        mTabModelObserverCaptor
                .getValue()
                .didAddTab(
                        mTab1,
                        TabLaunchType.FROM_RESTORE,
                        TabCreationState.LIVE_IN_BACKGROUND,
                        false);
        verify(mTabGroupSyncService, times(1))
                .addTab(
                        eq(LOCAL_TAB_GROUP_ID_1),
                        eq(TAB_ID_1),
                        eq(TAB_TITLE_1),
                        eq(TAB_URL_1),
                        anyInt());
    }

    @Test
    public void testTabAddedLocally_NonSaveableUrl() {
        Mockito.doReturn(new GURL("ftp://someurl.com")).when(mTab1).getUrl();
        mTabModelObserverCaptor
                .getValue()
                .didAddTab(
                        mTab1,
                        TabLaunchType.FROM_RESTORE,
                        TabCreationState.LIVE_IN_BACKGROUND,
                        false);
        verify(mTabGroupSyncService, times(1))
                .addTab(
                        eq(LOCAL_TAB_GROUP_ID_1),
                        eq(TAB_ID_1),
                        eq(TabGroupSyncUtils.UNSAVEABLE_TAB_TITLE),
                        eq(TabGroupSyncUtils.UNSAVEABLE_URL_OVERRIDE),
                        anyInt());
    }

    @Test
    public void testFinishedClosingTabGroup_Hiding() {
        mTabGroupModelFilterObserverCaptor
                .getValue()
                .committedTabGroupClosure(TOKEN_1, /* wasHiding= */ true);
        verify(mTabGroupSyncService).removeLocalTabGroupMapping(LOCAL_TAB_GROUP_ID_1);

        // Verify metrics.
        EventDetails eventDetails = mEventDetailsCaptor.getValue();
        assertEquals(TabGroupEvent.TAB_GROUP_CLOSED, eventDetails.eventType);
        assertEquals(LOCAL_TAB_GROUP_ID_1, eventDetails.localGroupId);
        assertEquals(ClosingSource.CLOSED_BY_USER, eventDetails.closingSource);
    }

    @Test
    public void testFinishedClosingTabGroup_Deleted() {
        mTabGroupModelFilterObserverCaptor
                .getValue()
                .committedTabGroupClosure(TOKEN_1, /* wasHiding= */ false);
        verify(mTabGroupSyncService).removeLocalTabGroupMapping(LOCAL_TAB_GROUP_ID_1);
        verify(mTabGroupSyncService).removeGroup(LOCAL_TAB_GROUP_ID_1);

        // Verify metrics.
        EventDetails eventDetails = mEventDetailsCaptor.getValue();
        assertEquals(TabGroupEvent.TAB_GROUP_CLOSED, eventDetails.eventType);
        assertEquals(LOCAL_TAB_GROUP_ID_1, eventDetails.localGroupId);
        assertEquals(ClosingSource.DELETED_BY_USER, eventDetails.closingSource);
    }

    @Test
    public void testCloseMultipleTabs_EmptyList() {
        mTabModelObserverCaptor
                .getValue()
                .onFinishingMultipleTabClosure(new ArrayList<Tab>(), /* canRestore= */ true);

        verify(mTabGroupSyncService, never()).removeTab(any(), anyInt());
    }

    @Test
    public void testCloseMultipleTabs_HidingTabGroup() {
        List<Tab> tabs = new ArrayList<>();
        tabs.add(mTab1);
        when(mTabGroupModelFilter.getLazyAllTabGroupIdsInComprehensiveModel(any()))
                .thenReturn(LazyOneshotSupplier.fromValue(new HashSet<Token>()));
        when(mTabGroupModelFilter.isTabGroupHiding(TOKEN_1)).thenReturn(true);
        mTabModelObserverCaptor
                .getValue()
                .onFinishingMultipleTabClosure(tabs, /* canRestore= */ true);

        verify(mTabGroupSyncService, never()).removeTab(LOCAL_TAB_GROUP_ID_1, TAB_ID_1);
    }

    @Test
    public void testCloseMultipleTabs_HidingTabGroup_NotLastTabInGroup() {
        List<Tab> tabs = new ArrayList<>();
        tabs.add(mTab1);
        when(mTabGroupModelFilter.getLazyAllTabGroupIdsInComprehensiveModel(any()))
                .thenReturn(LazyOneshotSupplier.fromValue(Set.of(TOKEN_1)));
        when(mTabGroupModelFilter.isTabGroupHiding(TOKEN_1)).thenReturn(true);
        mTabModelObserverCaptor
                .getValue()
                .onFinishingMultipleTabClosure(tabs, /* canRestore= */ true);

        // In this scenario the tab group is hiding, but tabs were closed in multiple phases. We
        // should commit any tab removals as "deletions" from the group except for the last event
        // that actually hides the group.
        verify(mTabGroupSyncService).removeTab(LOCAL_TAB_GROUP_ID_1, TAB_ID_1);
    }

    @Test
    public void testCloseMultipleTabs_DeletingTabGroup() {
        List<Tab> tabs = new ArrayList<>();
        tabs.add(mTab1);
        when(mTabGroupModelFilter.isTabGroupHiding(TOKEN_1)).thenReturn(false);
        mTabModelObserverCaptor
                .getValue()
                .onFinishingMultipleTabClosure(tabs, /* canRestore= */ true);

        verify(mTabGroupSyncService).removeTab(LOCAL_TAB_GROUP_ID_1, TAB_ID_1);
    }

    @Test
    public void testDidMergeTabToGroup() {
        mTabGroupModelFilterObserverCaptor.getValue().didMergeTabToGroup(mTab1, 1);
        verify(mTabGroupSyncService, times(1)).createGroup(eq(LOCAL_TAB_GROUP_ID_1));
        verify(mTabGroupModelFilter, never()).getRelatedTabList(anyInt());
        verify(mTabGroupModelFilter, times(1)).getRelatedTabListForRootId(1);
    }

    @Test
    public void testDidMoveTabOutOfGroup() {
        // Add tab 1 and 2 to the tab model and create a group.
        mTabModel.addTab(
                mTab1, 0, TabLaunchType.FROM_TAB_GROUP_UI, TabCreationState.LIVE_IN_BACKGROUND);
        mTabModel.addTab(
                mTab2, 1, TabLaunchType.FROM_TAB_GROUP_UI, TabCreationState.LIVE_IN_BACKGROUND);
        when(mTabGroupModelFilter.getTabAt(0)).thenReturn(mTab1);

        // Move tab 2 out of group and verify.
        mTabGroupModelFilterObserverCaptor.getValue().didMoveTabOutOfGroup(mTab2, 0);
        verify(mTabGroupSyncService, times(1)).removeTab(eq(LOCAL_TAB_GROUP_ID_1), eq(2));
    }

    @Test
    public void testDidCreateNewGroup() {
        mTabGroupModelFilterObserverCaptor
                .getValue()
                .didCreateNewGroup(mTab1, mTabGroupModelFilter);
        verify(mTabGroupSyncService, times(1)).createGroup(eq(LOCAL_TAB_GROUP_ID_1));
    }

    @Test
    public void testDidMoveTabWithinGroup() {
        when(mTabGroupModelFilter.getIndexOfTabInGroup(mTab1)).thenReturn(0);
        mTabGroupModelFilterObserverCaptor.getValue().didMoveWithinGroup(mTab1, 0, 1);
        verify(mTabGroupSyncService, times(1))
                .moveTab(eq(LOCAL_TAB_GROUP_ID_1), eq(TAB_ID_1), anyInt());
    }

    @Test
    public void testDidChangeTitle() {
        // Valid title.
        when(mTabGroupModelFilter.getTabGroupTitle(ROOT_ID_1)).thenReturn(TITLE_1);
        mTabGroupModelFilterObserverCaptor.getValue().didChangeTabGroupTitle(ROOT_ID_1, TITLE_1);
        verify(mTabGroupSyncService, times(1))
                .updateVisualData(eq(LOCAL_TAB_GROUP_ID_1), eq(TITLE_1), anyInt());

        // Null title.
        when(mTabGroupModelFilter.getTabGroupTitle(ROOT_ID_1)).thenReturn(null);
        mTabGroupModelFilterObserverCaptor.getValue().didChangeTabGroupTitle(ROOT_ID_1, null);
        verify(mTabGroupSyncService, times(1))
                .updateVisualData(eq(LOCAL_TAB_GROUP_ID_1), eq(new String()), anyInt());
    }

    @Test
    public void testDidChangeColor() {
        // Mock that we have a stored color (red) stored with reference to ROOT_ID_1.
        when(mTabGroupModelFilter.getTabGroupColor(ROOT_ID_1)).thenReturn(TabGroupColorId.RED);

        mTabGroupModelFilterObserverCaptor
                .getValue()
                .didChangeTabGroupColor(ROOT_ID_1, TabGroupColorId.RED);
        verify(mTabGroupSyncService, times(1))
                .updateVisualData(eq(LOCAL_TAB_GROUP_ID_1), any(), eq(TabGroupColorId.RED));
    }

    @Test
    public void testDidChangeTitleAndColorForNonExistingGroup() {
        // Handle updates for non-existing groups.
        when(mTabGroupModelFilter.getTabGroupTitle(12)).thenReturn(null);
        mTabGroupModelFilterObserverCaptor.getValue().didChangeTabGroupTitle(12, TITLE_1);
        verify(mTabGroupSyncService, never()).updateVisualData(any(), any(), anyInt());

        // Handle updates for non-existing groups.
        when(mTabGroupModelFilter.getTabGroupColorWithFallback(12))
                .thenReturn(TabGroupColorId.BLUE);
        mTabGroupModelFilterObserverCaptor
                .getValue()
                .didChangeTabGroupColor(12, TabGroupColorId.BLUE);
        verify(mTabGroupSyncService, never()).updateVisualData(any(), any(), anyInt());
    }

    @Test
    public void testDidRemoveGroup_Close() {
        mTabGroupModelFilterObserverCaptor
                .getValue()
                .didRemoveTabGroup(ROOT_ID_1, TOKEN_1, DidRemoveTabGroupReason.CLOSE);
        verify(mTabGroupSyncService, never()).removeGroup(LOCAL_TAB_GROUP_ID_1);
    }

    @Test
    public void testDidRemoveGroup_Merge() {
        mTabGroupModelFilterObserverCaptor
                .getValue()
                .didRemoveTabGroup(ROOT_ID_1, TOKEN_1, DidRemoveTabGroupReason.MERGE);
        verify(mTabGroupSyncService).removeGroup(LOCAL_TAB_GROUP_ID_1);
    }

    @Test
    public void testDidRemoveGroup_Ungroup() {
        mTabGroupModelFilterObserverCaptor
                .getValue()
                .didRemoveTabGroup(ROOT_ID_1, TOKEN_1, DidRemoveTabGroupReason.UNGROUP);
        verify(mTabGroupSyncService).removeGroup(LOCAL_TAB_GROUP_ID_1);
    }
}