chromium/base/test/android/javatests/src/org/chromium/base/test/transit/Transition.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 androidx.annotation.Nullable;

import org.chromium.base.Log;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/** A transition into and/or out of {@link ConditionalState}s. */
public abstract class Transition {
    /**
     * A trigger that will be executed to start the transition after all Conditions are in place and
     * states are set to TRANSITIONING_*.
     */
    public interface Trigger {
        /** Code to trigger the transition, e.g. click a View. */
        void triggerTransition();
    }

    private static final String TAG = "Transit";
    private static int sLastTripId;

    protected final int mId;
    protected final TransitionOptions mOptions;
    @Nullable protected final Trigger mTrigger;
    protected final List<? extends ConditionalState> mExitedStates;
    protected final List<? extends ConditionalState> mEnteredStates;
    protected final ConditionWaiter mConditionWaiter;

    Transition(
            TransitionOptions options,
            List<? extends ConditionalState> exitedStates,
            List<? extends ConditionalState> enteredStates,
            @Nullable Trigger trigger) {
        mId = ++sLastTripId;
        mOptions = options;
        mTrigger = trigger;
        mExitedStates = exitedStates;
        mEnteredStates = enteredStates;
        mConditionWaiter = new ConditionWaiter(this);
    }

    void transitionSync() {
        try {
            Log.i(TAG, "%s: started", toDebugString());
            onBeforeTransition();
            performTransitionWithRetries();
            onAfterTransition();
            Log.i(TAG, "%s: finished", toDebugString());
        } catch (TravelException e) {
            throw e;
        } catch (Throwable e) {
            throw newTransitionException(e);
        }

        PublicTransitConfig.maybePauseAfterTransition(this);
    }

    private boolean shouldFailOnAlreadyFulfilled() {
        // At least one Condition should be not fulfilled, or this is likely an incorrectly
        // designed Transition. Exceptions to this rule:
        //     1. null Trigger, for example when focusing on secondary elements of a screen that
        //        aren't declared in Station#declareElements().
        //     2. A explicit exception is made with TransitionOptions.mPossiblyAlreadyFulfilled.
        //        E.g. when not possible to determine whether the trigger needs to be run.
        return !mOptions.mPossiblyAlreadyFulfilled && mTrigger != null;
    }

    protected void onBeforeTransition() {
        for (ConditionalState exited : mExitedStates) {
            exited.setStateTransitioningFrom();
        }
        for (ConditionalState entered : mEnteredStates) {
            entered.setStateTransitioningTo();
        }
        mConditionWaiter.onBeforeTransition(shouldFailOnAlreadyFulfilled());
    }

    protected void onAfterTransition() {
        for (ConditionalState exited : mExitedStates) {
            exited.setStateFinished();
        }
        for (ConditionalState entered : mEnteredStates) {
            entered.setStateActive();
        }
        mConditionWaiter.onAfterTransition();
    }

    protected void performTransitionWithRetries() {
        if (mOptions.mTries == 1) {
            triggerTransition();
            Log.i(TAG, "%s: waiting for conditions", toDebugString());
            waitUntilConditionsFulfilled();
        } else {
            for (int tryNumber = 1; tryNumber <= mOptions.mTries; tryNumber++) {
                try {
                    triggerTransition();
                    Log.i(
                            TAG,
                            "%s: try #%d/%d, waiting for conditions",
                            toDebugString(),
                            tryNumber,
                            mOptions.mTries);
                    waitUntilConditionsFulfilled();
                    break;
                } catch (TravelException e) {
                    Log.w(TAG, "%s: try #%d failed", toDebugString(), tryNumber, e);
                    if (tryNumber >= mOptions.mTries) {
                        throw e;
                    }
                }
            }
        }
    }

