BaseRecyclerViewInstrumentationTest.java revision d7e2f2ab1d253133fa54f309e74a7ee384c33971
1/*
2 * Copyright (C) 2014 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 android.os.Looper;
20import android.test.ActivityInstrumentationTestCase2;
21import android.util.Log;
22import android.view.View;
23import android.view.ViewGroup;
24import android.widget.FrameLayout;
25import android.widget.TextView;
26
27import java.util.ArrayList;
28import java.util.List;
29import java.util.concurrent.CountDownLatch;
30import java.util.concurrent.TimeUnit;
31import java.util.concurrent.atomic.AtomicInteger;
32
33abstract public class BaseRecyclerViewInstrumentationTest extends
34        ActivityInstrumentationTestCase2<TestActivity> {
35
36    private static final String TAG = "RecyclerViewTest";
37
38    private boolean mDebug;
39
40    protected RecyclerView mRecyclerView;
41
42    protected AdapterHelper mAdapterHelper;
43
44    Throwable mainThreadException;
45
46    public BaseRecyclerViewInstrumentationTest() {
47        this(false);
48    }
49
50    public BaseRecyclerViewInstrumentationTest(boolean debug) {
51        super("android.support.v7.recyclerview", TestActivity.class);
52        mDebug = debug;
53    }
54
55    void checkForMainThreadException() throws Throwable {
56        if (mainThreadException != null) {
57            throw mainThreadException;
58        }
59    }
60
61    void setAdapter(final RecyclerView.Adapter adapter) throws Throwable {
62        runTestOnUiThread(new Runnable() {
63            @Override
64            public void run() {
65                mRecyclerView.setAdapter(adapter);
66            }
67        });
68    }
69
70    void swapAdapter(final RecyclerView.Adapter adapter,
71            final boolean removeAndRecycleExistingViews) throws Throwable {
72        runTestOnUiThread(new Runnable() {
73            @Override
74            public void run() {
75                mRecyclerView.swapAdapter(adapter, removeAndRecycleExistingViews);
76            }
77        });
78    }
79
80    void postExceptionToInstrumentation(Throwable t) {
81        if (mDebug) {
82            Log.e(TAG, "captured exception on main thread", t);
83        }
84        if (mainThreadException != null) {
85            Log.e(TAG, "receiving another main thread exception. dropping.", t);
86        } else {
87            mainThreadException = t;
88        }
89
90        if (mRecyclerView != null && mRecyclerView
91                .getLayoutManager() instanceof TestLayoutManager) {
92            TestLayoutManager lm = (TestLayoutManager) mRecyclerView.getLayoutManager();
93            // finish all layouts so that we get the correct exception
94            while (lm.layoutLatch.getCount() > 0) {
95                lm.layoutLatch.countDown();
96            }
97        }
98    }
99
100    @Override
101    protected void tearDown() throws Exception {
102        if (mRecyclerView != null) {
103            try {
104                removeRecyclerView();
105            } catch (Throwable throwable) {
106                throwable.printStackTrace();
107            }
108        }
109        getInstrumentation().waitForIdleSync();
110        try {
111            checkForMainThreadException();
112        } catch (Exception e) {
113            throw e;
114        } catch (Throwable throwable) {
115            throwable.printStackTrace();
116        }
117        super.tearDown();
118    }
119
120    public void removeRecyclerView() throws Throwable {
121        if (mRecyclerView == null) {
122            return;
123        }
124        mRecyclerView = null;
125        runTestOnUiThread(new Runnable() {
126            @Override
127            public void run() {
128                getActivity().mContainer.removeAllViews();
129            }
130        });
131    }
132
133    void waitForAnimations(int seconds) throws InterruptedException {
134        final CountDownLatch latch = new CountDownLatch(2);
135        boolean running = mRecyclerView.mItemAnimator
136                .isRunning(new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() {
137                    @Override
138                    public void onAnimationsFinished() {
139                        latch.countDown();
140                    }
141                });
142        if (running) {
143            latch.await(seconds, TimeUnit.SECONDS);
144        }
145    }
146
147    public void setRecyclerView(final RecyclerView recyclerView) throws Throwable {
148        setRecyclerView(recyclerView, true);
149    }
150    public void setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool)
151            throws Throwable {
152        mRecyclerView = recyclerView;
153        if (assignDummyPool) {
154            RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool() {
155                @Override
156                public RecyclerView.ViewHolder getRecycledView(int viewType) {
157                    RecyclerView.ViewHolder viewHolder = super.getRecycledView(viewType);
158                    if (viewHolder == null) {
159                        return null;
160                    }
161                    viewHolder.addFlags(RecyclerView.ViewHolder.FLAG_BOUND);
162                    viewHolder.mPosition = 200;
163                    viewHolder.mOldPosition = 300;
164                    viewHolder.mPreLayoutPosition = 500;
165                    return viewHolder;
166                }
167
168                @Override
169                public void putRecycledView(RecyclerView.ViewHolder scrap) {
170                    super.putRecycledView(scrap);
171                }
172            };
173            mRecyclerView.setRecycledViewPool(pool);
174        }
175        mAdapterHelper = recyclerView.mAdapterHelper;
176        runTestOnUiThread(new Runnable() {
177            @Override
178            public void run() {
179                getActivity().mContainer.addView(recyclerView);
180            }
181        });
182    }
183
184    protected FrameLayout getRecyclerViewContainer() {
185        return getActivity().mContainer;
186    }
187
188    public void requestLayoutOnUIThread(final View view) throws Throwable {
189        runTestOnUiThread(new Runnable() {
190            @Override
191            public void run() {
192                view.requestLayout();
193            }
194        });
195    }
196
197    public void scrollBy(final int dt) throws Throwable {
198        runTestOnUiThread(new Runnable() {
199            @Override
200            public void run() {
201                if (mRecyclerView.getLayoutManager().canScrollHorizontally()) {
202                    mRecyclerView.scrollBy(dt, 0);
203                } else {
204                    mRecyclerView.scrollBy(0, dt);
205                }
206
207            }
208        });
209    }
210
211    void scrollToPosition(final int position) throws Throwable {
212        runTestOnUiThread(new Runnable() {
213            @Override
214            public void run() {
215                mRecyclerView.getLayoutManager().scrollToPosition(position);
216            }
217        });
218    }
219
220    void smoothScrollToPosition(final int position)
221            throws Throwable {
222        Log.d(TAG, "SMOOTH scrolling to " + position);
223        runTestOnUiThread(new Runnable() {
224            @Override
225            public void run() {
226                mRecyclerView.smoothScrollToPosition(position);
227            }
228        });
229        while (mRecyclerView.getLayoutManager().isSmoothScrolling() ||
230                mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) {
231            if (mDebug) {
232                Log.d(TAG, "SMOOTH scrolling step");
233            }
234            Thread.sleep(200);
235        }
236        Log.d(TAG, "SMOOTH scrolling done");
237        getInstrumentation().waitForIdleSync();
238    }
239
240    class TestViewHolder extends RecyclerView.ViewHolder {
241
242        Item mBindedItem;
243
244        public TestViewHolder(View itemView) {
245            super(itemView);
246            itemView.setFocusable(true);
247        }
248
249        @Override
250        public String toString() {
251            return super.toString() + " item:" + mBindedItem;
252        }
253    }
254
255    class TestLayoutManager extends RecyclerView.LayoutManager {
256
257        CountDownLatch layoutLatch;
258
259        public void expectLayouts(int count) {
260            layoutLatch = new CountDownLatch(count);
261        }
262
263        public void waitForLayout(long timeout, TimeUnit timeUnit) throws Throwable {
264            layoutLatch.await(timeout * (mDebug ? 100 : 1), timeUnit);
265            assertEquals("all expected layouts should be executed at the expected time",
266                    0, layoutLatch.getCount());
267            getInstrumentation().waitForIdleSync();
268        }
269
270        public void assertLayoutCount(int count, String msg, long timeout) throws Throwable {
271            layoutLatch.await(timeout, TimeUnit.SECONDS);
272            assertEquals(msg, count, layoutLatch.getCount());
273        }
274
275        public void assertNoLayout(String msg, long timeout) throws Throwable {
276            layoutLatch.await(timeout, TimeUnit.SECONDS);
277            assertFalse(msg, layoutLatch.getCount() == 0);
278        }
279
280        public void waitForLayout(long timeout) throws Throwable {
281            waitForLayout(timeout * (mDebug ? 10000 : 1), TimeUnit.SECONDS);
282        }
283
284        @Override
285        public RecyclerView.LayoutParams generateDefaultLayoutParams() {
286            return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
287                    ViewGroup.LayoutParams.WRAP_CONTENT);
288        }
289
290        void assertVisibleItemPositions() {
291            int i = getChildCount();
292            TestAdapter testAdapter = (TestAdapter) mRecyclerView.getAdapter();
293            while (i-- > 0) {
294                View view = getChildAt(i);
295                RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) view.getLayoutParams();
296                Item item = ((TestViewHolder) lp.mViewHolder).mBindedItem;
297                if (mDebug) {
298                    Log.d(TAG, "testing item " + i);
299                }
300                if (!lp.isItemRemoved()) {
301                    RecyclerView.ViewHolder vh = mRecyclerView.getChildViewHolder(view);
302                    assertSame("item position in LP should match adapter value :" + vh,
303                            testAdapter.mItems.get(vh.mPosition), item);
304                }
305            }
306        }
307
308        RecyclerView.LayoutParams getLp(View v) {
309            return (RecyclerView.LayoutParams) v.getLayoutParams();
310        }
311
312        void layoutRange(RecyclerView.Recycler recycler, int start, int end) {
313            assertScrap(recycler);
314            if (mDebug) {
315                Log.d(TAG, "will layout items from " + start + " to " + end);
316            }
317            int diff = end > start ? 1 : -1;
318            int top = 0;
319            for (int i = start; i != end; i+=diff) {
320                if (mDebug) {
321                    Log.d(TAG, "laying out item " + i);
322                }
323                View view = recycler.getViewForPosition(i);
324                assertNotNull("view should not be null for valid position. "
325                        + "got null view at position " + i, view);
326                if (!mRecyclerView.mState.isPreLayout()) {
327                    RecyclerView.LayoutParams layoutParams = (RecyclerView.LayoutParams) view
328                            .getLayoutParams();
329                    assertFalse("In post layout, getViewForPosition should never return a view "
330                            + "that is removed", layoutParams != null
331                            && layoutParams.isItemRemoved());
332
333                }
334                assertEquals("getViewForPosition should return correct position",
335                        i, getPosition(view));
336                addView(view);
337
338                measureChildWithMargins(view, 0, 0);
339                layoutDecorated(view, 0, top, getDecoratedMeasuredWidth(view)
340                        , top + getDecoratedMeasuredHeight(view));
341                top += view.getMeasuredHeight();
342            }
343        }
344
345        private void assertScrap(RecyclerView.Recycler recycler) {
346            if (mRecyclerView.getAdapter() != null &&
347                    !mRecyclerView.getAdapter().hasStableIds()) {
348                for (RecyclerView.ViewHolder viewHolder : recycler.getScrapList()) {
349                    assertFalse("Invalid scrap should be no kept", viewHolder.isInvalid());
350                }
351            }
352        }
353    }
354
355    static class Item {
356        final static AtomicInteger idCounter = new AtomicInteger(0);
357        final public int mId = idCounter.incrementAndGet();
358
359        int mAdapterIndex;
360
361        final String mText;
362
363        Item(int adapterIndex, String text) {
364            mAdapterIndex = adapterIndex;
365            mText = text;
366        }
367
368        @Override
369        public String toString() {
370            return "Item{" +
371                    "mId=" + mId +
372                    ", originalIndex=" + mAdapterIndex +
373                    ", text='" + mText + '\'' +
374                    '}';
375        }
376    }
377
378    class TestAdapter extends RecyclerView.Adapter<TestViewHolder> {
379
380        List<Item> mItems;
381
382        TestAdapter(int count) {
383            mItems = new ArrayList<Item>(count);
384            for (int i = 0; i < count; i++) {
385                mItems.add(new Item(i, "Item " + i));
386            }
387        }
388
389        @Override
390        public TestViewHolder onCreateViewHolder(ViewGroup parent,
391                int viewType) {
392            return new TestViewHolder(new TextView(parent.getContext()));
393        }
394
395        @Override
396        public void onBindViewHolder(TestViewHolder holder, int position) {
397            final Item item = mItems.get(position);
398            ((TextView) (holder.itemView)).setText(item.mText + "(" + item.mAdapterIndex + ")");
399            holder.mBindedItem = item;
400        }
401
402        public void deleteAndNotify(final int start, final int count) throws Throwable {
403            deleteAndNotify(new int[]{start, count});
404        }
405
406        /**
407         * Deletes items in the given ranges.
408         * <p>
409         * Note that each operation affects the one after so you should offset them properly.
410         * <p>
411         * For example, if adapter has 5 items (A,B,C,D,E), and then you call this method with
412         * <code>[1, 2],[2, 1]</code>, it will first delete items B,C and the new adapter will be
413         * A D E. Then it will delete 2,1 which means it will delete E.
414         */
415        public void deleteAndNotify(final int[]... startCountTuples) throws Throwable {
416            for (int[] tuple : startCountTuples) {
417                tuple[1] = -tuple[1];
418            }
419            new AddRemoveRunnable(startCountTuples).runOnMainThread();
420        }
421
422        @Override
423        public long getItemId(int position) {
424            return hasStableIds() ? mItems.get(position).mId : super.getItemId(position);
425        }
426
427        public void offsetOriginalIndices(int start, int offset) {
428            for (int i = start; i < mItems.size(); i++) {
429                mItems.get(i).mAdapterIndex += offset;
430            }
431        }
432
433        /**
434         * @param start inclusive
435         * @param end exclusive
436         * @param offset
437         */
438        public void offsetOriginalIndicesBetween(int start, int end, int offset) {
439            for (int i = start; i < end && i < mItems.size(); i++) {
440                mItems.get(i).mAdapterIndex += offset;
441            }
442        }
443
444        public void addAndNotify(final int start, final int count) throws Throwable {
445            addAndNotify(new int[]{start, count});
446        }
447
448        public void addAndNotify(final int[]... startCountTuples) throws Throwable {
449            new AddRemoveRunnable(startCountTuples).runOnMainThread();
450        }
451
452        public void dispatchDataSetChanged() throws Throwable {
453            runTestOnUiThread(new Runnable() {
454                @Override
455                public void run() {
456                    notifyDataSetChanged();
457                }
458            });
459        }
460
461        public void changeAndNotify(final int start, final int count) throws Throwable {
462            runTestOnUiThread(new Runnable() {
463                @Override
464                public void run() {
465                    notifyItemRangeChanged(start, count);
466                }
467            });
468        }
469
470        public void changePositionsAndNotify(final int... positions) throws Throwable {
471            runTestOnUiThread(new Runnable() {
472                @Override
473                public void run() {
474                    for (int i = 0; i < positions.length; i += 1) {
475                        TestAdapter.super.notifyItemRangeChanged(positions[i], 1);
476                    }
477                }
478            });
479        }
480
481        /**
482         * Similar to other methods but negative count means delete and position count means add.
483         * <p>
484         * For instance, calling this method with <code>[1,1], [2,-1]</code> it will first add an
485         * item to index 1, then remove an item from index 2 (updated index 2)
486         */
487        public void addDeleteAndNotify(final int[]... startCountTuples) throws Throwable {
488            new AddRemoveRunnable(startCountTuples).runOnMainThread();
489        }
490
491        @Override
492        public int getItemCount() {
493            return mItems.size();
494        }
495
496        public void moveItems(boolean notifyChange, int[]... fromToTuples) throws Throwable {
497            for (int i = 0; i < fromToTuples.length; i += 1) {
498                int[] tuple = fromToTuples[i];
499                moveItem(tuple[0], tuple[1], false);
500            }
501            if (notifyChange) {
502                dispatchDataSetChanged();
503            }
504        }
505
506        public void moveItem(final int from, final int to, final boolean notifyChange)
507                throws Throwable {
508            runTestOnUiThread(new Runnable() {
509                @Override
510                public void run() {
511                    Item item = mItems.remove(from);
512                    mItems.add(to, item);
513                    offsetOriginalIndices(from, to - 1);
514                    item.mAdapterIndex = to;
515                    if (notifyChange) {
516                        notifyDataSetChanged();
517                    }
518                }
519            });
520        }
521
522
523        private class AddRemoveRunnable implements Runnable {
524            final int[][] mStartCountTuples;
525
526            public AddRemoveRunnable(int[][] startCountTuples) {
527                mStartCountTuples = startCountTuples;
528            }
529
530            public void runOnMainThread() throws Throwable {
531                if (Looper.myLooper() == Looper.getMainLooper()) {
532                    run();
533                } else {
534                    runTestOnUiThread(this);
535                }
536            }
537
538            @Override
539            public void run() {
540                for (int[] tuple : mStartCountTuples) {
541                    if (tuple[1] < 0) {
542                        delete(tuple);
543                    } else {
544                        add(tuple);
545                    }
546                }
547            }
548
549            private void add(int[] tuple) {
550                // offset others
551                offsetOriginalIndices(tuple[0], tuple[1]);
552                for (int i = 0; i < tuple[1]; i++) {
553                    mItems.add(tuple[0], new Item(i, "new item " + i));
554                }
555                notifyItemRangeInserted(tuple[0], tuple[1]);
556            }
557
558            private void delete(int[] tuple) {
559                final int count = -tuple[1];
560                offsetOriginalIndices(tuple[0] + count, tuple[1]);
561                for (int i = 0; i < count; i++) {
562                    mItems.remove(tuple[0]);
563                }
564                notifyItemRangeRemoved(tuple[0], count);
565            }
566        }
567    }
568
569    @Override
570    public void runTestOnUiThread(Runnable r) throws Throwable {
571        if (Looper.myLooper() == Looper.getMainLooper()) {
572            r.run();
573        } else {
574            super.runTestOnUiThread(r);
575        }
576    }
577}
578