chromium/chrome/browser/ui/android/hats/java/src/org/chromium/chrome/browser/ui/hats/MessageSurveyUiDelegateUnitTest.java

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.ui.hats;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;

import android.content.res.Resources;

import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;

import org.chromium.base.ContextUtils;
import org.chromium.base.ObserverList;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabHidingType;
import org.chromium.chrome.browser.tab.TabObserver;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tabmodel.TabModelSelector;
import org.chromium.chrome.browser.tabmodel.TabModelSelectorObserver;
import org.chromium.chrome.browser.ui.hats.MessageSurveyUiDelegate.State;
import org.chromium.components.messages.DismissReason;
import org.chromium.components.messages.MessageBannerProperties;
import org.chromium.components.messages.MessageDispatcher;
import org.chromium.components.messages.MessageIdentifier;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.test.util.MockitoHelper;
import org.chromium.url.JUnitTestGURLs;

/** Unit test for {@link MessageSurveyUiDelegate}. */
@RunWith(BaseRobolectricTestRunner.class)
public class MessageSurveyUiDelegateUnitTest {
    @Rule public MockitoRule mMockitoRule = MockitoJUnit.rule();

    private MessageSurveyUiDelegate mMessageSurveyUiDelegate;
    private PropertyModel mModel;
    private SurveyTestTabModelHelper mTabModelHelper;
    private TestMessageDispatcher mTestMessageDispatcher;

    @Mock TabModelSelector mTabModelSelector;
    @Mock private Tab mTab;
    private boolean mCrashUploadAllowed = true;

    private final CallbackHelper mOnAcceptedCallback = new CallbackHelper();
    private final CallbackHelper mOnDeclinedCallback = new CallbackHelper();
    private final CallbackHelper mOnPresentationFailedCallback = new CallbackHelper();

    @Before
    public void setup() {
        mTabModelHelper = new SurveyTestTabModelHelper();
        mTestMessageDispatcher = new TestMessageDispatcher();
        mModel =
                new PropertyModel.Builder(MessageBannerProperties.ALL_KEYS)
                        .with(
                                MessageBannerProperties.MESSAGE_IDENTIFIER,
                                MessageIdentifier.CHROME_SURVEY)
                        .build();
        mMessageSurveyUiDelegate =
                new MessageSurveyUiDelegate(
                        mModel,
                        mTestMessageDispatcher,
                        mTabModelSelector,
                        () -> mCrashUploadAllowed);
    }

    @After
    public void tearDown() {
        mTabModelHelper.assertAllObserverDetached();
    }

    @Test
    public void showSurveyInvitationSuccess() {
        mTabModelHelper.skipToReadyForSurvey();
        showSurveyInvitation();
        mTestMessageDispatcher.assertMessageEnqueued(true);

        mTestMessageDispatcher.acceptMessage();
        assertEquals(
                "Delegate state should end at ACCEPTED.",
                State.ACCEPTED,
                mMessageSurveyUiDelegate.getStateForTesting());
        assertEquals("OnAcceptedCallback is not called.", 1, mOnAcceptedCallback.getCallCount());
    }

    @Test
    public void dismissMessageWhenTabHidden() {
        mTabModelHelper.tabStateInitialized();
        showSurveyInvitation();

        mTabModelHelper.skipToReadyForSurvey();
        mTestMessageDispatcher.assertMessageEnqueued(true);

        mTabModelHelper.tabHidden(true);
        mTestMessageDispatcher.assertMessageDismissed(DismissReason.TAB_SWITCHED);
        assertEquals("OnDeclinedCallback not called.", 1, mOnDeclinedCallback.getCallCount());
        assertEquals(
                "Delegate state should end at DISMISSED.",
                State.DISMISSED,
                mMessageSurveyUiDelegate.getStateForTesting());
    }

    @Test
    public void dismissMessageByDismissCall() {
        mTabModelHelper.tabStateInitialized();
        showSurveyInvitation();

        mTabModelHelper.skipToReadyForSurvey();
        mTestMessageDispatcher.assertMessageEnqueued(true);

        mMessageSurveyUiDelegate.dismiss();
        mTestMessageDispatcher.assertMessageDismissed(DismissReason.DISMISSED_BY_FEATURE);
        assertEquals(
                "Delegate state should end at DISMISSED.",
                State.DISMISSED,
                mMessageSurveyUiDelegate.getStateForTesting());
        assertEquals(
                "OnPresentationFailedCallback not called.", 1, mOnDeclinedCallback.getCallCount());
    }

