BaseRecyclerViewInstrumentationTest.java revision c50c4cad31d73e574b27bb3d7581542975e37263
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.graphics.Rect;
20import android.os.Looper;
21import android.test.ActivityInstrumentationTestCase2;
22import android.util.Log;
23import android.view.View;
24import android.view.ViewGroup;
25import android.widget.FrameLayout;
26import android.widget.TextView;
27
28import java.util.ArrayList;
29import java.util.List;
30import java.util.concurrent.CountDownLatch;
31import java.util.concurrent.TimeUnit;
32import java.util.concurrent.atomic.AtomicInteger;
33
34abstract public class BaseRecyclerViewInstrumentationTest extends
35        ActivityInstrumentationTestCase2<TestActivity> {
36
37    private static final String TAG = "RecyclerViewTest";
38
39    private boolean mDebug;
40
41    protected RecyclerView mRecyclerView;
42
43    protected AdapterHelper mAdapterHelper;
44
45    Throwable mainThreadException;
46
47    public BaseRecyclerViewInstrumentationTest() {
48        this(false);
49    }
50
51    public BaseRecyclerViewInstrumentationTest(boolean debug) {
52        super("android.support.v7.recyclerview", TestActivity.class);
53        mDebug = debug;
54    }
55
56    void checkForMainThreadException() throws Throwable {
57        if (mainThreadException != null) {
58            throw mainThreadException;
59        }
60    }
61
62    void setAdapter(final RecyclerView.Adapter adapter) throws Throwable {
63        runTestOnUiThread(new Runnable() {
64            @Override
65            public void run() {
66                mRecyclerView.setAdapter(adapter);
67            }
68        });
69    }
70
71    void swapAdapter(final RecyclerView.Adapter adapter,
72            final boolean removeAndRecycleExistingViews) throws Throwable {
73        runTestOnUiThread(new Runnable() {
74            @Override
75            public void run() {
76                mRecyclerView.swapAdapter(adapter, removeAndRecycleExistingViews);
77            }
78        });
79    }
80
81    void postExceptionToInstrumentation(Throwable t) {
82        if (mDebug) {
83            Log.e(TAG, "captured exception on main thread", t);
84        }
85        if (mainThreadException != null) {
86            Log.e(TAG, "receiving another main thread exception. dropping.", t);
87        } else {
88            mainThreadException = t;
89        }
90
91        if (mRecyclerView != null && mRecyclerView
92                .getLayoutManager() instanceof TestLayoutManager) {
93            TestLayoutManager lm = (TestLayoutManager) mRecyclerView.getLayoutManager();
94            // finish all layouts so that we get the correct exception
95            while (lm.layoutLatch.getCount() > 0) {
96                lm.layoutLatch.countDown();
97            }
98        }
99    }
100
101    @Override
102    protected void tearDown() throws Exception {
103        if (mRecyclerView != null) {
104            try {
105                removeRecyclerView();
106            } catch (Throwable throwable) {
107                throwable.printStackTrace();
108            }
109        }
110        getInstrumentation().waitForIdleSync();
111        try {
112            checkForMainThreadException();
113        } catch (Exception e) {
114            throw e;
115        } catch (Throwable throwable) {
116            throwable.printStackTrace();
117        }
118        super.tearDown();
119    }
120
121    public Rect getDecoratedRecyclerViewBounds() {
122        return new Rect(
123                mRecyclerView.getPaddingLeft(),
124                mRecyclerView.getPaddingTop(),
125                mRecyclerView.getPaddingLeft() + mRecyclerView.getWidth(),
126                mRecyclerView.getPaddingTop() + mRecyclerView.getHeight()
127        );
128    }
129
130    public void removeRecyclerView() throws Throwable {
131        mRecyclerView = null;
132        runTestOnUiThread(new Runnable() {
133            @Override
134            public void run() {
135                getActivity().mContainer.removeAllViews();
136            }
137        });
138    }
139
140    void waitForAnimations(int seconds) throws InterruptedException {
141        final CountDownLatch latch = new CountDownLatch(2);
142        boolean running = mRecyclerView.mItemAnimator
143                .isRunning(new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() {
144                    @Override
145                    public void onAnimationsFinished() {
146                        latch.countDown();
147                    }
148                });
149        if (running) {
150            latch.await(seconds, TimeUnit.SECONDS);
151        }
152    }
153
154    public void setRecyclerView(final RecyclerView recyclerView) throws Throwable {
155        setRecyclerView(recyclerView, true);
156    }
157    public void setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool)
158            throws Throwable {
159        mRecyclerView = recyclerView;
160        if (assignDummyPool) {
161            RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool() {
162                @Override
163                public RecyclerView.ViewHolder getRecycledView(int viewType) {
164                    RecyclerView.ViewHolder viewHolder = super.getRecycledView(viewType);
165                    if (viewHolder == null) {
166                        return null;
167                    }
168                    viewHolder.addFlags(RecyclerView.ViewHolder.FLAG_BOUND);
169                    viewHolder.mPosition = 200;
170                    viewHolder.mOldPosition = 300;
171                    viewHolder.mPreLayoutPosition = 500;
172                    return viewHolder;
173                }
174
175                @Override
176                public void putRecycledView(RecyclerView.ViewHolder scrap) {
177                    super.putRecycledView(scrap);
178                }
179            };
180            mRecyclerView.setRecycledViewPool(pool);
181        }
182        mAdapterHelper = recyclerView.mAdapterHelper;
183        runTestOnUiThread(new Runnable() {
184            @Override
185            public void run() {
186                getActivity().mContainer.addView(recyclerView);
187            }
188        });
189    }
190
191    protected FrameLayout getRecyclerViewContainer() {
192        return getActivity().mContainer;
193    }
194
195    public void requestLayoutOnUIThread(final View view) throws Throwable {
196        runTestOnUiThread(new Runnable() {
197            @Override
198            public void run() {
199                view.requestLayout();
200            }
201        });
202    }
203
204    public void scrollBy(final int dt) throws Throwable {
205        runTestOnUiThread(new Runnable() {
206            @Override
207            public void run() {
208                if (mRecyclerView.getLayoutManager().canScrollHorizontally()) {
209                    mRecyclerView.scrollBy(dt, 0);
210                } else {
211                    mRecyclerView.scrollBy(0, dt);
212                }
213
214            }
215        });
216    }
217
218    void scrollToPosition(final int position) throws Throwable {
219        runTestOnUiThread(new Runnable() {
220            @Override
221            public void run() {
222                mRecyclerView.getLayoutManager().scrollToPosition(position);
223            }
224        });
225    }
226
227    void smoothScrollToPosition(final int position)
228            throws Throwable {
229        Log.d(TAG, "SMOOTH scrolling to " + position);
230        runTestOnUiThread(new Runnable() {
231            @Override
232            public void run() {
233                mRecyclerView.smoothScrollToPosition(position);
234            }
235        });
236        while (mRecyclerView.getLayoutManager().isSmoothScrolling() ||
237                mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) {
238            if (mDebug) {
239                Log.d(TAG, "SMOOTH scrolling step");
240            }
241            Thread.sleep(200);
242        }
243        Log.d(TAG, "SMOOTH scrolling done");
244        getInstrumentation().waitForIdleSync();
245    }
246
247    class TestViewHolder extends RecyclerView.ViewHolder {
248
249        Item mBindedItem;
250
251        public TestViewHolder(View itemView) {
252            super(itemView);
253            itemView.setFocusable(true);
254        }
255
256        @Override
257        public String toString() {
258            return super.toString() + " item:" + mBindedItem;
259        }
260    }
261
262    class TestLayoutManager extends RecyclerView.LayoutManager {
263
264        CountDownLatch layoutLatch;
265
266        public void expectLayouts(int count) {
267            layoutLatch = new CountDownLatch(count);
268        }
269
270        public void waitForLayout(long timeout, TimeUnit timeUnit) throws Throwable {
271            layoutLatch.await(timeout * (mDebug ? 100 : 1), timeUnit);
272            assertEquals("all expected layouts should be executed at the expected time",
273                    0, layoutLatch.getCount());
274            getInstrumentation().waitForIdleSync();
275        }
276
277        public void assertLayoutCount(int count, String msg, long timeout) throws Throwable {
278            layoutLatch.await(timeout, TimeUnit.SECONDS);
279            assertEquals(msg, count, layoutLatch.getCount());
280        }
281
282        public void assertNoLayout(String msg, long timeout) throws Throwable {
283            layoutLatch.await(timeout, TimeUnit.SECONDS);
284            assertFalse(msg, layoutLatch.getCount() == 0);
285        }
286
287        public void waitForLayout(long timeout) throws Throwable {
288            waitForLayout(timeout * (mDebug ? 10000 : 1), TimeUnit.SECONDS);
289        }
290
291        @Override
292        public RecyclerView.LayoutParams generateDefaultLayoutParams() {
293            return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
294                    ViewGroup.LayoutParams.WRAP_CONTENT);
295        }
296
297        void assertVisibleItemPositions() {
298            int i = getChildCount();
299            TestAdapter testAdapter = (TestAdapter) mRecyclerView.getAdapter();
300            while (i-- > 0) {
301                View view = getChildAt(i);
302                RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) view.getLayoutParams();
303                Item item = ((TestViewHolder) lp.mViewHolder).mBindedItem;
304                if (mDebug) {
305                    Log.d(TAG, "testing item " + i);
306                }
307                if (!lp.isItemRemoved()) {
308                    RecyclerView.ViewHolder vh = mRecyclerView.getChildViewHolder(view);
309                    assertSame("item position in LP should match adapter value :" + vh,
310                            testAdapter.mItems.get(vh.mPosition), item);
311                }
312            }
313        }
314
315        RecyclerView.LayoutParams getLp(View v) {
316            return (RecyclerView.LayoutParams) v.getLayoutParams();
317        }
318
319        void layoutRange(RecyclerView.Recycler recycler, int start, int end) {
320            assertScrap(recycler);
321            if (mDebug) {
322                Log.d(TAG, "will layout items from " + start + " to " + end);
323            }
324            int diff = end > start ? 1 : -1;
325            int top = 0;
326            for (int i = start; i != end; i+=diff) {
327                if (mDebug) {
328                    Log.d(TAG, "laying out item " + i);
329                }
330                View view = recycler.getViewForPosition(i);
331                assertNotNull("view should not be null for valid position. "
332                        + "got null view at position " + i, view);
333                if (!mRecyclerView.mState.isPreLayout()) {
334                    RecyclerView.LayoutParams layoutParams = (RecyclerView.LayoutParams) view
335                            .getLayoutParams();
336                    assertFalse("In post layout, getViewForPosition should never return a view "
337                            + "that is removed", layoutParams != null
338                            && layoutParams.isItemRemoved());
339
340                }
341                assertEquals("getViewForPosition should return correct position",
342                        i, getPosition(view));
343                addView(view);
344
345                measureChildWithMargins(view, 0, 0);
346                layoutDecorated(view, 0, top, getDecoratedMeasuredWidth(view)
347                        , top + getDecoratedMeasuredHeight(view));
348                top += view.getMeasuredHeight();
349            }
350        }
351
352        private void assertScrap(RecyclerView.Recycler recycler) {
353            if (mRecyclerView.getAdapter() != null &&
354                    !mRecyclerView.getAdapter().hasStableIds()) {
355                for (RecyclerView.ViewHolder viewHolder : recycler.getScrapList()) {
356                    assertFalse("Invalid scrap should be no kept", viewHolder.isInvalid());
357                }
358            }
359        }
360    }
361
362    static class Item {
363        final static AtomicInteger idCounter = new AtomicInteger(0);
364        final public int mId = idCounter.incrementAndGet();
365
366        int mAdapterIndex;
367
368        final String mText;
369
370        Item(int adapterIndex, String text) {
371            mAdapterIndex = adapterIndex;
372            mText = text;
373        }
374
375        @Override
376        public String toString() {
377            return "Item{" +
378                    "mId=" + mId +
379                    ", originalIndex=" + mAdapterIndex +
380                    ", text='" + mText + '\'' +
381                    '}';
382        }
383    }
384
385    class TestAdapter extends RecyclerView.Adapter<TestViewHolder> {
386
387        List<Item> mItems;
388
389        TestAdapter(int count) {
390            mItems = new ArrayList<Item>(count);
391            for (int i = 0; i < count; i++) {
392                mItems.add(new Item(i, "Item " + i));
393            }
394        }
395
396        @Override
397        public TestViewHolder onCreateViewHolder(ViewGroup parent,
398                int viewType) {
399            return new TestViewHolder(new TextView(parent.getContext()));
400        }
401
402        @Override
403        public void onBindViewHolder(TestViewHolder holder, int position) {
404            final Item item = mItems.get(position);
405            ((TextView) (holder.itemView)).setText(item.mText + "(" + item.mAdapterIndex + ")");
406            holder.mBindedItem = item;
407        }
408
409        public void deleteAndNotify(final int start, final int count) throws Throwable {
410            deleteAndNotify(new int[]{start, count});
411        }
412
413        /**
414         * Deletes items in the given ranges.
415         * <p>
416         * Note that each operation affects the one after so you should offset them properly.
417         * <p>
418         * For example, if adapter has 5 items (A,B,C,D,E), and then you call this method with
419         * <code>[1, 2],[2, 1]</code>, it will first delete items B,C and the new adapter will be
420         * A D E. Then it will delete 2,1 which means it will delete E.
421         */
422        public void deleteAndNotify(final int[]... startCountTuples) throws Throwable {
423            for (int[] tuple : startCountTuples) {
424                tuple[1] = -tuple[1];
425            }
426            new AddRemoveRunnable(startCountTuples).runOnMainThread();
427        }
428
429        @Override
430        public long getItemId(int position) {
431            return hasStableIds() ? mItems.get(position).mId : super.getItemId(position);
432        }
433
434        public void offsetOriginalIndices(int start, int offset) {
435            for (int i = start; i < mItems.size(); i++) {
436                mItems.get(i).mAdapterIndex += offset;
437            }
438        }
439
440        /**
441         * @param start inclusive
442         * @param end exclusive
443         * @param offset
444         */
445        public void offsetOriginalIndicesBetween(int start, int end, int offset) {
446            for (int i = start; i < end && i < mItems.size(); i++) {
447                mItems.get(i).mAdapterIndex += offset;
448            }
449        }
450
451        public void addAndNotify(final int start, final int count) throws Throwable {
452            addAndNotify(new int[]{start, count});
453        }
454
455        public void addAndNotify(final int[]... startCountTuples) throws Throwable {
456            new AddRemoveRunnable(startCountTuples).runOnMainThread();
457        }
458
459        public void dispatchDataSetChanged() throws Throwable {
460            runTestOnUiThread(new Runnable() {
461                @Override
462                public void run() {
463                    notifyDataSetChanged();
464                }
465            });
466        }
467
468        public void changeAndNotify(final int start, final int count) throws Throwable {
469            runTestOnUiThread(new Runnable() {
470                @Override
471                public void run() {
472                    notifyItemRangeChanged(start, count);
473                }
474            });
475        }
476
477        public void changePositionsAndNotify(final int... positions) throws Throwable {
478            runTestOnUiThread(new Runnable() {
479                @Override
480                public void run() {
481                    for (int i = 0; i < positions.length; i += 1) {
482                        TestAdapter.super.notifyItemRangeChanged(positions[i], 1);
483                    }
484                }
485            });
486        }
487
488        /**
489         * Similar to other methods but negative count means delete and position count means add.
490         * <p>
491         * For instance, calling this method with <code>[1,1], [2,-1]</code> it will first add an
492         * item to index 1, then remove an item from index 2 (updated index 2)
493         */
494        public void addDeleteAndNotify(final int[]... startCountTuples) throws Throwable {
495            new AddRemoveRunnable(startCountTuples).runOnMainThread();
496        }
497
498        @Override
499        public int getItemCount() {
500            return mItems.size();
501        }
502
503        public void moveItems(boolean notifyChange, int[]... fromToTuples) throws Throwable {
504            for (int i = 0; i < fromToTuples.length; i += 1) {
505                int[] tuple = fromToTuples[i];
506                moveItem(tuple[0], tuple[1], false);
507            }
508            if (notifyChange) {
509                dispatchDataSetChanged();
510            }
511        }
512
513        public void moveItem(final int from, final int to, final boolean notifyChange)
514                throws Throwable {
515            runTestOnUiThread(new Runnable() {
516                @Override
517                public void run() {
518                    Item item = mItems.remove(from);
519                    mItems.add(to, item);
520                    offsetOriginalIndices(from, to - 1);
521                    item.mAdapterIndex = to;
522                    if (notifyChange) {
523                        notifyDataSetChanged();
524                    }
525                }
526            });
527        }
528
529
530        private class AddRemoveRunnable implements Runnable {
531            final int[][] mStartCountTuples;
532
533            public AddRemoveRunnable(int[][] startCountTuples) {
534                mStartCountTuples = startCountTuples;
535            }
536
537            public void runOnMainThread() throws Throwable {
538                if (Looper.myLooper() == Looper.getMainLooper()) {
539                    run();
540                } else {
541                    runTestOnUiThread(this);
542                }
543            }
544
545            @Override
546            public void run() {
547                for (int[] tuple : mStartCountTuples) {
548                    if (tuple[1] < 0) {
549                        delete(tuple);
550                    } else {
551                        add(tuple);
552                    }
553                }
554            }
555
556            private void add(int[] tuple) {
557                // offset others
558                offsetOriginalIndices(tuple[0], tuple[1]);
559                for (int i = 0; i < tuple[1]; i++) {
560                    mItems.add(tuple[0], new Item(i, "new item " + i));
561                }
562                notifyItemRangeInserted(tuple[0], tuple[1]);
563            }
564
565            private void delete(int[] tuple) {
566                final int count = -tuple[1];
567                offsetOriginalIndices(tuple[0] + count, tuple[1]);
568                for (int i = 0; i < count; i++) {
569                    mItems.remove(tuple[0]);
570                }
571                notifyItemRangeRemoved(tuple[0], count);
572            }
573        }
574    }
575
576    @Override
577    public void runTestOnUiThread(Runnable r) throws Throwable {
578        if (Looper.myLooper() == Looper.getMainLooper()) {
579            r.run();
580        } else {
581            super.runTestOnUiThread(r);
582        }
583    }
584
585    static class TargetTuple {
586
587        final int mPosition;
588
589        final int mLayoutDirection;
590
591        TargetTuple(int position, int layoutDirection) {
592            this.mPosition = position;
593            this.mLayoutDirection = layoutDirection;
594        }
595
596        @Override
597        public String toString() {
598            return "TargetTuple{" +
599                    "mPosition=" + mPosition +
600                    ", mLayoutDirection=" + mLayoutDirection +
601                    '}';
602        }
603    }
604}
605