BaseRecyclerViewInstrumentationTest.java revision 7e9da1a2629b657d56e2f54abd18c6249f3be037
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.TextView; 25 26import java.util.ArrayList; 27import java.util.List; 28import java.util.concurrent.CountDownLatch; 29import java.util.concurrent.TimeUnit; 30import java.util.concurrent.atomic.AtomicInteger; 31 32abstract public class BaseRecyclerViewInstrumentationTest extends 33 ActivityInstrumentationTestCase2<TestActivity> { 34 35 private static final String TAG = "RecyclerViewTest"; 36 37 private boolean mDebug; 38 39 protected RecyclerView mRecyclerView; 40 41 protected AdapterHelper mAdapterHelper; 42 43 Throwable mainThreadException; 44 45 public BaseRecyclerViewInstrumentationTest() { 46 this(false); 47 } 48 49 public BaseRecyclerViewInstrumentationTest(boolean debug) { 50 super("android.support.v7.recyclerview", TestActivity.class); 51 mDebug = debug; 52 } 53 54 void checkForMainThreadException() throws Throwable { 55 if (mainThreadException != null) { 56 throw mainThreadException; 57 } 58 } 59 60 void postExceptionToInstrumentation(Throwable t) { 61 if (mDebug) { 62 Log.e(TAG, "captured exception on main thread", t); 63 } 64 mainThreadException = t; 65 if (mRecyclerView != null && mRecyclerView 66 .getLayoutManager() instanceof TestLayoutManager) { 67 TestLayoutManager lm = (TestLayoutManager) mRecyclerView.getLayoutManager(); 68 // finish all layouts so that we get the correct exception 69 while (lm.layoutLatch.getCount() > 0) { 70 lm.layoutLatch.countDown(); 71 } 72 } 73 } 74 75 @Override 76 protected void tearDown() throws Exception { 77 if (mRecyclerView != null) { 78 try { 79 removeRecyclerView(); 80 } catch (Throwable throwable) { 81 throwable.printStackTrace(); 82 } 83 } 84 getInstrumentation().waitForIdleSync(); 85 super.tearDown(); 86 } 87 88 public void removeRecyclerView() throws Throwable { 89 mRecyclerView = null; 90 runTestOnUiThread(new Runnable() { 91 @Override 92 public void run() { 93 getActivity().mContainer.removeAllViews(); 94 } 95 }); 96 } 97 98 public void setRecyclerView(final RecyclerView recyclerView) throws Throwable { 99 mRecyclerView = recyclerView; 100 mAdapterHelper = recyclerView.mAdapterHelper; 101 runTestOnUiThread(new Runnable() { 102 @Override 103 public void run() { 104 getActivity().mContainer.addView(recyclerView); 105 } 106 }); 107 } 108 109 public void requestLayoutOnUIThread(final View view) throws Throwable { 110 runTestOnUiThread(new Runnable() { 111 @Override 112 public void run() { 113 view.requestLayout(); 114 } 115 }); 116 } 117 118 public void scrollBy(final int dt) throws Throwable { 119 runTestOnUiThread(new Runnable() { 120 @Override 121 public void run() { 122 if (mRecyclerView.getLayoutManager().canScrollHorizontally()) { 123 mRecyclerView.scrollBy(dt, 0); 124 } else { 125 mRecyclerView.scrollBy(0, dt); 126 } 127 128 } 129 }); 130 } 131 132 void scrollToPosition(final int position) throws Throwable { 133 runTestOnUiThread(new Runnable() { 134 @Override 135 public void run() { 136 mRecyclerView.getLayoutManager().scrollToPosition(position); 137 } 138 }); 139 } 140 141 void smoothScrollToPosition(final int position) 142 throws Throwable { 143 Log.d(TAG, "SMOOTH scrolling to " + position); 144 runTestOnUiThread(new Runnable() { 145 @Override 146 public void run() { 147 mRecyclerView.smoothScrollToPosition(position); 148 } 149 }); 150 while (mRecyclerView.getLayoutManager().isSmoothScrolling() || 151 mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) { 152 if (mDebug) { 153 Log.d(TAG, "SMOOTH scrolling step"); 154 } 155 Thread.sleep(200); 156 } 157 Log.d(TAG, "SMOOTH scrolling done"); 158 } 159 160 class TestViewHolder extends RecyclerView.ViewHolder { 161 162 Item mBindedItem; 163 164 public TestViewHolder(View itemView) { 165 super(itemView); 166 } 167 } 168 169 class TestLayoutManager extends RecyclerView.LayoutManager { 170 171 CountDownLatch layoutLatch; 172 173 public void expectLayouts(int count) { 174 layoutLatch = new CountDownLatch(count); 175 } 176 177 public void waitForLayout(long timeout, TimeUnit timeUnit) throws Throwable { 178 layoutLatch.await(timeout * (mDebug ? 100 : 1), timeUnit); 179 assertEquals("all expected layouts should be executed at the expected time", 180 0, layoutLatch.getCount()); 181 } 182 183 public void assertLayoutCount(int count, String msg, long timeout) throws Throwable { 184 layoutLatch.await(timeout, TimeUnit.SECONDS); 185 assertEquals(msg, count, layoutLatch.getCount()); 186 } 187 188 public void assertNoLayout(String msg, long timeout) throws Throwable { 189 layoutLatch.await(timeout, TimeUnit.SECONDS); 190 assertFalse(msg, layoutLatch.getCount() == 0); 191 } 192 193 public void waitForLayout(long timeout) throws Throwable { 194 waitForLayout(timeout * (mDebug ? 10000 : 1), TimeUnit.SECONDS); 195 } 196 197 @Override 198 public RecyclerView.LayoutParams generateDefaultLayoutParams() { 199 return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, 200 ViewGroup.LayoutParams.WRAP_CONTENT); 201 } 202 203 void assertVisibleItemPositions() { 204 int i = getChildCount(); 205 TestAdapter testAdapter = (TestAdapter) mRecyclerView.getAdapter(); 206 while (i-- > 0) { 207 View view = getChildAt(i); 208 RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) view.getLayoutParams(); 209 Item item = ((TestViewHolder) lp.mViewHolder).mBindedItem; 210 if (mDebug) { 211 Log.d(TAG, "testing item " + i); 212 } 213 if (!lp.isItemRemoved()) { 214 RecyclerView.ViewHolder vh = mRecyclerView.getChildViewHolder(view); 215 assertSame("item position in LP should match adapter value :" + vh, 216 testAdapter.mItems.get(vh.mPosition), item); 217 } 218 } 219 } 220 221 RecyclerView.LayoutParams getLp(View v) { 222 return (RecyclerView.LayoutParams) v.getLayoutParams(); 223 } 224 225 /** 226 * returns skipped (removed) view count. 227 */ 228 int layoutRange(RecyclerView.Recycler recycler, int start, 229 int end) { 230 int skippedAdd = 0; 231 if (mDebug) { 232 Log.d(TAG, "will layout items from " + start + " to " + end); 233 } 234 for (int i = start; i < end; i++) { 235 if (mDebug) { 236 Log.d(TAG, "laying out item " + i); 237 } 238 View view = recycler.getViewForPosition(i); 239 assertNotNull("view should not be null for valid position. " 240 + "got null view at position " + i, view); 241 if (!getLp(view).isItemRemoved()) { 242 addView(view); 243 } else { 244 skippedAdd ++; 245 } 246 247 measureChildWithMargins(view, 0, 0); 248 layoutDecorated(view, 0, (i - start) * 10, getDecoratedMeasuredWidth(view) 249 , getDecoratedMeasuredHeight(view)); 250 } 251 return skippedAdd; 252 } 253 } 254 255 static class Item { 256 final static AtomicInteger idCounter = new AtomicInteger(0); 257 final public int mId = idCounter.incrementAndGet(); 258 259 int originalIndex; 260 261 final String text; 262 263 Item(int originalIndex, String text) { 264 this.originalIndex = originalIndex; 265 this.text = text; 266 } 267 268 @Override 269 public String toString() { 270 return "Item{" + 271 "mId=" + mId + 272 ", originalIndex=" + originalIndex + 273 ", text='" + text + '\'' + 274 '}'; 275 } 276 } 277 278 class TestAdapter extends RecyclerView.Adapter<TestViewHolder> { 279 280 List<Item> mItems; 281 282 TestAdapter(int count) { 283 mItems = new ArrayList<Item>(count); 284 for (int i = 0; i < count; i++) { 285 mItems.add(new Item(i, "Item " + i)); 286 } 287 } 288 289 @Override 290 public TestViewHolder onCreateViewHolder(ViewGroup parent, 291 int viewType) { 292 return new TestViewHolder(new TextView(parent.getContext())); 293 } 294 295 @Override 296 public void onBindViewHolder(TestViewHolder holder, int position) { 297 final Item item = mItems.get(position); 298 ((TextView) (holder.itemView)).setText(item.text); 299 holder.mBindedItem = item; 300 } 301 302 public void deleteAndNotify(final int start, final int count) throws Throwable { 303 deleteAndNotify(new int[]{start, count}); 304 } 305 306 /** 307 * Deletes items in the given ranges. 308 * <p> 309 * Note that each operation affects the one after so you should offset them properly. 310 * <p> 311 * For example, if adapter has 5 items (A,B,C,D,E), and then you call this method with 312 * <code>[1, 2],[2, 1]</code>, it will first delete items B,C and the new adapter will be 313 * A D E. Then it will delete 2,1 which means it will delete E. 314 */ 315 public void deleteAndNotify(final int[]... startCountTuples) throws Throwable { 316 for (int[] tuple : startCountTuples) { 317 tuple[1] = -tuple[1]; 318 } 319 new AddRemoveRunnable(startCountTuples).runOnMainThread(); 320 } 321 322 public void addAndNotify(final int start, final int count) throws Throwable { 323 addAndNotify(new int[]{start, count}); 324 } 325 326 public void addAndNotify(final int[]... startCountTuples) throws Throwable { 327 new AddRemoveRunnable(startCountTuples).runOnMainThread(); 328 } 329 330 public void notifyChange() throws Throwable { 331 runTestOnUiThread(new Runnable() { 332 @Override 333 public void run() { 334 notifyDataSetChanged(); 335 } 336 }); 337 } 338 339 public void notifyItemChange(final int start, final int count) throws Throwable { 340 runTestOnUiThread(new Runnable() { 341 @Override 342 public void run() { 343 notifyItemRangeChanged(start, count); 344 } 345 }); 346 } 347 348 /** 349 * Similar to other methods but negative count means delete and position count means add. 350 * <p> 351 * For instance, calling this method with <code>[1,1], [2,-1]</code> it will first add an 352 * item to index 1, then remove an item from index 2 (updated index 2) 353 */ 354 public void addDeleteAndNotify(final int[]... startCountTuples) throws Throwable { 355 new AddRemoveRunnable(startCountTuples).runOnMainThread(); 356 } 357 358 @Override 359 public int getItemCount() { 360 return mItems.size(); 361 } 362 363 364 private class AddRemoveRunnable implements Runnable { 365 final int[][] mStartCountTuples; 366 367 public AddRemoveRunnable(int[][] startCountTuples) { 368 mStartCountTuples = startCountTuples; 369 } 370 371 public void runOnMainThread() throws Throwable { 372 if (Looper.myLooper() == Looper.getMainLooper()) { 373 run(); 374 } else { 375 runTestOnUiThread(this); 376 } 377 } 378 379 @Override 380 public void run() { 381 for (int[] tuple : mStartCountTuples) { 382 if (tuple[1] < 0) { 383 delete(tuple); 384 } else { 385 add(tuple); 386 } 387 } 388 } 389 390 private void add(int[] tuple) { 391 for (int i = 0; i < tuple[1]; i++) { 392 mItems.add(tuple[0], new Item(i, "new item " + i)); 393 } 394 // offset others 395 for (int i = tuple[0] + tuple[1]; i < mItems.size(); i++) { 396 mItems.get(i).originalIndex += tuple[1]; 397 } 398 notifyItemRangeInserted(tuple[0], tuple[1]); 399 } 400 401 private void delete(int[] tuple) { 402 for (int i = 0; i < -tuple[1]; i++) { 403 mItems.remove(tuple[0]); 404 } 405 notifyItemRangeRemoved(tuple[0], -tuple[1]); 406 } 407 } 408 } 409 410 @Override 411 public void runTestOnUiThread(Runnable r) throws Throwable { 412 if (Looper.myLooper() == Looper.getMainLooper()) { 413 r.run(); 414 } else { 415 super.runTestOnUiThread(r); 416 } 417 } 418}