/* * Copyright (C) 2017 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.wear.widget.drawer; import static android.support.test.espresso.Espresso.onView; import static android.support.test.espresso.action.ViewActions.click; import static android.support.test.espresso.action.ViewActions.swipeDown; import static android.support.test.espresso.assertion.ViewAssertions.matches; import static android.support.test.espresso.matcher.ViewMatchers.isAssignableFrom; import static android.support.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed; import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; import static android.support.test.espresso.matcher.ViewMatchers.withId; import static android.support.test.espresso.matcher.ViewMatchers.withParent; import static android.support.test.espresso.matcher.ViewMatchers.withText; import static android.support.wear.widget.util.AsyncViewActions.waitForMatchingView; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.mockito.Matchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import android.content.Intent; import android.support.test.espresso.PerformException; import android.support.test.espresso.UiController; import android.support.test.espresso.ViewAction; import android.support.test.espresso.util.HumanReadables; import android.support.test.espresso.util.TreeIterables; import android.support.test.filters.LargeTest; import android.support.test.rule.ActivityTestRule; import android.support.test.runner.AndroidJUnit4; import android.support.v7.widget.RecyclerView; import android.support.wear.test.R; import android.support.wear.widget.drawer.DrawerTestActivity.DrawerStyle; import android.view.Menu; import android.view.MenuItem; import android.view.MenuItem.OnMenuItemClickListener; import android.view.View; import android.widget.ImageView; import android.widget.TextView; import org.hamcrest.Description; import org.hamcrest.Matcher; import org.hamcrest.TypeSafeMatcher; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import java.util.concurrent.TimeoutException; import javax.annotation.Nullable; /** * Espresso tests for {@link WearableDrawerLayout}. */ @LargeTest @RunWith(AndroidJUnit4.class) public class WearableDrawerLayoutEspressoTest { private static final long MAX_WAIT_MS = 4000; @Rule public final ActivityTestRule activityRule = new ActivityTestRule<>( DrawerTestActivity.class, true /* touchMode */, false /* initialLaunch*/); private final Intent mSinglePageIntent = new DrawerTestActivity.Builder().setStyle(DrawerStyle.BOTH_DRAWER_NAV_SINGLE_PAGE) .build(); @Mock WearableNavigationDrawerView.OnItemSelectedListener mNavDrawerItemSelectedListener; @Before public void setUp() { MockitoAnnotations.initMocks(this); } @Test public void openingNavigationDrawerDoesNotCloseActionDrawer() { // GIVEN a drawer layout with a peeking action and navigation drawer activityRule.launchActivity(mSinglePageIntent); DrawerTestActivity activity = activityRule.getActivity(); WearableDrawerView actionDrawer = (WearableDrawerView) activity.findViewById(R.id.action_drawer); WearableDrawerView navigationDrawer = (WearableDrawerView) activity.findViewById(R.id.navigation_drawer); assertTrue(actionDrawer.isPeeking()); assertTrue(navigationDrawer.isPeeking()); // WHEN the top drawer is opened openDrawer(navigationDrawer); onView(withId(R.id.navigation_drawer)) .perform( waitForMatchingView( allOf(withId(R.id.navigation_drawer), isOpened(true)), MAX_WAIT_MS)); // THEN the action drawer should still be peeking assertTrue(actionDrawer.isPeeking()); } @Test public void swipingDownNavigationDrawerDoesNotCloseActionDrawer() { // GIVEN a drawer layout with a peeking action and navigation drawer activityRule.launchActivity(mSinglePageIntent); onView(withId(R.id.action_drawer)).check(matches(isPeeking())); onView(withId(R.id.navigation_drawer)).check(matches(isPeeking())); // WHEN the top drawer is opened by swiping down onView(withId(R.id.drawer_layout)).perform(swipeDown()); onView(withId(R.id.navigation_drawer)) .perform( waitForMatchingView( allOf(withId(R.id.navigation_drawer), isOpened(true)), MAX_WAIT_MS)); // THEN the action drawer should still be peeking onView(withId(R.id.action_drawer)).check(matches(isPeeking())); } @Test public void firstNavDrawerItemShouldBeSelectedInitially() { // GIVEN a top drawer // WHEN it is first opened activityRule.launchActivity(mSinglePageIntent); onView(withId(R.id.drawer_layout)).perform(swipeDown()); onView(withId(R.id.navigation_drawer)) .perform( waitForMatchingView( allOf(withId(R.id.navigation_drawer), isOpened(true)), MAX_WAIT_MS)); // THEN the text should display "0". onView(withId(R.id.ws_nav_drawer_text)).check(matches(withText("0"))); } @Test public void selectingNavItemChangesTextAndClosedDrawer() { // GIVEN an open top drawer activityRule.launchActivity(mSinglePageIntent); onView(withId(R.id.drawer_layout)).perform(swipeDown()); onView(withId(R.id.navigation_drawer)) .perform( waitForMatchingView( allOf(withId(R.id.navigation_drawer), isOpened(true)), MAX_WAIT_MS)); // WHEN the second item is selected onView(withId(R.id.ws_nav_drawer_icon_1)).perform(click()); // THEN the text should display "1" and it should close. onView(withId(R.id.ws_nav_drawer_text)) .perform( waitForMatchingView( allOf(withId(R.id.ws_nav_drawer_text), withText("1")), MAX_WAIT_MS)); onView(withId(R.id.navigation_drawer)) .perform( waitForMatchingView( allOf(withId(R.id.navigation_drawer), isClosed(true)), MAX_WAIT_MS)); } @Test public void programmaticallySelectingNavItemChangesTextInSinglePage() { // GIVEN an open top drawer activityRule.launchActivity(new DrawerTestActivity.Builder() .setStyle(DrawerStyle.BOTH_DRAWER_NAV_SINGLE_PAGE) .openTopDrawerInOnCreate() .build()); final WearableNavigationDrawerView navDrawer = activityRule.getActivity().findViewById(R.id.navigation_drawer); navDrawer.addOnItemSelectedListener(mNavDrawerItemSelectedListener); // WHEN the second item is selected programmatically selectNavItem(navDrawer, 1); // THEN the text should display "1" and the listener should be notified. onView(withId(R.id.ws_nav_drawer_text)) .check(matches(withText("1"))); verify(mNavDrawerItemSelectedListener).onItemSelected(1); } @Test public void programmaticallySelectingNavItemChangesTextInMultiPage() { // GIVEN an open top drawer activityRule.launchActivity(new DrawerTestActivity.Builder() .setStyle(DrawerStyle.BOTH_DRAWER_NAV_MULTI_PAGE) .openTopDrawerInOnCreate() .build()); final WearableNavigationDrawerView navDrawer = activityRule.getActivity().findViewById(R.id.navigation_drawer); navDrawer.addOnItemSelectedListener(mNavDrawerItemSelectedListener); // WHEN the second item is selected programmatically selectNavItem(navDrawer, 1); // THEN the text should display "1" and the listener should be notified. onView(allOf(withId(R.id.ws_navigation_drawer_item_text), isDisplayed())) .check(matches(withText("1"))); verify(mNavDrawerItemSelectedListener).onItemSelected(1); } @Test public void navDrawerShouldOpenWhenCalledInOnCreate() { // GIVEN an activity which calls openDrawer(Gravity.TOP) in onCreate // WHEN it is launched activityRule.launchActivity( new DrawerTestActivity.Builder() .setStyle(DrawerStyle.BOTH_DRAWER_NAV_SINGLE_PAGE) .openTopDrawerInOnCreate() .build()); // THEN the nav drawer should be open onView(withId(R.id.navigation_drawer)).check(matches(isOpened(true))); } @Test public void actionDrawerShouldOpenWhenCalledInOnCreate() { // GIVEN an activity with only an action drawer which is opened in onCreate // WHEN it is launched activityRule.launchActivity( new DrawerTestActivity.Builder() .setStyle(DrawerStyle.ONLY_ACTION_DRAWER_WITH_TITLE) .openBottomDrawerInOnCreate() .build()); // THEN the action drawer should be open onView(withId(R.id.action_drawer)).check(matches(isOpened(true))); } @Test public void navDrawerShouldOpenWhenCalledInOnCreateAndThenCloseWhenRequested() { // GIVEN an activity which calls openDrawer(Gravity.TOP) in onCreate, then closes it // WHEN it is launched activityRule.launchActivity( new DrawerTestActivity.Builder() .setStyle(DrawerStyle.BOTH_DRAWER_NAV_SINGLE_PAGE) .openTopDrawerInOnCreate() .closeFirstDrawerOpened() .build()); // THEN the nav drawer should be open and then close onView(withId(R.id.navigation_drawer)) .check(matches(isOpened(true))) .perform( waitForMatchingView( allOf(withId(R.id.navigation_drawer), isClosed(true)), MAX_WAIT_MS)); } @Test public void openedNavDrawerShouldPreventSwipeToClose() { // GIVEN an activity which calls openDrawer(Gravity.TOP) in onCreate activityRule.launchActivity( new DrawerTestActivity.Builder() .setStyle(DrawerStyle.BOTH_DRAWER_NAV_SINGLE_PAGE) .openTopDrawerInOnCreate() .build()); // THEN the view should prevent swipe to close onView(withId(R.id.navigation_drawer)).check(matches(not(allowsSwipeToClose()))); } @Test public void closedNavDrawerShouldNotPreventSwipeToClose() { // GIVEN an activity which doesn't start with the nav drawer open activityRule.launchActivity(mSinglePageIntent); // THEN the view should allow swipe to close onView(withId(R.id.navigation_drawer)).check(matches(allowsSwipeToClose())); } @Test public void scrolledDownActionDrawerCanScrollUpWhenReOpened() { // GIVEN a freshly launched activity activityRule.launchActivity(mSinglePageIntent); WearableActionDrawerView actionDrawer = (WearableActionDrawerView) activityRule.getActivity() .findViewById(R.id.action_drawer); RecyclerView recyclerView = (RecyclerView) actionDrawer.getDrawerContent(); // WHEN the action drawer is opened and scrolled to the last item (Item 6) openDrawer(actionDrawer); scrollToPosition(recyclerView, 5); onView(withId(R.id.action_drawer)) .perform( waitForMatchingView(allOf(withId(R.id.action_drawer), isOpened(true)), MAX_WAIT_MS)) .perform( waitForMatchingView(allOf(withText("Item 6"), isCompletelyDisplayed()), MAX_WAIT_MS)); // and then it is peeked peekDrawer(actionDrawer); onView(withId(R.id.action_drawer)) .perform(waitForMatchingView(allOf(withId(R.id.action_drawer), isPeeking()), MAX_WAIT_MS)); // and re-opened openDrawer(actionDrawer); onView(withId(R.id.action_drawer)) .perform( waitForMatchingView(allOf(withId(R.id.action_drawer), isOpened(true)), MAX_WAIT_MS)); // THEN item 6 should be visible, but swiping down should scroll up, not close the drawer. onView(withText("Item 6")).check(matches(isDisplayed())); onView(withId(R.id.action_drawer)).perform(swipeDown()).check(matches(isOpened(true))); } @Test public void actionDrawerPeekIconShouldNotBeNull() { // GIVEN a drawer layout with a peeking action drawer whose menu is initialized in XML activityRule.launchActivity(mSinglePageIntent); DrawerTestActivity activity = activityRule.getActivity(); ImageView peekIconView = (ImageView) activity .findViewById(R.id.ws_action_drawer_peek_action_icon); // THEN its peek icon should not be null assertNotNull(peekIconView.getDrawable()); } @Test public void tappingActionDrawerPeekIconShouldTriggerFirstAction() { // GIVEN a drawer layout with a peeking action drawer, title, and mock click listener activityRule.launchActivity( new DrawerTestActivity.Builder() .setStyle(DrawerStyle.ONLY_ACTION_DRAWER_WITH_TITLE) .build()); WearableActionDrawerView actionDrawer = (WearableActionDrawerView) activityRule.getActivity() .findViewById(R.id.action_drawer); OnMenuItemClickListener mockClickListener = mock(OnMenuItemClickListener.class); actionDrawer.setOnMenuItemClickListener(mockClickListener); // WHEN the action drawer peek view is tapped onView(withId(R.id.ws_drawer_view_peek_container)) .perform(waitForMatchingView( allOf( withId(R.id.ws_drawer_view_peek_container), isCompletelyDisplayed()), MAX_WAIT_MS)) .perform(click()); // THEN its click listener should be notified verify(mockClickListener).onMenuItemClick(any(MenuItem.class)); } @Test public void tappingActionDrawerPeekIconShouldTriggerFirstActionAfterItWasOpened() { // GIVEN a drawer layout with an open action drawer with a title, and mock click listener activityRule.launchActivity( new DrawerTestActivity.Builder() .setStyle(DrawerStyle.ONLY_ACTION_DRAWER_WITH_TITLE) .openBottomDrawerInOnCreate() .build()); WearableActionDrawerView actionDrawer = (WearableActionDrawerView) activityRule.getActivity() .findViewById(R.id.action_drawer); OnMenuItemClickListener mockClickListener = mock(OnMenuItemClickListener.class); actionDrawer.setOnMenuItemClickListener(mockClickListener); // WHEN the action drawer is closed to its peek state and then tapped peekDrawer(actionDrawer); onView(withId(R.id.action_drawer)) .perform(waitForMatchingView(allOf(withId(R.id.action_drawer), isPeeking()), MAX_WAIT_MS)); actionDrawer.getPeekContainer().callOnClick(); // THEN its click listener should be notified verify(mockClickListener).onMenuItemClick(any(MenuItem.class)); } @Test public void changingActionDrawerItemShouldUpdateView() { // GIVEN a drawer layout with an open action drawer activityRule.launchActivity( new DrawerTestActivity.Builder() .setStyle(DrawerStyle.ONLY_ACTION_DRAWER_WITH_TITLE) .openBottomDrawerInOnCreate() .build()); WearableActionDrawerView actionDrawer = activityRule.getActivity().findViewById(R.id.action_drawer); final MenuItem secondItem = actionDrawer.getMenu().getItem(1); // WHEN its second item is changed actionDrawer.post(new Runnable() { @Override public void run() { secondItem.setTitle("Modified item"); } }); // THEN the new item should be displayed onView(withText("Modified item")).check(matches(isDisplayed())); } @Test public void removingActionDrawerItemShouldUpdateView() { // GIVEN a drawer layout with an open action drawer activityRule.launchActivity( new DrawerTestActivity.Builder() .setStyle(DrawerStyle.ONLY_ACTION_DRAWER_WITH_TITLE) .openBottomDrawerInOnCreate() .build()); final WearableActionDrawerView actionDrawer = activityRule.getActivity().findViewById(R.id.action_drawer); MenuItem secondItem = actionDrawer.getMenu().getItem(1); final int itemId = secondItem.getItemId(); final String title = secondItem.getTitle().toString(); final int initialSize = getChildByType(actionDrawer, RecyclerView.class) .getAdapter() .getItemCount(); // WHEN its second item is removed actionDrawer.post(new Runnable() { @Override public void run() { actionDrawer.getMenu().removeItem(itemId); } }); // THEN it should decrease by 1 in size and it should no longer contain the item's text onView(allOf(withParent(withId(R.id.action_drawer)), isAssignableFrom(RecyclerView.class))) .perform(waitForRecyclerToBeSize(initialSize - 1, MAX_WAIT_MS)) .perform(waitForMatchingView(recyclerWithoutText(is(title)), MAX_WAIT_MS)); } @Test public void addingActionDrawerItemShouldUpdateView() { // GIVEN a drawer layout with an open action drawer activityRule.launchActivity( new DrawerTestActivity.Builder() .setStyle(DrawerStyle.ONLY_ACTION_DRAWER_WITH_TITLE) .openBottomDrawerInOnCreate() .build()); final WearableActionDrawerView actionDrawer = activityRule.getActivity().findViewById(R.id.action_drawer); RecyclerView recycler = getChildByType(actionDrawer, RecyclerView.class); final RecyclerView.LayoutManager layoutManager = recycler.getLayoutManager(); final int initialSize = recycler.getAdapter().getItemCount(); // WHEN an item is added and the view is scrolled down (to make sure the view is created) actionDrawer.post(new Runnable() { @Override public void run() { actionDrawer.getMenu().add(0, 42, Menu.NONE, "New Item"); layoutManager.scrollToPosition(initialSize); } }); // THEN it should decrease by 1 in size and the there should be a view with the item's text onView(allOf(withParent(withId(R.id.action_drawer)), isAssignableFrom(RecyclerView.class))) .perform(waitForRecyclerToBeSize(initialSize + 1, MAX_WAIT_MS)) .perform(waitForMatchingView(withText("New Item"), MAX_WAIT_MS)); } private void scrollToPosition(final RecyclerView recyclerView, final int position) { recyclerView.post(new Runnable() { @Override public void run() { recyclerView.scrollToPosition(position); } }); } private void selectNavItem(final WearableNavigationDrawerView navDrawer, final int index) { navDrawer.post(new Runnable() { @Override public void run() { navDrawer.setCurrentItem(index, false); } }); } private void peekDrawer(final WearableDrawerView drawer) { drawer.post(new Runnable() { @Override public void run() { drawer.getController().peekDrawer(); } }); } private void openDrawer(final WearableDrawerView drawer) { drawer.post(new Runnable() { @Override public void run() { drawer.getController().openDrawer(); } }); } private static TypeSafeMatcher isOpened(final boolean isOpened) { return new TypeSafeMatcher() { @Override public void describeTo(Description description) { description.appendText("is opened == " + isOpened); } @Override public boolean matchesSafely(View view) { return ((WearableDrawerView) view).isOpened() == isOpened; } }; } private static TypeSafeMatcher isClosed(final boolean isClosed) { return new TypeSafeMatcher() { @Override protected boolean matchesSafely(View view) { WearableDrawerView drawer = (WearableDrawerView) view; return drawer.isClosed() == isClosed; } @Override public void describeTo(Description description) { description.appendText("is closed"); } }; } private TypeSafeMatcher isPeeking() { return new TypeSafeMatcher() { @Override protected boolean matchesSafely(View view) { WearableDrawerView drawer = (WearableDrawerView) view; return drawer.isPeeking(); } @Override public void describeTo(Description description) { description.appendText("is peeking"); } }; } private TypeSafeMatcher allowsSwipeToClose() { return new TypeSafeMatcher() { @Override protected boolean matchesSafely(View view) { return !view.canScrollHorizontally(-2) && !view.canScrollHorizontally(2); } @Override public void describeTo(Description description) { description.appendText("can be swiped closed"); } }; } /** * Returns a {@link TypeSafeMatcher} that returns {@code true} when the {@link RecyclerView} * does not contain a {@link TextView} with text matched by {@code textMatcher}. */ private TypeSafeMatcher recyclerWithoutText(final Matcher textMatcher) { return new TypeSafeMatcher() { @Override public void describeTo(Description description) { description.appendText("Without recycler text "); textMatcher.describeTo(description); } @Override public boolean matchesSafely(View view) { if (!(view instanceof RecyclerView)) { return false; } RecyclerView recycler = ((RecyclerView) view); if (recycler.isAnimating()) { // While the RecyclerView is animating, it will return null ViewHolders and we // won't be able to tell whether the item has been removed or not. return false; } for (int i = 0; i < recycler.getAdapter().getItemCount(); i++) { RecyclerView.ViewHolder holder = recycler.findViewHolderForAdapterPosition(i); if (holder != null) { TextView text = getChildByType(holder.itemView, TextView.class); if (text != null && textMatcher.matches(text.getText())) { return false; } } } return true; } }; } /** * Waits for the {@link RecyclerView} to contain {@code targetCount} items, up to {@code millis} * milliseconds. Throws exception if the time limit is reached before reaching the desired * number of items. */ public ViewAction waitForRecyclerToBeSize(final int targetCount, final long millis) { return new ViewAction() { @Override public Matcher getConstraints() { return isAssignableFrom(RecyclerView.class); } @Override public String getDescription() { return "Waiting for recycler to be size=" + targetCount; } @Override public void perform(UiController uiController, View view) { if (!(view instanceof RecyclerView)) { return; } RecyclerView recycler = (RecyclerView) view; uiController.loopMainThreadUntilIdle(); final long startTime = System.currentTimeMillis(); final long endTime = startTime + millis; do { if (recycler.getAdapter().getItemCount() == targetCount) { return; } uiController.loopMainThreadForAtLeast(100); // at least 3 frames } while (System.currentTimeMillis() < endTime); // timeout happens throw new PerformException.Builder() .withActionDescription(this.getDescription()) .withViewDescription(HumanReadables.describe(view)) .withCause(new TimeoutException()) .build(); } }; } /** * Returns the first child of {@code root} to be an instance of class {@code T}, or {@code null} * if none were found. */ @Nullable private T getChildByType(View root, Class classOfChildToFind) { for (View child : TreeIterables.breadthFirstViewTraversal(root)) { if (classOfChildToFind.isInstance(child)) { return (T) child; } } return null; } }