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