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