chromium/components/messages/android/internal/java/src/org/chromium/components/messages/MessageQueueManagerTest.java

// Copyright 2020 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.components.messages;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.description;
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.times;
import static org.mockito.Mockito.verify;

import android.animation.Animator;
import android.animation.AnimatorSet;

import androidx.test.filters.SmallTest;

import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.robolectric.annotation.Config;

import org.chromium.base.ActivityState;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.Features.EnableFeatures;
import org.chromium.base.test.util.HistogramWatcher;
import org.chromium.components.messages.MessageQueueManager.MessageState;
import org.chromium.components.messages.MessageScopeChange.ChangeType;
import org.chromium.components.messages.MessageStateHandler.Position;
import org.chromium.content_public.browser.Visibility;
import org.chromium.content_public.browser.test.mock.MockWebContents;
import org.chromium.ui.base.WindowAndroid;

/** Unit tests for MessageQueueManager. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
@EnableFeatures({MessageFeatureList.MESSAGES_ANDROID_EXTRA_HISTOGRAMS})
public class MessageQueueManagerTest {

    private MessageQueueDelegate mEmptyDelegate =
            new MessageQueueDelegate() {
                boolean mIsReadyForShowing;

                @Override
                public void onRequestShowing(Runnable callback) {
                    mIsReadyForShowing = true;
                    callback.run();
                }

                @Override
                public void onFinishHiding() {
                    mIsReadyForShowing = false;
                }

                @Override
                public void onAnimationStart() {}

                @Override
                public void onAnimationEnd() {}

                @Override
                public boolean isReadyForShowing() {
                    return mIsReadyForShowing;
                }

                @Override
                public boolean isPendingShow() {
                    return false;
                }

                @Override
                public boolean isDestroyed() {
                    return false;
                }

                @Override
                public boolean isSwitchingScope() {
                    return false;
                }
            };

    private class EmptyMessageStateHandler implements MessageStateHandler {

        private int mId = MessageIdentifier.TEST_MESSAGE;

        public EmptyMessageStateHandler(int id) {
            mId = id;
        }

        public EmptyMessageStateHandler() {}

        @Override
        public Animator show(int fromIndex, int toIndex) {
            return new AnimatorSet();
        }

        @Override
        public Animator hide(int fromIndex, int toIndex, boolean animate) {
            return new AnimatorSet();
        }

        @Override
        public void dismiss(@DismissReason int dismissReason) {}

        @Override
        public int getMessageIdentifier() {
            return mId;
        }
    }

    private static class ActiveMockWebContents extends MockWebContents {
        @Override
        public @Visibility int getVisibility() {
            return Visibility.VISIBLE;
        }
    }

    private static class InactiveMockWebContents extends MockWebContents {
        @Override
        public @Visibility int getVisibility() {
            return Visibility.HIDDEN;
        }
    }

    private static class MockWindowAndroidWebContents extends MockWebContents {
        @Override
        public WindowAndroid getTopLevelNativeWindow() {
            // WindowAndroid includes some APIs not available on L. Do not mock this
            // on Android L.
            WindowAndroid windowAndroid = mock(WindowAndroid.class);
            doNothing().when(windowAndroid).addActivityStateObserver(any());
            doReturn(ActivityState.RESUMED).when(windowAndroid).getActivityState();
            return windowAndroid;
        }
    }

    private static final int SCOPE_TYPE = MessageScopeType.NAVIGATION;
    private static final ScopeKey SCOPE_INSTANCE_ID =
            new ScopeKey(SCOPE_TYPE, new ActiveMockWebContents());

    private static final ScopeKey SCOPE_INSTANCE_ID_A =
            new ScopeKey(SCOPE_TYPE, new ActiveMockWebContents());

    private MessageAnimationCoordinator mAnimationCoordinator;

    @Before
    public void setUp() {
        MessageContainer container = Mockito.mock(MessageContainer.class);
        doAnswer(
                        invocation -> {
                            Runnable runnable = invocation.getArgument(0);
                            runnable.run();
                            return null;
                        })
                .when(container)
                .runAfterInitialMessageLayout(any(Runnable.class));
        doReturn(false).when(container).isIsInitializingLayout();
        mAnimationCoordinator = new MessageAnimationCoordinator(container, Animator::start);
    }

    /**
     * Tests lifecycle of a single message: - enqueueMessage() calls show() - dismissMessage() calls
     * hide() and dismiss()
     */
    @Test
    @SmallTest
    public void testEnqueueMessage() {
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(mEmptyDelegate);
        MessageStateHandler m1 =
                Mockito.spy(new EmptyMessageStateHandler(MessageIdentifier.POPUP_BLOCKED));
        MessageStateHandler m2 =
                Mockito.spy(new EmptyMessageStateHandler(MessageIdentifier.SYNC_ERROR));

        var enqueued =
                HistogramWatcher.newBuilder()
                        .expectIntRecord("Android.Messages.Enqueued", m1.getMessageIdentifier())
                        .expectIntRecord(
                                "Android.Messages.Enqueued.Visible", m1.getMessageIdentifier())
                        .expectNoRecords("Android.Messages.Enqueued.Hiding")
                        .expectNoRecords("Android.Messages.Enqueued.Hidden")
                        .build();
        var dismissed =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.Messages.Dismissed.PopupBlocked", DismissReason.TIMER);
        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        enqueued.assertExpected();

        verify(m1).show(eq(Position.INVISIBLE), eq(Position.FRONT));
        queueManager.dismissMessage(m1, DismissReason.TIMER);
        verify(m1).hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
        verify(m1).dismiss(DismissReason.TIMER);
        dismissed.assertExpected();

        enqueued =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.Messages.Enqueued", m2.getMessageIdentifier());
        dismissed =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.Messages.Dismissed.SyncError", DismissReason.TIMER);
        queueManager.enqueueMessage(m2, m2, SCOPE_INSTANCE_ID, false);
        enqueued.assertExpected();
        verify(m2).show(eq(Position.INVISIBLE), eq(Position.FRONT));
        queueManager.dismissMessage(m2, DismissReason.TIMER);
        dismissed.assertExpected();
        verify(m2).hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
        verify(m2).dismiss(DismissReason.TIMER);
    }

    /**
     * Tests lifecycle of a single message: - enqueueMessage() calls show() - dismissMessage() calls
     * hide() and dismiss() when a queue is enqueued with multiple messages
     */
    @Test
    @SmallTest
    public void testEnqueueMultipleMessages() {
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(mEmptyDelegate);
        MessageStateHandler m1 =
                Mockito.spy(new EmptyMessageStateHandler(MessageIdentifier.POPUP_BLOCKED));
        MessageStateHandler m2 =
                Mockito.spy(new EmptyMessageStateHandler(MessageIdentifier.SYNC_ERROR));

        var enqueued =
                HistogramWatcher.newBuilder()
                        .expectIntRecords(
                                "Android.Messages.Enqueued",
                                m1.getMessageIdentifier(),
                                m2.getMessageIdentifier())
                        .expectIntRecord(
                                "Android.Messages.Enqueued.Visible", m1.getMessageIdentifier())
                        .expectIntRecord(
                                "Android.Messages.Enqueued.Hiding", m1.getMessageIdentifier())
                        .expectIntRecord(
                                "Android.Messages.Enqueued.Hidden", m2.getMessageIdentifier())
                        .build();
        var dismissed =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.Messages.Dismissed.PopupBlocked", DismissReason.TIMER);
        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        queueManager.enqueueMessage(m2, m2, SCOPE_INSTANCE_ID, false);
        enqueued.assertExpected();

        verify(m1).show(eq(Position.INVISIBLE), eq(Position.FRONT));
        queueManager.dismissMessage(m1, DismissReason.TIMER);
        verify(m1).hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
        verify(m1).dismiss(DismissReason.TIMER);
        dismissed.assertExpected();

        dismissed =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.Messages.Dismissed.SyncError", DismissReason.TIMER);
        enqueued.assertExpected();
        verify(m2).show(eq(Position.BACK), eq(Position.FRONT));
        queueManager.dismissMessage(m2, DismissReason.TIMER);
        dismissed.assertExpected();
        verify(m2).hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
        verify(m2).dismiss(DismissReason.TIMER);
    }

    /** Histograms are recorded with whether queue is suspended. */
    @Test
    @SmallTest
    public void testEnqueueWithQueueSuspension() {
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(mEmptyDelegate);
        int token = queueManager.suspend();

        MessageStateHandler m1 =
                Mockito.spy(new EmptyMessageStateHandler(MessageIdentifier.POPUP_BLOCKED));
        MessageStateHandler m2 =
                Mockito.spy(new EmptyMessageStateHandler(MessageIdentifier.SYNC_ERROR));

        var enqueued =
                HistogramWatcher.newBuilder()
                        .expectIntRecords(
                                "Android.Messages.Enqueued",
                                m1.getMessageIdentifier(),
                                m2.getMessageIdentifier())
                        .expectIntRecords(
                                "Android.Messages.Enqueued.Suspended",
                                m1.getMessageIdentifier(),
                                m2.getMessageIdentifier())
                        .expectNoRecords("Android.Messages.Enqueued.Resumed")
                        .build();

        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        queueManager.enqueueMessage(m2, m2, SCOPE_INSTANCE_ID, false);
        enqueued.assertExpected();

        MessageStateHandler m3 =
                Mockito.spy(new EmptyMessageStateHandler(MessageIdentifier.ABOUT_THIS_SITE));
        MessageStateHandler m4 =
                Mockito.spy(new EmptyMessageStateHandler(MessageIdentifier.DOWNLOAD_PROGRESS));
        enqueued =
                HistogramWatcher.newBuilder()
                        .expectIntRecords(
                                "Android.Messages.Enqueued",
                                m3.getMessageIdentifier(),
                                m4.getMessageIdentifier())
                        .expectIntRecords(
                                "Android.Messages.Enqueued.Resumed",
                                m3.getMessageIdentifier(),
                                m4.getMessageIdentifier())
                        .expectNoRecords("Android.Messages.Enqueued.Suspended")
                        .build();
        queueManager.resume(token);

        queueManager.enqueueMessage(m3, m3, SCOPE_INSTANCE_ID, false);
        queueManager.enqueueMessage(m4, m4, SCOPE_INSTANCE_ID, false);
        enqueued.assertExpected();
    }

    /** Histograms are recorded with whether scope is active. */
    @Test
    @SmallTest
    public void testEnqueueWithScopeActivation() {
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(mEmptyDelegate);

        MessageStateHandler m1 =
                Mockito.spy(new EmptyMessageStateHandler(MessageIdentifier.POPUP_BLOCKED));
        MessageStateHandler m2 =
                Mockito.spy(new EmptyMessageStateHandler(MessageIdentifier.SYNC_ERROR));
        MessageStateHandler m3 =
                Mockito.spy(new EmptyMessageStateHandler(MessageIdentifier.DOWNLOAD_PROGRESS));

        var enqueued =
                HistogramWatcher.newBuilder()
                        .expectIntRecords(
                                "Android.Messages.Enqueued",
                                m1.getMessageIdentifier(),
                                m2.getMessageIdentifier(),
                                m3.getMessageIdentifier())
                        .expectIntRecords(
                                "Android.Messages.Enqueued.ScopeInactive",
                                m2.getMessageIdentifier())
                        .expectIntRecords(
                                "Android.Messages.Enqueued.ScopeActive",
                                m1.getMessageIdentifier(),
                                m3.getMessageIdentifier())
                        .build();

        final ScopeKey inactiveScopeKey = new ScopeKey(SCOPE_TYPE, new InactiveMockWebContents());
        final ScopeKey windowScopeKey =
                new ScopeKey(new MockWindowAndroidWebContents().getTopLevelNativeWindow());
        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        queueManager.enqueueMessage(m2, m2, inactiveScopeKey, true);
        queueManager.enqueueMessage(m3, m3, windowScopeKey, false);
        enqueued.assertExpected();

        // Do not record again when there scopes are updated
        enqueued =
                HistogramWatcher.newBuilder()
                        .expectNoRecords("Android.Messages.Enqueued.ScopeInactive")
                        .expectNoRecords("Android.Messages.Enqueued.ScopeActive")
                        .build();
        queueManager.onScopeChange(
                new MessageScopeChange(
                        MessageScopeType.NAVIGATION, SCOPE_INSTANCE_ID, ChangeType.INACTIVE));
        queueManager.onScopeChange(
                new MessageScopeChange(
                        MessageScopeType.NAVIGATION, inactiveScopeKey, ChangeType.ACTIVE));
        enqueued.assertExpected();
    }

    /** Test method {@link MessageQueueManager#dismissAllMessages(int)}. */
    @Test
    @SmallTest
    public void testDismissAllMessages() {
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(mEmptyDelegate);
        MessageStateHandler m1 = Mockito.spy(new EmptyMessageStateHandler());
        MessageStateHandler m2 = Mockito.spy(new EmptyMessageStateHandler());
        MessageStateHandler m3 = Mockito.spy(new EmptyMessageStateHandler());

        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        queueManager.enqueueMessage(m2, m2, SCOPE_INSTANCE_ID, false);
        queueManager.enqueueMessage(m3, m3, SCOPE_INSTANCE_ID_A, false);

        var dismissed =
                HistogramWatcher.newBuilder()
                        .expectIntRecordTimes(
                                "Android.Messages.Dismissed.TestMessage",
                                DismissReason.ACTIVITY_DESTROYED,
                                3)
                        .build();
        queueManager.dismissAllMessages(DismissReason.ACTIVITY_DESTROYED);
        dismissed.assertExpected();
        verify(m1).dismiss(DismissReason.ACTIVITY_DESTROYED);
        verify(m2).dismiss(DismissReason.ACTIVITY_DESTROYED);
        verify(m3).dismiss(DismissReason.ACTIVITY_DESTROYED);

        Assert.assertTrue(
                "#dismissAllMessages should clear the message queue.",
                queueManager.getMessagesForTesting().isEmpty());
    }

    /**
     * Tests that, when the message is dismissed before it was shown, neither show() nor hide() is
     * called.
     */
    @Test
    @SmallTest
    public void testDismissBeforeShow() {
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(mEmptyDelegate);
        MessageStateHandler m1 = Mockito.mock(MessageStateHandler.class);
        MessageStateHandler m2 = Mockito.mock(MessageStateHandler.class);

        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        queueManager.enqueueMessage(m2, m2, SCOPE_INSTANCE_ID, false);
        verify(m1).show(eq(Position.INVISIBLE), eq(Position.FRONT));
        verify(m2, never()).show(eq(Position.INVISIBLE), eq(Position.FRONT));

        queueManager.dismissMessage(m2, DismissReason.TIMER);

        verify(m2).dismiss(DismissReason.TIMER);
        queueManager.dismissMessage(m1, DismissReason.TIMER);
        Assert.assertNull(queueManager.getNextMessage());
        verify(m2, never()).show(eq(Position.INVISIBLE), eq(Position.FRONT));
        verify(m2, never()).hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
    }

    /**
     * Tests that enqueueing two messages with the same key is not allowed, it results in
     * IllegalStateException.
     */
    @Test(expected = IllegalStateException.class)
    @SmallTest
    public void testEnqueueDuplicateKey() {
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(mEmptyDelegate);
        MessageStateHandler m1 = Mockito.mock(MessageStateHandler.class);
        MessageStateHandler m2 = Mockito.mock(MessageStateHandler.class);
        Object key = new Object();

        queueManager.enqueueMessage(m1, key, SCOPE_INSTANCE_ID, false);
        queueManager.enqueueMessage(m2, key, SCOPE_INSTANCE_ID, false);
        queueManager.onScopeChange(
                new MessageScopeChange(SCOPE_TYPE, SCOPE_INSTANCE_ID, ChangeType.ACTIVE));
    }

    /** Tests that dismissing a message more than once is handled correctly. */
    @Test
    @SmallTest
    public void testDismissMessageTwice() {
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(mEmptyDelegate);
        MessageStateHandler m1 = Mockito.spy(new EmptyMessageStateHandler());
        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        queueManager.dismissMessage(m1, DismissReason.TIMER);
        queueManager.dismissMessage(m1, DismissReason.TIMER);
        verify(m1, times(1)).dismiss(DismissReason.TIMER);
    }

    /** Tests that delegate methods are properly called when queue is suspended and resumed. */
    @Test
    @SmallTest
    public void testSuspendAndResumeQueue() {
        MessageQueueDelegate delegate = Mockito.spy(mEmptyDelegate);
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(delegate);
        int token = queueManager.suspend();
        MessageStateHandler m1 = Mockito.spy(new EmptyMessageStateHandler());
        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        verify(delegate, never()).onRequestShowing(any());
        verify(delegate, never()).onFinishHiding();
        verify(m1, never()).show(eq(Position.INVISIBLE), eq(Position.FRONT));
        verify(m1, never()).hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());

        queueManager.resume(token);
        verify(delegate).onRequestShowing(any());
        verify(m1).show(eq(Position.INVISIBLE), eq(Position.FRONT));

        queueManager.suspend();
        verify(delegate).onFinishHiding();
        verify(m1).hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
    }

    /** Tests that delegate methods are properly called to show/hide message when queue is suspended. */
    @Test
    @SmallTest
    public void testDismissOnSuspend() {
        MessageQueueDelegate delegate = Mockito.spy(mEmptyDelegate);
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(delegate);
        queueManager.suspend();
        MessageStateHandler m1 = Mockito.mock(MessageStateHandler.class);
        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        verify(delegate, never()).onRequestShowing(any());
        verify(delegate, never()).onFinishHiding();
        verify(m1, never()).show(eq(Position.INVISIBLE), eq(Position.FRONT));
        verify(m1, never()).hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());

        queueManager.dismissMessage(m1, DismissReason.TIMER);
        verify(delegate, never()).onRequestShowing(any());
        verify(delegate, never()).onFinishHiding();
        verify(m1, never()).show(eq(Position.INVISIBLE), eq(Position.FRONT));
        verify(m1, never()).hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
    }

    /**
     * Test that the message can show/hide correctly when its corresponding scope is
     * activated/deactivated.
     */
    @Test
    @SmallTest
    public void testMessageShowOnScopeChange() {
        // TODO(crbug.com/40740060): cover more various scenarios, such as re-activating scopes
        //                          which have been destroyed.
        MessageQueueDelegate delegate = Mockito.spy(mEmptyDelegate);
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(delegate);
        final ScopeKey inactiveScopeKey = new ScopeKey(SCOPE_TYPE, new InactiveMockWebContents());
        final ScopeKey inactiveScopeKey2 = new ScopeKey(SCOPE_TYPE, new InactiveMockWebContents());
        MessageStateHandler m1 = Mockito.spy(new EmptyMessageStateHandler());
        queueManager.enqueueMessage(m1, m1, inactiveScopeKey2, false);

        MessageStateHandler m2 = Mockito.spy(new EmptyMessageStateHandler());
        queueManager.enqueueMessage(m2, m2, inactiveScopeKey, false);

        queueManager.onScopeChange(
                new MessageScopeChange(SCOPE_TYPE, inactiveScopeKey, ChangeType.ACTIVE));
        verify(m1, never().description("A message should not be shown on another scope instance."))
                .show(eq(Position.INVISIBLE), eq(Position.FRONT));
        verify(
                        m1,
                        never().description(
                                        "A message should never have been shown or hidden before"
                                                + " its target scope is activated."))
                .hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());

        verify(m2, description("A message should show on its target scope instance."))
                .show(eq(Position.INVISIBLE), eq(Position.FRONT));

        queueManager.onScopeChange(
                new MessageScopeChange(SCOPE_TYPE, inactiveScopeKey, ChangeType.INACTIVE));
        queueManager.onScopeChange(
                new MessageScopeChange(SCOPE_TYPE, inactiveScopeKey2, ChangeType.ACTIVE));

        verify(
                        m2,
                        description(
                                "The message should hide when its target scope instance is"
                                        + " deactivated."))
                .hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
        verify(
                        m1,
                        description(
                                "The message should show when its target scope instance is"
                                        + " activated."))
                .show(eq(Position.INVISIBLE), eq(Position.FRONT));
        verify(
                        m1,
                        never().description(
                                        "The message should stay on the screen when its target"
                                                + " scope instance is activated."))
                .hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
    }

    /** Test that messages of multiple scope types can be correctly shown. */
    @Test
    @SmallTest
    public void testMessageOnMultipleScopeTypes() {
        MessageQueueDelegate delegate = Mockito.spy(mEmptyDelegate);
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(delegate);
        final ScopeKey navScopeKey =
                new ScopeKey(MessageScopeType.NAVIGATION, new ActiveMockWebContents());
        final ScopeKey windowScopeKey =
                new ScopeKey(new MockWindowAndroidWebContents().getTopLevelNativeWindow());

        MessageStateHandler m1 = Mockito.spy(new EmptyMessageStateHandler());
        queueManager.enqueueMessage(m1, m1, navScopeKey, false);

        MessageStateHandler m2 = Mockito.spy(new EmptyMessageStateHandler());
        queueManager.enqueueMessage(m2, m2, windowScopeKey, false);

        verify(m1, description("A message should be shown when the associated scope is active"))
                .show(eq(Position.INVISIBLE), eq(Position.FRONT));
        verify(
                        m1,
                        never().description(
                                        "The message should not be hidden when its scope is still"
                                                + " active"))
                .hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());

        verify(m2, description("The message should show in the background"))
                .show(eq(Position.FRONT), eq(Position.BACK));

        queueManager.onScopeChange(
                new MessageScopeChange(
                        MessageScopeType.NAVIGATION, navScopeKey, ChangeType.DESTROY));

        verify(m1, description("The message should be hidden when its scope is inactive"))
                .hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());

        verify(m1, description("The message should be dismissed when its scope is destroyed"))
                .dismiss(anyInt());

        verify(m2, description("A message should be shown when the associated scope is active"))
                .show(eq(Position.BACK), eq(Position.FRONT));

        queueManager.onScopeChange(
                new MessageScopeChange(
                        MessageScopeType.WINDOW, windowScopeKey, ChangeType.DESTROY));

        verify(m2, description("The message should be hidden when its scope is inactive"))
                .hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());

        verify(m2, description("The message should be dismissed when its scope is destroyed"))
                .dismiss(anyInt());
    }

    /** Test that animateTransition gets propagated from MessageScopeChange to hide() call correctly. */
    @Test
    @SmallTest
    public void testMessageAnimationTransitionOnScopeChange() {
        MessageQueueDelegate delegate = Mockito.spy(mEmptyDelegate);
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(delegate);
        MessageStateHandler m1 = Mockito.spy(new EmptyMessageStateHandler());
        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        queueManager.onScopeChange(
                new MessageScopeChange(SCOPE_TYPE, SCOPE_INSTANCE_ID, ChangeType.INACTIVE));

        verify(
                        m1,
                        description(
                                "A message should be hidden with animation when animationTransition"
                                        + " is set true."))
                .hide(eq(Position.FRONT), eq(Position.INVISIBLE), eq(true));
    }

    /** Test that the message is correctly dismissed when the scope is destroyed. */
    @Test
    @SmallTest
    public void testMessageDismissedOnScopeDestroy() {
        MessageQueueDelegate delegate = Mockito.spy(mEmptyDelegate);
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(delegate);
        MessageStateHandler m1 = Mockito.spy(new EmptyMessageStateHandler());

        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        verify(
                        m1,
                        description(
                                "The message should show when its target scope instance is"
                                        + " activated."))
                .show(eq(Position.INVISIBLE), eq(Position.FRONT));

        queueManager.onScopeChange(
                new MessageScopeChange(SCOPE_TYPE, SCOPE_INSTANCE_ID, ChangeType.DESTROY));
        verify(
                        m1,
                        description(
                                "The message should hide when its target scope instance is"
                                        + " destroyed."))
                .hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
        verify(
                        m1,
                        description(
                                "Message should be dismissed when its target scope instance is"
                                        + " destroyed."))
                .dismiss(anyInt());
    }

    /** Test that callback can be correctly called if #hide is called without #show called before. */
    @Test
    @SmallTest
    public void testShowHideMultipleTimes() {
        MessageQueueDelegate delegate = Mockito.spy(MessageQueueDelegate.class);
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(delegate);
        MessageStateHandler m1 = Mockito.spy(new EmptyMessageStateHandler());
        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);

        // Show and hide twice.
        ArgumentCaptor<Runnable> runnableCaptor = ArgumentCaptor.forClass(Runnable.class);
        verify(delegate).onRequestShowing(runnableCaptor.capture());
        Runnable onShow = runnableCaptor.getValue();
        verify(m1, never()).show(eq(Position.INVISIBLE), eq(Position.FRONT));
        // Become inactive before onStartShowing is finished.
        queueManager.onScopeChange(
                new MessageScopeChange(SCOPE_TYPE, SCOPE_INSTANCE_ID, ChangeType.INACTIVE));
        verify(m1, never()).hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
        onShow.run();
        verify(m1, never()).show(eq(Position.INVISIBLE), eq(Position.FRONT));

        queueManager.onScopeChange(
                new MessageScopeChange(SCOPE_TYPE, SCOPE_INSTANCE_ID, ChangeType.ACTIVE));
        runnableCaptor = ArgumentCaptor.forClass(Runnable.class);
        verify(delegate, times(2)).onRequestShowing(runnableCaptor.capture());
        doReturn(true).when(delegate).isReadyForShowing();
        runnableCaptor.getValue().run();
        verify(m1).show(eq(Position.INVISIBLE), eq(Position.FRONT));

        queueManager.onScopeChange(
                new MessageScopeChange(SCOPE_TYPE, SCOPE_INSTANCE_ID, ChangeType.DESTROY));
        verify(m1).hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
    }

    @Test
    @SmallTest
    public void testSuspendDuringOnStartingShow() {
        MessageQueueDelegate delegate = Mockito.spy(mEmptyDelegate);
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(delegate);
        MessageStateHandler m1 = Mockito.spy(new EmptyMessageStateHandler());
        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);

        queueManager.onScopeChange(
                new MessageScopeChange(SCOPE_TYPE, SCOPE_INSTANCE_ID, ChangeType.ACTIVE));
        verify(m1).show(eq(Position.INVISIBLE), eq(Position.FRONT));

        // Hide
        queueManager.onScopeChange(
                new MessageScopeChange(SCOPE_TYPE, SCOPE_INSTANCE_ID, ChangeType.INACTIVE));
        verify(m1).hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());

        // Re-assign a delegate so that onStartShowing will not trigger callback at once.
        delegate = Mockito.spy(MessageQueueDelegate.class);
        queueManager.setDelegate(delegate);
        queueManager.onScopeChange(
                new MessageScopeChange(SCOPE_TYPE, SCOPE_INSTANCE_ID, ChangeType.ACTIVE));
        // Suspend queue before onStartShowing is finished.
        queueManager.suspend();

        verify(
                        m1,
                        times(1).description(
                                        "Message should not show again before onStartShowing is"
                                                + " finished"))
                .show(eq(Position.INVISIBLE), eq(Position.FRONT));
        verify(m1, times(1).description("Message should not call #hide if it is not shown before"))
                .hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
    }

    /** Test scope change controller is properly called when message is enqueued and dismissed. */
    @Test
    @SmallTest
    public void testScopeChangeControllerInvoked() {
        ScopeChangeController controller = Mockito.mock(ScopeChangeController.class);
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(mEmptyDelegate);
        queueManager.setScopeChangeControllerForTesting(controller);
        MessageStateHandler m1 = Mockito.spy(new EmptyMessageStateHandler());
        MessageStateHandler m2 = Mockito.spy(new EmptyMessageStateHandler());
        MessageStateHandler m3 = Mockito.spy(new EmptyMessageStateHandler());

        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        verify(
                        controller,
                        description(
                                "ScopeChangeController should be notified when the queue of scope"
                                        + " gets its first message"))
                .firstMessageEnqueued(SCOPE_INSTANCE_ID);

        queueManager.enqueueMessage(m2, m2, SCOPE_INSTANCE_ID, false);
        verify(
                        controller,
                        times(1).description(
                                        "ScopeChangeController should be notified **only** when the"
                                                + " queue of scope gets its first message"))
                .firstMessageEnqueued(SCOPE_INSTANCE_ID);

        queueManager.enqueueMessage(m3, m3, SCOPE_INSTANCE_ID_A, false);
        verify(
                        controller,
                        times(1).description(
                                        "ScopeChangeController should be notified **only** when the"
                                                + " queue of scope gets its first message"))
                .firstMessageEnqueued(SCOPE_INSTANCE_ID);
        verify(
                        controller,
                        description(
                                "ScopeChangeController should be notified when the queue of scope"
                                        + " gets its first message"))
                .firstMessageEnqueued(SCOPE_INSTANCE_ID_A);

        queueManager.dismissMessage(m3, DismissReason.TIMER);
        verify(
                        controller,
                        never().description(
                                        "ScopeChangeController should not be notified when the"
                                                + " queue of scope is not empty."))
                .lastMessageDismissed(SCOPE_INSTANCE_ID);
        verify(
                        controller,
                        description(
                                "ScopeChangeController should be notified when the queue of scope"
                                        + " becomes empty."))
                .lastMessageDismissed(SCOPE_INSTANCE_ID_A);

        queueManager.dismissMessage(m1, DismissReason.TIMER);
        verify(
                        controller,
                        never().description(
                                        "ScopeChangeController should not be notified when the"
                                                + " queue of scope is not empty."))
                .lastMessageDismissed(SCOPE_INSTANCE_ID);
        queueManager.dismissMessage(m2, DismissReason.TIMER);
        verify(
                        controller,
                        description(
                                "ScopeChangeController should be notified when the queue of scope"
                                        + " is empty."))
                .lastMessageDismissed(SCOPE_INSTANCE_ID);
    }

    /** Test that the higher priority message is displayed when being enqueued. */
    @Test
    @SmallTest
    public void testEnqueueHigherPriorityMessage() {
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(mEmptyDelegate);
        MessageStateHandler m1 = Mockito.spy(new EmptyMessageStateHandler());
        MessageStateHandler m2 = Mockito.spy(new EmptyMessageStateHandler());

        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        verify(m1).show(eq(Position.INVISIBLE), eq(Position.FRONT));

        queueManager.enqueueMessage(m2, m2, SCOPE_INSTANCE_ID, true);
        verify(m1).show(eq(Position.FRONT), eq(Position.BACK));
        verify(m2).show(eq(Position.INVISIBLE), eq(Position.FRONT));
        queueManager.dismissMessage(m2, DismissReason.TIMER);
        verify(m2).hide(eq(Position.FRONT), eq(Position.INVISIBLE), anyBoolean());
        verify(m2).dismiss(DismissReason.TIMER);
        verify(m1).show(eq(Position.BACK), eq(Position.FRONT));
    }

    /** Test that {@link MessageQueueManager#getNextMessages()} returns correct list. */
    @Test
    @SmallTest
    public void testGetNextTwoMessages() {
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        queueManager.setDelegate(mEmptyDelegate);
        var messages = queueManager.getNextMessages();
        Assert.assertArrayEquals(new MessageState[] {null, null}, messages.toArray());
        MessageStateHandler m1 = Mockito.spy(new EmptyMessageStateHandler());
        MessageStateHandler m2 = Mockito.spy(new EmptyMessageStateHandler());

        queueManager.enqueueMessage(m1, m1, SCOPE_INSTANCE_ID, false);
        messages = queueManager.getNextMessages();
        Assert.assertEquals(m1, messages.get(0).handler);
        Assert.assertNull(messages.get(1));

        queueManager.enqueueMessage(m2, m2, SCOPE_INSTANCE_ID, false);
        messages = queueManager.getNextMessages();
        Assert.assertEquals(m1, messages.get(0).handler);
        Assert.assertEquals(m2, messages.get(1).handler);

        MessageStateHandler m3 = Mockito.spy(new EmptyMessageStateHandler());
        queueManager.enqueueMessage(m3, m3, SCOPE_INSTANCE_ID, true);
        messages = queueManager.getNextMessages();
        Assert.assertEquals(m3, messages.get(0).handler);
        Assert.assertEquals(m1, messages.get(1).handler);
    }

    @Test
    @SmallTest
    public void testIsLowerPriority() {
        MessageQueueManager queueManager = new MessageQueueManager(mAnimationCoordinator);
        // highPriority first but id is larger.
        Assert.assertFalse(
                "High-priority message has a higher priority.",
                queueManager.isLowerPriority(
                        buildMessageState(true, 2), buildMessageState(false, 1)));

        // highPriority first but id is smaller.
        Assert.assertFalse(
                "High-priority message has a higher priority.",
                queueManager.isLowerPriority(
                        buildMessageState(true, 2), buildMessageState(false, 3)));

        // highPriority second but id is larger.
        Assert.assertTrue(
                "High-priority message has a higher priority.",
                queueManager.isLowerPriority(
                        buildMessageState(false, 2), buildMessageState(true, 3)));

        // highPriority second but id is smaller.
        Assert.assertTrue(
                "High-priority message has a higher priority.",
                queueManager.isLowerPriority(
                        buildMessageState(false, 2), buildMessageState(true, 1)));

        // both high priority. Smaller id has a higher priority
        Assert.assertTrue(
                "Message with a smaller id has a higher priority.",
                queueManager.isLowerPriority(
                        buildMessageState(true, 2), buildMessageState(true, 1)));

        // both high priority. Smaller id has a higher priority
        Assert.assertFalse(
                "Message with a smaller id has a higher priority.",
                queueManager.isLowerPriority(
                        buildMessageState(true, 2), buildMessageState(true, 3)));

        // both normal priority. Smaller id has a higher priority
        Assert.assertTrue(
                "Message with a smaller id has a higher priority.",
                queueManager.isLowerPriority(
                        buildMessageState(false, 2), buildMessageState(false, 1)));

        // both normal priority. Smaller id has a higher priority
        Assert.assertFalse(
                "Message with a smaller id has a higher priority.",
                queueManager.isLowerPriority(
                        buildMessageState(false, 2), buildMessageState(false, 3)));
    }

    private MessageState buildMessageState(boolean highPriority, int id) {
        MessageStateHandler m1 = Mockito.spy(new EmptyMessageStateHandler());
        return new MessageState(SCOPE_INSTANCE_ID, m1, m1, highPriority, id);
    }
}