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