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