// Copyright 2021 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 android.os.Looper.getMainLooper;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.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 static org.robolectric.annotation.LooperMode.Mode.PAUSED;
import android.animation.Animator;
import android.content.res.Resources;
import android.util.DisplayMetrics;
import android.view.MotionEvent;
import androidx.test.filters.SmallTest;
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.robolectric.annotation.LooperMode;
import org.chromium.base.MathUtils;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.ScrollDirection;
import org.chromium.components.messages.MessageStateHandler.Position;
import org.chromium.ui.modelutil.PropertyModel;
/** Unit tests for {@link MessageBannerMediator}. */
@SmallTest
@RunWith(BaseRobolectricTestRunner.class)
@LooperMode(PAUSED)
public class MessageBannerMediatorUnitTest {
@Rule public MockitoRule mMockitoRule = MockitoJUnit.rule();
private static final int PEEKING_LAYER_HEIGHT = 20;
private static final int DEFAULT_MARGIN = 18;
private static final int PEEKING_MARGIN = PEEKING_LAYER_HEIGHT + DEFAULT_MARGIN;
@Mock private Resources mResources;
@Mock private DisplayMetrics mDisplayMetrics;
@Mock private Supplier<Integer> mTopOffsetSupplier;
@Mock private Supplier<Integer> mMaxTranslationSupplier;
@Mock private Runnable mDismissedRunnable;
@Mock private Runnable mShownRunnable;
@Mock private Runnable mHiddenRunnable;
@Mock private SwipeAnimationHandler mSwipeAnimationHandler;
private MessageBannerMediator mMediator;
private PropertyModel mModel;
@Before
public void setUp() {
mModel =
new PropertyModel.Builder(MessageBannerProperties.ALL_KEYS)
.with(
MessageBannerProperties.MESSAGE_IDENTIFIER,
MessageIdentifier.TEST_MESSAGE)
.with(MessageBannerProperties.TITLE, "Title")
.with(MessageBannerProperties.DESCRIPTION, "Desc")
.build();
when(mResources.getDisplayMetrics()).thenReturn(mDisplayMetrics);
mDisplayMetrics.widthPixels = 500;
when(mResources.getDimensionPixelSize(R.dimen.message_vertical_hide_threshold))
.thenReturn(16);
when(mResources.getDimensionPixelSize(R.dimen.message_horizontal_hide_threshold))
.thenReturn(24);
when(mResources.getDimensionPixelSize(R.dimen.message_peeking_layer_height))
.thenReturn(PEEKING_LAYER_HEIGHT);
when(mResources.getDimensionPixelSize(R.dimen.message_shadow_top_margin))
.thenReturn(DEFAULT_MARGIN);
doAnswer(
invocation -> {
((Animator) invocation.getArguments()[0]).start();
return null;
})
.when(mSwipeAnimationHandler)
.onSwipeEnd(any(Animator.class));
mMediator =
new MessageBannerMediator(
mModel,
mTopOffsetSupplier,
mMaxTranslationSupplier,
mResources,
mDismissedRunnable,
mSwipeAnimationHandler);
when(mTopOffsetSupplier.get()).thenReturn(75);
when(mMaxTranslationSupplier.get()).thenReturn(100);
}
@Test
public void testShowMessage() {
Animator animator = mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable);
verify(mShownRunnable, times(0)).run();
assertModelState(0, -75, 0, 0, DEFAULT_MARGIN, "before showing.");
animator.start();
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
verify(mShownRunnable, times(1)).run();
}
@Test
public void testShowBackMessage() {
Animator animator = mMediator.show(Position.FRONT, Position.BACK, 0, mShownRunnable);
verify(mShownRunnable, times(0)).run();
assertModelState(0, 0, 0, 0, DEFAULT_MARGIN, "before showing.");
animator.start();
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, 1, PEEKING_MARGIN, "fully shown.");
verify(mShownRunnable, times(1)).run();
}
@Test
public void testShowBackMessageWithOffset() {
Animator animator = mMediator.show(Position.FRONT, Position.BACK, 20, mShownRunnable);
verify(mShownRunnable, times(0)).run();
assertModelState(0, 0, 0, 0, DEFAULT_MARGIN, "before showing.");
animator.start();
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, 1, PEEKING_MARGIN + 20, "fully shown.");
verify(mShownRunnable, times(1)).run();
}
@Test
public void testHideMessage() {
Animator animator = mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable);
animator.start();
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
animator = mMediator.hide(Position.FRONT, Position.INVISIBLE, true, mHiddenRunnable);
verify(mHiddenRunnable, times(0)).run();
animator.start();
shadowOf(getMainLooper()).idle();
assertModelState(0, -75, 0, 0, DEFAULT_MARGIN, "after hidden.");
verify(mHiddenRunnable, times(1)).run();
}
@Test
public void testHideMessageFromBack() {
Animator animator = mMediator.show(Position.FRONT, Position.BACK, 0, mShownRunnable);
animator.start();
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, 1, PEEKING_MARGIN, "fully shown.");
animator = mMediator.hide(Position.BACK, Position.FRONT, true, mHiddenRunnable);
verify(mHiddenRunnable, times(0)).run();
animator.start();
shadowOf(getMainLooper()).idle();
// because of it is hidden, marginTop is not reset to default margin top
assertModelState(0, 0, 0, 0, PEEKING_MARGIN, "after hidden.");
verify(mHiddenRunnable, times(1)).run();
}
@Test
public void testHideMessageNoAnimation() {
Animator animator = mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable);
animator.start();
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
mMediator.hide(Position.FRONT, Position.INVISIBLE, false, mHiddenRunnable);
assertModelState(0, -75, 0, 0, DEFAULT_MARGIN, "after hidden.");
verify(mHiddenRunnable, times(1)).run();
}
@Test
public void testVerticalDismiss() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// More than the threshold to dismiss
swipeVertical(-20, 0);
assertModelState(0, -20, 1 - 20 / 75f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, -75, 0, 0, DEFAULT_MARGIN, "after dismiss animation.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testVerticalNotDismissed() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// Less than the threshold to dismiss
swipeVertical(-10, 0);
// alpha: 1 - move distance / max translation
assertModelState(0, -10, 1 - 10 / 75f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
// Should return back to idle position
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "animated to idle position.");
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testSwipeDownIsNoop() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
swipeVertical(10, 0);
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "swipe doesn't do anything.");
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testLeftDismiss() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// More than the threshold to dismiss
swipeHorizontal(-30, 0);
assertModelState(-30, 0, 1 - 30 / 500f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(-500, 0, 0, 1, DEFAULT_MARGIN, "after dismiss animation.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testLeftNotDismissed() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// Less than the threshold to dismiss
swipeHorizontal(-12, 0);
// Alpha is 1 (fully opaque) - 12 (translationY) / 500 (maxTranslation)
assertModelState(-12, 0, 1 - 12 / 500f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "animated to idle position.");
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testRightDismiss() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// More than the threshold to dismiss
swipeHorizontal(30, 0);
// Alpha is 1 (fully opaque) - 30 (translationY) / 500 (screenWidth)
assertModelState(30, 0, 1 - 30 / 500f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(500, 0, 0, 1, DEFAULT_MARGIN, "after dismiss animation.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testRightNotDismissed() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// Less than the threshold to dismiss
swipeHorizontal(12, 0);
// Alpha is 1 (fully opaque) - 12 (translationY) / 500 (screen width)
assertModelState(12, 0, 1 - 12 / 500f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "animated to idle position.");
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testHorizontalFlingFromOutsideThresholdToCenterDismissed() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// More than the threshold to dismiss, fling back to center
swipeHorizontal(60, -100);
// Alpha is 1 (fully opaque) - 60 (translationY) / 500 (maxTranslation)
assertModelState(60, 0, 1 - 60 / 500f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(500, 0, 0, 1, DEFAULT_MARGIN, "after swipe");
verify(mDismissedRunnable).run();
}
@Test
public void testVerticalFlingDown() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// More than the threshold to dismiss, fling back to center
swipeVertical(-20, 100);
// Alpha is 1 (fully opaque) - 20 (translationY) / 75 (maxTranslation)
assertModelState(0, -20, 1 - 20 / 75f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, -75, 0, 0, DEFAULT_MARGIN, "after swipe");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testVerticalFlingDownIsNoop() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// Swipe and fling down
swipeVertical(10, 100);
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "gesture doesn't do anything.");
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testVerticalFlingUpWithinThresholdDismisses() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// Swipe less than threshold and fling up
swipeVertical(-10, -75);
// Alpha is 1 (fully opaque) - 10 (translationY) / 75 (maxTranslation)
assertModelState(0, -10, 1 - 10 / 75f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, -75, 0, 0, DEFAULT_MARGIN, "after dismiss animation.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testVerticalFlingUpOutsideThresholdDismisses() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// Swipe more than threshold and fling up
swipeVertical(-20, -75);
// .8 is 1 (fully opaque) - 10 (translationY) / 100 (maxTranslation)
assertModelState(0, -20, 1 - 20 / 75f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, -75, 0, 0, DEFAULT_MARGIN, "after dismiss animation.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testLeftFlingWithinThresholdPositiveXNoDismisses() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// Less than the threshold to dismiss to the right, fling left
swipeHorizontal(12, -75);
// Alpha is 1 (fully opaque) - 12 (translationY) / 500 (maxTranslation: i.e. screen width)
assertModelState(12, 0, 1 - 12 / 500f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "animate back to center.");
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testLeftFlingWithinThresholdNegativeXNoDismisses() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// Less than the threshold to dismiss to the left, fling left
swipeHorizontal(-12, -75);
// Alpha is 1 (fully opaque) - 12 (translationY) / 500 (maxTranslation: i.e. screen width)
assertModelState(-12, 0, 1 - 12 / 500f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "animate back to center.");
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testRightFlingWithinThresholdNegativeXNoDismisses() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// Less than the threshold to dismiss to the left, fling right
swipeHorizontal(-12, 100);
// Alpha is 1 (fully opaque) - 12 (translationY) / 500 (maxTranslation: i.e. screen width)
assertModelState(-12, 0, 1 - 12 / 500f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "animate back to center.");
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testRightFlingWithinThresholdPositiveXNoDismisses() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// Less than the threshold to dismiss to the right, fling right
swipeHorizontal(12, 100);
// Alpha is 1 (fully opaque) - 12 (translationY) / 500 (maxTranslation: i.e. screen width)
assertModelState(12, 0, 1 - 12 / 500f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "animate back to center.");
verify(mDismissedRunnable, times(0)).run();
}
@Test
public void testLeftFlingOutsideThresholdDismisses() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// More than the threshold to dismiss to the left, fling left
swipeHorizontal(-30, -75);
// Alpha is 1 (fully opaque) - 30 (translationY) / 500 (maxTranslation: i.e. screen width)
assertModelState(-30, 0, 1 - 30 / 500f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(-500, 0, 0, 1, DEFAULT_MARGIN, "dismissed to left after fling.");
verify(mDismissedRunnable, times(1)).run();
}
@Test
public void testRightFlingOutsideThresholdDismisses() {
mMediator.show(Position.INVISIBLE, Position.FRONT, 0, mShownRunnable).start();
shadowOf(getMainLooper()).idle();
verify(mDismissedRunnable, times(0)).run();
assertModelState(0, 0, 1, 1, DEFAULT_MARGIN, "fully shown.");
// More than the threshold to dismiss to the right, fling right
swipeHorizontal(30, 100);
// Alpha is 1 (fully opaque) - 30 (translationY) / 500 (maxTranslation)
assertModelState(30, 0, 1 - 30 / 500f, 1, DEFAULT_MARGIN, "after swipe.");
shadowOf(getMainLooper()).idle();
assertModelState(500, 0, 0, 1, DEFAULT_MARGIN, "dismissed to right after fling.");
verify(mDismissedRunnable, times(1)).run();
}
/**
* @param distance Positive is down.
* @param flingVelocityAtEnd Velocity of the fling gesture at the end; 0 if there is no fling.
*/
private void swipeVertical(int distance, int flingVelocityAtEnd) {
MotionEvent e1 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0, 0, 0);
MotionEvent e2 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 0, distance, 0);
mMediator.onSwipeStarted(distance < 0 ? ScrollDirection.UP : ScrollDirection.DOWN, e1);
mMediator.onSwipeUpdated(e2, 0, distance, 0, distance);
if (flingVelocityAtEnd != 0) {
MotionEvent e3 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, 0, distance, 0);
mMediator.onFling(
flingVelocityAtEnd < 0 ? ScrollDirection.UP : ScrollDirection.DOWN,
e3,
0,
distance,
0,
flingVelocityAtEnd);
}
mMediator.onSwipeFinished();
}
/**
* @param distance Positive is right.
* @param flingVelocityAtEnd Velocity of the fling gesture at the end; 0 if there is no fling.
*/
private void swipeHorizontal(int distance, int flingVelocityAtEnd) {
MotionEvent e1 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0, 0, 0);
MotionEvent e2 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, distance, 0, 0);
mMediator.onSwipeStarted(distance < 0 ? ScrollDirection.LEFT : ScrollDirection.RIGHT, e1);
mMediator.onSwipeUpdated(e2, distance, 0, distance, 0);
if (flingVelocityAtEnd != 0) {
MotionEvent e3 = MotionEvent.obtain(0, 0, MotionEvent.ACTION_UP, distance, 0, 0);
mMediator.onFling(
flingVelocityAtEnd < 0 ? ScrollDirection.LEFT : ScrollDirection.RIGHT,
e3,
distance,
0,
flingVelocityAtEnd,
0);
}
mMediator.onSwipeFinished();
}
private void assertModelState(
float translationXExpected,
float translationYExpected,
float alphaExpected,
float heightExpected,
int marginTopExpected,
String message) {
assertEquals(
"Incorrect translation x, " + message,
translationXExpected,
mModel.get(MessageBannerProperties.TRANSLATION_X),
MathUtils.EPSILON);
assertEquals(
"Incorrect translation y, " + message,
translationYExpected,
mModel.get(MessageBannerProperties.TRANSLATION_Y),
MathUtils.EPSILON);
assertEquals(
"Incorrect alpha, " + message,
alphaExpected,
mModel.get(MessageBannerProperties.CONTENT_ALPHA),
MathUtils.EPSILON);
assertEquals(
"Incorrect visual height, " + message,
heightExpected,
mModel.get(MessageBannerProperties.VISUAL_HEIGHT),
MathUtils.EPSILON);
assertEquals(
"Incorrect margin top, " + message,
marginTopExpected,
mModel.get(MessageBannerProperties.MARGIN_TOP));
}
}