chromium/base/test/android/javatests/src/org/chromium/base/test/transit/ViewConditions.java

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.base.test.transit;

import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;

import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.any;

import android.view.View;

import androidx.test.espresso.AmbiguousViewMatcherException;
import androidx.test.espresso.NoMatchingRootException;
import androidx.test.espresso.NoMatchingViewException;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.ViewInteraction;

import org.hamcrest.Matcher;
import org.hamcrest.StringDescription;

import org.chromium.base.ApplicationStatus;
import org.chromium.base.test.util.RawFailureHandler;
import org.chromium.base.test.util.ViewPrinter;

import java.util.ArrayList;

/** {@link Condition}s related to Android {@link View}s. */
public class ViewConditions {

    private static final ViewPrinter.Options PRINT_SHALLOW_WITH_BOUNDS =
            new ViewPrinter.Options()
                    .setPrintChildren(false)
                    .setPrintNonVisibleViews(true)
                    .setPrintViewBounds(true);
    private static final ViewPrinter.Options PRINT_SHALLOW =
            new ViewPrinter.Options().setPrintChildren(false).setPrintNonVisibleViews(true);

    /** Fulfilled when a single matching View exists and is displayed. */
    public static class DisplayedCondition extends ConditionWithResult<View> {
        private final Matcher<View> mMatcher;
        private final Options mOptions;
        private View mViewMatched;

        public DisplayedCondition(Matcher<View> matcher, Options options) {
            super(/* isRunOnUiThread= */ false);
            mMatcher = matcher /*, withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)*/;
            mOptions = options;
        }

        @Override
        public String buildDescription() {
            StringBuilder description = new StringBuilder();
            description
                    .append("View: ")
                    .append(StringDescription.toString(mMatcher))
                    .append(" (>= ")
                    .append(mOptions.mDisplayedPercentageRequired)
                    .append("% displayed, ")
                    .append(mOptions.mExpectEnabled ? "enabled" : "disabled")
                    .append(")");
            return description.toString();
        }

        @Override
        protected ConditionStatusWithResult<View> resolveWithSuppliers() {
            if (!ApplicationStatus.hasVisibleActivities()) {
                return awaiting("No visible activities").withoutResult();
            }

            ViewInteraction viewInteraction =
                    onView(mMatcher).withFailureHandler(RawFailureHandler.getInstance());
            ArrayList<String> messages = new ArrayList<>();
            try {
                viewInteraction.perform(
                        new ViewAction() {
                            @Override
                            public Matcher<View> getConstraints() {
                                return any(View.class);
                            }

                            @Override
                            public String getDescription() {
                                return "check existence, visibility and displayed percentage";
                            }

                            @Override
                            public void perform(UiController uiController, View view) {
                                mViewMatched = view;
                            }
                        });
            } catch (NoMatchingViewException
                    | NoMatchingRootException
                    | AmbiguousViewMatcherException e) {
                return notFulfilled(e.getClass().getSimpleName()).withoutResult();
            }

            // Assume found a View, or NoMatchingViewException would be thrown.
            boolean fulfilled = true;
            messages.add(ViewPrinter.describeView(mViewMatched, PRINT_SHALLOW_WITH_BOUNDS));

            View view = mViewMatched;
            int visibility = view.getVisibility();
            if (visibility != View.VISIBLE) {
                fulfilled = false;
                messages.add(String.format("visibility = %s", visibilityIntToString(visibility)));
            } else {
                while (view.getParent() instanceof View) {
                    view = (View) view.getParent();
                    visibility = view.getVisibility();
                    if (visibility != View.VISIBLE) {
                        fulfilled = false;
                        messages.add(
                                String.format(
                                        "visibility of ancestor [%s] = %s",
                                        ViewPrinter.describeView(view, PRINT_SHALLOW),
                                        visibilityIntToString(visibility)));
                        break;
                    }
                }
            }

            // Since perform() above did not throw an Exception, mViewMatched is non-null.
            if (mOptions.mDisplayedPercentageRequired > 0) {
                DisplayedPortion portion = DisplayedPortion.ofView(mViewMatched);
                if (portion.mPercentage < mOptions.mDisplayedPercentageRequired) {
                    fulfilled = false;
                    messages.add(
                            String.format(
                                    "%d%% displayed, expected >= %d%%",
                                    portion.mPercentage, mOptions.mDisplayedPercentageRequired));
                    messages.add("% displayed calculation: " + portion);
                } else {
                    messages.add(String.format("%d%% displayed", portion.mPercentage));
                }
            }
            if (mOptions.mExpectEnabled) {
                if (!mViewMatched.isEnabled()) {
                    fulfilled = false;
                    messages.add("disabled");
                }
            } else { // Expected a displayed but disabled View.
                if (mViewMatched.isEnabled()) {
                    fulfilled = false;
                    messages.add("enabled");
                }
            }

            String message = String.join("; ", messages);
            if (fulfilled) {
                return fulfilled(message).withResult(mViewMatched);
            } else {
                return notFulfilled(message).withoutResult();
            }
        }

        private static String visibilityIntToString(int visibility) {
            return switch (visibility) {
                case View.VISIBLE -> "VISIBLE";
                case View.INVISIBLE -> "INVISIBLE";
                case View.GONE -> "GONE";
                default -> "invalid";
            };
        }

        /**
         * @return an Options builder to customize the ViewCondition.
         */
        public static Options.Builder newOptions() {
            return new Options().new Builder();
        }

        /** Extra options for declaring DisplayedCondition. */
        public static class Options {
            boolean mExpectEnabled = true;
            int mDisplayedPercentageRequired = ViewElement.MIN_DISPLAYED_PERCENT;

            private Options() {}

            public class Builder {
                public Options build() {
                    return Options.this;
                }

                /** Whether the View is expected to be enabled or disabled. */
                public Builder withExpectEnabled(boolean state) {
                    mExpectEnabled = state;
                    return this;
                }

                /** Minimum percentage of the View that needs to be displayed. */
                public Builder withDisplayingAtLeast(int displayedPercentageRequired) {
                    mDisplayedPercentageRequired = displayedPercentageRequired;
                    return this;
                }
            }
        }
    }

    /** Fulfilled when no matching Views exist and are displayed. */
    public static class NotDisplayedAnymoreCondition extends InstrumentationThreadCondition {
        private final Matcher<View> mMatcher;

        private static final String VERBOSE_DESCRIPTION =
                "(view has effective visibility <VISIBLE> and view.getGlobalVisibleRect() to return"
                        + " non-empty rectangle)";
        private static final String SUCCINCT_DESCRIPTION = "isDisplayed()";

        public NotDisplayedAnymoreCondition(Matcher<View> matcher) {
            super();
            mMatcher = allOf(matcher, isDisplayed());
        }

        @Override
        public String buildDescription() {
            return "No more view: "
                    + StringDescription.toString(mMatcher)
                            .replace(VERBOSE_DESCRIPTION, SUCCINCT_DESCRIPTION);
        }

        @Override
        protected ConditionStatus checkWithSuppliers() {
            if (!ApplicationStatus.hasVisibleActivities()) {
                return fulfilled("No visible activities");
            }

            try {
                onView(mMatcher)
                        .withFailureHandler(RawFailureHandler.getInstance())
                        .check(doesNotExist());
                return fulfilled();
            } catch (AssertionError e) {
                return notFulfilled();
            }
        }
    }
}