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

// Copyright 2024 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.onData;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed;

import static org.chromium.base.test.transit.ViewSpec.viewSpec;

import android.view.View;

import androidx.annotation.CallSuper;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.test.espresso.NoMatchingViewException;
import androidx.test.espresso.PerformException;
import androidx.test.espresso.action.ViewActions;

import org.hamcrest.Matcher;

import org.chromium.base.test.transit.ScrollableFacility.Item.Presence;
import org.chromium.base.test.util.RawFailureHandler;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.function.Function;

/**
 * Represents a facility that contains items which may or may not be visible due to scrolling.
 *
 * @param <HostStationT> the type of host {@link Station} this is scoped to.
 */
public abstract class ScrollableFacility<HostStationT extends Station>
        extends Facility<HostStationT> {

    private ArrayList<Item<?>> mItems;

    /** Must populate |items| with the expected items. */
    protected abstract void declareItems(ItemsBuilder items);

    /** Returns the minimum number of items declared expected to be displayed screen initially. */
    protected abstract int getMinimumOnScreenItemCount();

    @CallSuper
    @Override
    public void declareElements(Elements.Builder elements) {
        mItems = new ArrayList<>();
        declareItems(new ItemsBuilder());

        int i = 0;
        int itemsToExpect = getMinimumOnScreenItemCount();
        for (Item<?> item : mItems) {
            // Expect only the first |itemsToExpect| items because of scrolling.
            // Items that should be absent should be checked regardless of position.
            if (item.getPresence() == Presence.ABSENT || i < itemsToExpect) {
                switch (item.mPresence) {
                    case Presence.ABSENT:
                        elements.declareNoView(item.mOnScreenViewMatcher);
                        break;
                    case Presence.PRESENT_AND_ENABLED:
                    case Presence.PRESENT_AND_DISABLED:
                        elements.declareView(item.mViewSpec, item.mViewElementOptions);
                        break;
                    case Presence.MAYBE_PRESENT:
                    case Presence.MAYBE_PRESENT_STUB:
                        // No ViewElements are declared.
                        break;
                }
            }

            i++;
        }
    }

    /**
     * Subclasses' {@link #declareItems(ItemsBuilder)} should declare items through ItemsBuilder.
     */
    public class ItemsBuilder {
        /** Create a new item stub which throws UnsupportedOperationException if selected. */
        public Item<Void> declareStubItem(
                Matcher<View> onScreenViewMatcher, @Nullable Matcher<?> offScreenDataMatcher) {
            Item<Void> item =
                    new Item<>(
                            onScreenViewMatcher,
                            offScreenDataMatcher,
                            Presence.PRESENT_AND_ENABLED,
                            ItemsBuilder::unsupported);
            mItems.add(item);
            return item;
        }

        /** Create a new item which runs |selectHandler| when selected. */
        public <SelectReturnT> Item<SelectReturnT> declareItem(
                Matcher<View> onScreenViewMatcher,
                @Nullable Matcher<?> offScreenDataMatcher,
                Function<ItemOnScreenFacility<SelectReturnT>, SelectReturnT> selectHandler) {
            Item<SelectReturnT> item =
                    new Item<>(
                            onScreenViewMatcher,
                            offScreenDataMatcher,
                            Presence.PRESENT_AND_ENABLED,
                            selectHandler);
            mItems.add(item);
            return item;
        }

        /** Create a new item which transitions to a |DestinationStationT| when selected. */
        public <DestinationStationT extends Station> Item<DestinationStationT> declareItemToStation(
                Matcher<View> onScreenViewMatcher,
                @Nullable Matcher<?> offScreenDataMatcher,
                Callable<DestinationStationT> destinationStationFactory) {
            var item =
                    new Item<DestinationStationT>(
                            onScreenViewMatcher,
                            offScreenDataMatcher,
                            Presence.PRESENT_AND_ENABLED,
                            /* selectHandler= */ null);
            item.setSelectHandler(
                    (itemOnScreenFacility) ->
                            travelToStation(item, itemOnScreenFacility, destinationStationFactory));
            mItems.add(item);
            return item;
        }

        /** Create a new item which enters a |EnteredFacilityT| when selected. */
        public <EnteredFacilityT extends Facility<HostStationT>>
                Item<EnteredFacilityT> declareItemToFacility(
                        Matcher<View> onScreenViewMatcher,
                        @Nullable Matcher<?> offScreenDataMatcher,
                        Callable<EnteredFacilityT> destinationFacilityFactory) {
            final var item =
                    new Item<EnteredFacilityT>(
                            onScreenViewMatcher,
                            offScreenDataMatcher,
                            Presence.PRESENT_AND_ENABLED,
                            /* selectHandler= */ null);
            item.setSelectHandler(
                    (itemOnScreenFacility) ->
                            enterFacility(item, itemOnScreenFacility, destinationFacilityFactory));
            mItems.add(item);
            return item;
        }

        /** Create a new disabled item. */
        public Item<Void> declareDisabledItem(
                Matcher<View> onScreenViewMatcher, @Nullable Matcher<?> offScreenDataMatcher) {
            Item<Void> item =
                    new Item<>(
                            onScreenViewMatcher,
                            offScreenDataMatcher,
                            Presence.PRESENT_AND_DISABLED,
                            null);
            mItems.add(item);
            return item;
        }

        /** Create a new item expected to be absent. */
        public Item<Void> declareAbsentItem(
                Matcher<View> onScreenViewMatcher, @Nullable Matcher<?> offScreenDataMatcher) {
            Item<Void> item =
                    new Item<>(onScreenViewMatcher, offScreenDataMatcher, Presence.ABSENT, null);
            mItems.add(item);
            return item;
        }

        /** Create a new item which may or may not be present. */
        public <SelectReturnT> Item<SelectReturnT> declarePossibleItem(
                Matcher<View> onScreenViewMatcher,
                @Nullable Matcher<?> offScreenDataMatcher,
                Function<ItemOnScreenFacility<SelectReturnT>, SelectReturnT> selectHandler) {
            Item<SelectReturnT> item =
                    new Item<>(
                            onScreenViewMatcher,
                            offScreenDataMatcher,
                            Presence.MAYBE_PRESENT,
                            selectHandler);
            mItems.add(item);
            return item;
        }

        /** Create a new item stub which may or may not be present. */
        public <SelectReturnT> Item<SelectReturnT> declarePossibleStubItem() {
            Item<SelectReturnT> item =
                    new Item<>(
                            /* onScreenViewMatcher= */ null,
                            /* offScreenDataMatcher= */ null,
                            Presence.MAYBE_PRESENT_STUB,
                            /* selectHandler= */ null);
            mItems.add(item);
            return item;
        }

        private static <HostStationT extends Station> Void unsupported(
                ScrollableFacility<HostStationT>.ItemOnScreenFacility<Void> itemOnScreen) {
            // Selected an item created with newStubItem().
            // Use newItemToStation(), newItemToFacility() or newItem() to declare expected behavior
            // when this item is selected.
            throw new UnsupportedOperationException(
                    "This item is a stub and has not been bound to a select handler.");
        }
    }

    /**
     * Represents an item in a specific {@link ScrollableFacility}.
     *
     * <p>{@link ScrollableFacility} subclasses should use these to represent their items.
     *
     * @param <SelectReturnT> the return type of the |selectHandler|.
     */
    public class Item<SelectReturnT> {

        /** Whether the item is expected to be present and enabled. */
        @IntDef({
            Presence.ABSENT,
            Presence.PRESENT_AND_ENABLED,
            Presence.PRESENT_AND_DISABLED,
            Presence.MAYBE_PRESENT,
            Presence.MAYBE_PRESENT_STUB,
        })
        @Retention(RetentionPolicy.SOURCE)
        public @interface Presence {
            // Item must not be present.
            int ABSENT = 0;

            // Item must be present and enabled.
            int PRESENT_AND_ENABLED = 1;

            // Item must be present and disabled.
            int PRESENT_AND_DISABLED = 2;

            // No expectations on item being present or enabled.
            int MAYBE_PRESENT = 3;

            // No expectations on item being present or enabled, and select trigger is not
            // implemented. Some optimizations can be made.
            int MAYBE_PRESENT_STUB = 4;
        }

        protected final @Nullable Matcher<View> mOnScreenViewMatcher;
        protected final @Nullable Matcher<?> mOffScreenDataMatcher;
        protected final @Presence int mPresence;
        protected final @Nullable ViewSpec mViewSpec;
        protected final @Nullable ViewElement.Options mViewElementOptions;
        protected @Nullable Function<ItemOnScreenFacility<SelectReturnT>, SelectReturnT>
                mSelectHandler;

        /**
         * Use one of {@link ScrollableFacility.ItemsBuilder}'s methods to instantiate:
         *
         * <ul>
         *   <li>{@link ItemsBuilder#declareItem(Matcher, Matcher, Function)}
         *   <li>{@link ItemsBuilder#declareItemToFacility(Matcher, Matcher, Callable)}
         *   <li>{@link ItemsBuilder#declareItemToStation(Matcher, Matcher, Callable)}
         *   <li>{@link ItemsBuilder#declareDisabledItem(Matcher, Matcher)}
         *   <li>{@link ItemsBuilder#declareAbsentItem(Matcher, Matcher)}
         *   <li>{@link ItemsBuilder#declareStubItem(Matcher, Matcher)}
         *   <li>{@link ItemsBuilder#declarePossibleItem(Matcher, Matcher, Function)}
         *   <li>{@link ItemsBuilder#declarePossibleStubItem()}
         * </ul>
         */
        protected Item(
                @Nullable Matcher<View> onScreenViewMatcher,
                @Nullable Matcher<?> offScreenDataMatcher,
                @Presence int presence,
                @Nullable
                        Function<ItemOnScreenFacility<SelectReturnT>, SelectReturnT>
                                selectHandler) {
            mPresence = presence;
            mOnScreenViewMatcher = onScreenViewMatcher;
            mOffScreenDataMatcher = offScreenDataMatcher;
            mSelectHandler = selectHandler;

            switch (mPresence) {
                case Presence.ABSENT, Presence.MAYBE_PRESENT_STUB:
                    mViewSpec = null;
                    mViewElementOptions = null;
                    break;
                case Presence.PRESENT_AND_ENABLED:
                case Presence.MAYBE_PRESENT:
                    mViewSpec = viewSpec(mOnScreenViewMatcher);
                    mViewElementOptions = ViewElement.Options.DEFAULT;
                    break;
                case Presence.PRESENT_AND_DISABLED:
                    mViewSpec = viewSpec(mOnScreenViewMatcher);
                    mViewElementOptions = ViewElement.expectDisabledOption();
                    break;
                default:
                    mViewSpec = null;
                    mViewElementOptions = null;
                    assert false;
            }
        }

        /**
         * Select the item, scrolling to it if necessary.
         *
         * @return the return value of the |selectHandler|. e.g. a Facility or Station.
         */
        public SelectReturnT scrollToAndSelect() {
            return scrollTo().select();
        }

        /**
         * Scroll to the item if necessary.
         *
         * @return a ItemScrolledTo facility representing the item on the screen, which runs the
         *     |selectHandler| when selected.
         */
        public ItemOnScreenFacility<SelectReturnT> scrollTo() {
            assert mPresence != Presence.ABSENT;

            // Could in theory try to scroll to a stub, but not supporting this prevents the
            // creation of a number of objects that are likely not going to be used.
            assert mPresence != Presence.MAYBE_PRESENT_STUB;

            ItemOnScreenFacility<SelectReturnT> focusedItem = new ItemOnScreenFacility<>(this);

            try {
                onView(mOnScreenViewMatcher)
                        .withFailureHandler(RawFailureHandler.getInstance())
                        .check(matches(isCompletelyDisplayed()));
                return mHostStation.enterFacilitySync(focusedItem, /* trigger= */ null);
            } catch (AssertionError | NoMatchingViewException e) {
                return mHostStation.enterFacilitySync(focusedItem, this::triggerScrollTo);
            }
        }

        protected void setSelectHandler(
                Function<ItemOnScreenFacility<SelectReturnT>, SelectReturnT> selectHandler) {
            assert mSelectHandler == null;
            mSelectHandler = selectHandler;
        }

        public @Presence int getPresence() {
            return mPresence;
        }

        public ViewSpec getViewSpec() {
            return mViewSpec;
        }

        public ViewElement.Options getViewElementOptions() {
            return mViewElementOptions;
        }

        protected Function<ItemOnScreenFacility<SelectReturnT>, SelectReturnT> getSelectHandler() {
            return mSelectHandler;
        }

        private void triggerScrollTo() {
            if (mOffScreenDataMatcher != null) {
                // If there is a data matcher, use it to scroll as the item might be in a
                // RecyclerView.
                try {
                    onData(mOffScreenDataMatcher).perform(ViewActions.scrollTo());
                } catch (PerformException performException) {
                    throw TravelException.newTravelException(
                            String.format(
                                    "Could not scroll using data matcher %s",
                                    mOffScreenDataMatcher),
                            performException);
                }
            } else {
                // If there is no data matcher, use the ViewMatcher to scroll as the item should be
                // created but not displayed.
                try {
                    onView(mOnScreenViewMatcher).perform(ViewActions.scrollTo());
                } catch (PerformException performException) {
                    throw TravelException.newTravelException(
                            String.format(
                                    "Could not scroll using view matcher %s", mOnScreenViewMatcher),
                            performException);
                }
            }
        }
    }

    private <EnteredFacilityT extends Facility> EnteredFacilityT enterFacility(
            Item<EnteredFacilityT> item,
            ItemOnScreenFacility<EnteredFacilityT> itemOnScreenFacility,
            Callable<EnteredFacilityT> destinationFactory) {
        EnteredFacilityT destination;
        try {
            destination = destinationFactory.call();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }

        return mHostStation.swapFacilitySync(
                List.of(this, itemOnScreenFacility), destination, item.getViewSpec()::click);
    }

    private <DestinationStationT extends Station> DestinationStationT travelToStation(
            Item<DestinationStationT> item,
            ItemOnScreenFacility<DestinationStationT> itemOnScreenFacility,
            Callable<DestinationStationT> destinationFactory) {
        DestinationStationT destination;
        try {
            destination = destinationFactory.call();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }

        return mHostStation.travelToSync(destination, item.getViewSpec()::click);
    }

    /** Get all {@link Item}s declared in this {@link ScrollableFacility}. */
    public List<Item<?>> getItems() {
        return mItems;
    }

    /**
     * A facility representing an item inside a {@link ScrollableFacility} shown on the screen.
     *
     * @param <SelectReturnT> the return type of the |selectHandler|.
     */
    public class ItemOnScreenFacility<SelectReturnT> extends Facility<HostStationT> {

        protected final Item<SelectReturnT> mItem;

        protected ItemOnScreenFacility(Item<SelectReturnT> item) {
            mItem = item;
        }

        @Override
        public void declareElements(Elements.Builder elements) {
            elements.declareView(mItem.getViewSpec(), mItem.getViewElementOptions());
        }

        /** Select the item and trigger its |selectHandler|. */
        public SelectReturnT select() {
            if (mItem.getPresence() == Presence.ABSENT) {
                throw new IllegalStateException("Cannot click on an absent item");
            }
            if (mItem.getPresence() == Presence.PRESENT_AND_DISABLED) {
                throw new IllegalStateException("Cannot click on a disabled item");
            }

            try {
                return mItem.getSelectHandler().apply(this);
            } catch (Exception e) {
                throw TravelException.newTravelException("Select handler threw an exception:", e);
            }
        }

        /** Returns the {@link Item} that is on the screen. */
        public Item<SelectReturnT> getItem() {
            return mItem;
        }

        /** Returns a {@link Transition.Trigger} to click the item. */
        public Transition.Trigger clickTrigger() {
            return getItem().getViewSpec()::click;
        }
    }
}