    @Test
    public void cancelWhenCrashUploadDisabled() {
        mTabModelHelper.tabStateInitialized();
        showSurveyInvitation();

        mCrashUploadAllowed = false;
        mTabModelHelper.skipToReadyForSurvey();
        mTestMessageDispatcher.assertMessageEnqueued(false);
        assertEquals(
                "Delegate state should end at NOT_PRESENTED since message is never enqueued.",
                State.NOT_PRESENTED,
                mMessageSurveyUiDelegate.getStateForTesting());
        assertEquals(
                "OnPresentationFailedCallback not called.",
                1,
                mOnPresentationFailedCallback.getCallCount());
    }

    @Test
    public void cancelBeforeRequestShown() {
        assertEquals(
                "Delegate is not started when created.",
                State.NOT_STARTED,
                mMessageSurveyUiDelegate.getStateForTesting());

        mMessageSurveyUiDelegate.dismiss();
        assertEquals(
                "Delegate state should end at NOT_PRESENTED.",
                State.NOT_PRESENTED,
                mMessageSurveyUiDelegate.getStateForTesting());
        assertEquals(
                "OnPresentationFailedCallback not called, since we never requested.",
                0,
                mOnPresentationFailedCallback.getCallCount());
    }

    @Test
    public void cancelBeforeEnqueued() {
        showSurveyInvitation();
        mTestMessageDispatcher.assertMessageEnqueued(false);
        assertEquals(
                "State should stay at requested while waiting.",
                State.REQUESTED,
                mMessageSurveyUiDelegate.getStateForTesting());

        mMessageSurveyUiDelegate.dismiss();
        assertEquals(
                "Delegate state should end at NOT_PRESENTED.",
                State.NOT_PRESENTED,
                mMessageSurveyUiDelegate.getStateForTesting());
        assertEquals(
                "OnPresentationFailedCallback should be called after requested.",
                1,
                mOnPresentationFailedCallback.getCallCount());
    }

    @Test
    public void cancelWhenTabHiddenWhenLoading() {
        showSurveyInvitation();

        mTabModelHelper.tabStateInitialized().tabHidden(true);
        mTestMessageDispatcher.assertMessageEnqueued(false);
        assertEquals(
                "When tab is hidden before presenting the survey, cancel the request.",
                State.NOT_PRESENTED,
                mMessageSurveyUiDelegate.getStateForTesting());
        assertEquals(
                "OnPresentationFailedCallback is not called.",
                1,
                mOnPresentationFailedCallback.getCallCount());
    }

    @Test
    public void cancelBeforeTabFullyLoaded() {
        showSurveyInvitation();
        mTestMessageDispatcher.assertMessageEnqueued(false);
        assertEquals(
                "State should stay at requested while waiting.",
                State.REQUESTED,
                mMessageSurveyUiDelegate.getStateForTesting());

        mTabModelHelper.tabStateInitialized();

        mMessageSurveyUiDelegate.dismiss();
        assertEquals(
                "Delegate state should end at NOT_PRESENTED.",
                State.NOT_PRESENTED,
                mMessageSurveyUiDelegate.getStateForTesting());
        assertEquals(
                "Survey NOT_PRESENTED callback is called after requested.",
                1,
                mOnPresentationFailedCallback.getCallCount());
    }

    @Test
    public void cancelBeforeTabIsReadyForSurvey() {
        showSurveyInvitation();
        mTestMessageDispatcher.assertMessageEnqueued(false);
        assertEquals(
                "State should stay at requested while waiting.",
                State.REQUESTED,
                mMessageSurveyUiDelegate.getStateForTesting());

        mTabModelHelper.tabStateInitialized().tabFullyLoaded();

        mMessageSurveyUiDelegate.dismiss();
        assertEquals(
                "Delegate state should end at NOT_PRESENTED.",
                State.NOT_PRESENTED,
                mMessageSurveyUiDelegate.getStateForTesting());
        assertEquals(
                "Survey NOT_PRESENTED callback is called after requested.",
                1,
                mOnPresentationFailedCallback.getCallCount());
    }

    @Test
    public void cancelWhenSwitchedIntoIncognito() {
        mTabModelHelper.tabStateInitialized();
        showSurveyInvitation();

        mTabModelHelper.switchToIncognito(true);
        mTabModelHelper.skipToReadyForSurvey();
        mTestMessageDispatcher.assertMessageEnqueued(false);
        assertEquals(
                "Delegate state should end at NOT_PRESENTED since message is never enqueued.",
                State.NOT_PRESENTED,
                mMessageSurveyUiDelegate.getStateForTesting());
        assertEquals(
                "OnPresentationFailedCallback not called.",
                1,
                mOnPresentationFailedCallback.getCallCount());
    }

