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