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