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