    protected void triggerTransition() {
        if (mTrigger != null) {
            Log.i(TAG, "%s: will run trigger", toDebugString());
            try {
                mTrigger.triggerTransition();
                Log.i(TAG, "%s: finished running trigger", toDebugString());
            } catch (Throwable e) {
                throw TravelException.newTravelException(
                        String.format("%s: trigger threw ", toDebugString()), e);
            }
        } else {
            Log.i(TAG, "%s is triggerless", toDebugString());
        }
    }

    protected void waitUntilConditionsFulfilled() {
        // Throws CriteriaNotSatisfiedException if any conditions aren't met within the timeout and
        // prints the state of all conditions. The timeout can be reduced when explicitly looking
        // for flakiness due to tight timeouts.
        try {
            mConditionWaiter.waitFor(toDebugString());
        } catch (Throwable e) {
            throw newTransitionException(e);
        }
    }

    protected List<Condition> getTransitionConditions() {
        if (mOptions.mTransitionConditions == null) {
            return Collections.EMPTY_LIST;
        } else {
            return mOptions.mTransitionConditions;
        }
    }

    /**
     * Factory method for TravelException for an error during a {@link Transition}.
     *
     * @param cause the root cause
     * @return a new TravelException instance
     */
    public TravelException newTransitionException(Throwable cause) {
        return TravelException.newTravelException("Did not complete " + toDebugString(), cause);
    }

    /** Should return a String representation of the Transition for debugging. */
    public abstract String toDebugString();

    @Override
    public String toString() {
        return toDebugString();
    }

    public List<? extends ConditionalState> getEnteredStates() {
        return mEnteredStates;
    }

    public List<? extends ConditionalState> getExitedStates() {
        return mExitedStates;
    }

    public TransitionOptions getOptions() {
        return mOptions;
    }

    /**
     * @return builder to specify Transition options.
     *     <p>e.g.: Transition.newOptions().withTimeout(10000L).build()
     */
    public static TransitionOptions.Builder newOptions() {
        return new TransitionOptions().new Builder();
    }

    /** Convenience method equivalent to newOptions().withTimeout().build(). */
    public static TransitionOptions timeoutOption(long timeoutMs) {
        return newOptions().withTimeout(timeoutMs).build();
    }

    /** Convenience method equivalent to newOptions().withRetry().build(). */
    public static TransitionOptions retryOption() {
        return newOptions().withRetry().build();
    }

    /** Convenience method equivalent to newOptions().withCondition().withCondition().build(). */
    public static TransitionOptions conditionOption(Condition... conditions) {
        TransitionOptions.Builder builder = newOptions();
        for (Condition condition : conditions) {
            builder = builder.withCondition(condition);
        }
        return builder.build();
    }

    /** Options to configure the Transition. */
    public static class TransitionOptions {

        static final TransitionOptions DEFAULT = new TransitionOptions();
        @Nullable List<Condition> mTransitionConditions;
        long mTimeoutMs;
        int mTries = 1;
        boolean mPossiblyAlreadyFulfilled;

        private TransitionOptions() {}

        /** Builder for TransitionOptions. Call {@link Transition#newOptions()} to instantiate. */
        public class Builder {
            public TransitionOptions build() {
                return TransitionOptions.this;
            }

            /**
             * Add an extra |condition| to the Transition that is not in the exit or enter
             * conditions of the states involved.
             */
            public Builder withCondition(Condition condition) {
                if (mTransitionConditions == null) {
                    mTransitionConditions = new ArrayList<>();
                }
                mTransitionConditions.add(condition);
                return this;
            }

            /**
             * @param timeoutMs how long to poll for during the transition
             */
            public Builder withTimeout(long timeoutMs) {
                mTimeoutMs = timeoutMs;
                return this;
            }

            /**
             * Retry the transition trigger once, if the transition does not finish within the
             * timeout.
             */
            public Builder withRetry() {
                mTries = 2;
                return this;
            }

            /** The Transition's Conditions might already be all fulfilled before the Trigger. */
            public Builder withPossiblyAlreadyFulfilled() {
                mPossiblyAlreadyFulfilled = true;
                return this;
            }
        }
    }
}