chromium/chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/suggestions/base/DynamicSpacingRecyclerViewItemDecorationUnitTest.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.omnibox.suggestions.base;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.lenient;

import android.graphics.Rect;
import android.view.View;

import androidx.recyclerview.widget.RecyclerView;

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.test.BaseRobolectricTestRunner;

/** Tests for {@link DynamicSpacingRecyclerViewItemDecoration}. */
@RunWith(BaseRobolectricTestRunner.class)
public class DynamicSpacingRecyclerViewItemDecorationUnitTest {
    private static final int CONTAINER_SIZE = 1000;
    private static final int LEAD_IN_SPACE = 10;
    private static final int MIN_ELEMENT_SPACE = 50;
    private static final int ITEM_FIRST = 0;
    private static final int ITEM_MIDDLE = 1;
    private static final int ITEM_LAST = 2;
    private static final int ITEM_COUNT = ITEM_LAST + 1;

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

    private @Mock RecyclerView mRecyclerView;
    private @Mock RecyclerView.Adapter mAdapter;
    private @Mock View mFirstView;
    private @Mock View mMiddleView;
    private @Mock View mLastView;

    private DynamicSpacingRecyclerViewItemDecoration mDecoration;
    private Rect mOffsets;

    @Before
    public void setUp() {
        mOffsets = new Rect();

        lenient().doReturn(ITEM_COUNT).when(mAdapter).getItemCount();
        lenient().doReturn(mAdapter).when(mRecyclerView).getAdapter();
        lenient().doReturn(ITEM_FIRST).when(mRecyclerView).getChildAdapterPosition(mFirstView);
        lenient().doReturn(ITEM_MIDDLE).when(mRecyclerView).getChildAdapterPosition(mMiddleView);
        lenient().doReturn(ITEM_LAST).when(mRecyclerView).getChildAdapterPosition(mLastView);
        lenient().doReturn(ContextUtils.getApplicationContext()).when(mRecyclerView).getContext();

        doReturn(CONTAINER_SIZE).when(mRecyclerView).getMeasuredWidth();
    }

    /**
     * Simulate horizontal view resize.
     *
     * @param newWidth new measured width to be reported by the container.
     */
    void resizeContainer(int newWidth) {
        mDecoration.notifyViewSizeChanged(true, newWidth, 100);
    }

    // Compares spacing against expected value.
    // Note that the supplied space is normally applied on both sides of every item, so the
    // totalSpacing = 2 * expectedSpacing
    void verifyItemSpacing(int expectedSpacing) {
        // First item, RTL: lead-in space on the right.
        doReturn(View.LAYOUT_DIRECTION_RTL).when(mRecyclerView).getLayoutDirection();
        mDecoration.getItemOffsets(mOffsets, mFirstView, mRecyclerView, null);
        assertEquals(LEAD_IN_SPACE, mOffsets.right);
        assertEquals(expectedSpacing / 2, mOffsets.left);

        // First item, LTR: lead-in space on the left.
        doReturn(View.LAYOUT_DIRECTION_LTR).when(mRecyclerView).getLayoutDirection();
        mDecoration.getItemOffsets(mOffsets, mFirstView, mRecyclerView, null);
        assertEquals(LEAD_IN_SPACE, mOffsets.left);
        assertEquals(expectedSpacing / 2, mOffsets.right);

        // Second item: same spacing on both sides.
        mDecoration.getItemOffsets(mOffsets, mMiddleView, mRecyclerView, null);
        assertEquals(expectedSpacing / 2, mOffsets.left);
        assertEquals(expectedSpacing / 2, mOffsets.right);

        // Last item, RTL: lead-in space on the left.
        doReturn(View.LAYOUT_DIRECTION_RTL).when(mRecyclerView).getLayoutDirection();
        mDecoration.getItemOffsets(mOffsets, mLastView, mRecyclerView, null);
        assertEquals(expectedSpacing / 2, mOffsets.right);
        assertEquals(LEAD_IN_SPACE, mOffsets.left);

        // Last item, LTR: lead-in space on the right.
        doReturn(View.LAYOUT_DIRECTION_LTR).when(mRecyclerView).getLayoutDirection();
        mDecoration.getItemOffsets(mOffsets, mLastView, mRecyclerView, null);
        assertEquals(expectedSpacing / 2, mOffsets.left);
        assertEquals(LEAD_IN_SPACE, mOffsets.right);
    }

