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