Implement dynamic waits, setup methods, isolate gps tests

This commit is contained in:
Marcatili, Manuel
2026-04-16 09:21:45 +03:00
committed by mmarca.tech
parent a94f023a17
commit 40494557d4
13 changed files with 809 additions and 170 deletions
@@ -1,14 +1,25 @@
package de.dennisguse.opentracks.util;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
import android.app.Activity;
import android.os.SystemClock;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.ListView;
import androidx.recyclerview.widget.RecyclerView;
import androidx.test.espresso.PerformException;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
import androidx.test.runner.lifecycle.Stage;
import androidx.test.espresso.util.HumanReadables;
import androidx.test.espresso.util.TreeIterables;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.tabs.TabLayout;
@@ -16,8 +27,15 @@ import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.TimeoutException;
import java.util.function.Predicate;
public class EspressoUtils {
private static final long VIEW_POLLING_INTERVAL_MS = 50;
public static Matcher<View> childAtPosition(
final Matcher<View> parentMatcher, final int position) {
@@ -51,26 +69,138 @@ public class EspressoUtils {
};
}
public static ViewAction waitFor(final long duration_ms) {
return new ViewAction() {
public static Matcher<View> withRecyclerViewItemCount(final int count) {
return new TypeSafeMatcher<>() {
@Override
protected boolean matchesSafely(View view) {
if (!(view instanceof RecyclerView recyclerView)) {
return false;
}
RecyclerView.Adapter<?> adapter = recyclerView.getAdapter();
return adapter != null && adapter.getItemCount() == count;
}
@Override
public void describeTo(Description description) {
description.appendText("RecyclerView should have " + count + " items");
}
};
}
public static Matcher<View> withSelectedTab(final int index) {
return new TypeSafeMatcher<>() {
@Override
protected boolean matchesSafely(View view) {
return view instanceof TabLayout tabLayout
&& tabLayout.getSelectedTabPosition() == index;
}
@Override
public void describeTo(Description description) {
description.appendText("TabLayout should have selected tab " + index);
}
};
}
public static Matcher<View> withViewPagerCurrentItem(final int index) {
return new TypeSafeMatcher<>() {
@Override
protected boolean matchesSafely(View view) {
return view instanceof ViewPager2 viewPager2
&& viewPager2.getCurrentItem() == index
&& viewPager2.getScrollState() == ViewPager2.SCROLL_STATE_IDLE;
}
@Override
public void describeTo(Description description) {
description.appendText("ViewPager2 should be idle on item " + index);
}
};
}
public static ViewAction waitForView(final Matcher<View> viewMatcher, final Duration timeout) {
return waitForViewCondition(viewMatcher, ignored -> true, "view to match " + viewMatcher, timeout);
}
public static ViewAction waitForViewCondition(final Matcher<View> viewMatcher,
final Predicate<View> condition,
final String conditionDescription,
final Duration timeout) {
return new ViewAction() {
@Override
public String getDescription() {
return "Wait for milliseconds.";
return "wait up to " + timeout + " ms for " + conditionDescription;
}
@Override
public Matcher<View> getConstraints() {
return isDisplayed();
return isRoot();
}
@Override
public void perform(UiController uiController, final View view) {
uiController.loopMainThreadForAtLeast(duration_ms);
public void perform(UiController uiController, View view) {
long deadline = SystemClock.uptimeMillis() + timeout.toMillis();
do {
uiController.loopMainThreadUntilIdle();
for (View child : TreeIterables.breadthFirstViewTraversal(view)) {
if (viewMatcher.matches(child) && condition.test(child)) {
return;
}
}
uiController.loopMainThreadForAtLeast(VIEW_POLLING_INTERVAL_MS);
} while (SystemClock.uptimeMillis() < deadline);
throw new PerformException.Builder()
.withActionDescription(getDescription())
.withViewDescription(HumanReadables.describe(view))
.withCause(new TimeoutException("Timed out waiting for " + conditionDescription))
.build();
}
};
}
public static void waitForResumedView(final Matcher<View> viewMatcher, final Duration timeout) {
waitForResumedViewCondition(viewMatcher, ignored -> true, "view to match " + viewMatcher, timeout);
}
public static void waitForResumedViewCondition(final Matcher<View> viewMatcher,
final Predicate<View> condition,
final String conditionDescription,
final Duration timeout) {
long deadline = SystemClock.uptimeMillis() + timeout.toMillis();
do {
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
AtomicBoolean matched = new AtomicBoolean(false);
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED)) {
View decorView = activity.getWindow() != null ? activity.getWindow().getDecorView() : null;
if (decorView == null) {
continue;
}
for (View child : TreeIterables.breadthFirstViewTraversal(decorView)) {
if (viewMatcher.matches(child) && condition.test(child)) {
matched.set(true);
return;
}
}
}
});
if (matched.get()) {
return;
}
} while (SystemClock.uptimeMillis() < deadline);
throw new AssertionError("Timed out waiting for " + conditionDescription);
}
public static ViewAction selectTabAtIndex(final int index) {
return new ViewAction() {
@Override