From f98e7e9d6cf6e1fa728b023dca5b3b2c0ea116a4 Mon Sep 17 00:00:00 2001 From: Ivan Arcuschin Date: Tue, 19 May 2020 11:27:56 -0300 Subject: [PATCH] Add test case for MainDialog Activity --- .../ClickWithoutDisplayConstraint.java | 203 ++++++++++++++++++ .../IsEqualTrimmingAndIgnoringCase.java | 52 +++++ .../de/dotwee/micropinner/MainDialogTest.java | 137 ++++++++++++ .../micropinner/VisibleViewMatcher.java | 40 ++++ 4 files changed, 432 insertions(+) create mode 100644 app/src/androidTest/java/de/dotwee/micropinner/ClickWithoutDisplayConstraint.java create mode 100644 app/src/androidTest/java/de/dotwee/micropinner/IsEqualTrimmingAndIgnoringCase.java create mode 100644 app/src/androidTest/java/de/dotwee/micropinner/MainDialogTest.java create mode 100644 app/src/androidTest/java/de/dotwee/micropinner/VisibleViewMatcher.java diff --git a/app/src/androidTest/java/de/dotwee/micropinner/ClickWithoutDisplayConstraint.java b/app/src/androidTest/java/de/dotwee/micropinner/ClickWithoutDisplayConstraint.java new file mode 100644 index 0000000..1b3d13d --- /dev/null +++ b/app/src/androidTest/java/de/dotwee/micropinner/ClickWithoutDisplayConstraint.java @@ -0,0 +1,203 @@ +package de.dotwee.micropinner; + +import static de.dotwee.micropinner.VisibleViewMatcher.isVisible; +import static org.hamcrest.Matchers.allOf; + +import android.util.Log; +import android.view.View; +import android.view.ViewConfiguration; +import android.webkit.WebView; +import android.support.test.espresso.PerformException; +import android.support.test.espresso.UiController; +import android.support.test.espresso.ViewAction; +import android.support.test.espresso.action.CoordinatesProvider; +import android.support.test.espresso.action.PrecisionDescriber; +import android.support.test.espresso.action.Tap; +import android.support.test.espresso.action.Tapper; +import android.support.test.espresso.util.HumanReadables; +import java.util.Locale; +import java.util.Optional; + +import org.hamcrest.Matcher; + +/** + * Custom click action similar to the GeneralClickAction provided by Espresso. + * + * The only difference is that it does not force the target view to be displayed at least 90% on + * screen (i.e., 90% of the view in sight of the user). + * In this custom class, the only constraint is that the view needs to have "Visible" visibility and + * positive height and width. A typical example is when a long form has a visible view at the + * bottom, but the UI needs to be scrolled to reach it. + */ +public final class ClickWithoutDisplayConstraint implements ViewAction { + private static final String TAG = "ClickWithoutDisplayConstraint"; + + final CoordinatesProvider coordinatesProvider; + final Tapper tapper; + final PrecisionDescriber precisionDescriber; + private final Optional rollbackAction; + private final int inputDevice; + private final int buttonState; + + + @Deprecated + public ClickWithoutDisplayConstraint( + Tapper tapper, + CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber) { + this(tapper, coordinatesProvider, precisionDescriber, 0, 0, null); + } + + public ClickWithoutDisplayConstraint( + Tapper tapper, + CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber, + int inputDevice, + int buttonState) { + this(tapper, coordinatesProvider, precisionDescriber, inputDevice, buttonState, null); + } + + @Deprecated + public ClickWithoutDisplayConstraint( + Tapper tapper, + CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber, + ViewAction rollbackAction) { + this(tapper, coordinatesProvider, precisionDescriber, 0, 0, rollbackAction); + } + + public ClickWithoutDisplayConstraint( + Tapper tapper, + CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber, + int inputDevice, + int buttonState, + ViewAction rollbackAction) { + this.coordinatesProvider = coordinatesProvider; + this.tapper = tapper; + this.precisionDescriber = precisionDescriber; + this.inputDevice = inputDevice; + this.buttonState = buttonState; + this.rollbackAction = Optional.ofNullable(rollbackAction); + } + + @Override + @SuppressWarnings("unchecked") + public Matcher getConstraints() { + Matcher standardConstraint = isVisible(); + if (rollbackAction.isPresent()) { + return allOf(standardConstraint, rollbackAction.get().getConstraints()); + } else { + return standardConstraint; + } + } + + @Override + public void perform(UiController uiController, View view) { + float[] coordinates = coordinatesProvider.calculateCoordinates(view); + float[] precision = precisionDescriber.describePrecision(); + + Tapper.Status status = Tapper.Status.FAILURE; + int loopCount = 0; + // Native event injection is quite a tricky process. A tap is actually 2 + // seperate motion events which need to get injected into the system. Injection + // makes an RPC call from our app under test to the Android system server, the + // system server decides which window layer to deliver the event to, the system + // server makes an RPC to that window layer, that window layer delivers the event + // to the correct UI element, activity, or window object. Now we need to repeat + // that 2x. for a simple down and up. Oh and the down event triggers timers to + // detect whether or not the event is a long vs. short press. The timers are + // removed the moment the up event is received (NOTE: the possibility of eventTime + // being in the future is totally ignored by most motion event processors). + // + // Phew. + // + // The net result of this is sometimes we'll want to do a regular tap, and for + // whatever reason the up event (last half) of the tap is delivered after long + // press timeout (depending on system load) and the long press behaviour is + // displayed (EG: show a context menu). There is no way to avoid or handle this more + // gracefully. Also the longpress behavour is app/widget specific. So if you have + // a seperate long press behaviour from your short press, you can pass in a + // 'RollBack' ViewAction which when executed will undo the effects of long press. + + while (status != Tapper.Status.SUCCESS && loopCount < 3) { + try { + status = tapper.sendTap(uiController, coordinates, precision, inputDevice, buttonState); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d( + TAG, + "perform: " + + String.format( + Locale.ROOT, + "%s - At Coordinates: %d, %d and precision: %d, %d", + this.getDescription(), + (int) coordinates[0], + (int) coordinates[1], + (int) precision[0], + (int) precision[1])); + } + } catch (RuntimeException re) { + throw new PerformException.Builder() + .withActionDescription( + String.format( + Locale.ROOT, + "%s - At Coordinates: %d, %d and precision: %d, %d", + this.getDescription(), + (int) coordinates[0], + (int) coordinates[1], + (int) precision[0], + (int) precision[1])) + .withViewDescription(HumanReadables.describe(view)) + .withCause(re) + .build(); + } + + int duration = ViewConfiguration.getPressedStateDuration(); + // ensures that all work enqueued to process the tap has been run. + if (duration > 0) { + uiController.loopMainThreadForAtLeast(duration); + } + if (status == Tapper.Status.WARNING) { + if (rollbackAction.isPresent()) { + rollbackAction.get().perform(uiController, view); + } else { + break; + } + } + loopCount++; + } + if (status == Tapper.Status.FAILURE) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause( + new RuntimeException( + String.format( + Locale.ROOT, + "Couldn't click at: %s,%s precision: %s, %s . Tapper: %s coordinate" + + " provider: %s precision describer: %s. Tried %s times. With Rollback?" + + " %s", + coordinates[0], + coordinates[1], + precision[0], + precision[1], + tapper, + coordinatesProvider, + precisionDescriber, + loopCount, + rollbackAction.isPresent()))) + .build(); + } + + if (tapper == Tap.SINGLE && view instanceof WebView) { + // WebViews will not process click events until double tap + // timeout. Not the best place for this - but good for now. + uiController.loopMainThreadForAtLeast(ViewConfiguration.getDoubleTapTimeout()); + } + } + + @Override + public String getDescription() { + return tapper.toString().toLowerCase() + " click"; + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/dotwee/micropinner/IsEqualTrimmingAndIgnoringCase.java b/app/src/androidTest/java/de/dotwee/micropinner/IsEqualTrimmingAndIgnoringCase.java new file mode 100644 index 0000000..4fc6902 --- /dev/null +++ b/app/src/androidTest/java/de/dotwee/micropinner/IsEqualTrimmingAndIgnoringCase.java @@ -0,0 +1,52 @@ +package de.dotwee.micropinner; + +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; + +/** + * Custom BaseMatcher to match strings ignoring case as well as leading and trailing spaces + */ +public class IsEqualTrimmingAndIgnoringCase extends BaseMatcher { + + private final String string; + + public IsEqualTrimmingAndIgnoringCase(String string) { + if (string == null) { + throw new IllegalArgumentException("Non-null value required by IsEqualTrimmingAndIgnoringCase()"); + } + this.string = string; + } + + public boolean matchesSafely(String item) { + return string.trim().equalsIgnoreCase(item.trim()); + } + + private void describeMismatchSafely(String item, Description mismatchDescription) { + mismatchDescription.appendText("was ").appendText(item); + } + + @Override + public void describeTo(Description description) { + description.appendText("equalToTrimmingAndIgnoringCase(") + .appendValue(string) + .appendText(")"); + } + + public static IsEqualTrimmingAndIgnoringCase equalToTrimmingAndIgnoringCase(String string) { + return new IsEqualTrimmingAndIgnoringCase(string); + } + + @Override + public boolean matches(Object item) { + return item != null && matchesSafely(item.toString()); + } + + @Override + final public void describeMismatch(Object item, Description description) { + if (item == null) { + super.describeMismatch(item, description); + } else { + describeMismatchSafely(item.toString(), description); + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/dotwee/micropinner/MainDialogTest.java b/app/src/androidTest/java/de/dotwee/micropinner/MainDialogTest.java new file mode 100644 index 0000000..af85f34 --- /dev/null +++ b/app/src/androidTest/java/de/dotwee/micropinner/MainDialogTest.java @@ -0,0 +1,137 @@ +package de.dotwee.micropinner; + +import android.support.test.espresso.Espresso; +import android.support.test.espresso.ViewInteraction; +import android.support.test.espresso.action.GeneralLocation; +import android.support.test.espresso.action.Press; +import android.support.test.espresso.action.Tap; +import android.support.test.filters.LargeTest; +import android.support.test.rule.ActivityTestRule; +import android.support.test.runner.AndroidJUnit4; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.View; + +import org.hamcrest.Matcher; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import de.dotwee.micropinner.view.MainDialog; + +import static android.support.test.espresso.Espresso.onView; +import static android.support.test.espresso.action.ViewActions.replaceText; +import static android.support.test.espresso.matcher.ViewMatchers.hasDescendant; +import static android.support.test.espresso.matcher.ViewMatchers.isDescendantOfA; +import static android.support.test.espresso.matcher.ViewMatchers.withHint; +import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.matcher.ViewMatchers.withText; +import static de.dotwee.micropinner.IsEqualTrimmingAndIgnoringCase.equalToTrimmingAndIgnoringCase; +import static de.dotwee.micropinner.VisibleViewMatcher.isVisible; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; + +@LargeTest +@RunWith(AndroidJUnit4.class) +public class MainDialogTest { + + @Rule + public ActivityTestRule mActivityTestRule = + new ActivityTestRule<>(MainDialog.class); + + @Test + public void mainDialogTest() { + System.out.println("Starting run of ETGTestCaseForPR"); + ViewInteraction android_widget_Spinner = + onView( + allOf( + withId(R.id.spinnerVisibility), + isVisible(), + isDescendantOfA(withId(R.id.dialogContentView)))); + android_widget_Spinner.perform(getLongClickAction()); + + Espresso.pressBackUnconditionally(); + + ViewInteraction android_widget_EditText = + onView( + allOf( + withId(R.id.editTextContent), + withTextOrHint(equalToTrimmingAndIgnoringCase("Content")), + isVisible(), + isDescendantOfA(withId(R.id.dialogContentView)))); + android_widget_EditText.perform(replaceText("holoptychiid")); + + ViewInteraction android_widget_Switch = + onView( + allOf( + withId(R.id.switchAdvanced), + isVisible(), + isDescendantOfA( + allOf( + withId(R.id.linearLayoutHeader), + isDescendantOfA(withId(R.id.dialogHeaderView)))))); + android_widget_Switch.perform(getClickAction()); + + ViewInteraction android_widget_LinearLayout = + onView( + allOf( + withId(R.id.linearLayoutHeader), + isVisible(), + hasDescendant( + allOf( + withId(R.id.dialogTitle), + withTextOrHint(equalToTrimmingAndIgnoringCase("MicroPinner")))), + hasDescendant(withId(R.id.switchAdvanced)), + isDescendantOfA(withId(R.id.dialogHeaderView)))); + android_widget_LinearLayout.perform(getLongClickAction()); + + ViewInteraction android_widget_CheckBox = + onView( + allOf( + withId(R.id.checkBoxShowActions), + withTextOrHint(equalToTrimmingAndIgnoringCase("Show notification actions")), + isVisible(), + isDescendantOfA(withId(R.id.dialogContentView)))); + android_widget_CheckBox.perform(getClickAction()); + + ViewInteraction android_widget_EditText2 = + onView( + allOf( + withId(R.id.editTextTitle), + withTextOrHint(equalToTrimmingAndIgnoringCase("Title")), + isVisible(), + isDescendantOfA(withId(R.id.dialogContentView)))); + android_widget_EditText2.perform(replaceText("tetraspgia")); + + ViewInteraction android_widget_Button = + onView( + allOf( + withId(R.id.buttonPin), + withTextOrHint(equalToTrimmingAndIgnoringCase("PIN")), + isVisible(), + isDescendantOfA(withId(R.id.dialogFooterView)))); + android_widget_Button.perform(getClickAction()); + } + + private static Matcher withTextOrHint(final Matcher stringMatcher) { + return anyOf(withText(stringMatcher), withHint(stringMatcher)); + } + + private ClickWithoutDisplayConstraint getClickAction() { + return new ClickWithoutDisplayConstraint( + Tap.SINGLE, + GeneralLocation.VISIBLE_CENTER, + Press.FINGER, + InputDevice.SOURCE_UNKNOWN, + MotionEvent.BUTTON_PRIMARY); + } + + private ClickWithoutDisplayConstraint getLongClickAction() { + return new ClickWithoutDisplayConstraint( + Tap.LONG, + GeneralLocation.CENTER, + Press.FINGER, + InputDevice.SOURCE_UNKNOWN, + MotionEvent.BUTTON_PRIMARY); + } +} diff --git a/app/src/androidTest/java/de/dotwee/micropinner/VisibleViewMatcher.java b/app/src/androidTest/java/de/dotwee/micropinner/VisibleViewMatcher.java new file mode 100644 index 0000000..4af1934 --- /dev/null +++ b/app/src/androidTest/java/de/dotwee/micropinner/VisibleViewMatcher.java @@ -0,0 +1,40 @@ +package de.dotwee.micropinner; + +import android.view.View; + +import android.support.test.espresso.matcher.ViewMatchers.Visibility; + +import org.hamcrest.Description; +import org.hamcrest.TypeSafeMatcher; + +import static android.support.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; + +/** + * Custom ViewMatcher to match a view that has a "Visible" state but that is not necessarily + * displayed to the user. + * + * Specifically, it matches with views that have "Visible" visibility and positive height and width. + * A typical example is when a long form has a visible view at the bottom, but the UI needs to be + * scrolled to reach it. + */ +public final class VisibleViewMatcher extends TypeSafeMatcher { + + public VisibleViewMatcher() { + super(View.class); + } + + public static VisibleViewMatcher isVisible(){ + return new VisibleViewMatcher(); + } + + @Override + protected boolean matchesSafely(View target) { + return withEffectiveVisibility(Visibility.VISIBLE).matches(target) && + target.getWidth() > 0 && target.getHeight() > 0; + } + + @Override + public void describeTo(Description description) { + description.appendText("view has effective visibility VISIBLE and has width and height greater than zero"); + } +} \ No newline at end of file