blob: 3c16903b2490b2f81d5b92009dc287f2d2b443d1 [file] [log] [blame]
/*
* 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;
}
};
}
}