chromium/chrome/browser/tab_resumption/junit/src/org/chromium/chrome/browser/tab_resumption/TabResumptionModuleMediatorUnitTest.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_resumption;

import static android.os.Looper.getMainLooper;

import static org.mockito.Mockito.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.robolectric.Shadows.shadowOf;

import androidx.test.core.app.ApplicationProvider;
import androidx.test.filters.SmallTest;

import org.junit.After;
import org.junit.Assert;
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.invocation.InvocationOnMock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.robolectric.annotation.Config;

import org.chromium.base.Callback;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.Features.EnableFeatures;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabObserver;
import org.chromium.chrome.browser.tab_resumption.TabResumptionDataProvider.ResultStrength;
import org.chromium.chrome.browser.tab_resumption.TabResumptionDataProvider.SuggestionsResult;
import org.chromium.chrome.browser.tab_resumption.TabResumptionModuleUtils.SuggestionClickCallback;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.components.visited_url_ranking.ScoredURLUserAction;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.url.JUnitTestGURLs;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeoutException;

@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
@EnableFeatures({ChromeFeatureList.TAB_RESUMPTION_MODULE_ANDROID})
public class TabResumptionModuleMediatorUnitTest extends TestSupportExtended {

    /** Custom mock for TrainingInfo. */
    class FakeTrainingInfo extends TrainingInfo {
        public String recordHistory = "";

        FakeTrainingInfo(SuggestionEntry entry, long requestId) {
            super(
                    /* nativeVisitedUrlRankingBackend= */ 0L,
                    /* visitId= */ entry.url.getSpec(),
                    /* requestId= */ requestId);
        }

        @Override
        void record(@ScoredURLUserAction int scoredUrlUserAction) {
            switch (scoredUrlUserAction) {
                case ScoredURLUserAction.SEEN:
                    recordHistory += "(SEEN)";
                    break;
                case ScoredURLUserAction.ACTIVATED:
                    recordHistory += "(ACTIVATED)";
                    break;
                case ScoredURLUserAction.DISMISSED:
                    recordHistory += "(DISMISSED)";
                    break;
                default:
                    recordHistory += "(?)";
                    break;
            }
        }
    }

    @Rule public MockitoRule mMockitoRule = MockitoJUnit.rule();

    @Mock private TabResumptionDataProvider mDataProvider;
    @Mock private UrlImageProvider mUrlImageProvider;
    @Mock private SuggestionClickCallback mClickCallback;
    @Mock private TabModelSelector mTabModelSelector;

    @Captor private ArgumentCaptor<Callback<SuggestionsResult>> mFetchSuggestionCallbackCaptor;

    // Fake Tab observation helper, assumes each Tab has at most one TabObserver.
    private Map<Integer, TabObserver> mTabObserverMap;

    private TabResumptionModuleMediator mMediator;

    private int mReloadSessionCounter;
    private CallbackHelper mReloadSessionCallbackHelper;
    private ObservableSupplierImpl<TabModelSelector> mTabModelSelectorSupplier;

    @Before
    public void setUp() {
        mContext = ApplicationProvider.getApplicationContext();
        mContext.setTheme(R.style.Theme_BrowserUI_DayNight);

        mTabObserverMap = new HashMap<Integer, TabObserver>();
        mTabModelSelectorSupplier = new ObservableSupplierImpl<>();
        mTabModelSelectorSupplier.set(mTabModelSelector);
        when(mTabModelSelector.getModel(false)).thenReturn(mTabModel);

        TabResumptionModuleUtils.setFakeCurrentTimeMsForTesting(() -> CURRENT_TIME_MS);
        mModel = new PropertyModel(TabResumptionModuleProperties.ALL_KEYS);

        mMediator =
                new TabResumptionModuleMediator(
                        /* context= */ mContext,
                        /* moduleDelegate= */ mModuleDelegate,
                        /* tabModelSelectorSupplier= */ mTabModelSelectorSupplier,
                        /* model= */ mModel,
                        /* urlImageProvider= */ mUrlImageProvider,
                        /* reloadSessionCallback= */ () -> {
                            ++mReloadSessionCounter;
                            mReloadSessionCallbackHelper.notifyCalled();
                        },
                        /* statusChangedCallback= */ () -> {},
                        /* seeMoreLinkClickCallback= */ () -> {},
                        /* suggestionClickCallback= */ mClickCallback);
        mMediator.startSession(mDataProvider);

        Assert.assertFalse((Boolean) mModel.get(TabResumptionModuleProperties.IS_VISIBLE));
        Assert.assertEquals(
                mUrlImageProvider, mModel.get(TabResumptionModuleProperties.URL_IMAGE_PROVIDER));
        // `mClickCallback` may get wrapped, so just check for non-null.
        Assert.assertNotNull(mModel.get(TabResumptionModuleProperties.CLICK_CALLBACK));
    }

