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