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