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