1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.support.v7.widget;
18
19import org.hamcrest.CoreMatchers;
20import org.hamcrest.MatcherAssert;
21import org.junit.Test;
22import org.junit.runner.RunWith;
23
24import android.content.Context;
25import android.support.test.runner.AndroidJUnit4;
26import android.support.v7.util.AsyncListUtil;
27import android.test.suitebuilder.annotation.MediumTest;
28import android.view.View;
29import android.view.ViewGroup;
30import android.widget.TextView;
31
32import java.util.BitSet;
33import java.util.concurrent.CountDownLatch;
34import java.util.concurrent.TimeUnit;
35import static org.junit.Assert.*;
36
37import static java.util.concurrent.TimeUnit.SECONDS;
38
39@MediumTest
40@RunWith(AndroidJUnit4.class)
41public class AsyncListUtilLayoutTest extends BaseRecyclerViewInstrumentationTest {
42
43    private static final boolean DEBUG = false;
44
45    private static final String TAG = "AsyncListUtilLayoutTest";
46
47    private static final int ITEM_COUNT = 1000;
48    private static final int TILE_SIZE = 5;
49
50    AsyncTestAdapter mAdapter;
51
52    WrappedLinearLayoutManager mLayoutManager;
53
54    private TestDataCallback mDataCallback;
55    private TestViewCallback mViewCallback;
56    private AsyncListUtil<String> mAsyncListUtil;
57
58    public int mStartPrefetch = 0;
59    public int mEndPrefetch = 0;
60
61    @Test
62    public void asyncListUtil() throws Throwable {
63        mRecyclerView = inflateWrappedRV();
64        mRecyclerView.setHasFixedSize(true);
65
66        mAdapter = new AsyncTestAdapter();
67        mRecyclerView.setAdapter(mAdapter);
68
69        mLayoutManager = new WrappedLinearLayoutManager(
70                getActivity(), LinearLayoutManager.VERTICAL, false);
71        mRecyclerView.setLayoutManager(mLayoutManager);
72
73        mLayoutManager.expectLayouts(1);
74        setRecyclerView(mRecyclerView);
75        mLayoutManager.waitForLayout(2);
76
77        int rangeStart = 0;
78        assertEquals(rangeStart, mLayoutManager.findFirstVisibleItemPosition());
79
80        final int rangeSize = mLayoutManager.findLastVisibleItemPosition() + 1;
81        assertTrue("No visible items", rangeSize > 0);
82
83        assertEquals("All visible items must be empty at first",
84                rangeSize, getEmptyVisibleChildCount());
85
86        mDataCallback = new TestDataCallback();
87        mViewCallback = new TestViewCallback();
88
89        mDataCallback.expectTilesInRange(rangeStart, rangeSize);
90        mAdapter.expectItemsInRange(rangeStart, rangeSize);
91
92        runTestOnUiThread(new Runnable() {
93            @Override
94            public void run() {
95                mAsyncListUtil = new AsyncListUtil<String>(
96                        String.class, TILE_SIZE, mDataCallback, mViewCallback);
97            }
98        });
99
100        mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
101            @Override
102            public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
103                mAsyncListUtil.onRangeChanged();
104            }
105        });
106        assertAllLoaded("First load");
107
108        rangeStart = roundUp(rangeSize);
109        scrollAndAssert("Scroll with no prefetch", rangeStart, rangeSize);
110
111        rangeStart = roundUp(rangeStart + rangeSize);
112        mEndPrefetch = TILE_SIZE * 2;
113        scrollAndAssert("Scroll with prefetch", rangeStart, rangeSize);
114
115        rangeStart += mEndPrefetch;
116        mEndPrefetch = 0;
117        scrollAndAssert("Scroll a little down, no prefetch", rangeStart, 0);
118
119        rangeStart = ITEM_COUNT / 2;
120        mStartPrefetch = TILE_SIZE * 2;
121        mEndPrefetch = TILE_SIZE * 3;
122        scrollAndAssert("Scroll to middle, prefetch", rangeStart, rangeSize);
123
124        rangeStart -= mStartPrefetch;
125        mStartPrefetch = 0;
126        mEndPrefetch = 0;
127        scrollAndAssert("Scroll a little up, no prefetch", rangeStart, 0);
128
129        Thread.sleep(500);  // Wait for possible spurious messages.
130    }
131
132    private void assertAllLoaded(String context)
133            throws InterruptedException {
134        assertTrue(context + ", timed out while waiting for items", mAdapter.waitForItems(2));
135        assertTrue(context + ", timed out while waiting for tiles", mDataCallback.waitForTiles(2));
136        assertEquals(context + ", empty child found", 0, getEmptyVisibleChildCount());
137    }
138
139    private void scrollAndAssert(String context, int rangeStart, int rangeSize) throws Throwable {
140        if (rangeSize > 0) {
141            mDataCallback.expectTilesInRange(rangeStart, rangeSize);
142        } else {
143            mDataCallback.expectNoNewTilesLoaded();
144        }
145        mAdapter.expectItemsInRange(rangeStart, rangeSize);
146        mLayoutManager.expectLayouts(1);
147        scrollToPositionWithOffset(rangeStart, 0);
148        mLayoutManager.waitForLayout(1);
149        assertAllLoaded(context);
150    }
151
152    void scrollToPositionWithOffset(final int position, final int offset) throws Throwable {
153        runTestOnUiThread(new Runnable() {
154            @Override
155            public void run() {
156                mLayoutManager.scrollToPositionWithOffset(position, offset);
157            }
158        });
159    }
160
161    private int roundUp(int value) {
162        return value - value % TILE_SIZE + TILE_SIZE;
163    }
164
165    private int getTileCount(int start, int size) {
166        return ((start + size - 1) / TILE_SIZE) - (start / TILE_SIZE) + 1;
167    }
168
169    private int getEmptyVisibleChildCount() {
170        int emptyChildCount = 0;
171        int firstVisible = mLayoutManager.findFirstVisibleItemPosition();
172        int lastVisible = mLayoutManager.findLastVisibleItemPosition();
173        for (int i = firstVisible; i <= lastVisible; i++) {
174            View child = mLayoutManager.findViewByPosition(i);
175            assertTrue(child instanceof TextView);
176            if (((TextView) child).getText() == "") {
177                emptyChildCount++;
178            }
179        }
180        return emptyChildCount;
181    }
182
183    private class TestDataCallback extends AsyncListUtil.DataCallback<String> {
184
185        private CountDownLatch mTilesLatch;
186
187        @Override
188        public void fillData(String[] data, int startPosition, int itemCount) {
189            assertTrue("Unexpected tile load @" + startPosition, mTilesLatch.getCount() > 0);
190            try {
191                Thread.sleep(100);
192            } catch (InterruptedException e) {
193            }
194            for (int i = 0; i < itemCount; i++) {
195                data[i] = "Item #" + (startPosition + i);
196            }
197            mTilesLatch.countDown();
198        }
199
200        @Override
201        public int refreshData() {
202            return ITEM_COUNT;
203        }
204
205        private void expectTiles(int count) {
206            mTilesLatch = new CountDownLatch(count);
207        }
208
209        public void expectTilesInRange(int rangeStart, int rangeSize) {
210            expectTiles(getTileCount(rangeStart - mStartPrefetch,
211                    rangeSize + mStartPrefetch + mEndPrefetch));
212        }
213
214        public void expectNoNewTilesLoaded() {
215            expectTiles(0);
216        }
217
218        public boolean waitForTiles(long timeoutInSeconds) throws InterruptedException {
219            return mTilesLatch.await(timeoutInSeconds, TimeUnit.SECONDS);
220        }
221    }
222
223    private class TestViewCallback extends AsyncListUtil.ViewCallback {
224        @Override
225        public void getItemRangeInto(int[] outRange) {
226            outRange[0] = mLayoutManager.findFirstVisibleItemPosition();
227            outRange[1] = mLayoutManager.findLastVisibleItemPosition();
228        }
229
230        @Override
231        public void extendRangeInto(int[] range, int[] outRange, int scrollHint) {
232            outRange[0] = range[0] - mStartPrefetch;
233            outRange[1] = range[1] + mEndPrefetch;
234        }
235
236        @Override
237        public void onDataRefresh() {
238            mRecyclerView.getAdapter().notifyDataSetChanged();
239        }
240
241        @Override
242        public void onItemLoaded(int position) {
243            mRecyclerView.getAdapter().notifyItemChanged(position);
244        }
245    }
246
247    private static class SimpleViewHolder extends RecyclerView.ViewHolder {
248
249        public SimpleViewHolder(Context context) {
250            super(new TextView(context));
251        }
252    }
253
254    private class AsyncTestAdapter extends RecyclerView.Adapter<SimpleViewHolder> {
255
256        private BitSet mLoadedPositions;
257        private BitSet mExpectedPositions;
258
259        private CountDownLatch mItemsLatch;
260        public AsyncTestAdapter() {
261            mLoadedPositions = new BitSet(ITEM_COUNT);
262        }
263
264        @Override
265        public SimpleViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
266            return new SimpleViewHolder(parent.getContext());
267        }
268
269        @Override
270        public void onBindViewHolder(SimpleViewHolder holder, int position) {
271            final String item = mAsyncListUtil == null ? null : mAsyncListUtil.getItem(position);
272            ((TextView) (holder.itemView)).setText(item == null ? "" : item);
273
274            if (item != null) {
275                mLoadedPositions.set(position);
276                if (mExpectedPositions.get(position)) {
277                    mExpectedPositions.clear(position);
278                    if (mExpectedPositions.cardinality() == 0) {
279                        mItemsLatch.countDown();
280                    }
281                }
282            }
283        }
284
285        @Override
286        public int getItemCount() {
287            return ITEM_COUNT;
288        }
289
290        private void expectItemsInRange(int rangeStart, int rangeSize) {
291            mExpectedPositions = new BitSet(rangeStart + rangeSize);
292            for (int i = 0; i < rangeSize; i++) {
293                if (!mLoadedPositions.get(rangeStart + i)) {
294                    mExpectedPositions.set(rangeStart + i);
295                }
296            }
297            mItemsLatch = new CountDownLatch(1);
298            if (mExpectedPositions.cardinality() == 0) {
299                mItemsLatch.countDown();
300            }
301        }
302
303        public boolean waitForItems(long timeoutInSeconds) throws InterruptedException {
304            return mItemsLatch.await(timeoutInSeconds, TimeUnit.SECONDS);
305        }
306    }
307
308    class WrappedLinearLayoutManager extends LinearLayoutManager {
309
310        CountDownLatch mLayoutLatch;
311
312        public WrappedLinearLayoutManager(Context context, int orientation, boolean reverseLayout) {
313            super(context, orientation, reverseLayout);
314        }
315
316        public void expectLayouts(int count) {
317            mLayoutLatch = new CountDownLatch(count);
318        }
319
320        public void waitForLayout(int seconds) throws Throwable {
321            mLayoutLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS);
322            checkForMainThreadException();
323            MatcherAssert.assertThat("all layouts should complete on time",
324                    mLayoutLatch.getCount(), CoreMatchers.is(0L));
325            // use a runnable to ensure RV layout is finished
326            getInstrumentation().runOnMainSync(new Runnable() {
327                @Override
328                public void run() {
329                }
330            });
331        }
332
333        @Override
334        public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
335            try {
336                super.onLayoutChildren(recycler, state);
337            } catch (Throwable t) {
338                postExceptionToInstrumentation(t);
339            }
340            mLayoutLatch.countDown();
341        }
342    }
343}
344