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