chromium/chrome/browser/feed/android/java/src/org/chromium/chrome/browser/feed/FeedSliceViewTrackerTest.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.chrome.browser.feed;

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.AdditionalMatchers.leq;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyFloat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
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 android.app.Activity;
import android.graphics.Rect;
import android.os.Looper;
import android.view.View;
import android.view.ViewTreeObserver;
import android.view.Window;

import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.test.filters.SmallTest;

import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowLog;
import org.robolectric.shadows.ShadowSystemClock;

import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.chrome.browser.xsurface.ListLayoutHelper;

import java.util.Arrays;
import java.util.concurrent.TimeUnit;

/** Unit tests for {@link FeedSliceViewTracker}. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(
        manifest = Config.NONE,
        shadows = {ShadowSystemClock.class})
public class FeedSliceViewTrackerTest {
    // Mocking dependencies that are always present, but using a real FeedListContentManager.
    @Mock RecyclerView mParentView;
    @Mock FeedSliceViewTracker.Observer mObserver;
    @Mock LinearLayoutManager mLayoutManager;
    @Mock ListLayoutHelper mLayoutHelper;
    @Mock ViewTreeObserver mViewTreeObserver;
    @Mock Activity mActivity;
    @Mock Window mWindow;
    @Mock View mDecorView;
    FeedListContentManager mContentManager;

    FeedSliceViewTracker mTracker;

    // Child view mocks are used as needed in some tests.
    @Mock View mChildA;
    @Mock View mChildB;

    boolean mChildAVisibleRunnable1Called;
    boolean mChildAVisibleRunnable2Called;
    boolean mChildAVisibleRunnable3Called;
    boolean mChildBVisibleRunnable1Called;
    boolean mChildBVisibleRunnable2Called;

    @Before
    public void setUp() {
        ShadowLog.stream = System.out;
        MockitoAnnotations.initMocks(this);
        mContentManager = new FeedListContentManager();
        doReturn(mLayoutManager).when(mParentView).getLayoutManager();
        doReturn(mViewTreeObserver).when(mParentView).getViewTreeObserver();
        doReturn(mWindow).when(mActivity).getWindow();
        doReturn(mDecorView).when(mWindow).getDecorView();
        mTracker =
                Mockito.spy(
                        new FeedSliceViewTracker(
                                mParentView,
                                mActivity,
                                mContentManager,
                                mLayoutHelper,
                                /* mWatchForUserInteractionReliabilityReport= */ true,
                                mObserver));
    }

    @After
    public void tearDown() {
        ShadowSystemClock.reset();
    }

    @Test
    @SmallTest
    public void testIsItemVisible_JustEnoughnViewport() {
        mockViewDimensions(mChildA, 10, 10);
        mockGetChildVisibleRect(mChildA, 0, 0, 10, 7);
        Assert.assertTrue(mTracker.isViewVisible(mChildA, 0.66f));
    }

    @Test
    @SmallTest
    public void testIsItemVisible_NotEnoughnViewport() {
        mockViewDimensions(mChildA, 10, 10);
        mockGetChildVisibleRect(mChildA, 0, 0, 10, 6);
        Assert.assertFalse(mTracker.isViewVisible(mChildA, 0.66f));
    }

    @Test
    @SmallTest
    public void testIsItemVisible_ZeroAreaInViewport() {
        mockViewDimensions(mChildA, 10, 10);
        mockGetChildVisibleRect(mChildA, 0, 0, 0, 0);
        Assert.assertFalse(mTracker.isViewVisible(mChildA, 0.66f));
    }

    @Test
    @SmallTest
    public void testIsItemVisible_getChildVisibleRectReturnsFalse() {
        mockViewDimensions(mChildA, 10, 10);
        mockGetChildVisibleRectIsEmpty(mChildA);
        Assert.assertFalse(mTracker.isViewVisible(mChildA, 0.66f));
    }

    @Test
    @SmallTest
    public void testIsItemVisible_ZeroArea() {
        mockViewDimensions(mChildA, 0, 0);
        mockGetChildVisibleRect(mChildA, 0, 0, 0, 0);
        Assert.assertFalse(mTracker.isViewVisible(mChildA, 0.66f));
    }

    @Test
    @SmallTest
    public void testGetChildVisibleRectCalledWithChildRect() {
        mockViewDimensions(mChildA, 10, 10);
        mTracker.isViewVisible(mChildA, 0.66f);
        verify(mParentView).getChildVisibleRect(eq(mChildA), eq(new Rect(0, 0, 10, 10)), eq(null));
    }

    @Test
    @SmallTest
    public void testIsItemCoveringViewport_JustEnough() {
        mockViewDimensions(mChildA, 100, 100);
        mockGetChildVisibleRect(mChildA, 0, 0, 100, 26);
        mockViewportRect(0, 0, 100, 100);
        Assert.assertTrue(mTracker.isViewCoveringViewport(mChildA, 0.25f));
    }

    @Test
    @SmallTest
    public void testIsViewCoveringViewport_NotEnough() {
        mockViewDimensions(mChildA, 100, 100);
        mockGetChildVisibleRect(mChildA, 0, 0, 100, 24);
        mockViewportRect(0, 0, 100, 100);
        Assert.assertFalse(mTracker.isViewCoveringViewport(mChildA, 0.25f));
    }

    @Test
    @SmallTest
    public void testIsContentCoveringViewport_ZeroArea() {
        mockViewDimensions(mChildA, 0, 0);
        mockGetChildVisibleRect(mChildA, 0, 0, 0, 0);
        mockViewportRect(0, 0, 100, 100);
        Assert.assertFalse(mTracker.isViewCoveringViewport(mChildA, 0.25f));
    }

    @Test
    @SmallTest
    public void testIsContentCoveringViewport_NoViewport() {
        mockViewDimensions(mChildA, 100, 100);
        mockGetChildVisibleRect(mChildA, 0, 0, 100, 26);
        mockViewportRect(0, 0, 0, 0);
        Assert.assertFalse(mTracker.isViewCoveringViewport(mChildA, 0.25f));
    }

    @Test
    @SmallTest
    public void testOnPreDraw_BothVisibleAreReportedExactlyOnce() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(0, "c/key1", mChildA),
                            new FeedListContentManager.NativeViewContent(0, "c/key2", mChildB),
                        }));
        doReturn(0).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(1).when(mLayoutHelper).findLastVisibleItemPosition();
        doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));
        doReturn(mChildB).when(mLayoutManager).findViewByPosition(eq(1));

        doReturn(true).when(mTracker).isViewVisible(eq(mChildA), anyFloat());
        doReturn(true).when(mTracker).isViewVisible(eq(mChildB), anyFloat());

        mTracker.onPreDraw();

        verify(mObserver).feedContentVisible();
        verify(mObserver).sliceVisible(eq("c/key1"));
        verify(mObserver).sliceVisible(eq("c/key2"));

        mTracker.onPreDraw(); // Does not repeat call to sliceVisible().
    }

    @Test
    @SmallTest
    public void testOnPreDraw_AfterClearReportsAgain() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(0, "c/key1", mChildA),
                            new FeedListContentManager.NativeViewContent(0, "c/key2", mChildB),
                        }));
        doReturn(0).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(1).when(mLayoutHelper).findLastVisibleItemPosition();
        doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));
        doReturn(mChildB).when(mLayoutManager).findViewByPosition(eq(1));

        doReturn(true).when(mTracker).isViewVisible(eq(mChildA), anyFloat());
        doReturn(true).when(mTracker).isViewVisible(eq(mChildB), anyFloat());

        mTracker.onPreDraw();
        mTracker.clear();
        mTracker.onPreDraw(); // repeats observer calls.

        verify(mObserver, times(2)).feedContentVisible();
        verify(mObserver, times(2)).sliceVisible(eq("c/key1"));
        verify(mObserver, times(2)).sliceVisible(eq("c/key2"));
    }

    @Test
    @SmallTest
    public void testOnPreDraw_IgnoresNonContentViews() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(
                                    0, "non-content-key1", mChildA),
                            new FeedListContentManager.NativeViewContent(
                                    0, "non-content-key2", mChildB),
                        }));
        doReturn(0).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(1).when(mLayoutHelper).findLastVisibleItemPosition();
        doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));
        doReturn(mChildB).when(mLayoutManager).findViewByPosition(eq(1));

        doReturn(true).when(mTracker).isViewVisible(eq(mChildA), anyFloat());
        doReturn(true).when(mTracker).isViewVisible(eq(mChildB), anyFloat());

        mTracker.onPreDraw();

        verify(mObserver, times(0)).feedContentVisible();
        verify(mObserver, times(0)).sliceVisible(any());

        mTracker.onPreDraw(); // Does not repeat call to sliceVisible().
    }

    @Test
    @SmallTest
    public void testOnPreDraw_OnlyOneVisible() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(0, "c/key1", mChildA),
                            new FeedListContentManager.NativeViewContent(0, "c/key2", mChildB),
                        }));
        doReturn(0).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(1).when(mLayoutHelper).findLastVisibleItemPosition();
        doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));
        doReturn(mChildB).when(mLayoutManager).findViewByPosition(eq(1));

        doReturn(false).when(mTracker).isViewVisible(eq(mChildA), anyFloat());
        doReturn(true).when(mTracker).isViewVisible(eq(mChildB), anyFloat());

        mTracker.onPreDraw();

        verify(mObserver).sliceVisible(eq("c/key2"));
    }

    @Test
    @SmallTest
    public void testOnPreDraw_EmptyRecyclerView() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(0, "c/key1", mChildA),
                            new FeedListContentManager.NativeViewContent(0, "c/key2", mChildB),
                        }));
        doReturn(RecyclerView.NO_POSITION).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(RecyclerView.NO_POSITION).when(mLayoutHelper).findLastVisibleItemPosition();

        mTracker.onPreDraw();
    }

    @Test
    @SmallTest
    public void testDestroy() {
        doReturn(true).when(mViewTreeObserver).isAlive();
        mTracker.destroy();
        verify(mViewTreeObserver).removeOnPreDrawListener(any());

        // These calls shouldn't do anything.
        mTracker.destroy();
        mTracker.clear();
        mTracker.watchForFirstVisible("c/key1", 0.5f, () -> {});
        mTracker.stopWatchingForFirstVisible("c/key1", () -> {});
    }

    @Test
    @SmallTest
    public void testWatchForFirstVisible() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(0, "c/key1", mChildA),
                            new FeedListContentManager.NativeViewContent(0, "c/key2", mChildB),
                        }));
        doReturn(0).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(1).when(mLayoutHelper).findLastVisibleItemPosition();
        doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));
        doReturn(mChildB).when(mLayoutManager).findViewByPosition(eq(1));

        // Associates 3 observers with one content key.
        mTracker.watchForFirstVisible(
                "c/key1",
                0.5f,
                () -> {
                    mChildAVisibleRunnable1Called = true;
                });
        mTracker.watchForFirstVisible(
                "c/key1",
                0.7f,
                () -> {
                    mChildAVisibleRunnable2Called = true;
                });
        mTracker.watchForFirstVisible(
                "c/key1",
                0.4f,
                () -> {
                    mChildAVisibleRunnable3Called = true;
                });

        // Associates 2 observers with another content key.
        Runnable mChildBVisibleRunnable1 =
                () -> {
                    mChildBVisibleRunnable1Called = true;
                };
        mTracker.watchForFirstVisible("c/key2", 0.6f, mChildBVisibleRunnable1);
        mTracker.watchForFirstVisible(
                "c/key2",
                0.7f,
                () -> {
                    mChildBVisibleRunnable2Called = true;
                });

        // Expects that 2 observers associated with same content key get invoked.
        doReturn(true).when(mTracker).isViewVisible(eq(mChildA), leq(0.5f));
        doReturn(false).when(mTracker).isViewVisible(eq(mChildB), leq(0.5f));
        clearVisibleRunnableCalledStates();
        mTracker.onPreDraw();
        assertTrue(mChildAVisibleRunnable1Called);
        assertFalse(mChildAVisibleRunnable2Called);
        assertTrue(mChildAVisibleRunnable3Called);
        assertFalse(mChildBVisibleRunnable1Called);
        assertFalse(mChildBVisibleRunnable2Called);

        // Raises the threshold. Exepcts that 2 observers notified last time will not get notified
        // this time, while another observer is notified due to the raised threshold.
        doReturn(true).when(mTracker).isViewVisible(eq(mChildA), leq(0.7f));
        clearVisibleRunnableCalledStates();
        mTracker.onPreDraw();
        assertFalse(mChildAVisibleRunnable1Called);
        assertTrue(mChildAVisibleRunnable2Called);
        assertFalse(mChildAVisibleRunnable3Called);
        assertFalse(mChildBVisibleRunnable1Called);
        assertFalse(mChildBVisibleRunnable2Called);

        // Stops watching an observer. Expects that this observe will not get notified.
        mTracker.stopWatchingForFirstVisible("c/key2", mChildBVisibleRunnable1);
        doReturn(true).when(mTracker).isViewVisible(eq(mChildB), leq(0.7f));
        clearVisibleRunnableCalledStates();
        mTracker.onPreDraw();
        assertFalse(mChildAVisibleRunnable1Called);
        assertFalse(mChildAVisibleRunnable2Called);
        assertFalse(mChildAVisibleRunnable3Called);
        assertFalse(mChildBVisibleRunnable1Called);
        assertTrue(mChildBVisibleRunnable2Called);
    }

    @Test
    @SmallTest
    public void testReportContentVisibleTime_visibleAndCovering() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(0, "c/key1", mChildA),
                            new FeedListContentManager.NativeViewContent(0, "c/key2", mChildB),
                        }));
        doReturn(0).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(1).when(mLayoutHelper).findLastVisibleItemPosition();
        doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));
        doReturn(mChildB).when(mLayoutManager).findViewByPosition(eq(1));

        // Not visible or covering: no time reported.
        doReturn(false).when(mTracker).isViewVisible(eq(mChildA), anyFloat());
        doReturn(false).when(mTracker).isViewCoveringViewport(eq(mChildA), anyFloat());
        mTracker.onPreDraw();
        advanceByMs(1L);
        mTracker.onPreDraw();
        verify(mObserver, never()).reportContentSliceVisibleTime(anyLong());

        // Visible enough; time is reported.
        doReturn(true).when(mTracker).isViewVisible(eq(mChildA), anyFloat());
        doReturn(false).when(mTracker).isViewCoveringViewport(eq(mChildA), anyFloat());
        mTracker.onPreDraw();
        advanceByMs(1L);
        mTracker.onPreDraw();
        verify(mObserver, times(1)).reportContentSliceVisibleTime(eq(1L));
        reset(mObserver);

        // Covering enough; time is reported.
        doReturn(false).when(mTracker).isViewVisible(eq(mChildA), anyFloat());
        doReturn(true).when(mTracker).isViewCoveringViewport(eq(mChildA), anyFloat());
        advanceByMs(1L);
        mTracker.onPreDraw();
        verify(mObserver, times(1)).reportContentSliceVisibleTime(eq(1L));
        reset(mObserver);

        // Visible enough and covering enough: report some time spent in feed.
        doReturn(true).when(mTracker).isViewVisible(eq(mChildA), anyFloat());
        doReturn(true).when(mTracker).isViewCoveringViewport(eq(mChildA), anyFloat());
        advanceByMs(1L);
        mTracker.onPreDraw();
        verify(mObserver, times(1)).reportContentSliceVisibleTime(eq(1L));
    }

    @Test
    @SmallTest
    public void testReportContentVisibleTime_testSmallCardsCoveringEnough() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(0, "c/key1", mChildA),
                            new FeedListContentManager.NativeViewContent(0, "c/key2", mChildB),
                        }));
        doReturn(0).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(1).when(mLayoutHelper).findLastVisibleItemPosition();
        doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));
        doReturn(mChildB).when(mLayoutManager).findViewByPosition(eq(1));

        // Views are completely exposed so time is tracked.
        mockViewportRect(0, 0, 100, 100);
        mockViewDimensions(mChildA, 100, 15);
        mockGetChildVisibleRect(mChildA, 0, 0, 100, 15);
        mockViewDimensions(mChildB, 100, 15);
        mockGetChildVisibleRect(mChildB, 0, 15, 100, 30);

        mTracker.onPreDraw();
        advanceByMs(1L);
        mTracker.onPreDraw();
        verify(mObserver, times(1)).reportContentSliceVisibleTime(eq(1L));
    }

    @Test
    @SmallTest
    public void testReportContentVisibleTime_testBigCardCoveringEnough() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(0, "c/key1", mChildA),
                        }));
        doReturn(0).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(0).when(mLayoutHelper).findLastVisibleItemPosition();
        doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));

        // View is completely exposed and covers 30% of the viewport in total.
        mockViewportRect(0, 0, 100, 100);
        mockViewDimensions(mChildA, 100, 26);
        mockGetChildVisibleRect(mChildA, 0, 0, 100, 26);

        mTracker.onPreDraw();
        advanceByMs(1L);
        mTracker.onPreDraw();
        verify(mObserver, times(1)).reportContentSliceVisibleTime(eq(1L));
    }

    @Test
    @SmallTest
    public void testReportContentVisibleTime_testBigCardExposedEnough() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(0, "c/key1", mChildA),
                        }));
        doReturn(0).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(0).when(mLayoutHelper).findLastVisibleItemPosition();
        doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));

        // View is completely exposed but only covers 22% of the viewport.
        mockViewportRect(0, 0, 100, 100);
        mockViewDimensions(mChildA, 100, 22);
        mockGetChildVisibleRect(mChildA, 0, 0, 100, 22);

        mTracker.onPreDraw();
        advanceByMs(1L);
        mTracker.onPreDraw();
        verify(mObserver, times(1)).reportContentSliceVisibleTime(eq(1L));
    }

    @Test
    @SmallTest
    public void testReportContentVisibleTime_testReportTimeOnUnbind() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(0, "c/key1", mChildA),
                        }));
        doReturn(0).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(0).when(mLayoutHelper).findLastVisibleItemPosition();
        doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));

        // View is completely exposed but only covers 22% of the viewport.
        mockViewportRect(0, 0, 100, 100);
        mockViewDimensions(mChildA, 100, 22);
        mockGetChildVisibleRect(mChildA, 0, 0, 100, 22);

        mTracker.onPreDraw();
        advanceByMs(1L);
        mTracker.unbind();
        verify(mObserver, times(1)).reportContentSliceVisibleTime(eq(1L));
    }

    @Test
    @SmallTest
    public void testReportViewFirstVisibleAndRendered() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(0, "c/key1", mChildA),
                        }));
        doReturn(0).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(0).when(mLayoutHelper).findLastVisibleItemPosition();
        doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));

        // View only covers 5% of the viewport.
        mockViewportRect(0, 0, 100, 100);
        mockViewDimensions(mChildA, 100, 5);
        mockGetChildVisibleRect(mChildA, 0, 0, 100, 5);

        mTracker.onPreDraw();
        verify(mObserver, times(1)).reportViewFirstBarelyVisible(any());
        shadowOf(Looper.getMainLooper()).idle();
        verify(mObserver, times(1)).reportViewFirstRendered(any());
    }

    @Test
    @SmallTest
    public void testReportLoadMoreIndicatorVisible() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(
                                    0, "load-more-spinner1", mChildA),
                            new FeedListContentManager.NativeViewContent(
                                    1, "load-more-spinner2", mChildB),
                        }));
        doReturn(0).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(1).when(mLayoutHelper).findLastVisibleItemPosition();
        doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));
        doReturn(mChildB).when(mLayoutManager).findViewByPosition(eq(1));

        mockViewportRect(0, 0, 500, 500);
        mockViewDimensions(mChildA, 100, 100);
        mockViewDimensions(mChildB, 100, 100);

        // No report when less than 5% visible.
        mockGetChildVisibleRect(mChildA, 0, 0, 100, 4);
        mTracker.onPreDraw();
        verify(mObserver, times(0)).reportLoadMoreIndicatorVisible();

        // Report when 5% visible.
        mockGetChildVisibleRect(mChildA, 0, 0, 100, 5);
        mTracker.onPreDraw();
        verify(mObserver, times(1)).reportLoadMoreIndicatorVisible();

        // No more report when more visible.
        mockGetChildVisibleRect(mChildA, 0, 0, 100, 10);
        mTracker.onPreDraw();
        verify(mObserver, times(1)).reportLoadMoreIndicatorVisible();

        // Report for another indicator.
        mockGetChildVisibleRect(mChildB, 0, 0, 100, 5);
        mTracker.onPreDraw();
        verify(mObserver, times(2)).reportLoadMoreIndicatorVisible();
    }

    @Test
    @SmallTest
    public void testReportLoadMoreAwayFromIndicator() {
        mContentManager.addContents(
                0,
                Arrays.asList(
                        new FeedListContentManager.FeedContent[] {
                            new FeedListContentManager.NativeViewContent(
                                    0, "load-more-spinner1", mChildA),
                            new FeedListContentManager.NativeViewContent(
                                    1, "load-more-spinner2", mChildB),
                        }));
        doReturn(0).when(mLayoutHelper).findFirstVisibleItemPosition();
        doReturn(1).when(mLayoutHelper).findLastVisibleItemPosition();
        doReturn(mChildA).when(mLayoutManager).findViewByPosition(eq(0));
        doReturn(mChildB).when(mLayoutManager).findViewByPosition(eq(1));

        mockViewportRect(0, 0, 500, 500);
        mockViewDimensions(mChildA, 100, 100);
        mockViewDimensions(mChildB, 100, 100);

        // Report visible when 5% visible.
        mockGetChildVisibleRect(mChildA, 0, 0, 100, 5);
        mTracker.onPreDraw();
        verify(mObserver, times(1)).reportLoadMoreIndicatorVisible();
        verify(mObserver, times(0)).reportLoadMoreUserScrolledAwayFromIndicator();

        // Report away when not visible.
        mockGetChildVisibleRect(mChildA, 0, 0, 100, 0);
        mTracker.onPreDraw();
        verify(mObserver, times(1)).reportLoadMoreIndicatorVisible();
        verify(mObserver, times(1)).reportLoadMoreUserScrolledAwayFromIndicator();

        // No more report when further away.
        mockGetChildVisibleRect(mChildA, 0, 0, 100, -10);
        mTracker.onPreDraw();
        verify(mObserver, times(1)).reportLoadMoreIndicatorVisible();
        verify(mObserver, times(1)).reportLoadMoreUserScrolledAwayFromIndicator();

        // Report for another indicator.
        mockGetChildVisibleRect(mChildB, 0, 0, 100, 5);
        mTracker.onPreDraw();
        verify(mObserver, times(2)).reportLoadMoreIndicatorVisible();
        verify(mObserver, times(1)).reportLoadMoreUserScrolledAwayFromIndicator();

        mockGetChildVisibleRect(mChildB, 0, 0, 100, 0);
        mTracker.onPreDraw();
        verify(mObserver, times(2)).reportLoadMoreIndicatorVisible();
        verify(mObserver, times(2)).reportLoadMoreUserScrolledAwayFromIndicator();
    }

    void mockViewDimensions(View view, int width, int height) {
        when(view.getWidth()).thenReturn(width);
        when(view.getHeight()).thenReturn(height);
    }

    void mockGetChildVisibleRect(
            View child, int rectLeft, int rectTop, int rectRight, int rectBottom) {
        doAnswer(
                        new Answer() {
                            @Override
                            public Object answer(InvocationOnMock invocation) {
                                Rect rect = (Rect) invocation.getArguments()[1];
                                rect.top = rectTop;
                                rect.bottom = rectBottom;
                                rect.left = rectLeft;
                                rect.right = rectRight;
                                return true;
                            }
                        })
                .when(mParentView)
                .getChildVisibleRect(eq(child), any(), any());
    }

    void mockGetChildVisibleRectIsEmpty(View child) {
        doAnswer(
                        new Answer() {
                            @Override
                            public Object answer(InvocationOnMock invocation) {
                                return false;
                            }
                        })
                .when(mParentView)
                .getChildVisibleRect(eq(child), any(), any());
    }

    void mockViewportRect(int left, int top, int right, int bottom) {
        doAnswer(
                        new Answer() {
                            @Override
                            public Object answer(InvocationOnMock invocation) {
                                ((Rect) invocation.getArguments()[0])
                                        .set(new Rect(left, top, right, bottom));
                                return null;
                            }
                        })
                .when(mDecorView)
                .getWindowVisibleDisplayFrame(any());
    }

    void clearVisibleRunnableCalledStates() {
        mChildAVisibleRunnable1Called = false;
        mChildAVisibleRunnable2Called = false;
        mChildAVisibleRunnable3Called = false;
        mChildBVisibleRunnable1Called = false;
        mChildBVisibleRunnable2Called = false;
    }

    void advanceByMs(long ms) {
        ShadowSystemClock.advanceBy(ms, TimeUnit.MILLISECONDS);
    }
}