package com.beaker.reciperoulette; /* * 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. */ import static androidx.test.espresso.intent.Checks.checkArgument; import static androidx.test.espresso.intent.Checks.checkNotNull; import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static org.hamcrest.Matchers.allOf; import android.util.SparseArray; import android.view.View; import android.widget.AdapterView; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.Adapter; import androidx.recyclerview.widget.RecyclerView.ViewHolder; import androidx.test.espresso.Espresso; import androidx.test.espresso.PerformException; import androidx.test.espresso.UiController; import androidx.test.espresso.ViewAction; import androidx.test.espresso.util.HumanReadables; import org.hamcrest.Description; import org.hamcrest.Matcher; import org.hamcrest.TypeSafeMatcher; import java.util.ArrayList; import java.util.List; /** * {@link ViewAction}s to interact {@link RecyclerView}. RecyclerView works differently than * {@link AdapterView}. In fact, RecyclerView is not an AdapterView anymore, hence it can't be used * in combination with {@link Espresso#onData(Matcher)}. * * <p> * To use {@link ViewAction}s in this class use {@link Espresso#onView(Matcher)} with a * <a href="http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matcher.html"> * <code>Matcher</code></a> that matches your {@link RecyclerView}, then perform a * {@link ViewAction} from this class. * </p> **/ public final class RecyclerViewActions { private static final int NO_POSITION = -1; private RecyclerViewActions() { // no instance } /** * Most RecyclerViewActions are given a matcher to select a particular view / viewholder within * the RecyclerView. In this case the default behaviour is to expect that the matcher matches 1 * and only one item within the RecyclerView. * * <p>This interface gives users the ability to override that type of behaviour and explicitly * select an item in the RecyclerView at a given position. This is similar to on the * onData(...).atPosition() api for AdapterViews. */ public interface PositionableRecyclerViewAction extends ViewAction { /** * Returns a new ViewAction which will cause the ViewAction to operate upon the position-th * element which the matcher has selected. * * @param position a 0-based index into the list of matching elements within the RecyclerView. * @return PositionableRecyclerViewAction a new ViewAction focused on a particular position. * @throws IllegalArgumentException if position < 0. */ PositionableRecyclerViewAction atPosition(int position); } /** * Returns a {@link ViewAction} which scrolls {@link RecyclerView} to the view matched by * viewHolderMatcher. * * <p> * This approach uses {@link ViewHolder}s to find the target view. It will create one ViewHolder * per item type and bind adapter data to the ViewHolder. If the itemViewMatcher matches a * ViewHolder the current position of the View is used to perform a * {@link RecyclerView#scrollToPosition(int)}. Note: scrollTo method is not overloaded, method * overloading with generic parameters is not possible. * </p> * * @param viewHolderMatcher a * <a href="http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matcher.html"> * <code>Matcher</code></a> that matches an item view holder in {@link RecyclerView} * @throws PerformException if there are more than one items matching given viewHolderMatcher. */ public static <VH extends ViewHolder> PositionableRecyclerViewAction scrollToHolder( final Matcher<VH> viewHolderMatcher) { return new ScrollToViewAction<VH>(viewHolderMatcher); } /** * Returns a {@link ViewAction} which scrolls {@link RecyclerView} to the view matched by * itemViewMatcher. * * <p> * This approach uses {@link ViewHolder}s to find the target view. It will create one ViewHolder * per item type and bind adapter data to the ViewHolder. If the itemViewMatcher matches a * ViewHolder the current position of the View is used to perform a * {@link RecyclerView#scrollToPosition(int)}. * </p> * * @param itemViewMatcher a * <a href="http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matcher.html"> * <code>Matcher</code></a> that matches an item view in {@link RecyclerView} * @throws PerformException if there are more than one items matching given viewHolderMatcher. */ public static <VH extends ViewHolder> PositionableRecyclerViewAction scrollTo( final Matcher<View> itemViewMatcher) { Matcher<VH> viewHolderMatcher = viewHolderMatcher(itemViewMatcher); return new ScrollToViewAction<VH>(viewHolderMatcher); } /** * Returns a {@link ViewAction} which scrolls {@link RecyclerView} to a position. * * @param position the position of the view to scroll to */ public static <VH extends ViewHolder> ViewAction scrollToPosition(final int position) { return new ScrollToPositionViewAction(position); } /** * Performs a {@link ViewAction} on a view matched by viewHolderMatcher. * * <ol> * <li>Scroll Recycler View to the view matched by itemViewMatcher</li> * <li>Perform an action on the matched view</li> * </ol> * * @param itemViewMatcher a * <a href="http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matcher.html"> * <code>Matcher</code></a> that matches an item view in {@link RecyclerView} * @param viewAction the action that is performed on the view matched by viewHolderMatcher * @throws PerformException if there are more than one items matching given viewHolderMatcher. */ public static <VH extends ViewHolder> PositionableRecyclerViewAction actionOnItem( final Matcher<View> itemViewMatcher, final ViewAction viewAction) { Matcher<VH> viewHolderMatcher = viewHolderMatcher(itemViewMatcher); return new ActionOnItemViewAction<VH>(viewHolderMatcher, viewAction); } /** * Performs a {@link ViewAction} on a view matched by viewHolderMatcher. * * <ol> * <li>Scroll Recycler View to the view matched by itemViewMatcher</li> * <li>Perform an action on the matched view</li> * </ol> * Note: actionOnItem method is not overloaded, method overloading with * generic parameters is not possible. * * @param viewHolderMatcher a * <a href="http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matcher.html"> * <code>Matcher</code></a> that matchesan item view holder in {@link RecyclerView} * @param viewAction the action that is performed on the view matched by viewHolderMatcher * @throws PerformException if there are more than one items matching given viewHolderMatcher. */ public static <VH extends ViewHolder> PositionableRecyclerViewAction actionOnHolderItem( final Matcher<VH> viewHolderMatcher, final ViewAction viewAction) { return new ActionOnItemViewAction<VH>(viewHolderMatcher, viewAction); } private static final class ActionOnItemViewAction<VH extends ViewHolder> implements PositionableRecyclerViewAction { private final Matcher<VH> viewHolderMatcher; private final ViewAction viewAction; private final int atPosition; private final ScrollToViewAction<VH> scroller; private ActionOnItemViewAction(Matcher<VH> viewHolderMatcher, ViewAction viewAction) { this(viewHolderMatcher, viewAction, NO_POSITION); } private ActionOnItemViewAction(Matcher<VH> viewHolderMatcher, ViewAction viewAction, int atPosition) { this.viewHolderMatcher = checkNotNull(viewHolderMatcher); this.viewAction = checkNotNull(viewAction); this.atPosition = atPosition; this.scroller = new ScrollToViewAction<VH>(viewHolderMatcher, atPosition); } @SuppressWarnings("unchecked") @Override public Matcher<View> getConstraints() { return allOf(isAssignableFrom(RecyclerView.class), isDisplayed()); } @Override public PositionableRecyclerViewAction atPosition(int position) { checkArgument(position >= 0, "%d is used as an index - must be >= 0", position); return new ActionOnItemViewAction<VH>(viewHolderMatcher, viewAction, position); } @Override public String getDescription() { if (atPosition == NO_POSITION) { return String.format("performing ViewAction: %s on item matching: %s", viewAction.getDescription(), viewHolderMatcher); } else { return String.format("performing ViewAction: %s on %d-th item matching: %s", viewAction.getDescription(), atPosition, viewHolderMatcher); } } @Override public void perform(UiController uiController, View root) { RecyclerView recyclerView = (RecyclerView) root; try { scroller.perform(uiController, root); uiController.loopMainThreadUntilIdle(); // the above scroller has checked bounds, dupes (maybe) and brought the element into screen. int max = atPosition == NO_POSITION ? 2 : atPosition + 1; int selectIndex = atPosition == NO_POSITION ? 0 : atPosition; List<MatchedItem> matchedItems = itemsMatching(recyclerView, viewHolderMatcher, max); actionOnItemAtPosition(matchedItems.get(selectIndex).position, viewAction).perform( uiController, root); uiController.loopMainThreadUntilIdle(); } catch (RuntimeException e) { throw new PerformException.Builder().withActionDescription(this.getDescription()) .withViewDescription(HumanReadables.describe(root)).withCause(e).build(); } } } /** * Performs a {@link ViewAction} on a view at position. * * <ol> * <li>Scroll Recycler View to position</li> * <li>Perform an action on the view at position</li> * </ol> * * @param position position of a view in {@link RecyclerView} * @param viewAction the action that is performed on the view matched by itemViewMatcher */ public static <VH extends ViewHolder> ViewAction actionOnItemAtPosition(final int position, final ViewAction viewAction) { return new ActionOnItemAtPositionViewAction<VH>(position, viewAction); } private static final class ActionOnItemAtPositionViewAction<VH extends ViewHolder> implements ViewAction { private final int position; private final ViewAction viewAction; private ActionOnItemAtPositionViewAction(int position, ViewAction viewAction) { this.position = position; this.viewAction = viewAction; } @SuppressWarnings("unchecked") @Override public Matcher<View> getConstraints() { return allOf(isAssignableFrom(RecyclerView.class), isDisplayed()); } @Override public String getDescription() { return "actionOnItemAtPosition performing ViewAction: " + viewAction.getDescription() + " on item at position: " + position; } @Override public void perform(UiController uiController, View view) { RecyclerView recyclerView = (RecyclerView) view; new ScrollToPositionViewAction(position).perform(uiController, view); uiController.loopMainThreadUntilIdle(); @SuppressWarnings("unchecked") VH viewHolderForPosition = (VH) recyclerView.findViewHolderForPosition(position); if (null == viewHolderForPosition) { throw new PerformException.Builder().withActionDescription(this.toString()) .withViewDescription(HumanReadables.describe(view)) .withCause(new IllegalStateException("No view holder at position: " + position)) .build(); } View viewAtPosition = viewHolderForPosition.itemView; if (null == viewAtPosition) { throw new PerformException.Builder().withActionDescription(this.toString()) .withViewDescription(HumanReadables.describe(viewAtPosition)) .withCause(new IllegalStateException("No view at position: " + position)).build(); } viewAction.perform(uiController, viewAtPosition); } } /** * {@link ViewAction} which scrolls {@link RecyclerView} to the view matched by itemViewMatcher. * See {@link RecyclerViewActions#scrollTo(Matcher)} for more details. */ private static final class ScrollToViewAction<VH extends ViewHolder> implements PositionableRecyclerViewAction { private final Matcher<VH> viewHolderMatcher; private final int atPosition; private ScrollToViewAction(Matcher<VH> viewHolderMatcher) { this(viewHolderMatcher, NO_POSITION); } private ScrollToViewAction(Matcher<VH> viewHolderMatcher, int atPosition) { this.viewHolderMatcher = viewHolderMatcher; this.atPosition = atPosition; } @Override public PositionableRecyclerViewAction atPosition(int position) { checkArgument(position >= 0, "%d is used as an index - must be >= 0", position); return new ScrollToViewAction<VH>(viewHolderMatcher, position); } @SuppressWarnings("unchecked") @Override public Matcher<View> getConstraints() { return allOf(isAssignableFrom(RecyclerView.class), isDisplayed()); } @Override public String getDescription() { if (atPosition == NO_POSITION) { return "scroll RecyclerView to: " + viewHolderMatcher; } else { return String.format("scroll RecyclerView to the: %dth matching %s.", atPosition, viewHolderMatcher); } } @SuppressWarnings("unchecked") @Override public void perform(UiController uiController, View view) { RecyclerView recyclerView = (RecyclerView) view; try { int maxMatches = atPosition == NO_POSITION ? 2 : atPosition + 1; int selectIndex = atPosition == NO_POSITION ? 0 : atPosition; List<MatchedItem> matchedItems = itemsMatching(recyclerView, viewHolderMatcher, maxMatches); if (selectIndex >= matchedItems.size()) { throw new IllegalArgumentException(String.format( "Found %d items matching %s, but position %d was requested.", matchedItems.size(), viewHolderMatcher.toString(), atPosition)); } if (atPosition == NO_POSITION && matchedItems.size() == 2) { StringBuilder ambiguousViewError = new StringBuilder(); ambiguousViewError.append( String.format("Found more than one sub-view matching %s", viewHolderMatcher)); for (MatchedItem item : matchedItems) { ambiguousViewError.append(item + "\n"); } throw new IllegalArgumentException(ambiguousViewError.toString()); } recyclerView.scrollToPosition(matchedItems.get(selectIndex).position); uiController.loopMainThreadUntilIdle(); } catch (RuntimeException e) { throw new PerformException.Builder().withActionDescription(this.getDescription()) .withViewDescription(HumanReadables.describe(view)).withCause(e).build(); } } } /** * {@link ViewAction} which scrolls {@link RecyclerView} to a given position. See * {@link RecyclerViewActions#scrollToPosition(int)} for more details. */ private static final class ScrollToPositionViewAction implements ViewAction { private final int position; private ScrollToPositionViewAction(int position) { this.position = position; } @SuppressWarnings("unchecked") @Override public Matcher<View> getConstraints() { return allOf(isAssignableFrom(RecyclerView.class), isDisplayed()); } @Override public String getDescription() { return "scroll RecyclerView to position: " + position; } @Override public void perform(UiController uiController, View view) { RecyclerView recyclerView = (RecyclerView) view; recyclerView.scrollToPosition(position); } } /** * Finds positions of items in {@link RecyclerView} which is matching given viewHolderMatcher. * This is similar to positionMatching(RecyclerView, Matcher<VH>), except that it returns list of * multiple positions if there are, rather than throwing Ambiguous view error exception. * * @param recyclerView recycler view which is hosting items. * @param viewHolderMatcher a * <a href="http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matcher.html"> * <code>Matcher</code></a> that matches an item view in {@link RecyclerView} * @return list of MatchedItem which contains position and description of items in recyclerView. * @throws RuntimeException if more than one item or item could not be found. */ @SuppressWarnings("unchecked") private static <T extends VH, VH extends ViewHolder> List<MatchedItem> itemsMatching( final RecyclerView recyclerView, final Matcher<VH> viewHolderMatcher, int max) { final Adapter<T> adapter = recyclerView.getAdapter(); SparseArray<VH> viewHolderCache = new SparseArray<VH>(); List<MatchedItem> matchedItems = new ArrayList<MatchedItem>(); for (int position = 0; position < adapter.getItemCount(); position++) { int itemType = adapter.getItemViewType(position); VH cachedViewHolder = viewHolderCache.get(itemType); // Create a view holder per type if not exists if (null == cachedViewHolder) { cachedViewHolder = adapter.createViewHolder(recyclerView, itemType); viewHolderCache.put(itemType, cachedViewHolder); } // Bind data to ViewHolder and apply matcher to view descendants. adapter.bindViewHolder((T) cachedViewHolder, position); if (viewHolderMatcher.matches(cachedViewHolder)) { matchedItems.add(new MatchedItem(position, HumanReadables.getViewHierarchyErrorMessage( cachedViewHolder.itemView, null, "\n\n*** Matched ViewHolder item at position: " + position + " ***", null))); if (matchedItems.size() == max) { break; } } } return matchedItems; } /** * Wrapper for matched items in recycler view which contains position and description of matched * view. */ private static class MatchedItem { public final int position; public final String description; private MatchedItem(int position, String description) { this.position = position; this.description = description; } @Override public String toString() { return description; } } /** * Creates matcher for view holder with given item view matcher. * * @param itemViewMatcher a item view matcher which is used to match item. * @return a matcher which matches a view holder containing item matching itemViewMatcher. */ private static <VH extends ViewHolder> Matcher<VH> viewHolderMatcher( final Matcher<View> itemViewMatcher) { return new TypeSafeMatcher<VH>() { @Override public boolean matchesSafely(RecyclerView.ViewHolder viewHolder) { return itemViewMatcher.matches(viewHolder.itemView); } @Override public void describeTo(Description description) { description.appendText("holder with view: "); itemViewMatcher.describeTo(description); } }; } }