chromium/chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/suggestions/OmniboxSuggestionsDropdownUnitTest.java

// Copyright 2022 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.omnibox.suggestions;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
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.verifyNoMoreInteractions;

import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Rect;
import android.view.ContextThemeWrapper;
import android.view.View;
import android.view.View.MeasureSpec;
import android.view.ViewGroup;
import android.view.ViewGroup.MarginLayoutParams;

import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView.LayoutParams;
import androidx.test.core.app.ApplicationProvider;

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.Mockito;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.LooperMode;
import org.robolectric.annotation.LooperMode.Mode;
import org.robolectric.shadows.ShadowLooper;

import org.chromium.base.Callback;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.Feature;
import org.chromium.chrome.browser.omnibox.styles.OmniboxResourceProvider;
import org.chromium.chrome.browser.omnibox.suggestions.OmniboxSuggestionsDropdownEmbedder.OmniboxAlignment;
import org.chromium.chrome.browser.omnibox.test.R;
import org.chromium.components.browser_ui.styles.ChromeColors;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.base.WindowDelegate;

/** Unit tests for {@link OmniboxSuggestionsDropdown}. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(sdk = 28)
public class OmniboxSuggestionsDropdownUnitTest {
    public @Rule MockitoRule mMockitoRule = MockitoJUnit.rule();
    private @Mock Runnable mDropdownScrollListener;
    private @Mock Runnable mDropdownScrollToTopListener;
    private @Mock WindowDelegate mWindowDelegate;
    private @Mock OmniboxSuggestionsDropdownAdapter mAdapter;

    private Context mContext;

    private TestOmniboxSuggestionsDropdown mDropdown;
    private OmniboxSuggestionsDropdown.SuggestionLayoutScrollListener mListener;
    private OmniboxAlignment mOmniboxAlignment;
    private ObservableSupplierImpl<OmniboxAlignment> mOmniboxAlignmentSupplier =
            new ObservableSupplierImpl<>();
    private boolean mIsTablet;
    private boolean mAttachedToWindow;
    private OmniboxSuggestionsDropdownEmbedder mEmbedder =
            new OmniboxSuggestionsDropdownEmbedder() {
                @Override
                public boolean isTablet() {
                    return mIsTablet;
                }

                @Override
                public void onAttachedToWindow() {
                    mAttachedToWindow = true;
                }

                @Override
                public void onDetachedFromWindow() {
                    mAttachedToWindow = false;
                }

                @Override
                public OmniboxAlignment addAlignmentObserver(Callback<OmniboxAlignment> obs) {
                    return mOmniboxAlignmentSupplier.addObserver(obs);
                }

                @Override
                public void removeAlignmentObserver(Callback<OmniboxAlignment> obs) {
                    mOmniboxAlignmentSupplier.removeObserver(obs);
                }

                @Nullable
                @Override
                public OmniboxAlignment getCurrentAlignment() {
                    return mOmniboxAlignmentSupplier.get();
                }

                @Override
                public float getVerticalTranslationForAnimation() {
                    return 0.0f;
                }
            };

    // TODO(341377411): resolve issues with mockito not being able to stub the isInLayout method.
    private static class TestOmniboxSuggestionsDropdown extends OmniboxSuggestionsDropdown {
        private boolean mIsInLayout;

        public TestOmniboxSuggestionsDropdown(Context context) {
            super(context, null);
        }

        @Override
        public boolean isInLayout() {
            return mIsInLayout;
        }

        public void setIsInLayout(boolean isInLayout) {
            mIsInLayout = isInLayout;
        }
    }

    @Before
    public void setUp() {
        mContext =
                new ContextThemeWrapper(
                        ApplicationProvider.getApplicationContext(),
                        R.style.Theme_BrowserUI_DayNight);
        mDropdown = new TestOmniboxSuggestionsDropdown(mContext);
        mDropdown.setAdapter(mAdapter);
        mListener = mDropdown.getLayoutScrollListener();
    }

    @After
    public void tearDown() {
        mListener.resetKeyboardShownState();
    }

    /**
     * Simulate split screen window width.
     *
     * <p>Works in tandem with @Config(qualifiers = "sw###dp").
     */
    private Context getContextForWindowWidth(int windowWidthDp) {
        Configuration config = new Configuration();
        config.screenWidthDp = windowWidthDp;

        return mContext.createConfigurationContext(config);
    }

    @Test
    @Feature("Omnibox")
    public void testBackgroundColor() {
        assertEquals(
                OmniboxResourceProvider.getSuggestionsDropdownStandardBackgroundColor(mContext),
                ChromeColors.getSurfaceColor(
                        mContext, R.dimen.omnibox_suggestion_dropdown_bg_elevation));
        assertEquals(
                OmniboxResourceProvider.getSuggestionsDropdownIncognitoBackgroundColor(mContext),
                mContext.getColor(R.color.omnibox_dropdown_bg_incognito));
    }

    @Test
    public void testScrollListener_keyboardShouldDismissOnScrollAttemptFromTop() {
        mListener.setSuggestionDropdownScrollListener(mDropdownScrollListener);

        // Scroll attempt should suppress the scroll and emit keyboard dismiss.
        assertEquals(0, mListener.updateKeyboardVisibilityAndScroll(10, 10));
        verify(mDropdownScrollListener, times(1)).run();
        verifyNoMoreInteractions(mDropdownScrollListener);

        // Subsequent scroll events should pass through.
        // Keyboard should not be dismissed again.
        assertEquals(5, mListener.updateKeyboardVisibilityAndScroll(5, 10));
        verifyNoMoreInteractions(mDropdownScrollListener);
    }

    @Test
    public void testScrollListener_keyboardShouldDismissOnScrollAttemptFromScrolledList() {
        mListener.setSuggestionDropdownScrollListener(mDropdownScrollListener);

        // Scroll attempt should suppress the scroll and emit keyboard dismiss.
        assertEquals(0, mListener.updateKeyboardVisibilityAndScroll(10, 10));
        verify(mDropdownScrollListener, times(1)).run();
        verifyNoMoreInteractions(mDropdownScrollListener);

        // Subsequent scroll events should pass through.
        // Keyboard should not be dismissed again.
        assertEquals(5, mListener.updateKeyboardVisibilityAndScroll(5, 10));
        verifyNoMoreInteractions(mDropdownScrollListener);
    }

    @Test
    public void testScrollListener_keyboardShouldShowOnScrollToTop() {
        mListener.setSuggestionDropdownScrollListener(mDropdownScrollListener);
        mListener.setSuggestionDropdownOverscrolledToTopListener(mDropdownScrollToTopListener);

        // Scroll attempt should suppress the scroll and emit keyboard dismiss.
        assertEquals(0, mListener.updateKeyboardVisibilityAndScroll(10, 10));
        verify(mDropdownScrollListener, times(1)).run();
        verifyNoMoreInteractions(mDropdownScrollListener);

        // Pretend we scroll up, while keyboard is hidden.
        assertEquals(-5, mListener.updateKeyboardVisibilityAndScroll(-5, -5));
        verifyNoMoreInteractions(mDropdownScrollListener);

        // Overscroll to top. This is part of the same gesture.
        // Expect to see keyboard state unchanged.
        assertEquals(-5, mListener.updateKeyboardVisibilityAndScroll(-5, -10));
        verifyNoMoreInteractions(mDropdownScrollToTopListener);

        // Overscroll to top again, but this time as a new gesture.
        mListener.onNewGesture();
        assertEquals(-5, mListener.updateKeyboardVisibilityAndScroll(-5, -10));
        verify(mDropdownScrollToTopListener, times(1)).run();
        verifyNoMoreInteractions(mDropdownScrollToTopListener);

        // Overscroll again. Make sure we don't call the keyboard up again.
        assertEquals(0, mListener.updateKeyboardVisibilityAndScroll(0, -5));
        verifyNoMoreInteractions(mDropdownScrollListener);
    }

    @Test
    public void testScrollListener_dismissingKeyboardWhenScrollDoesNotHappen() {
        // In some cases the list may be long enough to stretch below the keyboard, but not long
        // enough to be scrollable. We want to dismiss the keyboard in these cases, too.
        mListener.setSuggestionDropdownScrollListener(mDropdownScrollListener);
        mListener.setSuggestionDropdownOverscrolledToTopListener(mDropdownScrollToTopListener);

        // Pretend we're scrolling down (delta=10) but there is no content to move to (scroll=0).
        assertEquals(0, mListener.updateKeyboardVisibilityAndScroll(0, 10));
        // Confirm that we're hiding the keyboard.
        verify(mDropdownScrollListener).run();

        // Simulate scroll up as part of the same gesture. Observe that no events are emitted.
        assertEquals(0, mListener.updateKeyboardVisibilityAndScroll(0, -10));
        verifyNoMoreInteractions(mDropdownScrollToTopListener);

        // Begin a new gesture.
        // Pretend we're scrolling up now (delta=-10) but we're already on top and can't move.
        mListener.onNewGesture();
        assertEquals(0, mListener.updateKeyboardVisibilityAndScroll(0, -10));
        // Confirm that we're not trying to show the keyboard.
        verify(mDropdownScrollToTopListener).run();

        verifyNoMoreInteractions(mDropdownScrollListener, mDropdownScrollToTopListener);
    }

    @Test
    public void testScrollListener_dismissingKeyboardWhenTheListIsOnlyBarelyUnderTheKeyboard() {
        mListener.setSuggestionDropdownScrollListener(mDropdownScrollListener);
        mListener.setSuggestionDropdownOverscrolledToTopListener(mDropdownScrollToTopListener);

        // We want to scroll by 10px, but there's only 1px of slack. This means the suggestions list
        // spans entirely under the keyboard. Hide the keyboard.
        assertEquals(0, mListener.updateKeyboardVisibilityAndScroll(1, 10));
        verify(mDropdownScrollListener).run();

        // Expect no more events emitted during the same gesture.
        assertEquals(-9, mListener.updateKeyboardVisibilityAndScroll(-9, -10));
        verifyNoMoreInteractions(mDropdownScrollToTopListener);

        // Reset keyboard state as part of the new gesture.
        mListener.onNewGesture();
        assertEquals(-9, mListener.updateKeyboardVisibilityAndScroll(-9, -10));
        verify(mDropdownScrollToTopListener).run();

        verifyNoMoreInteractions(mDropdownScrollListener, mDropdownScrollToTopListener);
    }

    @Test
    public void testScrollListener_reemitsKeyboardDismissOnReset() {
        mListener.setSuggestionDropdownScrollListener(mDropdownScrollListener);

        // Scroll attempt should suppress the scroll and emit keyboard dismiss.
        // This time the scroll happens, even if just by one pixel.
        assertEquals(0, mListener.updateKeyboardVisibilityAndScroll(10, 10));
        verify(mDropdownScrollListener, times(1)).run();
        verifyNoMoreInteractions(mDropdownScrollListener);

        // Simulate lists being shown again.
        mListener.resetKeyboardShownState();

        // Scroll attempt should suppress the scroll and emit keyboard dismiss.
        // Condition: the list is long enough that the scroll distance equals to delta.
        assertEquals(0, mListener.updateKeyboardVisibilityAndScroll(10, 10));
        verify(mDropdownScrollListener, times(2)).run();
        verifyNoMoreInteractions(mDropdownScrollListener);
    }

    @Test
    public void testScrollListener_inactiveWhenObserverNotEquipped() {
        // Note: do not equip the listeners (no calls to setSuggestionDropdownScrollListener() and
        // setSuggestionDropdownOverscrolledToTopListener).
        // None of the calls below should invoke listeners (and crash).
        // Scroll down from top.
        assertEquals(0, mListener.updateKeyboardVisibilityAndScroll(10, 10));
        // Scroll down from the middle. Confirm new scroll position is accepted.
        assertEquals(10, mListener.updateKeyboardVisibilityAndScroll(10, 10));
        // Overscroll to top.
        assertEquals(-10, mListener.updateKeyboardVisibilityAndScroll(-10, -10));
        verifyNoMoreInteractions(mDropdownScrollListener);
        verifyNoMoreInteractions(mDropdownScrollToTopListener);
    }

    @Test
    public void onOmniboxSessionStateChange_withEmbedder() {
        mDropdown.setEmbedder(mEmbedder);

        assertFalse(mAttachedToWindow);
        mDropdown.onOmniboxSessionStateChange(true);
        assertTrue(mAttachedToWindow);

        mDropdown.onOmniboxSessionStateChange(false);
        assertFalse(mAttachedToWindow);
    }

    @Test
    public void onOmniboxSessionStateChange_withoutEmbedder() {
        assertFalse(mAttachedToWindow);
        mDropdown.onOmniboxSessionStateChange(true);
        assertFalse(mAttachedToWindow);
        mDropdown.onOmniboxSessionStateChange(false);
        assertFalse(mAttachedToWindow);
    }

    @Test
    public void testAlignmentProvider_widthChange() {
        mDropdown.setEmbedder(mEmbedder);
        mDropdown.onOmniboxSessionStateChange(true);

        mOmniboxAlignment = new OmniboxAlignment(0, 100, 600, 0, 10, 10);
        mOmniboxAlignmentSupplier.set(mOmniboxAlignment);
        layoutDropdown(600, 800);
        assertEquals(600, mDropdown.getMeasuredWidth());

        mOmniboxAlignment = new OmniboxAlignment(0, 100, 400, 0, 10, 10);
        mOmniboxAlignmentSupplier.set(mOmniboxAlignment);
        ShadowLooper.runUiThreadTasks();
        assertTrue(mDropdown.isLayoutRequested());

        layoutDropdown(600, 800);
        assertEquals(400, mDropdown.getMeasuredWidth());
        assertFalse(mDropdown.isLayoutRequested());
    }

    @Test
    public void testAlignmentProvider_topChange() {
        mDropdown.setEmbedder(mEmbedder);
        mDropdown.onOmniboxSessionStateChange(true);

        mDropdown.setLayoutParams(
                new LayoutParams(
                        ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
        int marginTop = 100;
        int height = 800 - marginTop;
        mOmniboxAlignment = new OmniboxAlignment(0, 100, 600, height, 10, 10);
        mOmniboxAlignmentSupplier.set(mOmniboxAlignment);
        layoutDropdown(600, height);

        MarginLayoutParams layoutParams = (MarginLayoutParams) mDropdown.getLayoutParams();
        assertNotNull(layoutParams);
        assertEquals(marginTop, layoutParams.topMargin);

        mOmniboxAlignment = new OmniboxAlignment(0, 54, 600, 0, 10, 10);
        mOmniboxAlignmentSupplier.set(mOmniboxAlignment);
        layoutDropdown(600, height);

        layoutParams = (MarginLayoutParams) mDropdown.getLayoutParams();
        assertNotNull(layoutParams);
        assertEquals(54, layoutParams.topMargin);
    }

    @Test
    public void testAlignmentProvider_heightChange() {
        mDropdown.setEmbedder(mEmbedder);
        mDropdown.onOmniboxSessionStateChange(true);

        mDropdown.setLayoutParams(
                new LayoutParams(
                        ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
        int height = 400;
        mOmniboxAlignment = new OmniboxAlignment(0, 80, 600, height, 10, 10);
        mOmniboxAlignmentSupplier.set(mOmniboxAlignment);
        layoutDropdown(600, 800);

        assertEquals(height, mDropdown.getMeasuredHeight());

        height = 300;
        mOmniboxAlignment = new OmniboxAlignment(0, 80, 600, height, 10, 10);
        mOmniboxAlignmentSupplier.set(mOmniboxAlignment);
        layoutDropdown(600, 800);

        assertEquals(height, mDropdown.getMeasuredHeight());
    }

    @Test
    @LooperMode(Mode.PAUSED)
    public void testAlignmentProvider_changeDuringlayout() {
        mDropdown.setAdapter(mAdapter);
        mDropdown.setEmbedder(mEmbedder);
        mDropdown.onOmniboxSessionStateChange(true);

        mDropdown.setIsInLayout(true);
        mOmniboxAlignment = new OmniboxAlignment(0, 80, 400, 600, 10, 10);
        mOmniboxAlignmentSupplier.set(mOmniboxAlignment);

        mDropdown.layout(0, 0, 600, 800);
        assertFalse(mDropdown.isLayoutRequested());

        // The posted task should re-request layout.
        ShadowLooper.runUiThreadTasks();
        assertTrue(mDropdown.isLayoutRequested());
    }

    @Test
    public void translateChildrenVertical() {
        mDropdown.setAdapter(mAdapter);
        mDropdown.setEmbedder(mEmbedder);
        mDropdown.onOmniboxSessionStateChange(true);

        View childView = Mockito.mock(View.class);

        mDropdown.translateChildrenVertical(45.6f);
        mDropdown.onChildAttachedToWindow(childView);
        verify(childView).setTranslationY(45.6f);

        mDropdown.onChildDetachedFromWindow(childView);
        verify(childView).setTranslationY(0.0f);
    }

    @Test
    public void setChildAlpha() {
        mDropdown.setAdapter(mAdapter);
        mDropdown.setEmbedder(mEmbedder);
        mDropdown.onOmniboxSessionStateChange(true);

        View childView = Mockito.mock(View.class);

        mDropdown.setChildAlpha(0.6f);
        mDropdown.onChildAttachedToWindow(childView);
        verify(childView).setAlpha(0.6f);

        mDropdown.onChildDetachedFromWindow(childView);
        verify(childView).setAlpha(1.0f);
    }

    private void layoutDropdown(int width, int height) {
        doAnswer(
                        (invocation) -> {
                            Rect r = invocation.getArgument(0);
                            r.set(0, 0, 0, height);
                            return true;
                        })
                .when(mWindowDelegate)
                .getWindowVisibleDisplayFrame(any(Rect.class));
        int widthSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.AT_MOST);
        int heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.AT_MOST);
        mDropdown.measure(widthSpec, heightSpec);
        mDropdown.layout(0, 0, mDropdown.getMeasuredWidth(), mDropdown.getMeasuredHeight());
    }

    @Test
    @Config(qualifiers = "sw600dp")
    public void forcePhoneStyleOmnibox_forcing_noClippingWhenForced() {
        var dropdown = new OmniboxSuggestionsDropdown(mContext, null);
        dropdown.forcePhoneStyleOmnibox(true);
        assertFalse(dropdown.getClipToOutline());
        assertNull(dropdown.getOutlineProvider());
    }

    @Test
    @Config(qualifiers = "sw600dp")
    public void forcePhoneStyleOmnibox_nonForcing_clipsOnTablets_narrowWindow() {
        var context = getContextForWindowWidth(DeviceFormFactor.MINIMUM_TABLET_WIDTH_DP - 1);
        var dropdown = new OmniboxSuggestionsDropdown(context, null);
        dropdown.forcePhoneStyleOmnibox(false);
        assertFalse(dropdown.getClipToOutline());
        assertNull(dropdown.getOutlineProvider());
    }

    @Test
    @Config(qualifiers = "sw600dp")
    public void forcePhoneStyleOmnibox_nonForcing_clipsOnTablets_wideWindow() {
        var context = getContextForWindowWidth(DeviceFormFactor.MINIMUM_TABLET_WIDTH_DP);
        var dropdown = new OmniboxSuggestionsDropdown(context, null);
        dropdown.forcePhoneStyleOmnibox(false);
        assertTrue(dropdown.getClipToOutline());
        assertNotNull(dropdown.getOutlineProvider());
    }

    @Test
    @Config(qualifiers = "sw320dp")
    public void forcePhoneStyleOmnibox_nonForcing_noClippingOnPhones() {
        var dropdown = new OmniboxSuggestionsDropdown(mContext, null);
        dropdown.forcePhoneStyleOmnibox(false);
        assertFalse(dropdown.getClipToOutline());
        assertNull(dropdown.getOutlineProvider());
    }
}