diff --git a/myExpenses/src/androidTest/java/org/totschnig/myexpenses/test/espresso/SplashActivityTest.java b/myExpenses/src/androidTest/java/org/totschnig/myexpenses/test/espresso/SplashActivityTest.java new file mode 100644 index 0000000000..543054f09d --- /dev/null +++ b/myExpenses/src/androidTest/java/org/totschnig/myexpenses/test/espresso/SplashActivityTest.java @@ -0,0 +1,502 @@ +package org.totschnig.myexpenses.test.espresso; + +import android.os.SystemClock; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.View; + +import androidx.test.espresso.Espresso; +import androidx.test.espresso.ViewAction; +import androidx.test.espresso.ViewInteraction; +import androidx.test.espresso.action.CoordinatesProvider; +import androidx.test.espresso.action.GeneralLocation; +import androidx.test.espresso.action.GeneralSwipeAction; +import androidx.test.espresso.action.Press; +import androidx.test.espresso.action.Swipe; +import androidx.test.espresso.action.Tap; +import androidx.test.espresso.action.ViewActions; +import androidx.test.espresso.matcher.ViewMatchers; +import androidx.test.filters.LargeTest; +import androidx.test.rule.ActivityTestRule; +import androidx.test.runner.AndroidJUnit4; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.hamcrest.TypeSafeMatcher; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.totschnig.myexpenses.R; +import org.totschnig.myexpenses.activity.SplashActivity; +import org.totschnig.myexpenses.testutils.ClickWithPartialDisplayConstraint; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; +import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; +import static androidx.test.espresso.matcher.ViewMatchers.withHint; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.is; +import static org.totschnig.myexpenses.testutils.IsEqualTrimmingAndIgnoringCase.equalToTrimmingAndIgnoringCase; + +@LargeTest +@RunWith(AndroidJUnit4.class) +public class SplashActivityTest { + + @Rule + public ActivityTestRule mActivityTestRule = + new ActivityTestRule<>(SplashActivity.class); + + @Test + public void splashActivityTest() { + ViewInteraction root = onView(isRoot()); + root.perform(getSwipeAction(540, 928, 540, 628)); + + waitToScrollEnd(); + + ViewInteraction root2 = onView(isRoot()); + root2.perform(getSwipeAction(540, 897, 540, 0)); + + waitToScrollEnd(); + + ViewInteraction root3 = onView(isRoot()); + root3.perform(getSwipeAction(540, 928, 840, 928)); + + waitToScrollEnd(); + + ViewInteraction root4 = onView(isRoot()); + root4.perform(getSwipeAction(540, 928, 840, 928)); + + waitToScrollEnd(); + + ViewInteraction android_widget_Button = + onView( + Matchers.allOf( + ViewMatchers.withId(R.id.suw_navbar_next), + withTextOrHint(equalToTrimmingAndIgnoringCase("NEXT")), + isDisplayed(), + isDescendantOfA( + allOf( + withId(R.id.suw_layout_navigation_bar), + isDescendantOfA( + allOf( + withId(R.id.setup_wizard_layout), + isDescendantOfA(withId(R.id.viewpager)))))))); + android_widget_Button.perform(getClickAction()); + + ViewInteraction root5 = onView(isRoot()); + root5.perform(getSwipeAction(540, 928, 540, 628)); + + waitToScrollEnd(); + + ViewInteraction android_widget_Button2 = + onView( + allOf( + withId(R.id.suw_navbar_next), + withTextOrHint(equalToTrimmingAndIgnoringCase("NEXT")), + isDisplayed(), + isDescendantOfA( + allOf( + withId(R.id.suw_layout_navigation_bar), + isDescendantOfA( + allOf( + withId(R.id.setup_wizard_layout), + isDescendantOfA(withId(R.id.viewpager)))))))); + android_widget_Button2.perform(getClickAction()); + + ViewInteraction android_widget_EditText = + onView( + allOf( + withId(R.id.AmountEditText), + withTextOrHint(equalToTrimmingAndIgnoringCase("0")), + isDisplayed(), + isDescendantOfA( + allOf( + withId(R.id.Amount), + isDescendantOfA( + allOf( + withId(R.id.suw_layout_content), + isDescendantOfA( + allOf( + withId(R.id.suw_bottom_scroll_view), + isDescendantOfA( + allOf( + withId(R.id.setup_wizard_layout), + isDescendantOfA(withId(R.id.viewpager)))))))))))); + android_widget_EditText.perform(replaceText("075380603176906")); + + ViewInteraction android_widget_EditText2 = + onView( + allOf( + withId(R.id.Label), + withTextOrHint(equalToTrimmingAndIgnoringCase("Budget Book")), + isDisplayed(), + isDescendantOfA( + allOf( + withId(R.id.suw_layout_content), + isDescendantOfA( + allOf( + withId(R.id.suw_bottom_scroll_view), + isDescendantOfA( + allOf( + withId(R.id.setup_wizard_layout), + isDescendantOfA(withId(R.id.viewpager)))))))))); + android_widget_EditText2.perform(replaceText("veinings")); + + ViewInteraction root6 = onView(isRoot()); + root6.perform(getSwipeAction(540, 897, 540, 1794)); + + waitToScrollEnd(); + + ViewInteraction android_widget_Spinner = + onView( + allOf( + withId(R.id.Currency), + isDisplayed(), + isDescendantOfA( + allOf( + withId(R.id.suw_layout_content), + isDescendantOfA( + allOf( + withId(R.id.suw_bottom_scroll_view), + isDescendantOfA( + allOf( + withId(R.id.setup_wizard_layout), + isDescendantOfA(withId(R.id.viewpager)))))))))); + android_widget_Spinner.perform(getClickAction()); + + Espresso.pressBackUnconditionally(); + + ViewInteraction android_widget_TextView = + onView( + allOf( + withId(R.id.SetupMain), + isDisplayed(), + isDescendantOfA( + allOf( + withId(R.id.onboarding_menu), + isDescendantOfA( + allOf( + withId(R.id.suw_layout_navigation_bar), + isDescendantOfA( + allOf( + withId(R.id.setup_wizard_layout), + isDescendantOfA(withId(R.id.viewpager)))))))))); + android_widget_TextView.perform(getClickAction()); + + Espresso.pressBackUnconditionally(); + + ViewInteraction android_widget_Button3 = + onView( + allOf( + withId(R.id.suw_navbar_done), + withTextOrHint(equalToTrimmingAndIgnoringCase("GET STARTED")), + isDisplayed(), + isDescendantOfA( + allOf( + withId(R.id.suw_layout_navigation_bar), + isDescendantOfA( + allOf( + withId(R.id.setup_wizard_layout), + isDescendantOfA(withId(R.id.viewpager)))))))); + android_widget_Button3.perform(getClickAction()); + + ViewInteraction android_widget_ImageButton = + onView( + allOf( + withId(R.id.CREATE_COMMAND), + isDisplayed(), + isDescendantOfA( + allOf( + withId(R.id.fragment_container), + isDescendantOfA(withId(R.id.drawer_layout)))))); + android_widget_ImageButton.perform(getClickAction()); + + ViewInteraction android_widget_TextView2 = + onView( + allOf( + withId(R.id.SAVE_AND_NEW_COMMAND), + isDisplayed(), + isDescendantOfA( + allOf(withId(R.id.toolbar), isDescendantOfA(withId(R.id.edit_container)))))); + android_widget_TextView2.perform(getClickAction()); + + ViewInteraction root7 = onView(isRoot()); + root7.perform(getSwipeAction(540, 897, 540, 0)); + + waitToScrollEnd(); + + ViewInteraction android_view_ViewGroup = + onView( + allOf( + withId(R.id.Amount), + isDisplayed(), + hasDescendant(withId(R.id.TaType)), + hasDescendant(withId(R.id.AmountEditText)), + hasDescendant(withId(R.id.Calculator)), + isDescendantOfA( + allOf( + withId(R.id.AmountRow), + isDescendantOfA( + allOf( + withId(R.id.Table), + isDescendantOfA(withId(R.id.edit_container)))))))); + android_view_ViewGroup.perform(getClickAction()); + + ViewInteraction android_widget_ImageView = + onView( + allOf( + withId(R.id.SelectTag), + isDisplayed(), + isDescendantOfA( + allOf( + withId(R.id.TagRow), + isDescendantOfA( + allOf( + withId(R.id.Table), + isDescendantOfA(withId(R.id.edit_container)))))))); + android_widget_ImageView.perform(getClickAction()); + + ViewInteraction android_widget_ImageButton2 = + onView( + allOf( + withContentDescription(equalToTrimmingAndIgnoringCase("Navigate up")), + isDisplayed(), + isDescendantOfA(withId(R.id.toolbar)))); + android_widget_ImageButton2.perform(getClickAction()); + + ViewInteraction android_widget_EditText3 = + onView( + allOf( + withId(R.id.AmountEditText), + isDisplayed(), + isDescendantOfA( + allOf( + withId(R.id.Amount), + isDescendantOfA( + allOf( + withId(R.id.AmountRow), + isDescendantOfA( + allOf( + withId(R.id.Table), + isDescendantOfA(withId(R.id.edit_container)))))))))); + android_widget_EditText3.perform(replaceText("315961250803207")); + + ViewInteraction android_widget_Spinner2 = + onView( + allOf( + withId(R.id.OperationType), + isDisplayed(), + isDescendantOfA( + allOf(withId(R.id.toolbar), isDescendantOfA(withId(R.id.edit_container)))))); + android_widget_Spinner2.perform(getLongClickAction()); + + Espresso.pressBackUnconditionally(); + + ViewInteraction android_widget_Spinner3 = + onView( + allOf( + withId(R.id.Account), + isDisplayed(), + isDescendantOfA( + allOf( + withId(R.id.AccountRow), + isDescendantOfA( + allOf( + withId(R.id.Table), + isDescendantOfA(withId(R.id.edit_container)))))))); + android_widget_Spinner3.perform(getLongClickAction()); + + Espresso.pressBackUnconditionally(); + + ViewInteraction android_widget_TextView3 = + onView( + allOf( + withId(R.id.SAVE_COMMAND), + isDisplayed(), + isDescendantOfA( + allOf(withId(R.id.toolbar), isDescendantOfA(withId(R.id.edit_container)))))); + android_widget_TextView3.perform(getClickAction()); + + ViewInteraction root8 = onView(isRoot()); + root8.perform(getSwipeAction(540, 897, 540, 1794)); + + waitToScrollEnd(); + + ViewInteraction android_widget_ImageView2 = + onView( + allOf( + withContentDescription(equalToTrimmingAndIgnoringCase("More options")), + isDisplayed(), + isDescendantOfA( + allOf(withId(R.id.toolbar), isDescendantOfA(withId(R.id.drawer_layout)))))); + android_widget_ImageView2.perform(getClickAction()); + + ViewInteraction android_widget_LinearLayout = + onView( + allOf( + classOrSuperClassesName(is("android.widget.LinearLayout")), + isDisplayed(), + hasDescendant( + allOf( + withId(R.id.content), + hasDescendant( + allOf( + withId(R.id.title), + withTextOrHint(equalToTrimmingAndIgnoringCase("Grouping")))), + hasDescendant(withId(R.id.submenuarrow)))))); + android_widget_LinearLayout.perform(getClickAction()); + + ViewInteraction android_widget_LinearLayout2 = + onView( + allOf( + classOrSuperClassesName(is("android.widget.LinearLayout")), + isDisplayed(), + hasDescendant( + allOf( + withId(R.id.content), + hasDescendant( + allOf( + withId(R.id.title), + withTextOrHint(equalToTrimmingAndIgnoringCase("Month")))), + hasDescendant(withId(R.id.radio)))))); + android_widget_LinearLayout2.perform(getClickAction()); + + ViewInteraction android_widget_ImageView3 = + onView( + allOf( + withContentDescription(equalToTrimmingAndIgnoringCase("More options")), + isDisplayed(), + isDescendantOfA( + allOf(withId(R.id.toolbar), isDescendantOfA(withId(R.id.drawer_layout)))))); + android_widget_ImageView3.perform(getClickAction()); + + ViewInteraction android_widget_LinearLayout3 = + onView( + allOf( + classOrSuperClassesName(is("android.widget.LinearLayout")), + isDisplayed(), + hasDescendant( + allOf( + withId(R.id.content), + hasDescendant( + allOf( + withId(R.id.title), + withTextOrHint(equalToTrimmingAndIgnoringCase("Budgeting")))))))); + android_widget_LinearLayout3.perform(getClickAction()); + + ViewInteraction android_widget_RadioButton = + onView( + allOf( + withId(R.id.package_button), + isDisplayed(), + isDescendantOfA( + allOf( + withId(R.id.professional_feature_container), + isDescendantOfA( + allOf( + withId(R.id.feature_list), + isDescendantOfA( + allOf( + withId(R.id.aboutscrollview), + isDescendantOfA( + allOf( + withId(R.id.custom), + isDescendantOfA( + allOf( + withId(R.id.customPanel), + isDescendantOfA( + withId(R.id.parentPanel)))))))))))))); + android_widget_RadioButton.perform(getClickAction()); + } + + private static Matcher classOrSuperClassesName(final Matcher classNameMatcher) { + + return new TypeSafeMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("Class name or any super class name "); + classNameMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + Class clazz = view.getClass(); + String canonicalName; + + do { + canonicalName = clazz.getCanonicalName(); + if (canonicalName == null) { + return false; + } + + if (classNameMatcher.matches(canonicalName)) { + return true; + } + + clazz = clazz.getSuperclass(); + if (clazz == null) { + return false; + } + } while (!"java.lang.Object".equals(canonicalName)); + + return false; + } + }; + } + + private static Matcher withTextOrHint(final Matcher stringMatcher) { + return anyOf(withText(stringMatcher), withHint(stringMatcher)); + } + + private ViewAction getSwipeAction( + final int fromX, final int fromY, final int toX, final int toY) { + return ViewActions.actionWithAssertions( + new GeneralSwipeAction( + Swipe.SLOW, + new CoordinatesProvider() { + @Override + public float[] calculateCoordinates(View view) { + float[] coordinates = {fromX, fromY}; + return coordinates; + } + }, + new CoordinatesProvider() { + @Override + public float[] calculateCoordinates(View view) { + float[] coordinates = {toX, toY}; + return coordinates; + } + }, + Press.FINGER)); + } + + private void waitToScrollEnd() { + SystemClock.sleep(500); + } + + private ClickWithPartialDisplayConstraint getClickAction() { + return new ClickWithPartialDisplayConstraint( + Tap.SINGLE, + GeneralLocation.VISIBLE_CENTER, + Press.FINGER, + InputDevice.SOURCE_UNKNOWN, + MotionEvent.BUTTON_PRIMARY); + } + + private ClickWithPartialDisplayConstraint getLongClickAction() { + return new ClickWithPartialDisplayConstraint( + Tap.LONG, + GeneralLocation.CENTER, + Press.FINGER, + InputDevice.SOURCE_UNKNOWN, + MotionEvent.BUTTON_PRIMARY); + } +} diff --git a/myExpenses/src/androidTest/java/org/totschnig/myexpenses/testutils/ClickWithPartialDisplayConstraint.java b/myExpenses/src/androidTest/java/org/totschnig/myexpenses/testutils/ClickWithPartialDisplayConstraint.java new file mode 100644 index 0000000000..15f4483cfb --- /dev/null +++ b/myExpenses/src/androidTest/java/org/totschnig/myexpenses/testutils/ClickWithPartialDisplayConstraint.java @@ -0,0 +1,204 @@ +package org.totschnig.myexpenses.testutils; + +import android.util.Log; +import android.view.View; +import android.view.ViewConfiguration; +import android.webkit.WebView; + +import androidx.test.espresso.PerformException; +import androidx.test.espresso.UiController; +import androidx.test.espresso.ViewAction; +import androidx.test.espresso.action.CoordinatesProvider; +import androidx.test.espresso.action.PrecisionDescriber; +import androidx.test.espresso.action.Tap; +import androidx.test.espresso.action.Tapper; +import androidx.test.espresso.util.HumanReadables; + +import org.hamcrest.Matcher; + +import java.util.Locale; +import java.util.Optional; + +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static org.hamcrest.Matchers.allOf; + +/** + * 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 target view needs only to be displayed with a percentage greater than + * zero. + */ +public final class ClickWithPartialDisplayConstraint 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 ClickWithPartialDisplayConstraint( + Tapper tapper, + CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber) { + this(tapper, coordinatesProvider, precisionDescriber, 0, 0, null); + } + + public ClickWithPartialDisplayConstraint( + Tapper tapper, + CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber, + int inputDevice, + int buttonState) { + this(tapper, coordinatesProvider, precisionDescriber, inputDevice, buttonState, null); + } + + @Deprecated + public ClickWithPartialDisplayConstraint( + Tapper tapper, + CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber, + ViewAction rollbackAction) { + this(tapper, coordinatesProvider, precisionDescriber, 0, 0, rollbackAction); + } + + public ClickWithPartialDisplayConstraint( + 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 = isDisplayed(); + 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/myExpenses/src/androidTest/java/org/totschnig/myexpenses/testutils/IsEqualTrimmingAndIgnoringCase.java b/myExpenses/src/androidTest/java/org/totschnig/myexpenses/testutils/IsEqualTrimmingAndIgnoringCase.java new file mode 100644 index 0000000000..8fce07d53d --- /dev/null +++ b/myExpenses/src/androidTest/java/org/totschnig/myexpenses/testutils/IsEqualTrimmingAndIgnoringCase.java @@ -0,0 +1,52 @@ +package org.totschnig.myexpenses.testutils; + +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