    @Test
    public void noSurveyInvitationInIncognito() {
        mTabModelHelper.switchToIncognito(true);
        showSurveyInvitation();

        mTestMessageDispatcher.assertMessageEnqueued(false);
        assertEquals(
                "Never show survey invitation in incognito.",
                State.NOT_PRESENTED,
                mMessageSurveyUiDelegate.getStateForTesting());
        assertEquals(
                "OnPresentationFailedCallback is not called.",
                1,
                mOnPresentationFailedCallback.getCallCount());
    }

    @Test
    public void noStateUpdateAfterAccepted() {
        mTabModelHelper.skipToReadyForSurvey();
        showSurveyInvitation();
        mTestMessageDispatcher.acceptMessage();
        assertEquals(
                "Delegate state should end as ACCEPTED",
                mMessageSurveyUiDelegate.getStateForTesting(),
                State.ACCEPTED);

        mMessageSurveyUiDelegate.dismiss();
        assertEquals(
                "Delegate state should remain unchanged.",
                mMessageSurveyUiDelegate.getStateForTesting(),
                State.ACCEPTED);
    }

    @Test
    public void noDuplicateShowSurveyInvitation() {
        mTabModelHelper.skipToReadyForSurvey();

        showSurveyInvitation();
        mTestMessageDispatcher.assertMessageEnqueued(true);

        // Try to reuse the delegate to show message again will fail.
        assertThrows(
                "The 2nd #showSurveyInvitation should throw an AssertionError.",
                AssertionError.class,
                this::showSurveyInvitation);
        mMessageSurveyUiDelegate.dismiss();
    }

    @Test
    public void createDefaultMessageModel() {
        PropertyModel model =
                MessageSurveyUiDelegate.populateDefaultValuesForSurveyMessage(
                        ContextUtils.getApplicationContext().getResources(), mModel);

        Resources resources = ContextUtils.getApplicationContext().getResources();
        String defaultTitle = resources.getString(R.string.chrome_survey_message_title);
        String defaultButtonText = resources.getString(R.string.chrome_survey_message_button);
        assertEquals("Title is different.", defaultTitle, model.get(MessageBannerProperties.TITLE));
        assertEquals(
                "Icon resource Id different.",
                R.drawable.fre_product_logo,
                model.get(MessageBannerProperties.ICON_RESOURCE_ID));
        assertEquals(
                "Primary button text is different.",
                defaultButtonText,
                model.get(MessageBannerProperties.PRIMARY_BUTTON_TEXT));
    }

    @Test
    public void createDefaultMessageModelWithValuePopulated() {
        int iconRes = 1235415;
        String myTitle = "my title";
        String myButtonText = "my button text";
        mModel.set(MessageBannerProperties.ICON_RESOURCE_ID, iconRes);
        mModel.set(MessageBannerProperties.TITLE, myTitle);
        mModel.set(MessageBannerProperties.PRIMARY_BUTTON_TEXT, myButtonText);
        mModel.set(MessageBannerProperties.DESCRIPTION, "my description");

        MessageSurveyUiDelegate.populateDefaultValuesForSurveyMessage(
                ContextUtils.getApplicationContext().getResources(), mModel);

        assertTrue(mModel.containsKey(MessageBannerProperties.MESSAGE_IDENTIFIER));
        assertEquals("Title is override.", myTitle, mModel.get(MessageBannerProperties.TITLE));
        assertEquals(
                "Icon resource Id is override.",
                iconRes,
                mModel.get(MessageBannerProperties.ICON_RESOURCE_ID));
        assertEquals(
                "Primary button text is override.",
                myButtonText,
                mModel.get(MessageBannerProperties.PRIMARY_BUTTON_TEXT));
    }

    private void showSurveyInvitation() {
        mMessageSurveyUiDelegate.showSurveyInvitation(
                mOnAcceptedCallback::notifyCalled,
                mOnDeclinedCallback::notifyCalled,
                mOnPresentationFailedCallback::notifyCalled);
    }

    private class SurveyTestTabModelHelper {
        private boolean mTabStateInitialized;
        private boolean mTabFullyLoaded;
        private boolean mTabIsHidden;
        private final ObserverList<TabModelSelectorObserver> mTabModelSelectorObserverCaptor =
                new ObserverList<>();
        private final ObserverList<TabObserver> mTabObserverCaptor = new ObserverList<>();

