/*
* Copyright (C) 2014 SCVNGR, Inc. d/b/a LevelUp
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package com.scvngr.levelup.core.test;
import android.app.Activity;
import android.app.Instrumentation;
import android.os.Looper;
import android.os.SystemClock;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.support.v4.app.Fragment.SavedState;
import android.support.v4.app.FragmentActivity;
import android.support.v4.app.FragmentManager;
import android.test.AndroidTestCase;
import android.view.View;
import com.scvngr.levelup.core.util.NullUtils;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* Methods to help with some of the intricacies of threading in the test cases.
*/
public final class TestThreadingUtils {
/**
* Sentinel value to pass to
* {@link #validateFragmentAdded(Instrumentation, Activity, FragmentManager, String, int)} if no
* parent id should be validated.
*/
public static final int PARENT_ID_UNDEFINED = -1;
private static final long WAIT_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(4L);
private static final long WAIT_SLEEP_MILLIS = 20L;
/**
* Adds a fragment in a transaction synchronized in the main thread (tagged with the fragment's
* class name).
*
* @param instrumentation the test {@link Instrumentation}.
* @param activity the {@link FragmentActivity} to add it to.
* @param fragment Fragment to add.
* @param inView adds the fragment to the view hierarchy if true.
*/
public static void addFragmentInMainSync(@NonNull final Instrumentation instrumentation,
@NonNull final FragmentActivity activity, @NonNull final Fragment fragment,
final boolean inView) {
addFragmentInMainSync(instrumentation, activity, fragment, inView,
NullUtils.nonNullContract(fragment.getClass().getName()));
}
/**
* Adds a fragment in a transaction synchronized in the main thread (tagged with the fragment's
* class name).
*
* @param instrumentation the test {@link Instrumentation}.
* @param activity the {@link FragmentActivity} to add it to.
* @param fragment Fragment to add.
* @param inView adds the fragment to the view hierarchy if true.
* @param tag the Fragment's tag (null tag will fail fast).
*/
public static void addFragmentInMainSync(@NonNull final Instrumentation instrumentation,
@NonNull final FragmentActivity activity, @NonNull final Fragment fragment,
final boolean inView, final String tag) {
if (null == tag) {
throw new AssertionError("Cannot add fragment with null tag");
}
runOnMainSync(instrumentation, activity, new Runnable() {
@Override
public void run() {
if (!inView) {
activity.getSupportFragmentManager().beginTransaction()
.add(fragment, tag).commit();
} else {
activity.getSupportFragmentManager().beginTransaction()
.add(R.id.levelup_activity_content, fragment, tag).commit();
}
activity.getSupportFragmentManager().executePendingTransactions();
}
});
}
/**
* Saves a {@link Fragment} to instance state, removes it from the activity. Then creates a new
* one of the same class, restores the instance state, and re-adds it to the activity.
*
* @param <T> the type of Fragment
* @param instrumentation the test {@link Instrumentation}.
* @param activity the {@link FragmentActivity} to add it to.
* @param fragment Fragment to remove and re-add.
* @return the new instance of the input fragment created using the saved/restored state.
*/
@NonNull
public static <T extends Fragment> T saveAndRestoreFragmentStateSync(
@NonNull final Instrumentation instrumentation,
@NonNull final FragmentActivity activity, @NonNull final T fragment) {
final boolean inView = fragment.isInLayout();
final FragmentManager fm = activity.getSupportFragmentManager();
final SavedState savedState = fm.saveFragmentInstanceState(fragment);
@SuppressWarnings("unchecked")
final T newInstance = (T) Fragment.instantiate(activity, fragment.getClass().getName());
newInstance.setInitialSavedState(savedState);
runOnMainSync(instrumentation, activity, new Runnable() {
@Override
public void run() {
fm.beginTransaction().remove(fragment).commit();
}
});
addFragmentInMainSync(instrumentation, activity, newInstance, inView);
return newInstance;
}
/**
* Validates that the Activity becomes finished.
*
* @param instrumentation the test {@link Instrumentation}.
* @param activity the activity to check.
*/
public static void validateActivityFinished(@NonNull final Instrumentation instrumentation,
@NonNull final Activity activity) {
final LatchRunnable latchRunnable = new LatchRunnable() {
@Override
public void run() {
if (activity.isFinishing()) {
countDown();
}
}
};
AndroidTestCase.assertTrue(waitForAction(instrumentation, activity, latchRunnable, true));
}
/**
* Validates that a fragment was added successfully. Does not validate the container it was
* added to.
*
* @param <V> the type of Fragment.
* @param instrumentation the test {@link Instrumentation}.
* @param activity the activity for the test being run (null will fail validation).
* @param fragmentManager the fragment manager the fragment was added to (null will fail
* validation).
* @param tag the tag to check for (null will fail validation).
* @return the Fragment that is added.
*/
@NonNull
public static <V extends Fragment> V validateFragmentAdded(
@NonNull final Instrumentation instrumentation, final Activity activity,
final FragmentManager fragmentManager, final String tag) {
return validateFragmentAdded(instrumentation, activity, fragmentManager, tag,
PARENT_ID_UNDEFINED);
}
/**
* Validates that a fragment was added successfully and validates the ID of the container it was
* added to.
*
* @param <V> the type of Fragment.
* @param instrumentation the test {@link Instrumentation}.
* @param activity the activity for the test being run (null will fail validation).
* @param fragmentManager the fragment manager the fragment was added to (null will fail
* validation).
* @param tag the tag to check for (null will fail validation).
* @param parentId the id of the parent container the fragment is expected to be in or pass
* {@link #PARENT_ID_UNDEFINED} if no parent id should be validated.
* @return the Fragment that is added.
*/
@NonNull
@SuppressWarnings("unchecked")
public static <V extends Fragment> V validateFragmentAdded(
@NonNull final Instrumentation instrumentation, final Activity activity,
final FragmentManager fragmentManager, final String tag, final int parentId) {
AndroidTestCase.assertNotNull(tag);
AndroidTestCase.assertNotNull(activity);
AndroidTestCase.assertNotNull(fragmentManager);
final AtomicReference<Fragment> reference = new AtomicReference<Fragment>();
final LatchRunnable latchRunnable = new LatchRunnable() {
@Override
public void run() {
final Fragment fragment = fragmentManager.findFragmentByTag(tag);
if (null != fragment) {
if (fragment.isAdded()) {
if (PARENT_ID_UNDEFINED != parentId) {
final View parent = (View) fragment.getView().getParent();
AndroidTestCase.assertEquals("In the proper container",
parentId, parent.getId());
}
reference.set(fragment);
countDown();
}
}
}
};
AndroidTestCase.assertTrue(String.format(Locale.US, "%s added", tag),
waitForAction(instrumentation, NullUtils.nonNullContract(activity), latchRunnable,
true));
return (V) reference.get();
}
/**
* Validates that a fragment was removed.
*
* @param instrumentation the test {@link Instrumentation}.
* @param activity the activity for the test being run (null will fail validation).
* @param fragmentManager the fragment manager the fragment was removed from (null will fail
* validation).
* @param tag The tag of the fragment (null will fail validation).
*/
public static void validateFragmentRemoved(@NonNull final Instrumentation instrumentation,
final Activity activity, final FragmentManager fragmentManager, final String tag) {
AndroidTestCase.assertNotNull(tag);
AndroidTestCase.assertNotNull(activity);
AndroidTestCase.assertNotNull(fragmentManager);
final LatchRunnable latchRunnable = new LatchRunnable() {
@Override
public void run() {
final Fragment fragment = fragmentManager.findFragmentByTag(tag);
if (null == fragment) {
countDown();
}
}
};
AndroidTestCase.assertTrue(String.format(Locale.US, "%s removed", tag),
waitForAction(instrumentation, NullUtils.nonNullContract(activity), latchRunnable,
true));
}
/**
* Validates that a fragment is visible to the user.
*
* @param <V> the type of Fragment.
* @param instrumentation the test {@link Instrumentation}.
* @param activity the activity for the test being run (null will fail validation).
* @param fragmentManager the fragment manager the fragment was added to (null will fail
* validation).
* @param tag the tag to check for (null will fail validation).
* @return the Fragment that is visible to the user.
*/
@NonNull
@SuppressWarnings("unchecked")
public static <V extends Fragment> V validateFragmentIsVisible(
@NonNull final Instrumentation instrumentation, final Activity activity,
final FragmentManager fragmentManager, final String tag) {
AndroidTestCase.assertNotNull(tag);
AndroidTestCase.assertNotNull(activity);
AndroidTestCase.assertNotNull(fragmentManager);
final AtomicReference<Fragment> reference = new AtomicReference<Fragment>();
final LatchRunnable latchRunnable = new LatchRunnable() {
@Override
public void run() {
final Fragment fragment = fragmentManager.findFragmentByTag(tag);
if (null != fragment) {
if (fragment.isVisible()) {
reference.set(fragment);
countDown();
}
}
}
};
AndroidTestCase.assertTrue(String.format(Locale.US, "%s is visible", tag),
waitForAction(instrumentation, activity, latchRunnable, true));
return (V) reference.get();
}
/**
* Validates that a fragment is either not managed by the fragment manager or is not visible to
* the user.
*
* @param <V> the type of Fragment.
* @param instrumentation the test {@link Instrumentation}.
* @param activity the activity for the test being run (null will fail validation).
* @param fragmentManager the fragment manager the fragment was added to (null will fail
* validation).
* @param tag the tag to check for (null will fail validation).
* @return the Fragment that is not visible to the user.
*/
@NonNull
@SuppressWarnings("unchecked")
public static <V extends Fragment> V validateFragmentIsNotVisible(
@NonNull final Instrumentation instrumentation, final Activity activity,
final FragmentManager fragmentManager, final String tag) {
AndroidTestCase.assertNotNull(tag);
AndroidTestCase.assertNotNull(activity);
AndroidTestCase.assertNotNull(fragmentManager);
final AtomicReference<Fragment> reference = new AtomicReference<Fragment>();
final LatchRunnable latchRunnable = new LatchRunnable() {
@Override
public void run() {
final Fragment fragment = fragmentManager.findFragmentByTag(tag);
if (null == fragment || !fragment.isVisible()) {
reference.set(fragment);
countDown();
}
}
};
AndroidTestCase.assertTrue(String.format(Locale.US, "%s is not visible", tag),
waitForAction(instrumentation, activity, latchRunnable, true));
return (V) reference.get();
}
/**
* Helper method to wait for an action to occur.
*
* @param instrumentation the test {@link Instrumentation}.
* @param activity the activity for the test being run.
* @param latchRunnable the runnable that will check the condition and signal success via its
* {@link java.util.concurrent.CountDownLatch}.
* @param isMainThreadRunnable Determine whether or not the runnable must be invoked on the main
* thread.
* @return true if the action happened before the timeout, false otherwise.
*/
public static boolean waitForAction(@NonNull final Instrumentation instrumentation,
@NonNull final Activity activity, @NonNull final LatchRunnable latchRunnable,
final boolean isMainThreadRunnable) {
return waitForAction(instrumentation, activity, latchRunnable, WAIT_TIMEOUT_MILLIS,
isMainThreadRunnable);
}
/**
* Helper method to wait for an action to occur.
*
* @param instrumentation the test {@link Instrumentation}.
* @param activity the activity for the test being run.
* @param latchRunnable the runnable that will check the condition and signal success via its
* {@link java.util.concurrent.CountDownLatch}.
* @param timeoutMillis the timeout duration in milliseconds.
* @param isMainThreadRunnable Determine whether or not the runnable must be invoked on the main
* thread.
* @return true if the action happened before the timeout, false otherwise.
*/
public static boolean waitForAction(@NonNull final Instrumentation instrumentation,
@NonNull final Activity activity, @NonNull final LatchRunnable latchRunnable,
final long timeoutMillis, final boolean isMainThreadRunnable) {
final long endTime = SystemClock.elapsedRealtime() + timeoutMillis;
boolean result = true;
while (true) {
if (isMainThreadRunnable) {
runOnMainSync(instrumentation, activity, latchRunnable);
} else {
latchRunnable.run();
instrumentation.waitForIdleSync();
}
if (latchRunnable.getCount() == 0) {
break;
}
if (SystemClock.elapsedRealtime() >= endTime) {
result = false;
break;
}
SystemClock.sleep(WAIT_SLEEP_MILLIS);
}
return result;
}
/**
* Runs a runnable on the main thread, but also catches any errors thrown on the main thread and
* re-throws them on the test thread so they can be displayed more easily.
*
* @param instrumentation the {@link Instrumentation} for the test.
* @param activity the {@link Activity} that this this test is running in.
* @param runnable the runnable to run.
*/
public static void runOnMainSync(@NonNull final Instrumentation instrumentation,
@NonNull final Activity activity, @NonNull final Runnable runnable) {
if (activity.getMainLooper().equals(Looper.myLooper())) {
runnable.run();
} else {
final FutureAssertionError futureError = new FutureAssertionError();
instrumentation.runOnMainSync(new Runnable() {
@Override
public void run() {
try {
runnable.run();
} catch (final AssertionError e) {
futureError.setAssertionError(e);
}
}
});
futureError.throwPendingAssertionError();
instrumentation.waitForIdleSync();
}
}
/**
* This class is used to capture {@link AssertionError}s thrown inside anonymous
* {@link Runnable}s so they can be re-thrown on the {@link Instrumentation} test thread.
*/
/* package */ static class FutureAssertionError {
/**
* The {@link AssertionError} to be thrown by {@link #throwPendingAssertionError()}.
*/
@Nullable
private AssertionError mError;
/**
* Set the {@link AssertionError} to be thrown by {@link #throwPendingAssertionError()}.
*
* @param error The {@link AssertionError} to be thrown by
* {@link #throwPendingAssertionError()}.
*/
/* package */ void setAssertionError(@NonNull final AssertionError error) {
mError = error;
}
/**
* If an {@link AssertionError} was set via {@link #setAssertionError(AssertionError)} this
* method will throw that {@link AssertionError}, otherwise it does nothing.
*/
/* package */ void throwPendingAssertionError() {
if (null != mError) {
throw mError;
}
}
}
/**
* Private constructor prevents instantiation.
*/
private TestThreadingUtils() {
throw new UnsupportedOperationException("This class is non-instantiable");
}
}