    @After
    public void tearDown() {
        mMediator.endSession(); // Okay even if endSession() has already been called.
        mMediator.destroy();
        mMediator = null;
        Assert.assertNull(mModel.get(TabResumptionModuleProperties.URL_IMAGE_PROVIDER));
        mModel = null;
        TabResumptionModuleUtils.setFakeCurrentTimeMsForTesting(null);
    }

    @Test
    @SmallTest
    public void testNullSuggestions() {
        mMediator.loadModule();
        verify(mDataProvider, times(1)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getValue()
                .onResult(new SuggestionsResult(ResultStrength.TENTATIVE, null));
        Assert.assertFalse((Boolean) mModel.get(TabResumptionModuleProperties.IS_VISIBLE));

        mMediator.loadModule();
        verify(mDataProvider, times(2)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getValue()
                .onResult(new SuggestionsResult(ResultStrength.STABLE, null));
        Assert.assertFalse((Boolean) mModel.get(TabResumptionModuleProperties.IS_VISIBLE));

        // Fetch skipped on next load, since module is gone.
        mMediator.loadModule();
        verify(mDataProvider, times(2)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        Assert.assertFalse((Boolean) mModel.get(TabResumptionModuleProperties.IS_VISIBLE));
    }

    @Test
    @SmallTest
    public void testEmptySuggestions() {
        List<SuggestionEntry> emptySuggestions = new ArrayList<SuggestionEntry>();
        mMediator.loadModule();
        verify(mDataProvider, times(1)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getValue()
                .onResult(new SuggestionsResult(ResultStrength.TENTATIVE, emptySuggestions));
        Assert.assertFalse((Boolean) mModel.get(TabResumptionModuleProperties.IS_VISIBLE));

        mMediator.loadModule();
        verify(mDataProvider, times(2)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getValue()
                .onResult(new SuggestionsResult(ResultStrength.STABLE, emptySuggestions));
        Assert.assertFalse((Boolean) mModel.get(TabResumptionModuleProperties.IS_VISIBLE));
    }

    @Test
    @SmallTest
    public void testRejectInvalidOrStale() {
        // TabResumptionDataProvider filters (including staleness) and ranks suggestions. Only test
        // the selection and filtering layer in TabResumptionModuleMediator.
        SuggestionEntry entryValid =
                SuggestionEntry.createFromForeignFields(
                        /* sourceName= */ "Desktop",
                        /* url= */ JUnitTestGURLs.GOOGLE_URL_DOG,
                        /* title= */ "Google Dog",
                        /* timestamp= */ makeTimestamp(16, 0, 0));
        // Invalid due to empty title.
        SuggestionEntry entryInvalid =
                SuggestionEntry.createFromForeignFields(
                        /* sourceName= */ "Desktop",
                        /* url= */ JUnitTestGURLs.RED_2,
                        /* title= */ "",
                        /* timestamp= */ makeTimestamp(17, 0, 0));

        List<SuggestionEntry> suggestions = new ArrayList<SuggestionEntry>();
        suggestions.add(entryInvalid);
        suggestions.add(entryValid);
        Collections.sort(suggestions);

        Assert.assertFalse((Boolean) mModel.get(TabResumptionModuleProperties.IS_VISIBLE));
        mMediator.loadModule();
        verify(mDataProvider).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getValue()
                .onResult(new SuggestionsResult(ResultStrength.TENTATIVE, suggestions));

        Assert.assertTrue((Boolean) mModel.get(TabResumptionModuleProperties.IS_VISIBLE));
        Assert.assertEquals(
                "Continue with this tab", mModel.get(TabResumptionModuleProperties.TITLE));

        SuggestionBundle bundle = getSuggestionBundle();
        Assert.assertEquals(CURRENT_TIME_MS, bundle.referenceTimeMs);
        Assert.assertEquals(1, bundle.entries.size());
        Assert.assertEquals(entryValid, bundle.entries.get(0));
    }

    @Test
    @SmallTest
    public void testTakeMostRecent() {
        SuggestionEntry entryNewest =
                SuggestionEntry.createFromForeignFields(
                        /* sourceName= */ "Desktop",
                        /* url= */ JUnitTestGURLs.GOOGLE_URL_DOG,
                        /* title= */ "Google Dog",
                        /* timestamp= */ makeTimestamp(16, 0, 0));
        SuggestionEntry entryNewer =
                SuggestionEntry.createFromForeignFields(
                        /* sourceName= */ "Phone",
                        /* url= */ JUnitTestGURLs.RED_2,
                        /* title= */ "Red 2",
                        /* timestamp= */ makeTimestamp(13, 0, 0));
        SuggestionEntry entryOldest =
                SuggestionEntry.createFromForeignFields(
                        /* sourceName= */ "Desktop",
                        /* url= */ JUnitTestGURLs.BLUE_1,
                        /* title= */ "Blue 1",
                        /* timestamp= */ makeTimestamp(12, 0, 0));

        List<SuggestionEntry> suggestions = new ArrayList<SuggestionEntry>();
        suggestions.add(entryNewest);
        suggestions.add(entryNewer);
        suggestions.add(entryOldest);
        Collections.sort(suggestions);

        mMediator.loadModule();
        verify(mDataProvider).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getValue()
                .onResult(new SuggestionsResult(ResultStrength.TENTATIVE, suggestions));

        Assert.assertTrue((Boolean) mModel.get(TabResumptionModuleProperties.IS_VISIBLE));
        Assert.assertEquals(
                "Continue with these tabs", mModel.get(TabResumptionModuleProperties.TITLE));

        SuggestionBundle bundle = getSuggestionBundle();
        Assert.assertEquals(CURRENT_TIME_MS, bundle.referenceTimeMs);
        Assert.assertEquals(2, bundle.entries.size());
        Assert.assertEquals(entryNewest, bundle.entries.get(0));
        Assert.assertEquals(entryNewer, bundle.entries.get(1));
    }

    @Test
    @SmallTest
    public void testTentativeNothingStableNothing() {
        List<SuggestionEntry> tentativeSuggestions = new ArrayList<SuggestionEntry>();
        List<SuggestionEntry> stableSuggestions1 = new ArrayList<SuggestionEntry>();

        // Tentative suggestions = nothing: Don't fail yet; wait some more.
        mMediator.loadModule();
        verify(mDataProvider, times(1)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(0)
                .onResult(new SuggestionsResult(ResultStrength.TENTATIVE, tentativeSuggestions));
        checkModuleState(
                /* isVisible= */ false,
                /* expectOnDataReadyCalls= */ 0,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);

        // Stable suggestions = nothing: Call onDataFetchFailed().
        mMediator.loadModule();
        verify(mDataProvider, times(2)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(1)
                .onResult(new SuggestionsResult(ResultStrength.STABLE, stableSuggestions1));
        checkModuleState(
                /* isVisible= */ false,
                /* expectOnDataReadyCalls= */ 0,
                /* expectOnDataFetchFailedCalls= */ 1,
                /* expectRemoveModuleCalls= */ 0);
    }

    @Test
    @SmallTest
    public void testTentativeNothingStableSomething() {
        List<SuggestionEntry> tentativeSuggestions = new ArrayList<SuggestionEntry>();
        List<SuggestionEntry> stableSuggestions1 = Arrays.asList(makeSyncDerivedSuggestion(0));

        // Tentative suggestions = nothing: Don't fail yet; wait some more.
        mMediator.loadModule();
        verify(mDataProvider, times(1)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(0)
                .onResult(new SuggestionsResult(ResultStrength.TENTATIVE, tentativeSuggestions));
        checkModuleState(
                /* isVisible= */ false,
                /* expectOnDataReadyCalls= */ 0,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);

        // Stable suggestions = something: Call onDataReady() and show.
        mMediator.loadModule();
        verify(mDataProvider, times(2)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(1)
                .onResult(new SuggestionsResult(ResultStrength.STABLE, stableSuggestions1));
        checkModuleState(
                /* isVisible= */ true,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);
        Assert.assertEquals("Google Dog", getSuggestionBundle().entries.get(0).title);

        // Forced null: Call removeModule().
        mMediator.loadModule();
        verify(mDataProvider, times(3)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(2)
                .onResult(new SuggestionsResult(ResultStrength.FORCED_NULL, null));
        checkModuleState(
                /* isVisible= */ false,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 1);
    }

    @Test
    @SmallTest
    public void testTentativeSomethingStableNothing() {
        List<SuggestionEntry> tentativeSuggestions = Arrays.asList(makeSyncDerivedSuggestion(1));
        List<SuggestionEntry> stableSuggestions1 = new ArrayList<SuggestionEntry>();

        // Tentative suggestions = something: Call onDataReady() and show (tentative).
        mMediator.loadModule();
        verify(mDataProvider, times(1)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(0)
                .onResult(new SuggestionsResult(ResultStrength.TENTATIVE, tentativeSuggestions));
        checkModuleState(
                /* isVisible= */ true,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);
        Assert.assertEquals("Google Cat", getSuggestionBundle().entries.get(0).title);

        // Stable suggestions = nothing: Call removeModule().
        mMediator.loadModule();
        verify(mDataProvider, times(2)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(1)
                .onResult(new SuggestionsResult(ResultStrength.STABLE, stableSuggestions1));
        checkModuleState(
                /* isVisible= */ false,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 1);

        // Reached terminal state: Subsequent loadModule() calls do nothing.
        mMediator.loadModule();
        verify(mDataProvider, times(2)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
    }

    @Test
    @SmallTest
    public void testTentativeSomethingStableSomething() {
        List<SuggestionEntry> tentativeSuggestions = Arrays.asList(makeSyncDerivedSuggestion(0));
        List<SuggestionEntry> stableSuggestions1 =
                Arrays.asList(makeSyncDerivedSuggestion(1), makeSyncDerivedSuggestion(0));
        List<SuggestionEntry> stableSuggestions2 = Arrays.asList(makeSyncDerivedSuggestion(0));
        List<SuggestionEntry> stableSuggestions3 = new ArrayList<SuggestionEntry>();

        // Tentative suggestions = something: Call onDataReady() and show (tentative).
        mMediator.loadModule();
        verify(mDataProvider, times(1)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(0)
                .onResult(new SuggestionsResult(ResultStrength.TENTATIVE, tentativeSuggestions));
        checkModuleState(
                /* isVisible= */ true,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);
        Assert.assertEquals("Google Dog", getSuggestionBundle().entries.get(0).title);

        // Stable suggestions 1 = something: Show stable results.
        mMediator.loadModule();
        verify(mDataProvider, times(2)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(1)
                .onResult(new SuggestionsResult(ResultStrength.STABLE, stableSuggestions1));
        checkModuleState(
                /* isVisible= */ true,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);
        Assert.assertEquals("Google Cat", getSuggestionBundle().entries.get(0).title);
        Assert.assertEquals("Google Dog", getSuggestionBundle().entries.get(1).title);

        // Stable suggestions 2 = something: Update shown stable results.
        mMediator.loadModule();
        verify(mDataProvider, times(3)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(2)
                .onResult(new SuggestionsResult(ResultStrength.STABLE, stableSuggestions2));
        checkModuleState(
                /* isVisible= */ true,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);
        Assert.assertEquals("Google Dog", getSuggestionBundle().entries.get(0).title);

        // Stable suggestions 3 = nothing: Call removeModule().
        mMediator.loadModule();
        verify(mDataProvider, times(4)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(3)
                .onResult(new SuggestionsResult(ResultStrength.STABLE, stableSuggestions3));
        checkModuleState(
                /* isVisible= */ false,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 1);

        // Reached terminal state: Subsequent loadModule() calls do nothing.
        mMediator.loadModule();
        verify(mDataProvider, times(4)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        checkModuleState(
                /* isVisible= */ false,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 1);
    }

    @Test
    @SmallTest
    public void testMaxTilesNumber_Single() {
        testMaxTilesNumberImpl(1);
    }

    @Test
    @SmallTest
    public void testMaxTilesNumber_Double() {
        testMaxTilesNumberImpl(2);
    }

    @Test
    @SmallTest
    public void testShowOnlyOneLocalTab() {
        Tab tab0 = createMockLocalTab(0);
        Tab tab1 = createMockLocalTab(1);
        bindBasicTabObservation(tab0);
        bindBasicTabObservation(tab1);
        List<SuggestionEntry> suggestions =
                Arrays.asList(
                        SuggestionEntry.createFromLocalTab(tab0),
                        SuggestionEntry.createFromLocalTab(tab1));
        mReloadSessionCallbackHelper = new CallbackHelper();

        mMediator.loadModule();
        verify(mDataProvider, times(1)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(0)
                .onResult(new SuggestionsResult(ResultStrength.STABLE, suggestions));
        checkModuleState(
                /* isVisible= */ true,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);
        Assert.assertEquals(1, getSuggestionBundle().entries.size());
        Assert.assertEquals(suggestions.get(0), getSuggestionBundle().entries.get(0));

        // Examine Local Tab observation: Only the shown tab is observed.
        Assert.assertNotNull(mTabObserverMap.get(tab0.getId()));
        Assert.assertNull(mTabObserverMap.get(tab1.getId()));

        // Simulate closing the suggested Local Tab: Causes `reloadSessionCallback` to be called.
        Assert.assertEquals(0, mReloadSessionCounter);
        mTabObserverMap.get(tab0.getId()).onClosingStateChanged(tab0, /* closing= */ false);
        Assert.assertEquals(0, mReloadSessionCounter);
        mTabObserverMap.get(tab0.getId()).onClosingStateChanged(tab0, /* closing= */ true);
        try {
            shadowOf(getMainLooper()).idle();
            mReloadSessionCallbackHelper.waitForNext();
        } catch (TimeoutException e) {
            Assert.fail("Timed out waiting for reload callback.");
        }
        Assert.assertEquals(1, mReloadSessionCounter);

        // Simulate ending session: Observation should be released.
        mMediator.endSession();
        Assert.assertNull(mTabObserverMap.get(tab0.getId()));
        Assert.assertNull(mTabObserverMap.get(tab1.getId()));
    }

    @Test
    @SmallTest
    public void testShowOnlyOneLocalTab_WithForeignTab() {
        Tab tab0 = createMockLocalTab(0);
        Tab tab1 = createMockLocalTab(1);
        List<SuggestionEntry> suggestions =
                Arrays.asList(
                        SuggestionEntry.createFromLocalTab(tab0),
                        SuggestionEntry.createFromLocalTab(tab1),
                        makeSyncDerivedSuggestion(1));

        mMediator.loadModule();
        verify(mDataProvider, times(1)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(0)
                .onResult(new SuggestionsResult(ResultStrength.STABLE, suggestions));
        checkModuleState(
                /* isVisible= */ true,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);
        Assert.assertEquals(2, getSuggestionBundle().entries.size());
        Assert.assertEquals(suggestions.get(0), getSuggestionBundle().entries.get(0));
        Assert.assertEquals(suggestions.get(2), getSuggestionBundle().entries.get(1));
    }

    @Test
    @SmallTest
    public void testTrainingInfoUsageTentativeClick() {
        List<SuggestionEntry> tentativeSuggestions =
                Arrays.asList(makeSyncDerivedSuggestion(0), makeSyncDerivedSuggestion(1));
        List<FakeTrainingInfo> tentativeFakeTrainingInfos =
                addFakeTrainingInfo(tentativeSuggestions, Arrays.asList(1234L, -314159265358L));

        // Tentative suggestions = something: Call onDataReady() and show (tentative).
        mMediator.loadModule();
        verify(mDataProvider, times(1)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(0)
                .onResult(new SuggestionsResult(ResultStrength.TENTATIVE, tentativeSuggestions));
        checkModuleState(
                /* isVisible= */ true,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);
        Assert.assertEquals("Google Dog", getSuggestionBundle().entries.get(0).title);
        Assert.assertEquals("Google Cat", getSuggestionBundle().entries.get(1).title);

        // No user interactions.
        Assert.assertEquals("", tentativeFakeTrainingInfos.get(0).recordHistory);
        Assert.assertEquals("", tentativeFakeTrainingInfos.get(1).recordHistory);

        // User click on entry 1.
        mModel.get(TabResumptionModuleProperties.CLICK_CALLBACK)
                .onSuggestionClicked(tentativeSuggestions.get(1));
        Assert.assertEquals("", tentativeFakeTrainingInfos.get(0).recordHistory);
        Assert.assertEquals("(ACTIVATED)", tentativeFakeTrainingInfos.get(1).recordHistory);

        // Explicitly end session.
        mMediator.endSession();
        Assert.assertEquals("(SEEN)", tentativeFakeTrainingInfos.get(0).recordHistory);
        Assert.assertEquals("(ACTIVATED)(SEEN)", tentativeFakeTrainingInfos.get(1).recordHistory);
    }

    @Test
    @SmallTest
    public void testTrainingInfoUsageTentativeStableStable() {
        List<SuggestionEntry> tentativeSuggestions =
                Arrays.asList(makeSyncDerivedSuggestion(0), makeSyncDerivedSuggestion(1));
        List<SuggestionEntry> stableSuggestions1 =
                Arrays.asList(makeSyncDerivedSuggestion(1), makeSyncDerivedSuggestion(0));
        List<SuggestionEntry> stableSuggestions2 = Arrays.asList(makeSyncDerivedSuggestion(0));

        List<FakeTrainingInfo> tentativeFakeTrainingInfos =
                addFakeTrainingInfo(tentativeSuggestions, Arrays.asList(0L, -1L));
        List<FakeTrainingInfo> stableFakeTrainingInfos1 =
                addFakeTrainingInfo(stableSuggestions1, Arrays.asList(-2024L, -133700000000L));
        List<FakeTrainingInfo> stableFakeTrainingInfos2 =
                addFakeTrainingInfo(stableSuggestions2, Arrays.asList(9876543219876543L));

        // Tentative suggestions = something: Call onDataReady() and show (tentative).
        mMediator.loadModule();
        verify(mDataProvider, times(1)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(0)
                .onResult(new SuggestionsResult(ResultStrength.TENTATIVE, tentativeSuggestions));
        checkModuleState(
                /* isVisible= */ true,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);
        Assert.assertEquals("Google Dog", getSuggestionBundle().entries.get(0).title);
        Assert.assertEquals("Google Cat", getSuggestionBundle().entries.get(1).title);

        // No user interactions.
        Assert.assertEquals("", tentativeFakeTrainingInfos.get(0).recordHistory);
        Assert.assertEquals("", tentativeFakeTrainingInfos.get(1).recordHistory);

        // Stable suggestions 1 = something: Show stable results.
        mMediator.loadModule();
        verify(mDataProvider, times(2)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(1)
                .onResult(new SuggestionsResult(ResultStrength.STABLE, stableSuggestions1));
        checkModuleState(
                /* isVisible= */ true,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);
        Assert.assertEquals("Google Cat", getSuggestionBundle().entries.get(0).title);
        Assert.assertEquals("Google Dog", getSuggestionBundle().entries.get(1).title);

        // No user interactions.
        Assert.assertEquals("", stableFakeTrainingInfos1.get(0).recordHistory);
        Assert.assertEquals("", stableFakeTrainingInfos1.get(1).recordHistory);

        // The *previous* suggestions don't get training info logging since they're TENTATIVE.
        Assert.assertEquals("", tentativeFakeTrainingInfos.get(0).recordHistory);
        Assert.assertEquals("", tentativeFakeTrainingInfos.get(1).recordHistory);

        // Stable suggestions 2 = something: Update shown stable results.
        mMediator.loadModule();
        verify(mDataProvider, times(3)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(2)
                .onResult(new SuggestionsResult(ResultStrength.STABLE, stableSuggestions2));
        checkModuleState(
                /* isVisible= */ true,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);
        Assert.assertEquals("Google Dog", getSuggestionBundle().entries.get(0).title);

        // No user interactions.
        Assert.assertEquals("", stableFakeTrainingInfos2.get(0).recordHistory);

        // The *previous* suggestions get training info logging since they're STABLE.
        Assert.assertEquals("(SEEN)", stableFakeTrainingInfos1.get(0).recordHistory);
        Assert.assertEquals("(SEEN)", stableFakeTrainingInfos1.get(1).recordHistory);

        // Explicitly end session.
        mMediator.endSession();
        Assert.assertEquals("", tentativeFakeTrainingInfos.get(0).recordHistory);
        Assert.assertEquals("", tentativeFakeTrainingInfos.get(1).recordHistory);
        Assert.assertEquals("(SEEN)", stableFakeTrainingInfos1.get(0).recordHistory);
        Assert.assertEquals("(SEEN)", stableFakeTrainingInfos1.get(1).recordHistory);
        Assert.assertEquals("(SEEN)", stableFakeTrainingInfos2.get(0).recordHistory);
    }

    private void testMaxTilesNumberImpl(int maxTilesNumber) {
        TabResumptionModuleUtils.TAB_RESUMPTION_MAX_TILES_NUMBER.setForTesting(maxTilesNumber);
        List<SuggestionEntry> suggestions =
                Arrays.asList(makeSyncDerivedSuggestion(1), makeSyncDerivedSuggestion(0));

        mMediator.loadModule();
        verify(mDataProvider, times(1)).fetchSuggestions(mFetchSuggestionCallbackCaptor.capture());
        mFetchSuggestionCallbackCaptor
                .getAllValues()
                .get(0)
                .onResult(new SuggestionsResult(ResultStrength.STABLE, suggestions));
        checkModuleState(
                /* isVisible= */ true,
                /* expectOnDataReadyCalls= */ 1,
                /* expectOnDataFetchFailedCalls= */ 0,
                /* expectRemoveModuleCalls= */ 0);
        Assert.assertEquals(maxTilesNumber, getSuggestionBundle().entries.size());
    }

    private SuggestionBundle getSuggestionBundle() {
        return (SuggestionBundle) mModel.get(TabResumptionModuleProperties.SUGGESTION_BUNDLE);
    }

    private List<FakeTrainingInfo> addFakeTrainingInfo(
            List<SuggestionEntry> entries, List<Long> requestIds) {
        int n = entries.size();
        assert requestIds.size() == n;
        List<FakeTrainingInfo> fakeTrainingInfos = new ArrayList<FakeTrainingInfo>();
        for (int i = 0; i < n; ++i) {
            SuggestionEntry entry = entries.get(i);
            Assert.assertNull(entry.trainingInfo);
            FakeTrainingInfo traingInfo = new FakeTrainingInfo(entry, requestIds.get(i));
            fakeTrainingInfos.add(traingInfo);
            entry.trainingInfo = traingInfo;
        }
        return fakeTrainingInfos;
    }

    /** Simulates behavior of Tab.{addObserver(),removeObserver()}, captured in `mTabObserveMap` */
    private void bindBasicTabObservation(Tab tab) {
        doAnswer(
                        (InvocationOnMock invocation) -> {
                            // Enforce our restriction that a Tab can have at most one observer.
                            Assert.assertFalse(mTabObserverMap.containsKey(tab.getId()));
                            mTabObserverMap.put(
                                    tab.getId(), (TabObserver) invocation.getArguments()[0]);
                            return null;
                        })
                .when(tab)
                .addObserver(any(TabObserver.class));

        doAnswer(
                        (InvocationOnMock invocation) -> {
                            Assert.assertEquals(
                                    mTabObserverMap.get(tab.getId()),
                                    (TabObserver) invocation.getArguments()[0]);
                            mTabObserverMap.remove(tab.getId());
                            return null;
                        })
                .when(tab)
                .removeObserver(any(TabObserver.class));
    }
}