        SurveyTestTabModelHelper() {
            MockitoHelper.doCallback(mTabModelSelectorObserverCaptor::addObserver)
                    .when(mTabModelSelector)
                    .addObserver(any(TabModelSelectorObserver.class));
            MockitoHelper.doCallback(mTabModelSelectorObserverCaptor::removeObserver)
                    .when(mTabModelSelector)
                    .removeObserver(any(TabModelSelectorObserver.class));
            MockitoHelper.doCallback(mTabObserverCaptor::addObserver)
                    .when(mTab)
                    .addObserver(any(TabObserver.class));
            MockitoHelper.doCallback(mTabObserverCaptor::removeObserver)
                    .when(mTab)
                    .removeObserver(any(TabObserver.class));
        }

        SurveyTestTabModelHelper switchToIncognito(boolean isIncognito) {
            doReturn(isIncognito).when(mTabModelSelector).isIncognitoSelected();
            return this;
        }

        SurveyTestTabModelHelper tabStateInitialized() {
            mTabStateInitialized = true;

            doReturn(mTab).when(mTabModelSelector).getCurrentTab();
            doReturn(true).when(mTab).isLoading();
            doReturn(false).when(mTab).isUserInteractable();

            for (var observers : mTabModelSelectorObserverCaptor) {
                observers.onChange();
            }
            return this;
        }

        SurveyTestTabModelHelper tabFullyLoaded() {
            mTabFullyLoaded = true;

            doReturn(false).when(mTab).isLoading();
            for (var observer : mTabObserverCaptor) {
                observer.onPageLoadFinished(mTab, JUnitTestGURLs.EXAMPLE_URL);
            }
            return this;
        }

        SurveyTestTabModelHelper tabInteractabilityChanged(boolean isIntractable) {
            doReturn(isIntractable).when(mTab).isUserInteractable();
            for (var observer : mTabObserverCaptor) {
                observer.onInteractabilityChanged(mTab, isIntractable);
            }
            return this;
        }

        SurveyTestTabModelHelper tabHidden(boolean isHidden) {
            if (mTabIsHidden == isHidden) return this;

            mTabIsHidden = isHidden;
            doReturn(isHidden).when(mTab).isHidden();
            for (var observer : mTabObserverCaptor) {
                if (isHidden) {
                    observer.onHidden(mTab, TabHidingType.ACTIVITY_HIDDEN);
                } else {
                    observer.onShown(mTab, TabSelectionType.FROM_USER);
                }
            }
            return this;
        }

        void skipToReadyForSurvey() {
            if (!mTabStateInitialized) tabStateInitialized();
            if (!mTabFullyLoaded) tabFullyLoaded();

            tabInteractabilityChanged(true).tabHidden(false);
        }

        void assertAllObserverDetached() {
            if (!mTabModelSelectorObserverCaptor.isEmpty()) {
                // Use a mockito verification that should always fail
                verify(
                                mTabModelSelector,
                                never().description("TabModelSelectorObserver is not detached."))
                        .addObserver(any());
            }
            if (!mTabObserverCaptor.isEmpty()) {
                verify(mTab, never().description("TabObserver(s) are not detached."))
                        .addObserver(any());
            }
        }
    }

    private static class TestMessageDispatcher implements MessageDispatcher {
        PropertyModel mModel;
        Integer mDismissedReason;

        @Override
        public void enqueueMessage(
                PropertyModel messageProperties,
                WebContents webContents,
                int scopeType,
                boolean highPriority) {
            throw new UnsupportedOperationException("Should not be used in this test");
        }

        @Override
        public void enqueueWindowScopedMessage(
                PropertyModel messageProperties, boolean highPriority) {
            mModel = messageProperties;
        }

        @Override
        public void dismissMessage(PropertyModel messageProperties, int dismissReason) {
            mModel.get(MessageBannerProperties.ON_DISMISSED).onResult(dismissReason);
            mDismissedReason = dismissReason;
        }

        void acceptMessage() {
            mModel.get(MessageBannerProperties.ON_PRIMARY_ACTION).get();
            dismissMessage(mModel, DismissReason.PRIMARY_ACTION);
        }

        void assertMessageEnqueued(boolean hasEnqueued) {
            assertEquals("Message is not enqueued.", hasEnqueued, mModel != null);
        }

        void assertMessageDismissed(int dismissedReason) {
            assertEquals(
                    "Message is not dismissed the same reason.",
                    (int) mDismissedReason,
                    dismissedReason);
        }
    }
}