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