    @Test
    public void computeItemOffsets_reportsDefaultsWhenItemWidthNotKnown() {
        mDecoration =
                new DynamicSpacingRecyclerViewItemDecoration(
                        LEAD_IN_SPACE, MIN_ELEMENT_SPACE, /* itemWidth= */ 0);
        verifyItemSpacing(MIN_ELEMENT_SPACE);
    }

    @Test
    public void computeItemOffsets_portrait_exactFit() {
        int adjustedWidth = CONTAINER_SIZE - LEAD_IN_SPACE;

        // Find out how wide can our theoretical tile be to fit 4.5 times.
        int totalTileAreaSize = adjustedWidth - (4 * MIN_ELEMENT_SPACE);
        int singleTileSize = (int) (totalTileAreaSize / 4.5);

        // Quickly verify our logic. We should not deviate by more than 4.5 pixels (rounding).
        assertEquals((int) (singleTileSize * 4.5 + MIN_ELEMENT_SPACE * 4), adjustedWidth, 4.5);

        // Create a decoration where the tile fits exactly.
        mDecoration =
                new DynamicSpacingRecyclerViewItemDecoration(
                        LEAD_IN_SPACE, MIN_ELEMENT_SPACE, singleTileSize);
        resizeContainer(CONTAINER_SIZE);
        verifyItemSpacing(MIN_ELEMENT_SPACE);
    }

    @Test
    public void computeItemOffsets_portrait_tightFit() {
        int adjustedWidth = CONTAINER_SIZE - LEAD_IN_SPACE;

        // Find out how wide can our theoretical tile be to fit 4.5 times.
        // We want to show
        // - four entire tiles, separated by
        // - four spaces, and finally
        // - a half of the fifth tile.
        // Next, we increase the size of these tiles so that we know we can't fit them.
        int totalTileAreaSize = adjustedWidth - (4 * MIN_ELEMENT_SPACE);
        int singleTileSize = (int) (totalTileAreaSize / 4.5) + 5;

        // Quickly verify our logic. We should exceed the available space, forcing the algorithm to
        // reduce number of visible items.
        assertTrue((int) (singleTileSize * 4.5 + MIN_ELEMENT_SPACE * 4) > adjustedWidth);

        // Compute expected padding in that case.
        int expectedPadding = (int) (adjustedWidth - 3.5 * singleTileSize) / 3;

        // Create a decoration where the tile fits tightly.
        mDecoration =
                new DynamicSpacingRecyclerViewItemDecoration(
                        LEAD_IN_SPACE, MIN_ELEMENT_SPACE, singleTileSize);
        resizeContainer(CONTAINER_SIZE);
        verifyItemSpacing(expectedPadding);
    }

    @Test
    public void computeItemOffsets_portrait_impossibleFit() {
        // No way to fit in 1.5 tiles on screen.
        mDecoration =
                new DynamicSpacingRecyclerViewItemDecoration(
                        LEAD_IN_SPACE, MIN_ELEMENT_SPACE, CONTAINER_SIZE);
        resizeContainer(CONTAINER_SIZE);
        verifyItemSpacing(MIN_ELEMENT_SPACE);
    }

    @Test
    public void notifyViewMeasuredSizeChanged_updatesSpacing() {
        int itemWidth = 11 * MIN_ELEMENT_SPACE;
        int oldItemSpace = 2 * MIN_ELEMENT_SPACE;
        int newItemSpace = 3 * MIN_ELEMENT_SPACE;

        // Compute hypothetical screen sizes that would require the logic to fit:
        // - 5.5 items before and
        // - 4.5 items after measure.
        int oldContainerSize = LEAD_IN_SPACE + 5 * oldItemSpace + (int) (5.5 * itemWidth);
        int newContainerSize = LEAD_IN_SPACE + 4 * newItemSpace + (int) (4.5 * itemWidth);

        mDecoration =
                new DynamicSpacingRecyclerViewItemDecoration(
                        LEAD_IN_SPACE, MIN_ELEMENT_SPACE, itemWidth);

        resizeContainer(oldContainerSize);
        verifyItemSpacing(oldItemSpace);

        // Notify container has resized.
        resizeContainer(newContainerSize);
        verifyItemSpacing(newItemSpace);
    }

