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