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