blob: 5f7f24443c8de3b58dfe06a322968920359cc671 [file] [log] [blame]
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.support.v7.widget;
import static android.support.v7.widget.RecyclerView.SCROLL_STATE_IDLE;
import org.hamcrest.CoreMatchers;
import org.hamcrest.MatcherAssert;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import android.app.Instrumentation;
import android.graphics.Rect;
import android.os.Looper;
import android.support.test.InstrumentationRegistry;
import android.support.test.rule.ActivityTestRule;
import android.support.v4.view.ViewCompat;
import android.support.v7.recyclerview.test.SameActivityTestRule;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
import android.support.v7.recyclerview.test.R;
import static org.junit.Assert.*;
import static java.util.concurrent.TimeUnit.SECONDS;
abstract public class BaseRecyclerViewInstrumentationTest {
private static final String TAG = "RecyclerViewTest";
private boolean mDebug;
protected RecyclerView mRecyclerView;
protected AdapterHelper mAdapterHelper;
private Throwable mMainThreadException;
private boolean mIgnoreMainThreadException = false;
Thread mInstrumentationThread;
@Rule
public ActivityTestRule<TestActivity> mActivityRule = new SameActivityTestRule() {
@Override
public boolean canReUseActivity() {
return BaseRecyclerViewInstrumentationTest.this.canReUseActivity();
}
};
public BaseRecyclerViewInstrumentationTest() {
this(false);
}
public BaseRecyclerViewInstrumentationTest(boolean debug) {
mDebug = debug;
}
void checkForMainThreadException() throws Throwable {
if (!mIgnoreMainThreadException && mMainThreadException != null) {
throw mMainThreadException;
}
}
public void setIgnoreMainThreadException(boolean ignoreMainThreadException) {
mIgnoreMainThreadException = ignoreMainThreadException;
}
public Throwable getMainThreadException() {
return mMainThreadException;
}
protected TestActivity getActivity() {
return mActivityRule.getActivity();
}
@Before
public final void setUpInsThread() throws Exception {
mInstrumentationThread = Thread.currentThread();
Item.idCounter.set(0);
}
void setHasTransientState(final View view, final boolean value) {
try {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
ViewCompat.setHasTransientState(view, value);
}
});
} catch (Throwable throwable) {
Log.e(TAG, "", throwable);
}
}
public boolean canReUseActivity() {
return true;
}
protected void enableAccessibility()
throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Method getUIAutomation = Instrumentation.class.getMethod("getUiAutomation");
getUIAutomation.invoke(InstrumentationRegistry.getInstrumentation());
}
void setAdapter(final RecyclerView.Adapter adapter) throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
mRecyclerView.setAdapter(adapter);
}
});
}
public View focusSearch(final View focused, final int direction)
throws Throwable {
final View[] result = new View[1];
runTestOnUiThread(new Runnable() {
@Override
public void run() {
View view = focused.focusSearch(direction);
if (view != null && view != focused) {
view.requestFocus();
}
result[0] = view;
}
});
return result[0];
}
protected WrappedRecyclerView inflateWrappedRV() {
return (WrappedRecyclerView)
LayoutInflater.from(getActivity()).inflate(R.layout.wrapped_test_rv,
getRecyclerViewContainer(), false);
}
void swapAdapter(final RecyclerView.Adapter adapter,
final boolean removeAndRecycleExistingViews) throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
try {
mRecyclerView.swapAdapter(adapter, removeAndRecycleExistingViews);
} catch (Throwable t) {
postExceptionToInstrumentation(t);
}
}
});
checkForMainThreadException();
}
void postExceptionToInstrumentation(Throwable t) {
if (mInstrumentationThread == Thread.currentThread()) {
throw new RuntimeException(t);
}
if (mMainThreadException != null) {
Log.e(TAG, "receiving another main thread exception. dropping.", t);
} else {
Log.e(TAG, "captured exception on main thread", t);
mMainThreadException = t;
}
if (mRecyclerView != null && mRecyclerView
.getLayoutManager() instanceof TestLayoutManager) {
TestLayoutManager lm = (TestLayoutManager) mRecyclerView.getLayoutManager();
// finish all layouts so that we get the correct exception
if (lm.layoutLatch != null) {
while (lm.layoutLatch.getCount() > 0) {
lm.layoutLatch.countDown();
}
}
}
}
public Instrumentation getInstrumentation() {
return InstrumentationRegistry.getInstrumentation();
}
@After
public final void tearDown() throws Exception {
if (mRecyclerView != null) {
try {
removeRecyclerView();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
getInstrumentation().waitForIdleSync();
try {
checkForMainThreadException();
} catch (Exception e) {
throw e;
} catch (Throwable throwable) {
throw new Exception(Log.getStackTraceString(throwable));
}
}
public Rect getDecoratedRecyclerViewBounds() {
return new Rect(
mRecyclerView.getPaddingLeft(),
mRecyclerView.getPaddingTop(),
mRecyclerView.getWidth() - mRecyclerView.getPaddingRight(),
mRecyclerView.getHeight() - mRecyclerView.getPaddingBottom()
);
}
public void removeRecyclerView() throws Throwable {
if (mRecyclerView == null) {
return;
}
if (!isMainThread()) {
getInstrumentation().waitForIdleSync();
}
runTestOnUiThread(new Runnable() {
@Override
public void run() {
try {
// do not run validation if we already have an error
if (mMainThreadException == null) {
final RecyclerView.Adapter adapter = mRecyclerView.getAdapter();
if (adapter instanceof AttachDetachCountingAdapter) {
((AttachDetachCountingAdapter) adapter).getCounter()
.validateRemaining(mRecyclerView);
}
}
getActivity().getContainer().removeAllViews();
} catch (Throwable t) {
postExceptionToInstrumentation(t);
}
}
});
mRecyclerView = null;
}
void waitForAnimations(int seconds) throws Throwable {
final CountDownLatch latch = new CountDownLatch(1);
runTestOnUiThread(new Runnable() {
@Override
public void run() {
mRecyclerView.mItemAnimator
.isRunning(new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() {
@Override
public void onAnimationsFinished() {
latch.countDown();
}
});
}
});
assertTrue("animations didn't finish on expected time of " + seconds + " seconds",
latch.await(seconds, TimeUnit.SECONDS));
}
public void waitForIdleScroll(final RecyclerView recyclerView) throws Throwable {
final CountDownLatch latch = new CountDownLatch(1);
runTestOnUiThread(new Runnable() {
@Override
public void run() {
RecyclerView.OnScrollListener listener = new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
if (newState == SCROLL_STATE_IDLE) {
latch.countDown();
recyclerView.removeOnScrollListener(this);
}
}
};
if (recyclerView.getScrollState() == SCROLL_STATE_IDLE) {
latch.countDown();
} else {
recyclerView.addOnScrollListener(listener);
}
}
});
assertTrue("should go idle in 10 seconds", latch.await(10, TimeUnit.SECONDS));
}
public boolean requestFocus(final View view, boolean waitForScroll) throws Throwable {
final boolean[] result = new boolean[1];
try {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
result[0] = view.requestFocus();
}
});
} catch (Throwable throwable) {
fail(throwable.getMessage());
}
if (waitForScroll && result[0]) {
waitForIdleScroll(mRecyclerView);
}
return result[0];
}
public void setRecyclerView(final RecyclerView recyclerView) throws Throwable {
setRecyclerView(recyclerView, true);
}
public void setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool)
throws Throwable {
setRecyclerView(recyclerView, assignDummyPool, true);
}
public void setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool,
boolean addPositionCheckItemAnimator)
throws Throwable {
mRecyclerView = recyclerView;
if (assignDummyPool) {
RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool() {
@Override
public RecyclerView.ViewHolder getRecycledView(int viewType) {
RecyclerView.ViewHolder viewHolder = super.getRecycledView(viewType);
if (viewHolder == null) {
return null;
}
viewHolder.addFlags(RecyclerView.ViewHolder.FLAG_BOUND);
viewHolder.mPosition = 200;
viewHolder.mOldPosition = 300;
viewHolder.mPreLayoutPosition = 500;
return viewHolder;
}
@Override
public void putRecycledView(RecyclerView.ViewHolder scrap) {
assertNull(scrap.mOwnerRecyclerView);
super.putRecycledView(scrap);
}
};
mRecyclerView.setRecycledViewPool(pool);
}
if (addPositionCheckItemAnimator) {
mRecyclerView.addItemDecoration(new RecyclerView.ItemDecoration() {
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
RecyclerView.State state) {
RecyclerView.ViewHolder vh = parent.getChildViewHolder(view);
if (!vh.isRemoved()) {
assertNotSame("If getItemOffsets is called, child should have a valid"
+ " adapter position unless it is removed : " + vh,
vh.getAdapterPosition(), RecyclerView.NO_POSITION);
}
}
});
}
mAdapterHelper = recyclerView.mAdapterHelper;
runTestOnUiThread(new Runnable() {
@Override
public void run() {
getActivity().getContainer().addView(recyclerView);
}
});
}
protected FrameLayout getRecyclerViewContainer() {
return getActivity().getContainer();
}
public void requestLayoutOnUIThread(final View view) {
try {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
view.requestLayout();
}
});
} catch (Throwable throwable) {
Log.e(TAG, "", throwable);
}
}
public void scrollBy(final int dt) {
try {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
if (mRecyclerView.getLayoutManager().canScrollHorizontally()) {
mRecyclerView.scrollBy(dt, 0);
} else {
mRecyclerView.scrollBy(0, dt);
}
}
});
} catch (Throwable throwable) {
Log.e(TAG, "", throwable);
}
}
public void smoothScrollBy(final int dt) {
try {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
if (mRecyclerView.getLayoutManager().canScrollHorizontally()) {
mRecyclerView.smoothScrollBy(dt, 0);
} else {
mRecyclerView.smoothScrollBy(0, dt);
}
}
});
} catch (Throwable throwable) {
Log.e(TAG, "", throwable);
}
getInstrumentation().waitForIdleSync();
}
void scrollToPosition(final int position) throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
mRecyclerView.getLayoutManager().scrollToPosition(position);
}
});
}
void smoothScrollToPosition(final int position) throws Throwable {
smoothScrollToPosition(position, true);
}
void smoothScrollToPosition(final int position, boolean assertArrival) throws Throwable {
if (mDebug) {
Log.d(TAG, "SMOOTH scrolling to " + position);
}
final CountDownLatch viewAdded = new CountDownLatch(1);
final RecyclerView.OnChildAttachStateChangeListener listener =
new RecyclerView.OnChildAttachStateChangeListener() {
@Override
public void onChildViewAttachedToWindow(View view) {
if (position == mRecyclerView.getChildAdapterPosition(view)) {
viewAdded.countDown();
}
}
@Override
public void onChildViewDetachedFromWindow(View view) {
}
};
final AtomicBoolean addedListener = new AtomicBoolean(false);
runTestOnUiThread(new Runnable() {
@Override
public void run() {
RecyclerView.ViewHolder viewHolderForAdapterPosition =
mRecyclerView.findViewHolderForAdapterPosition(position);
if (viewHolderForAdapterPosition != null) {
viewAdded.countDown();
} else {
mRecyclerView.addOnChildAttachStateChangeListener(listener);
addedListener.set(true);
}
}
});
runTestOnUiThread(new Runnable() {
@Override
public void run() {
mRecyclerView.smoothScrollToPosition(position);
}
});
getInstrumentation().waitForIdleSync();
assertThat("should be able to scroll in 10 seconds", !assertArrival ||
viewAdded.await(10, TimeUnit.SECONDS),
CoreMatchers.is(true));
waitForIdleScroll(mRecyclerView);
if (mDebug) {
Log.d(TAG, "SMOOTH scrolling done");
}
if (addedListener.get()) {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
mRecyclerView.removeOnChildAttachStateChangeListener(listener);
}
});
}
getInstrumentation().waitForIdleSync();
}
void freezeLayout(final boolean freeze) throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
mRecyclerView.setLayoutFrozen(freeze);
}
});
}
public void setVisibility(final View view, final int visibility) throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
view.setVisibility(visibility);
}
});
}
public class TestViewHolder extends RecyclerView.ViewHolder {
Item mBoundItem;
Object mData;
public TestViewHolder(View itemView) {
super(itemView);
itemView.setFocusable(true);
}
@Override
public String toString() {
return super.toString() + " item:" + mBoundItem + ", data:" + mData;
}
public Object getData() {
return mData;
}
public void setData(Object data) {
mData = data;
}
}
class DumbLayoutManager extends TestLayoutManager {
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
detachAndScrapAttachedViews(recycler);
layoutRange(recycler, 0, state.getItemCount());
if (layoutLatch != null) {
layoutLatch.countDown();
}
}
}
public class TestLayoutManager extends RecyclerView.LayoutManager {
int mScrollVerticallyAmount;
int mScrollHorizontallyAmount;
protected CountDownLatch layoutLatch;
private boolean mSupportsPredictive = false;
public void expectLayouts(int count) {
layoutLatch = new CountDownLatch(count);
}
public void waitForLayout(int seconds) throws Throwable {
layoutLatch.await(seconds * (mDebug ? 1000 : 1), SECONDS);
checkForMainThreadException();
MatcherAssert.assertThat("all layouts should complete on time",
layoutLatch.getCount(), CoreMatchers.is(0L));
// use a runnable to ensure RV layout is finished
getInstrumentation().runOnMainSync(new Runnable() {
@Override
public void run() {
}
});
}
public boolean isSupportsPredictive() {
return mSupportsPredictive;
}
public void setSupportsPredictive(boolean supportsPredictive) {
mSupportsPredictive = supportsPredictive;
}
@Override
public boolean supportsPredictiveItemAnimations() {
return mSupportsPredictive;
}
public void assertLayoutCount(int count, String msg, long timeout) throws Throwable {
layoutLatch.await(timeout, TimeUnit.SECONDS);
assertEquals(msg, count, layoutLatch.getCount());
}
public void assertNoLayout(String msg, long timeout) throws Throwable {
layoutLatch.await(timeout, TimeUnit.SECONDS);
assertFalse(msg, layoutLatch.getCount() == 0);
}
@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
}
void assertVisibleItemPositions() {
int i = getChildCount();
TestAdapter testAdapter = (TestAdapter) mRecyclerView.getAdapter();
while (i-- > 0) {
View view = getChildAt(i);
RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) view.getLayoutParams();
Item item = ((TestViewHolder) lp.mViewHolder).mBoundItem;
if (mDebug) {
Log.d(TAG, "testing item " + i);
}
if (!lp.isItemRemoved()) {
RecyclerView.ViewHolder vh = mRecyclerView.getChildViewHolder(view);
assertSame("item position in LP should match adapter value :" + vh,
testAdapter.mItems.get(vh.mPosition), item);
}
}
}
RecyclerView.LayoutParams getLp(View v) {
return (RecyclerView.LayoutParams) v.getLayoutParams();
}
protected void layoutRange(RecyclerView.Recycler recycler, int start, int end) {
assertScrap(recycler);
if (mDebug) {
Log.d(TAG, "will layout items from " + start + " to " + end);
}
int diff = end > start ? 1 : -1;
int top = 0;
for (int i = start; i != end; i+=diff) {
if (mDebug) {
Log.d(TAG, "laying out item " + i);
}
View view = recycler.getViewForPosition(i);
assertNotNull("view should not be null for valid position. "
+ "got null view at position " + i, view);
if (!mRecyclerView.mState.isPreLayout()) {
RecyclerView.LayoutParams layoutParams = (RecyclerView.LayoutParams) view
.getLayoutParams();
assertFalse("In post layout, getViewForPosition should never return a view "
+ "that is removed", layoutParams != null
&& layoutParams.isItemRemoved());
}
assertEquals("getViewForPosition should return correct position",
i, getPosition(view));
addView(view);
measureChildWithMargins(view, 0, 0);
if (getLayoutDirection() == ViewCompat.LAYOUT_DIRECTION_RTL) {
layoutDecorated(view, getWidth() - getDecoratedMeasuredWidth(view), top,
getWidth(), top + getDecoratedMeasuredHeight(view));
} else {
layoutDecorated(view, 0, top, getDecoratedMeasuredWidth(view)
, top + getDecoratedMeasuredHeight(view));
}
top += view.getMeasuredHeight();
}
}
private void assertScrap(RecyclerView.Recycler recycler) {
if (mRecyclerView.getAdapter() != null &&
!mRecyclerView.getAdapter().hasStableIds()) {
for (RecyclerView.ViewHolder viewHolder : recycler.getScrapList()) {
assertFalse("Invalid scrap should be no kept", viewHolder.isInvalid());
}
}
}
@Override
public boolean canScrollHorizontally() {
return true;
}
@Override
public boolean canScrollVertically() {
return true;
}
@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler,
RecyclerView.State state) {
mScrollHorizontallyAmount += dx;
return dx;
}
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
RecyclerView.State state) {
mScrollVerticallyAmount += dy;
return dy;
}
// START MOCKITO OVERRIDES
// We override package protected methods to make them public. This is necessary to run
// mockito on Kitkat
@Override
public void setRecyclerView(RecyclerView recyclerView) {
super.setRecyclerView(recyclerView);
}
@Override
public void dispatchAttachedToWindow(RecyclerView view) {
super.dispatchAttachedToWindow(view);
}
@Override
public void dispatchDetachedFromWindow(RecyclerView view, RecyclerView.Recycler recycler) {
super.dispatchDetachedFromWindow(view, recycler);
}
@Override
public void setExactMeasureSpecsFrom(RecyclerView recyclerView) {
super.setExactMeasureSpecsFrom(recyclerView);
}
@Override
public void setMeasureSpecs(int wSpec, int hSpec) {
super.setMeasureSpecs(wSpec, hSpec);
}
@Override
public void setMeasuredDimensionFromChildren(int widthSpec, int heightSpec) {
super.setMeasuredDimensionFromChildren(widthSpec, heightSpec);
}
@Override
public boolean shouldReMeasureChild(View child, int widthSpec, int heightSpec,
RecyclerView.LayoutParams lp) {
return super.shouldReMeasureChild(child, widthSpec, heightSpec, lp);
}
@Override
public boolean shouldMeasureChild(View child, int widthSpec, int heightSpec,
RecyclerView.LayoutParams lp) {
return super.shouldMeasureChild(child, widthSpec, heightSpec, lp);
}
@Override
public void removeAndRecycleScrapInt(RecyclerView.Recycler recycler) {
super.removeAndRecycleScrapInt(recycler);
}
@Override
public void stopSmoothScroller() {
super.stopSmoothScroller();
}
// END MOCKITO OVERRIDES
}
static class Item {
final static AtomicInteger idCounter = new AtomicInteger(0);
final public int mId = idCounter.incrementAndGet();
int mAdapterIndex;
final String mText;
int mType = 0;
Item(int adapterIndex, String text) {
mAdapterIndex = adapterIndex;
mText = text;
}
@Override
public String toString() {
return "Item{" +
"mId=" + mId +
", originalIndex=" + mAdapterIndex +
", text='" + mText + '\'' +
'}';
}
}
public class TestAdapter extends RecyclerView.Adapter<TestViewHolder>
implements AttachDetachCountingAdapter {
public static final String DEFAULT_ITEM_PREFIX = "Item ";
ViewAttachDetachCounter mAttachmentCounter = new ViewAttachDetachCounter();
List<Item> mItems;
public TestAdapter(int count) {
mItems = new ArrayList<Item>(count);
addItems(0, count, DEFAULT_ITEM_PREFIX);
}
private void addItems(int pos, int count, String prefix) {
for (int i = 0; i < count; i++, pos++) {
mItems.add(pos, new Item(pos, prefix));
}
}
@Override
public int getItemViewType(int position) {
return getItemAt(position).mType;
}
@Override
public void onViewAttachedToWindow(TestViewHolder holder) {
super.onViewAttachedToWindow(holder);
mAttachmentCounter.onViewAttached(holder);
}
@Override
public void onViewDetachedFromWindow(TestViewHolder holder) {
super.onViewDetachedFromWindow(holder);
mAttachmentCounter.onViewDetached(holder);
}
@Override
public void onAttachedToRecyclerView(RecyclerView recyclerView) {
super.onAttachedToRecyclerView(recyclerView);
mAttachmentCounter.onAttached(recyclerView);
}
@Override
public void onDetachedFromRecyclerView(RecyclerView recyclerView) {
super.onDetachedFromRecyclerView(recyclerView);
mAttachmentCounter.onDetached(recyclerView);
}
@Override
public TestViewHolder onCreateViewHolder(ViewGroup parent,
int viewType) {
TextView itemView = new TextView(parent.getContext());
itemView.setFocusableInTouchMode(true);
itemView.setFocusable(true);
return new TestViewHolder(itemView);
}
@Override
public void onBindViewHolder(TestViewHolder holder, int position) {
assertNotNull(holder.mOwnerRecyclerView);
assertEquals(position, holder.getAdapterPosition());
final Item item = mItems.get(position);
((TextView) (holder.itemView)).setText(item.mText + "(" + item.mId + ")");
holder.mBoundItem = item;
}
public Item getItemAt(int position) {
return mItems.get(position);
}
@Override
public void onViewRecycled(TestViewHolder holder) {
super.onViewRecycled(holder);
final int adapterPosition = holder.getAdapterPosition();
final boolean shouldHavePosition = !holder.isRemoved() && holder.isBound() &&
!holder.isAdapterPositionUnknown() && !holder.isInvalid();
String log = "Position check for " + holder.toString();
assertEquals(log, shouldHavePosition, adapterPosition != RecyclerView.NO_POSITION);
if (shouldHavePosition) {
assertTrue(log, mItems.size() > adapterPosition);
assertSame(log, holder.mBoundItem, mItems.get(adapterPosition));
}
}
public void deleteAndNotify(final int start, final int count) throws Throwable {
deleteAndNotify(new int[]{start, count});
}
/**
* Deletes items in the given ranges.
* <p>
* Note that each operation affects the one after so you should offset them properly.
* <p>
* For example, if adapter has 5 items (A,B,C,D,E), and then you call this method with
* <code>[1, 2],[2, 1]</code>, it will first delete items B,C and the new adapter will be
* A D E. Then it will delete 2,1 which means it will delete E.
*/
public void deleteAndNotify(final int[]... startCountTuples) throws Throwable {
for (int[] tuple : startCountTuples) {
tuple[1] = -tuple[1];
}
new AddRemoveRunnable(startCountTuples).runOnMainThread();
}
@Override
public long getItemId(int position) {
return hasStableIds() ? mItems.get(position).mId : super.getItemId(position);
}
public void offsetOriginalIndices(int start, int offset) {
for (int i = start; i < mItems.size(); i++) {
mItems.get(i).mAdapterIndex += offset;
}
}
/**
* @param start inclusive
* @param end exclusive
* @param offset
*/
public void offsetOriginalIndicesBetween(int start, int end, int offset) {
for (int i = start; i < end && i < mItems.size(); i++) {
mItems.get(i).mAdapterIndex += offset;
}
}
public void addAndNotify(final int count) throws Throwable {
assertEquals(0, mItems.size());
new AddRemoveRunnable(DEFAULT_ITEM_PREFIX, new int[]{0, count}).runOnMainThread();
}
public void resetItemsTo(final List<Item> testItems) throws Throwable {
if (!mItems.isEmpty()) {
deleteAndNotify(0, mItems.size());
}
mItems = testItems;
runTestOnUiThread(new Runnable() {
@Override
public void run() {
notifyItemRangeInserted(0, testItems.size());
}
});
}
public void addAndNotify(final int start, final int count) throws Throwable {
addAndNotify(new int[]{start, count});
}
public void addAndNotify(final int[]... startCountTuples) throws Throwable {
new AddRemoveRunnable(startCountTuples).runOnMainThread();
}
public void dispatchDataSetChanged() throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
notifyDataSetChanged();
}
});
}
public void changeAndNotify(final int start, final int count) throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
notifyItemRangeChanged(start, count);
}
});
}
public void changeAndNotifyWithPayload(final int start, final int count,
final Object payload) throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
notifyItemRangeChanged(start, count, payload);
}
});
}
public void changePositionsAndNotify(final int... positions) throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < positions.length; i += 1) {
TestAdapter.super.notifyItemRangeChanged(positions[i], 1);
}
}
});
}
/**
* Similar to other methods but negative count means delete and position count means add.
* <p>
* For instance, calling this method with <code>[1,1], [2,-1]</code> it will first add an
* item to index 1, then remove an item from index 2 (updated index 2)
*/
public void addDeleteAndNotify(final int[]... startCountTuples) throws Throwable {
new AddRemoveRunnable(startCountTuples).runOnMainThread();
}
@Override
public int getItemCount() {
return mItems.size();
}
/**
* Uses notifyDataSetChanged
*/
public void moveItems(boolean notifyChange, int[]... fromToTuples) throws Throwable {
for (int i = 0; i < fromToTuples.length; i += 1) {
int[] tuple = fromToTuples[i];
moveItem(tuple[0], tuple[1], false);
}
if (notifyChange) {
dispatchDataSetChanged();
}
}
/**
* Uses notifyDataSetChanged
*/
public void moveItem(final int from, final int to, final boolean notifyChange)
throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
moveInUIThread(from, to);
if (notifyChange) {
notifyDataSetChanged();
}
}
});
}
/**
* Uses notifyItemMoved
*/
public void moveAndNotify(final int from, final int to) throws Throwable {
runTestOnUiThread(new Runnable() {
@Override
public void run() {
moveInUIThread(from, to);
notifyItemMoved(from, to);
}
});
}
public void clearOnUIThread() {
assertEquals("clearOnUIThread called from a wrong thread",
Looper.getMainLooper(), Looper.myLooper());
mItems = new ArrayList<Item>();
notifyDataSetChanged();
}
protected void moveInUIThread(int from, int to) {
Item item = mItems.remove(from);
offsetOriginalIndices(from, -1);
mItems.add(to, item);
offsetOriginalIndices(to + 1, 1);
item.mAdapterIndex = to;
}
@Override
public ViewAttachDetachCounter getCounter() {
return mAttachmentCounter;
}
private class AddRemoveRunnable implements Runnable {
final String mNewItemPrefix;
final int[][] mStartCountTuples;
public AddRemoveRunnable(String newItemPrefix, int[]... startCountTuples) {
mNewItemPrefix = newItemPrefix;
mStartCountTuples = startCountTuples;
}
public AddRemoveRunnable(int[][] startCountTuples) {
this("new item ", startCountTuples);
}
public void runOnMainThread() throws Throwable {
if (Looper.myLooper() == Looper.getMainLooper()) {
run();
} else {
runTestOnUiThread(this);
}
}
@Override
public void run() {
for (int[] tuple : mStartCountTuples) {
if (tuple[1] < 0) {
delete(tuple);
} else {
add(tuple);
}
}
}
private void add(int[] tuple) {
// offset others
offsetOriginalIndices(tuple[0], tuple[1]);
addItems(tuple[0], tuple[1], mNewItemPrefix);
notifyItemRangeInserted(tuple[0], tuple[1]);
}
private void delete(int[] tuple) {
final int count = -tuple[1];
offsetOriginalIndices(tuple[0] + count, tuple[1]);
for (int i = 0; i < count; i++) {
mItems.remove(tuple[0]);
}
notifyItemRangeRemoved(tuple[0], count);
}
}
}
public boolean isMainThread() {
return Looper.myLooper() == Looper.getMainLooper();
}
public void runTestOnUiThread(Runnable r) throws Throwable {
if (Looper.myLooper() == Looper.getMainLooper()) {
r.run();
} else {
InstrumentationRegistry.getInstrumentation().runOnMainSync(r);
}
}
static class TargetTuple {
final int mPosition;
final int mLayoutDirection;
TargetTuple(int position, int layoutDirection) {
this.mPosition = position;
this.mLayoutDirection = layoutDirection;
}
@Override
public String toString() {
return "TargetTuple{" +
"mPosition=" + mPosition +
", mLayoutDirection=" + mLayoutDirection +
'}';
}
}
public interface AttachDetachCountingAdapter {
ViewAttachDetachCounter getCounter();
}
public class ViewAttachDetachCounter {
Set<RecyclerView.ViewHolder> mAttachedSet = new HashSet<RecyclerView.ViewHolder>();
public void validateRemaining(RecyclerView recyclerView) {
final int childCount = recyclerView.getChildCount();
for (int i = 0; i < childCount; i++) {
View view = recyclerView.getChildAt(i);
RecyclerView.ViewHolder vh = recyclerView.getChildViewHolder(view);
assertTrue("remaining view should be in attached set " + vh,
mAttachedSet.contains(vh));
}
assertEquals("there should not be any views left in attached set",
childCount, mAttachedSet.size());
}
public void onViewDetached(RecyclerView.ViewHolder viewHolder) {
try {
assertTrue("view holder should be in attached set",
mAttachedSet.remove(viewHolder));
} catch (Throwable t) {
postExceptionToInstrumentation(t);
}
}
public void onViewAttached(RecyclerView.ViewHolder viewHolder) {
try {
assertTrue("view holder should not be in attached set",
mAttachedSet.add(viewHolder));
} catch (Throwable t) {
postExceptionToInstrumentation(t);
}
}
public void onAttached(RecyclerView recyclerView) {
// when a new RV is attached, clear the set and add all view holders
mAttachedSet.clear();
final int childCount = recyclerView.getChildCount();
for (int i = 0; i < childCount; i ++) {
View view = recyclerView.getChildAt(i);
mAttachedSet.add(recyclerView.getChildViewHolder(view));
}
}
public void onDetached(RecyclerView recyclerView) {
validateRemaining(recyclerView);
}
}
}