    @Test
    public void notifyViewMeasuredSizeChanged_suppressComputationWhenSizeNotChanged() {
        int itemWidth = 11 * MIN_ELEMENT_SPACE;
        int itemSpace = 2 * MIN_ELEMENT_SPACE;
        int containerSize = LEAD_IN_SPACE + 5 * itemSpace + (int) (5.5 * itemWidth);

        mDecoration =
                new DynamicSpacingRecyclerViewItemDecoration(
                        LEAD_IN_SPACE, MIN_ELEMENT_SPACE, itemWidth);
        // Expect the updates here:
        assertTrue(mDecoration.notifyViewSizeChanged(true, containerSize, 100));
        verifyItemSpacing(itemSpace);

        // ... but no updates here:
        assertFalse(mDecoration.notifyViewSizeChanged(true, containerSize, 100));
    }

    @Test
    public void formFactor_itemSpacingPhone_landscape() {
        int itemWidth = CONTAINER_SIZE / 3;
        mDecoration =
                new DynamicSpacingRecyclerViewItemDecoration(
                        LEAD_IN_SPACE, MIN_ELEMENT_SPACE, itemWidth);

        mDecoration.notifyViewSizeChanged(false, CONTAINER_SIZE, /* height= */ 100);

        // It's unlikely that the minimum spacing would guarantee 2.5 items to be shown, but we can
        // verify this fast.
        assertNotEquals(CONTAINER_SIZE, LEAD_IN_SPACE + itemWidth * 2.5 + MIN_ELEMENT_SPACE * 2);

        // However, we don't permit dynamic spacing in landscape mode, so this should fall back to
        // MIN_ELEMENT_SPACE.
        verifyItemSpacing(MIN_ELEMENT_SPACE);
    }

    @Test
    public void computeElementSpacingPx_minFractionalExposure() {
        // Ignore lead-in and element spacing completely to simplify computations.
        // Each element is 100px wide, with minimum exposure fraction of 0.1
        final int itemWidth = 100;
        final float minExposureFrac = 0.1f;
        final float maxExposureFrac = 0.9f;
        final int containerWidth = /* 2.1 * itemWidth=*/ 210;
        mDecoration =
                new DynamicSpacingRecyclerViewItemDecoration(
                        0, 0, itemWidth, minExposureFrac, maxExposureFrac);

        // This should fit EXACTLY 2.1 items with zero spacing.
        resizeContainer(containerWidth);
        mDecoration.getItemOffsets(mOffsets, mFirstView, mRecyclerView, null);
        assertEquals(0, mOffsets.right);
        assertEquals(0, mOffsets.left);
    }

    @Test
    public void computeElementSpacingPx_maxFractionalExposure() {
        // Ignore lead-in and element spacing completely to simplify computations.
        // Each element is 100px wide, with minimum exposure fraction of 0.1
        final int itemWidth = 100;
        final float minExposureFrac = 0.1f;
        final float maxExposureFrac = 0.9f;
        final int containerWidth = /* 2.1 * itemWidth - 1=*/ 209;
        mDecoration =
                new DynamicSpacingRecyclerViewItemDecoration(
                        0, 0, itemWidth, minExposureFrac, maxExposureFrac);

        // This should fit EXACTLY 1.9 items.
        resizeContainer(containerWidth);
        mDecoration.getItemOffsets(mOffsets, mFirstView, mRecyclerView, null);

        // The expected space to separate elements is:
        int itemSpacing = containerWidth - (int) ((1 + maxExposureFrac) * itemWidth);
        assertEquals(itemSpacing / 2, mOffsets.right);
        assertEquals(/* lead-in */ 0, mOffsets.left);
    }

    @Test
    public void computeElementSpacingPx_mediumFractionalExposure() {
        // Ignore lead-in and element spacing completely to simplify computations.
        // Each element is 100px wide, with minimum exposure fraction of 0.1
        final int itemWidth = 100;
        final float minExposureFrac = 0.1f;
        final float maxExposureFrac = 0.9f;
        final int containerWidth = /* 2.5 * itemWidth =*/ 250;
        mDecoration =
                new DynamicSpacingRecyclerViewItemDecoration(
                        0, 0, itemWidth, minExposureFrac, maxExposureFrac);

        resizeContainer(containerWidth);
        mDecoration.getItemOffsets(mOffsets, mFirstView, mRecyclerView, null);
        assertEquals(0, mOffsets.right);
        assertEquals(0, mOffsets.left);
    }
}