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.util;
18
19import org.junit.After;
20import org.junit.Before;
21import org.junit.Test;
22import org.junit.runner.RunWith;
23import org.junit.runners.JUnit4;
24
25import android.support.annotation.UiThread;
26import android.test.suitebuilder.annotation.MediumTest;
27import android.util.SparseBooleanArray;
28
29import java.util.concurrent.CountDownLatch;
30import java.util.concurrent.TimeUnit;
31import static org.junit.Assert.*;
32
33@MediumTest
34@RunWith(JUnit4.class)
35public class AsyncListUtilTest extends BaseThreadedTest {
36
37    private static final int TILE_SIZE = 10;
38
39    private TestDataCallback mDataCallback;
40    private TestViewCallback mViewCallback;
41
42    AsyncListUtil<String> mAsyncListUtil;
43
44    @Before
45    public final void setupCallbacks() throws Exception {
46        mDataCallback = new TestDataCallback();
47        mViewCallback = new TestViewCallback();
48        mDataCallback.expectTiles(0, 10, 20);
49        super.setUp();
50        mDataCallback.waitForTiles("initial load");
51    }
52
53    @Override
54    @UiThread
55    protected void setUpUi() {
56        mAsyncListUtil = new AsyncListUtil<String>(
57                String.class, TILE_SIZE, mDataCallback, mViewCallback);
58    }
59
60    @After
61    public void tearDown() throws Exception {
62        /// Wait a little extra to catch spurious messages.
63        new CountDownLatch(1).await(500, TimeUnit.MILLISECONDS);
64    }
65
66    @Test
67    public void withNoPreload() throws Throwable {
68        scrollAndExpectTiles(10, "scroll to 10", 30);
69        scrollAndExpectTiles(25, "scroll to 25", 40);
70        scrollAndExpectTiles(45, "scroll to 45", 50, 60);
71        scrollAndExpectTiles(70, "scroll to 70", 70, 80, 90);
72    }
73
74    @Test
75    public void withPreload() throws Throwable {
76        mViewCallback.mStartPreload = 5;
77        mViewCallback.mEndPreload = 15;
78        scrollAndExpectTiles(50, "scroll down a lot", 40, 50, 60, 70, 80);
79
80        mViewCallback.mStartPreload = 0;
81        mViewCallback.mEndPreload = 0;
82        scrollAndExpectTiles(60, "scroll down a little, no new tiles loaded");
83        scrollAndExpectTiles(40, "scroll up a little, no new tiles loaded");
84    }
85
86    @Test
87    public void tileCaching() throws Throwable {
88        scrollAndExpectTiles(25, "next screen", 30, 40);
89
90        scrollAndExpectTiles(0, "back at top, no new page loads");
91        scrollAndExpectTiles(25, "next screen again, no new page loads");
92
93        mDataCallback.mCacheSize = 3;
94        scrollAndExpectTiles(50, "scroll down more, all pages should load", 50, 60, 70);
95        scrollAndExpectTiles(0, "scroll back to top, all pages should reload", 0, 10, 20);
96    }
97
98    @Test
99    public void dataRefresh() throws Throwable {
100        mViewCallback.expectDataSetChanged(40);
101        mDataCallback.expectTiles(0, 10, 20);
102        refreshOnUiThread();
103        mViewCallback.waitForDataSetChanged("increasing item count");
104        mDataCallback.waitForTiles("increasing item count");
105
106        mViewCallback.expectDataSetChanged(15);
107        mDataCallback.expectTiles(0, 10);
108        refreshOnUiThread();
109        mViewCallback.waitForDataSetChanged("decreasing item count");
110        mDataCallback.waitForTiles("decreasing item count");
111    }
112
113    @Test
114    public void itemChanged() throws Throwable {
115        final int position = 30;
116        final int count = 20;
117
118        assertLoadedItemsOnUiThread("no new items should be loaded", 0, position, count);
119
120        mViewCallback.expectItemRangeChanged(position, count);
121        scrollAndExpectTiles(20, "scrolling to missing items", 30, 40);
122        mViewCallback.waitForItems();
123
124        assertLoadedItemsOnUiThread("all new items should be loaded", count, position, count);
125    }
126
127    @UiThread
128    private int getLoadedItemCount(int startPosition, int itemCount) {
129        int loaded = 0;
130        for (int i = 0; i < itemCount; i++) {
131            if (mAsyncListUtil.getItem(startPosition + i) != null) {
132                loaded++;
133            }
134        }
135        return loaded;
136    }
137
138    private void scrollAndExpectTiles(int position, String context, int... positions)
139            throws Throwable {
140        mDataCallback.expectTiles(positions);
141        scrollOnUiThread(position);
142        mDataCallback.waitForTiles(context);
143    }
144
145    private static void waitForLatch(String context, CountDownLatch latch)
146            throws InterruptedException {
147        assertTrue("timed out waiting for " + context, latch.await(1, TimeUnit.SECONDS));
148    }
149
150    private void refreshOnUiThread() throws Throwable {
151        runTestOnUiThread(new Runnable() {
152            @Override
153            public void run() {
154                mAsyncListUtil.refresh();
155            }
156        });
157    }
158
159    private void assertLoadedItemsOnUiThread(final String message,
160                                             final int expectedCount,
161                                             final int position,
162                                             final int count) throws Throwable {
163        runTestOnUiThread(new Runnable() {
164            @Override
165            public void run() {
166                assertEquals(message, expectedCount, getLoadedItemCount(position, count));
167            }
168        });
169    }
170
171    private void scrollOnUiThread(final int position) throws Throwable {
172        runTestOnUiThread(new Runnable() {
173            @Override
174            public void run() {
175                mViewCallback.scrollTo(position);
176            }
177        });
178    }
179
180    private class TestDataCallback extends AsyncListUtil.DataCallback<String> {
181        private int mCacheSize = 10;
182
183        int mDataItemCount = 100;
184
185        final PositionSetLatch mTilesFilledLatch = new PositionSetLatch("filled");
186
187        @Override
188        public void fillData(String[] data, int startPosition, int itemCount) {
189            synchronized (mTilesFilledLatch) {
190                assertEquals(Math.min(TILE_SIZE, mDataItemCount - startPosition), itemCount);
191                mTilesFilledLatch.countDown(startPosition);
192            }
193            for (int i = 0; i < itemCount; i++) {
194                data[i] = "item #" + startPosition;
195            }
196        }
197
198        @Override
199        public int refreshData() {
200            return mDataItemCount;
201        }
202
203        public int getMaxCachedTiles() {
204            return mCacheSize;
205        }
206
207        public void expectTiles(int... positions) {
208            synchronized (mTilesFilledLatch) {
209                mTilesFilledLatch.expect(positions);
210            }
211        }
212
213        private void waitForTiles(String context) throws InterruptedException {
214            waitForLatch("filled tiles (" + context + ")", mTilesFilledLatch.mLatch);
215        }
216    }
217
218    private class TestViewCallback extends AsyncListUtil.ViewCallback {
219        public static final int VIEWPORT_SIZE = 25;
220        private int mStartPreload;
221        private int mEndPreload;
222
223        int mFirstVisibleItem;
224        int mLastVisibleItem = VIEWPORT_SIZE - 1;
225
226        private int mExpectedItemCount;
227        CountDownLatch mDataRefreshLatch;
228
229        PositionSetLatch mItemsChangedLatch = new PositionSetLatch("item changed");
230
231        @Override
232        public void getItemRangeInto(int[] outRange) {
233            outRange[0] = mFirstVisibleItem;
234            outRange[1] = mLastVisibleItem;
235        }
236
237        @Override
238        public void extendRangeInto(int[] range, int[] outRange, int scrollHint) {
239            outRange[0] = range[0] - mStartPreload;
240            outRange[1] = range[1] + mEndPreload;
241        }
242
243        @Override
244        @UiThread
245        public void onDataRefresh() {
246            if (mDataRefreshLatch == null) {
247                return;
248            }
249            assertTrue("unexpected onDataRefresh notification", mDataRefreshLatch.getCount() == 1);
250            assertEquals(mExpectedItemCount, mAsyncListUtil.getItemCount());
251            mDataRefreshLatch.countDown();
252            updateViewport();
253        }
254
255        @Override
256        public void onItemLoaded(int position) {
257            mItemsChangedLatch.countDown(position);
258        }
259
260        public void expectDataSetChanged(int expectedItemCount) {
261            mDataCallback.mDataItemCount = expectedItemCount;
262            mExpectedItemCount = expectedItemCount;
263            mDataRefreshLatch = new CountDownLatch(1);
264        }
265
266        public void waitForDataSetChanged(String context) throws InterruptedException {
267            waitForLatch("timed out waiting for data set change (" + context + ")",
268                    mDataRefreshLatch);
269        }
270
271        public void expectItemRangeChanged(int startPosition, int itemCount) {
272            mItemsChangedLatch.expectRange(startPosition, itemCount);
273        }
274
275        public void waitForItems() throws InterruptedException {
276            waitForLatch("onItemChanged", mItemsChangedLatch.mLatch);
277        }
278
279        @UiThread
280        public void scrollTo(int position) {
281            mLastVisibleItem += position - mFirstVisibleItem;
282            mFirstVisibleItem = position;
283            mAsyncListUtil.onRangeChanged();
284        }
285
286        @UiThread
287        private void updateViewport() {
288            int itemCount = mAsyncListUtil.getItemCount();
289            if (mLastVisibleItem < itemCount) {
290                return;
291            }
292            mLastVisibleItem = itemCount - 1;
293            mFirstVisibleItem = Math.max(0, mLastVisibleItem - VIEWPORT_SIZE + 1);
294        }
295    }
296
297    private static class PositionSetLatch {
298        public CountDownLatch mLatch = new CountDownLatch(0);
299
300        final private SparseBooleanArray mExpectedPositions = new SparseBooleanArray();
301        final private String mKind;
302
303        PositionSetLatch(String kind) {
304            this.mKind = kind;
305        }
306
307        void expect(int ... positions) {
308            mExpectedPositions.clear();
309            for (int position : positions) {
310                mExpectedPositions.put(position, true);
311            }
312            createLatch();
313        }
314
315        void expectRange(int position, int count) {
316            mExpectedPositions.clear();
317            for (int i = 0; i < count; i++) {
318                mExpectedPositions.put(position + i, true);
319            }
320            createLatch();
321        }
322
323        void countDown(int position) {
324            if (mLatch == null) {
325                return;
326            }
327            assertTrue("unexpected " + mKind + " @" + position, mExpectedPositions.get(position));
328            mExpectedPositions.delete(position);
329            if (mExpectedPositions.size() == 0) {
330                mLatch.countDown();
331            }
332        }
333
334        private void createLatch() {
335            mLatch = new CountDownLatch(1);
336            if (mExpectedPositions.size() == 0) {
337                mLatch.countDown();
338            }
339        }
340    }
341}
342