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