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