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