| /* |
| * Copyright (C) 2016 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 androidx.appcompat.widget; |
| |
| import static androidx.appcompat.testutils.TestUtilsActions.setScreenOrientation; |
| import static androidx.appcompat.testutils.TestUtilsMatchers.asViewMatcher; |
| import static androidx.appcompat.testutils.TestUtilsMatchers.hasChild; |
| import static androidx.appcompat.testutils.TestUtilsMatchers.isCombinedBackground; |
| import static androidx.test.espresso.Espresso.onView; |
| import static androidx.test.espresso.action.ViewActions.click; |
| import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; |
| import static androidx.test.espresso.assertion.ViewAssertions.matches; |
| import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup; |
| import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom; |
| import static androidx.test.espresso.matcher.ViewMatchers.isRoot; |
| import static androidx.test.espresso.matcher.ViewMatchers.withId; |
| import static androidx.test.espresso.matcher.ViewMatchers.withText; |
| |
| import static org.hamcrest.CoreMatchers.instanceOf; |
| import static org.junit.Assert.assertNotNull; |
| import static org.junit.Assert.assertThat; |
| import static org.junit.Assert.assertTrue; |
| |
| import android.app.Instrumentation; |
| import android.content.pm.ActivityInfo; |
| import android.content.res.Resources; |
| import android.view.View; |
| import android.view.ViewGroup; |
| |
| import androidx.annotation.ColorInt; |
| import androidx.annotation.ColorRes; |
| import androidx.annotation.IdRes; |
| import androidx.appcompat.app.AlertDialog; |
| import androidx.appcompat.test.R; |
| import androidx.core.content.ContextCompat; |
| import androidx.core.content.res.ResourcesCompat; |
| import androidx.test.espresso.ViewAction; |
| import androidx.test.espresso.action.CoordinatesProvider; |
| import androidx.test.espresso.action.GeneralSwipeAction; |
| import androidx.test.espresso.action.Press; |
| import androidx.test.espresso.action.Swipe; |
| import androidx.test.filters.FlakyTest; |
| import androidx.test.filters.LargeTest; |
| import androidx.test.filters.MediumTest; |
| import androidx.test.filters.SdkSuppress; |
| import androidx.test.platform.app.InstrumentationRegistry; |
| import androidx.testutils.PollingCheck; |
| |
| import org.hamcrest.Description; |
| import org.hamcrest.Matcher; |
| import org.hamcrest.TypeSafeMatcher; |
| import org.junit.Test; |
| |
| /** |
| * In addition to all tinting-related tests done by the base class, this class provides |
| * tests specific to {@link AppCompatSpinner} class. |
| */ |
| @SdkSuppress(maxSdkVersion=29) // Broken on API 30 b/159743495 |
| @LargeTest |
| public class AppCompatSpinnerTest |
| extends AppCompatBaseViewTest<AppCompatSpinnerActivity, AppCompatSpinner> { |
| private static final String ONE = "1"; |
| private Instrumentation mInstrumentation; |
| |
| public AppCompatSpinnerTest() { |
| super(AppCompatSpinnerActivity.class); |
| } |
| |
| @Override |
| protected boolean hasBackgroundByDefault() { |
| // Spinner has default background set on it |
| return true; |
| } |
| |
| @Override |
| public void setUp() { |
| super.setUp(); |
| mInstrumentation = InstrumentationRegistry.getInstrumentation(); |
| } |
| |
| /** |
| * Helper method that verifies that the popup for the specified {@link AppCompatSpinner} |
| * is themed with the specified color. |
| */ |
| private void verifySpinnerPopupTheming(@IdRes int spinnerId, |
| @ColorRes int expectedPopupColorResId) { |
| // Wait until the activity can receive input. |
| PollingCheck.waitFor(new PollingCheck.PollingCheckCondition() { |
| @Override |
| public boolean canProceed() { |
| return mActivity.hasWindowFocus(); |
| } |
| }); |
| |
| final Resources res = mActivity.getResources(); |
| final @ColorInt int expectedPopupColor = |
| ResourcesCompat.getColor(res, expectedPopupColorResId, null); |
| final AppCompatSpinner spinner = mContainer.findViewById(spinnerId); |
| |
| // Click the spinner to show its popup content |
| onView(withId(spinnerId)).perform(click()); |
| |
| // Wait until the popup is showing |
| waitUntilPopupIsShown(spinner); |
| |
| // The internal implementation details of the AppCompatSpinner's popup content depends |
| // on the platform version itself (in android.widget.PopupWindow) as well as on when the |
| // popup theme is being applied first (in XML or at runtime). Instead of trying to catch |
| // all possible variations of how the popup content is wrapped, we use a view matcher that |
| // creates a single bitmap that combines backgrounds starting from the parent of the |
| // popup content items upwards (drawing them in reverse order), and then tests that the |
| // combined bitmap matches the expected color fill. This should remove dependency on the |
| // internal implementation details on which exact "chrome" part of the popup has the |
| // matching background. |
| String itemText = (String) spinner.getAdapter().getItem(2); |
| Matcher<ViewGroup> popupContentMatcher = hasChild(withText(itemText)); |
| // Note that we are only testing the center pixel of the combined popup background. This |
| // is to "eliminate" otherwise hacky code that would need to skip over rounded corners and |
| // drop shadow of the combined visual appearance of a popup. |
| onView(asViewMatcher(popupContentMatcher)).inRoot(isPlatformPopup()).check( |
| matches(isCombinedBackground(expectedPopupColor, true))); |
| |
| // Click an entry in the popup to dismiss it |
| onView(withText(itemText)).perform(click()); |
| |
| // Wait until the popup is gone |
| waitUntilPopupIsHidden(spinner); |
| } |
| |
| @Test |
| public void testPopupThemingFromXmlAttribute() { |
| verifySpinnerPopupTheming(R.id.view_magenta_themed_popup, R.color.test_magenta); |
| } |
| |
| @Test |
| public void testUnthemedPopupRuntimeTheming() { |
| final AppCompatSpinner spinner = |
| mContainer.findViewById(R.id.view_unthemed_popup); |
| spinner.setPopupBackgroundResource(R.drawable.test_background_blue); |
| verifySpinnerPopupTheming(R.id.view_unthemed_popup, R.color.test_blue); |
| |
| // Set a different popup background |
| spinner.setPopupBackgroundDrawable(ContextCompat.getDrawable( |
| mActivityTestRule.getActivity(), R.drawable.test_background_green)); |
| verifySpinnerPopupTheming(R.id.view_unthemed_popup, R.color.test_green); |
| } |
| |
| @Test |
| @FlakyTest |
| public void testThemedPopupRuntimeTheming() { |
| final AppCompatSpinner spinner = |
| mContainer.findViewById(R.id.view_ocean_themed_popup); |
| verifySpinnerPopupTheming(R.id.view_ocean_themed_popup, R.color.ocean_default); |
| |
| // Now set a different popup background |
| spinner.setPopupBackgroundResource(R.drawable.test_background_red); |
| verifySpinnerPopupTheming(R.id.view_ocean_themed_popup, R.color.test_red); |
| |
| // Set a different popup background |
| spinner.setPopupBackgroundDrawable(ContextCompat.getDrawable( |
| mActivityTestRule.getActivity(), R.drawable.test_background_blue)); |
| verifySpinnerPopupTheming(R.id.view_ocean_themed_popup, R.color.test_blue); |
| } |
| |
| @MediumTest |
| @Test |
| public void testHasAppCompatDialogMode() { |
| final AppCompatSpinner spinner = mContainer.findViewById(R.id.spinner_dialog_popup); |
| final AppCompatSpinner.SpinnerPopup popup = spinner.getInternalPopup(); |
| assertNotNull(popup); |
| assertThat(popup, instanceOf(AppCompatSpinner.DialogPopup.class)); |
| |
| onView(withId(R.id.spinner_dialog_popup)).perform(click()); |
| // Wait until the popup is showing |
| waitUntilPopupIsShown(spinner); |
| |
| final AppCompatSpinner.DialogPopup dialogPopup = (AppCompatSpinner.DialogPopup) popup; |
| assertThat(dialogPopup.mPopup, instanceOf(AlertDialog.class)); |
| } |
| |
| @Test |
| public void testChangeOrientationDialogPopupPersists() { |
| verifyChangeOrientationPopupPersists(R.id.spinner_dialog_popup); |
| } |
| |
| @Test |
| public void testChangeOrientationDropdownPopupPersists() { |
| verifyChangeOrientationPopupPersists(R.id.spinner_dropdown_popup); |
| } |
| |
| private void verifyChangeOrientationPopupPersists(@IdRes int spinnerId) { |
| onView(withId(spinnerId)).perform(click()); |
| // Wait until the popup is showing |
| waitUntilPopupIsShown((AppCompatSpinner) mActivity.findViewById(spinnerId)); |
| |
| // Use ActivityMonitor so that we can get the Activity instance after it has been |
| // recreated when the rotation request completes |
| Instrumentation.ActivityMonitor monitor = |
| new Instrumentation.ActivityMonitor(mActivity.getClass().getName(), null, false); |
| mInstrumentation.addMonitor(monitor); |
| |
| onView(isRoot()).perform( |
| setScreenOrientation(mActivity, ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)); |
| |
| mActivity = (AppCompatSpinnerActivity) mInstrumentation.waitForMonitor(monitor); |
| mInstrumentation.waitForIdleSync(); |
| |
| // Now we can get the new (post-rotation) instance of our spinner |
| AppCompatSpinner newSpinner = mActivity.findViewById(spinnerId); |
| // And check that it's showing the popup |
| assertTrue(newSpinner.getInternalPopup().isShowing()); |
| } |
| |
| @Test |
| @FlakyTest |
| public void testSlowScroll() { |
| final AppCompatSpinner spinner = mContainer |
| .findViewById(R.id.spinner_dropdown_popup_with_scroll); |
| onView(withId(R.id.spinner_dropdown_popup_with_scroll)).perform(click()); |
| |
| // Wait until the popup is showing |
| waitUntilPopupIsShown(spinner); |
| |
| String secondItem = (String) spinner.getAdapter().getItem(1); |
| |
| onView(isAssignableFrom(DropDownListView.class)).perform(slowScrollPopup()); |
| |
| // when we scroll slowly a second time the popup list might jump back to the first element |
| onView(isAssignableFrom(DropDownListView.class)).perform(slowScrollPopup()); |
| |
| // because we scroll twice with one element height each, |
| // the second item should not be visible |
| onView(withText(secondItem)).check(doesNotExist()); |
| } |
| |
| @Test |
| @FlakyTest |
| public void testHorizontalOffset() { |
| checkOffsetIsCorrect(mInstrumentation, mContainer, 500, false, false); |
| } |
| |
| @Test |
| public void testVerticalOffset() { |
| checkOffsetIsCorrect(mInstrumentation, mContainer, 100, true, false); |
| } |
| |
| public static void checkOffsetIsCorrect( |
| final Instrumentation instrumentation, |
| final ViewGroup container, |
| final int offset, |
| final boolean isVerticalOffset, |
| final boolean isRtl) { |
| final int spinnerId = R.id.spinner_dropdown_popup_small; |
| final AppCompatSpinner spinner = container.findViewById(spinnerId); |
| |
| instrumentation.runOnMainSync(new Runnable() { |
| @Override |
| public void run() { |
| if (isVerticalOffset) { |
| spinner.setDropDownVerticalOffset(offset); |
| } else { |
| spinner.setDropDownHorizontalOffset(offset); |
| } |
| } |
| }); |
| |
| onView(withId(spinnerId)).perform(click()); |
| waitUntilPopupIsShown(spinner); |
| |
| int computedOffset; |
| if (isVerticalOffset) { |
| int[] location = new int[2]; |
| spinner.getLocationOnScreen(location); |
| |
| computedOffset = location[1] + offset; |
| } else { |
| if (isRtl) { |
| int[] location = new int[2]; |
| spinner.getLocationOnScreen(location); |
| final AppCompatSpinner.SpinnerPopup spinnerPopup = spinner.getInternalPopup(); |
| AppCompatSpinner.DropdownPopup dropdownPopup = |
| (AppCompatSpinner.DropdownPopup) (spinnerPopup); |
| final int popupWidth = dropdownPopup.getWidth(); |
| final int spinnerWidth = spinner.getWidth(); |
| |
| computedOffset = location[0] + (spinnerWidth - popupWidth - offset); |
| } else { |
| computedOffset = offset; |
| } |
| } |
| |
| onView(withText(ONE)).check(matches( |
| hasOffset( |
| computedOffset, |
| isVerticalOffset ? "has vertical offset" : "has horizontal offset", |
| isVerticalOffset) |
| )); |
| } |
| |
| private ViewAction slowScrollPopup() { |
| return new GeneralSwipeAction(Swipe.SLOW, |
| new CoordinatesProvider() { |
| @Override |
| public float[] calculateCoordinates(View view) { |
| final float[] middleLocation = getViewMiddleLocation(view); |
| return new float[] { |
| middleLocation[0], |
| middleLocation[1] |
| }; |
| } |
| }, |
| new CoordinatesProvider() { |
| @Override |
| public float[] calculateCoordinates(View view) { |
| final float[] middleLocation = getViewMiddleLocation(view); |
| return new float[] { |
| middleLocation[0], |
| middleLocation[1] - getElementSize(view) |
| }; |
| } |
| }, |
| Press.PINPOINT |
| ); |
| } |
| |
| private float[] getViewMiddleLocation(View view) { |
| final DropDownListView list = (DropDownListView) view; |
| |
| final int[] location = new int[2]; |
| list.getLocationOnScreen(location); |
| |
| final float x = location[0] + list.getWidth() / 2f; |
| final float y = location[1] + list.getHeight() / 2f; |
| |
| return new float[] {x, y}; |
| } |
| |
| private int getElementSize(View view) { |
| final DropDownListView list = (DropDownListView) view; |
| |
| final View child = list.getChildAt(0); |
| final int[] location = new int[2]; |
| child.getLocationOnScreen(location); |
| |
| // espresso doesn't actually scroll for the full amount specified |
| // so we add a little bit more to be safe |
| return child.getHeight() * 2; |
| } |
| |
| private static void waitUntilPopupIsShown(final AppCompatSpinner spinner) { |
| PollingCheck.waitFor(new PollingCheck.PollingCheckCondition() { |
| @Override |
| public boolean canProceed() { |
| return spinner.getInternalPopup().isShowing(); |
| } |
| }); |
| } |
| |
| private static void waitUntilPopupIsHidden(final AppCompatSpinner spinner) { |
| PollingCheck.waitFor(new PollingCheck.PollingCheckCondition() { |
| @Override |
| public boolean canProceed() { |
| return !spinner.getInternalPopup().isShowing(); |
| } |
| }); |
| } |
| |
| public static Matcher<View> hasOffset( |
| final int offset, |
| final String desc, |
| final boolean isVerticalOffset) { |
| return new TypeSafeMatcher<View>() { |
| |
| @Override |
| public void describeTo(Description description) { |
| description.appendText(desc); |
| } |
| |
| @Override |
| protected boolean matchesSafely(View view) { |
| if (view.getParent() instanceof DropDownListView) { |
| final DropDownListView dropDownListView = (DropDownListView) (view.getParent()); |
| int[] location = new int[2]; |
| dropDownListView.getLocationOnScreen(location); |
| dropDownListView.getWidth(); |
| return location[isVerticalOffset ? 1 : 0] == offset; |
| } |
| |
| return false; |
| } |
| }; |
| } |
| } |