From 40494557d4013ccc601551b0af6b940fee85237d Mon Sep 17 00:00:00 2001 From: "Marcatili, Manuel" Date: Thu, 16 Apr 2026 09:21:45 +0300 Subject: [PATCH 1/6] Implement dynamic waits, setup methods, isolate gps tests --- .../opentracks/EspressoDeleteTrackTest.java | 151 ++++++++++++++---- .../EspressoEditTrackRecordingTest.java | 45 +++++- .../dennisguse/opentracks/EspressoUITest.java | 108 +++++++++++-- .../data/CustomContentProviderUtilsTest.java | 74 +++++---- ...cordingServiceNotificationManagerTest.java | 91 ++++++----- ...TrackRecordingServiceStateMachineTest.java | 38 +++++ .../services/TrackRecordingServiceUITest.java | 51 +++--- .../VoiceAnnouncementUtilsTest.java | 6 +- .../EspressoAggregatedFilterTest.java | 60 ++++--- .../opentracks/util/ActivityTestUtils.java | 106 ++++++++++++ .../opentracks/util/EspressoUtils.java | 142 +++++++++++++++- .../opentracks/util/LiveDataTestUtils.java | 57 +++++++ .../util/TrackRecordingServiceTestUtils.java | 50 ++++++ 13 files changed, 809 insertions(+), 170 deletions(-) create mode 100644 src/androidTest/java/de/dennisguse/opentracks/util/ActivityTestUtils.java create mode 100644 src/androidTest/java/de/dennisguse/opentracks/util/LiveDataTestUtils.java create mode 100644 src/androidTest/java/de/dennisguse/opentracks/util/TrackRecordingServiceTestUtils.java diff --git a/src/androidTest/java/de/dennisguse/opentracks/EspressoDeleteTrackTest.java b/src/androidTest/java/de/dennisguse/opentracks/EspressoDeleteTrackTest.java index ebee9d368..91417a656 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/EspressoDeleteTrackTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/EspressoDeleteTrackTest.java @@ -3,75 +3,160 @@ package de.dennisguse.opentracks; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; import static androidx.test.espresso.action.ViewActions.longClick; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.action.ViewActions.scrollTo; +import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; +import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withParent; -import static androidx.test.espresso.matcher.ViewMatchers.withParentIndex; import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static de.dennisguse.opentracks.util.ActivityTestUtils.waitForResumedActivity; +import static de.dennisguse.opentracks.util.EspressoUtils.waitForView; +import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedView; +import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedViewCondition; +import static de.dennisguse.opentracks.util.EspressoUtils.withRecyclerViewItemCount; +import static de.dennisguse.opentracks.util.TrackRecordingServiceTestUtils.waitForRecordingState; import static org.hamcrest.Matchers.allOf; -import static de.dennisguse.opentracks.util.EspressoUtils.childAtPosition; -import static de.dennisguse.opentracks.util.EspressoUtils.waitFor; +import android.content.Context; +import android.content.Intent; +import android.view.View; + +import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import androidx.test.rule.GrantPermissionRule; +import org.hamcrest.Matcher; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import java.time.Duration; +import de.dennisguse.opentracks.data.ContentProviderUtils; + @LargeTest @RunWith(AndroidJUnit4.class) public class EspressoDeleteTrackTest { + private static final Duration UI_TIMEOUT = Duration.ofSeconds(10); + @Rule public ActivityScenarioRule mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class); @Rule public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule(); + @Before + public void setUp() { + Context context = ApplicationProvider.getApplicationContext(); + context.stopService(new Intent(context, de.dennisguse.opentracks.services.TrackRecordingService.class)); + new ContentProviderUtils(context).deleteAllTracks(context); + } + @Test - public void espressoDeleteTrackTest() { + public void deleteTrackFromTrackStoppedActivity() { + recordTrackAndOpenTrackStoppedActivity(); + + // TrackStoppedActivity: discard the just-recorded track directly + onView(withId(R.id.discard_button)) + .perform(click()); + waitUntilDisplayedInCurrentRoot(withText(android.R.string.ok)); + + onView(withText(android.R.string.ok)) + .perform(click()); + waitForResumedActivity(TrackListActivity.class, UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.track_list)); + waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0); + + // the recycler is empty again after discarding the only recorded track + onView(withId(R.id.track_list)) + .check(matches(withRecyclerViewItemCount(0))); + } + + @Test + public void deleteTrackFromSelectedTrackInTrackList() { + String uniqueTrackName = "DeleteFromTrackList-" + System.currentTimeMillis(); + + recordTrackAndOpenTrackStoppedActivity(); + onView(withId(R.id.track_edit_name)) + .perform(scrollTo(), replaceText(uniqueTrackName), closeSoftKeyboard()); + + onView(withId(R.id.finish_button)) + .perform(click()); + waitForResumedActivity(TrackListActivity.class, UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.track_list)); + waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))); + waitUntilRecyclerViewItemCount(withId(R.id.track_list), 1); + + onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))) + .perform(longClick()); + + clickDeleteSelectedTrackAction(); + waitUntilDisplayedInCurrentRoot(withText(android.R.string.ok)); + + onView(withText(android.R.string.ok)) + .perform(click()); + waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0); + + onView(withId(R.id.track_list)) + .check(matches(withRecyclerViewItemCount(0))); + } + + private void recordTrackAndOpenTrackStoppedActivity() { // TrackListActivity: start recording onView(withId(R.id.track_list_fab_action)) .perform(click()); + waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); + waitForRecordingState(true, UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.track_recording_fab_action)); - // TrackRecordingActivity + // TrackRecordingActivity: stop recording and open TrackStoppedActivity onView(withId(R.id.track_recording_fab_action)) - // wait; stay recording - .perform(waitFor(5000)) - // stop; .perform(longClick()); + waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.track_edit_name)); + waitUntilDisplayed(withId(R.id.discard_button)); + waitUntilDisplayed(withId(R.id.finish_button)); + onView(withId(R.id.track_edit_name)) + .perform(closeSoftKeyboard()); + } - // TrackStoppedActivity - onView(withId(R.id.finish_button)) - .perform(click()); + private void clickDeleteSelectedTrackAction() { + try { + waitUntilDisplayed(withId(R.id.list_context_menu_delete)); + onView(withId(R.id.list_context_menu_delete)) + .perform(click()); + } catch (AssertionError ignored) { + onView(allOf( + withContentDescription(androidx.appcompat.R.string.abc_action_menu_overflow_description), + isDescendantOfA(withId(androidx.appcompat.R.id.action_mode_bar)))) + .perform(click()); + waitUntilDisplayedInCurrentRoot(withText(R.string.menu_delete)); + onView(withText(R.string.menu_delete)) + .perform(click()); + } + } - // select track - onView(allOf(withParent(withId(R.id.track_list)), withParentIndex(0))) - .perform(longClick()); + private void waitUntilDisplayed(Matcher viewMatcher) { + waitForResumedView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT); + } - // open menu and delete selected track - onView( - allOf(withContentDescription("More options"), - childAtPosition( - childAtPosition( - withId(androidx.appcompat.R.id.action_mode_bar), - 1), - 2), - isDisplayed())) - .perform(click()); + private void waitUntilDisplayedInCurrentRoot(Matcher viewMatcher) { + onView(isRoot()).perform(waitForView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT)); + } - onView(withText("Delete")) - .perform(click()); - - onView(withText("OK")) - .perform(click()); - - // tracklist is empty now - onView(allOf(withText("Start recording your next adventure here"), isDisplayed())); + private void waitUntilRecyclerViewItemCount(Matcher viewMatcher, int expectedCount) { + waitForResumedViewCondition( + viewMatcher, + view -> withRecyclerViewItemCount(expectedCount).matches(view), + "RecyclerView item count to be " + expectedCount, + UI_TIMEOUT); } } diff --git a/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java b/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java index adcfa5c83..a80b08ea9 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java @@ -10,26 +10,41 @@ import static androidx.test.espresso.action.ViewActions.replaceText; import static androidx.test.espresso.action.ViewActions.scrollTo; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static de.dennisguse.opentracks.util.ActivityTestUtils.waitForResumedActivity; +import static de.dennisguse.opentracks.util.EspressoUtils.waitForView; +import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedView; +import static de.dennisguse.opentracks.util.TrackRecordingServiceTestUtils.waitForRecordingState; import static org.hamcrest.Matchers.allOf; -import static de.dennisguse.opentracks.util.EspressoUtils.waitFor; +import android.view.View; +import android.content.Context; +import android.content.Intent; + +import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import androidx.test.rule.GrantPermissionRule; +import org.hamcrest.Matcher; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import java.time.Duration; import java.util.Locale; +import de.dennisguse.opentracks.data.ContentProviderUtils; @LargeTest @RunWith(AndroidJUnit4.class) public class EspressoEditTrackRecordingTest { + private static final Duration UI_TIMEOUT = Duration.ofSeconds(10); + @Rule public final LocaleRule mLocaleRule = new LocaleRule(Locale.ENGLISH); @@ -39,6 +54,13 @@ public class EspressoEditTrackRecordingTest { @Rule public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule(); + @Before + public void setUp() { + Context context = ApplicationProvider.getApplicationContext(); + context.stopService(new Intent(context, de.dennisguse.opentracks.services.TrackRecordingService.class)); + new ContentProviderUtils(context).deleteAllTracks(context); + } + @LargeTest @Test public void espressoEditTrackRecordingTest() { @@ -46,20 +68,21 @@ public class EspressoEditTrackRecordingTest { // TrackListActivity: start recording onView(withId(R.id.track_list_fab_action)) .perform(click()); + waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); + waitForRecordingState(true, UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.track_recording_fab_action)); } { // TrackRecordingActivity - // wait; stay recording - onView(withId(R.id.track_recording_fab_action)) - .perform(waitFor(4000)); - // open menu openContextualActionModeOverflowMenu(); + waitUntilDisplayedInCurrentRoot(withText(R.string.menu_edit)); // Click the item. onView(withText(R.string.menu_edit)) .perform(click()); + waitUntilDisplayed(withId(R.id.track_edit_name)); // change name for "New Name" onView(withId(R.id.track_edit_name)) @@ -71,10 +94,14 @@ public class EspressoEditTrackRecordingTest { // save edition onView(withId(R.id.track_edit_save)) .perform(click()); + waitUntilDisplayed(withId(R.id.track_recording_fab_action)); // stop; onView(withId(R.id.track_recording_fab_action)) .perform(longClick()); + waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.resume_button)); + waitUntilDisplayed(withId(R.id.finish_button)); // it's on track stopped activity and there are two buttons onView(withId(R.id.resume_button)) @@ -83,4 +110,12 @@ public class EspressoEditTrackRecordingTest { .check(matches(isDisplayed())); } } + + private void waitUntilDisplayed(Matcher viewMatcher) { + waitForResumedView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT); + } + + private void waitUntilDisplayedInCurrentRoot(Matcher viewMatcher) { + onView(isRoot()).perform(waitForView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT)); + } } diff --git a/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java b/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java index 42dc0b9c2..fea4b26dc 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java @@ -2,36 +2,67 @@ package de.dennisguse.opentracks; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; import static androidx.test.espresso.action.ViewActions.longClick; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.isClickable; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withId; -import static androidx.test.espresso.matcher.ViewMatchers.withParent; -import static androidx.test.espresso.matcher.ViewMatchers.withParentIndex; -import static org.hamcrest.Matchers.allOf; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static de.dennisguse.opentracks.util.ActivityTestUtils.waitForResumedActivity; import static de.dennisguse.opentracks.util.EspressoUtils.selectTabAtIndex; -import static de.dennisguse.opentracks.util.EspressoUtils.waitFor; +import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedView; +import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedViewCondition; +import static de.dennisguse.opentracks.util.EspressoUtils.withSelectedTab; +import static de.dennisguse.opentracks.util.EspressoUtils.withViewPagerCurrentItem; +import static de.dennisguse.opentracks.util.TrackRecordingServiceTestUtils.waitForRecordingState; +import static org.hamcrest.Matchers.allOf; +import android.util.Pair; +import android.content.Context; +import android.content.Intent; +import android.view.View; + +import androidx.test.core.app.ApplicationProvider; import androidx.test.espresso.ViewInteraction; import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import androidx.test.rule.GrantPermissionRule; +import org.hamcrest.Matcher; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import java.time.Duration; +import java.util.List; +import de.dennisguse.opentracks.content.data.TestDataUtil; +import de.dennisguse.opentracks.data.ContentProviderUtils; +import de.dennisguse.opentracks.data.models.Track; +import de.dennisguse.opentracks.data.models.TrackBuilder; +import de.dennisguse.opentracks.data.models.TrackPoint; + @LargeTest @RunWith(AndroidJUnit4.class) public class EspressoUITest { + private static final Duration UI_TIMEOUT = Duration.ofSeconds(10); + @Rule public ActivityScenarioRule mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class); @Rule public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule(); + + @Before + public void setUp() { + Context context = ApplicationProvider.getApplicationContext(); + context.stopService(new Intent(context, de.dennisguse.opentracks.services.TrackRecordingService.class)); + new ContentProviderUtils(context).deleteAllTracks(context); + } + @LargeTest @Test public void record_stop_resume_stop_finish() { @@ -39,25 +70,41 @@ public class EspressoUITest { // TrackListActivity: start recording onView(withId(R.id.track_list_fab_action)) .perform(click()); + waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); + waitForRecordingState(true, UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.track_recording_fab_action)); } { - // TrackRecordingActivity: wait to record some time and then stop + // TrackRecordingActivity: stop onView(withId(R.id.track_recording_fab_action)) - .perform(waitFor(5000)) .perform(longClick()); + waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.track_edit_name)); + waitUntilDisplayed(withId(R.id.resume_button)); + onView(withId(R.id.track_edit_name)) + .perform(closeSoftKeyboard()); // TrackStoppedActivity: resume onView(allOf(withId(R.id.resume_button), isClickable())) .perform(click()); + waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); + waitForRecordingState(true, UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.track_recording_fab_action)); - // TrackRecordingActivity: wait and then stop + // TrackRecordingActivity: stop again onView(withId(R.id.track_recording_fab_action)) - .perform(waitFor(5000)) .perform(longClick()); + waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.track_edit_name)); + waitUntilDisplayed(withId(R.id.finish_button)); + onView(withId(R.id.track_edit_name)) + .perform(closeSoftKeyboard()); // TrackStoppedActivity onView(withId(R.id.finish_button)) .perform(click()); + waitForResumedActivity(TrackListActivity.class, UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.track_list)); } } @@ -68,33 +115,64 @@ public class EspressoUITest { // TrackListActivity: start recording onView(withId(R.id.track_list_fab_action)) .perform(click()); + waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); + waitForRecordingState(true, UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.track_detail_activity_tablayout)); + waitUntilDisplayed(withId(R.id.track_detail_activity_view_pager)); } { // TrackRecordingActivity ViewInteraction tabLayout = onView(withId(R.id.track_detail_activity_tablayout)); tabLayout.perform(selectTabAtIndex(1)); - tabLayout.perform(waitFor(1000)); + waitUntilTabSelectionSettlesAt(1); tabLayout.perform(selectTabAtIndex(2)); - tabLayout.perform(waitFor(1000)); + waitUntilTabSelectionSettlesAt(2); tabLayout.perform(selectTabAtIndex(3)); - tabLayout.perform(waitFor(1000)); + waitUntilTabSelectionSettlesAt(3); tabLayout.perform(selectTabAtIndex(0)); - tabLayout.perform(waitFor(1000)); + waitUntilTabSelectionSettlesAt(0); // stop - onView(withId(R.id.track_recording_fab_action)). - perform(longClick()); + onView(withId(R.id.track_recording_fab_action)) + .perform(longClick()); + waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.resume_button)); } } @LargeTest @Test public void selectAndDeleteTrack() { + Pair> pair = TestDataUtil.createTrack(new Track.Id(System.currentTimeMillis()), 5); + String uniqueTrackName = "SelectAndDeleteTrack-" + System.currentTimeMillis(); + TrackBuilder trackBuilder = new TrackBuilder(pair.first); + trackBuilder.setName(uniqueTrackName); + TestDataUtil.insertTrackWithLocations(new ContentProviderUtils(ApplicationProvider.getApplicationContext()), trackBuilder.getTrack(), pair.second); + mActivityTestRule.getScenario().recreate(); + waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))); + onView(withId(R.id.track_list)).check(matches(isDisplayed())); - onView(allOf(withParent(withId(R.id.track_list)), withParentIndex(0))).perform(longClick()); + onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))).perform(longClick()); + } + + private void waitUntilDisplayed(Matcher viewMatcher) { + waitForResumedView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT); + } + + private void waitUntilTabSelectionSettlesAt(int index) { + waitForResumedViewCondition( + withId(R.id.track_detail_activity_tablayout), + view -> withSelectedTab(index).matches(view), + "selected tab to be " + index, + UI_TIMEOUT); + waitForResumedViewCondition( + withId(R.id.track_detail_activity_view_pager), + view -> withViewPagerCurrentItem(index).matches(view), + "ViewPager current item to be " + index, + UI_TIMEOUT); } } diff --git a/src/androidTest/java/de/dennisguse/opentracks/data/CustomContentProviderUtilsTest.java b/src/androidTest/java/de/dennisguse/opentracks/data/CustomContentProviderUtilsTest.java index edcfac15c..53b897eba 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/data/CustomContentProviderUtilsTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/data/CustomContentProviderUtilsTest.java @@ -20,22 +20,19 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.when; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; +import android.database.MatrixCursor; import android.util.Pair; import androidx.test.core.app.ApplicationProvider; import org.junit.Before; import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; import java.io.File; import java.io.IOException; @@ -78,7 +75,6 @@ import de.dennisguse.opentracks.util.FileUtils; * @author Bartlomiej Niechwiej * @author Youtao Liu */ -@RunWith(MockitoJUnitRunner.class) public class CustomContentProviderUtilsTest { private static final String NAME_PREFIX = "test name"; @@ -90,12 +86,6 @@ public class CustomContentProviderUtilsTest { private final Context context = ApplicationProvider.getApplicationContext(); private ContentProviderUtils contentProviderUtils; - @Mock - private Cursor cursorMock; - - @Mock - private ContentResolver contentResolverMock; - @Before public void setUp() { contentProviderUtils = new ContentProviderUtils(context); @@ -187,27 +177,49 @@ public class CustomContentProviderUtilsTest { @Test public void testCreateTrack() { Track.Id trackId = new Track.Id(System.currentTimeMillis()); - - int columnIndex = 1; - // Id - when(cursorMock.getColumnIndexOrThrow(TracksColumns._ID)).thenReturn(columnIndex); - when(cursorMock.isNull(columnIndex)).thenReturn(false); - when(cursorMock.getLong(columnIndex)).thenReturn(trackId.id()); - - //Uuid - columnIndex++; - when(cursorMock.getColumnIndexOrThrow(TracksColumns.UUID)).thenReturn(columnIndex); - when(cursorMock.isNull(columnIndex)).thenReturn(false); - when(cursorMock.getBlob(columnIndex)).thenReturn(UUIDUtils.toBytes(UUID.randomUUID())); - - // Name - columnIndex++; String name = NAME_PREFIX + trackId.id(); - when(cursorMock.getColumnIndexOrThrow(TracksColumns.NAME)).thenReturn(columnIndex); - when(cursorMock.isNull(columnIndex)).thenReturn(false); - when(cursorMock.getString(columnIndex)).thenReturn(name); - Track track = ContentProviderUtils.createTrack(cursorMock); + MatrixCursor cursor = new MatrixCursor(new String[]{ + TracksColumns._ID, + TracksColumns.UUID, + TracksColumns.NAME, + TracksColumns.DESCRIPTION, + TracksColumns.ACTIVITY_TYPE, + TracksColumns.ACTIVITY_TYPE_LOCALIZED, + TracksColumns.STARTTIME, + TracksColumns.STARTTIME_OFFSET, + TracksColumns.STOPTIME, + TracksColumns.TOTALDISTANCE, + TracksColumns.TOTALTIME, + TracksColumns.MOVINGTIME, + TracksColumns.MAXSPEED, + TracksColumns.MIN_ALTITUDE, + TracksColumns.MAX_ALTITUDE, + TracksColumns.ALTITUDE_GAIN, + TracksColumns.ALTITUDE_LOSS, + }); + cursor.addRow(new Object[]{ + trackId.id(), + UUIDUtils.toBytes(UUID.randomUUID()), + name, + "", + ActivityType.UNKNOWN.getId(), + "", + 1_000L, + ZoneOffset.UTC.getTotalSeconds(), + 2_000L, + 123.45f, + 1_000L, + 500L, + 12.34f, + null, + null, + null, + null, + }); + assertTrue(cursor.moveToFirst()); + + Track track = ContentProviderUtils.createTrack(cursor); assertEquals(trackId, track.id()); assertEquals(name, track.name()); } @@ -449,8 +461,6 @@ public class CustomContentProviderUtilsTest { marker.setDescription(TEST_DESC); contentProviderUtils.insertMarker(marker.getMarker()); - ContentProviderUtils contentProviderUtils = new ContentProviderUtils(contentResolverMock); - Marker.Id markerId = new Marker.Id(System.currentTimeMillis()); marker.setId(markerId); ContentValues contentValues = contentProviderUtils.createContentValues(marker.getMarker(), trackId); diff --git a/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceNotificationManagerTest.java b/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceNotificationManagerTest.java index 5a22fc87f..b6de1cd9f 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceNotificationManagerTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceNotificationManagerTest.java @@ -1,24 +1,23 @@ package de.dennisguse.opentracks.services; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.junit.Assert.assertEquals; +import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; import androidx.core.app.NotificationCompat; import androidx.test.core.app.ApplicationProvider; +import org.junit.After; import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; +import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.data.models.ActivityType; import de.dennisguse.opentracks.data.models.Altitude; import de.dennisguse.opentracks.data.models.Distance; @@ -29,32 +28,24 @@ import de.dennisguse.opentracks.data.models.Track; import de.dennisguse.opentracks.data.models.TrackPoint; import de.dennisguse.opentracks.settings.UnitSystem; -@RunWith(MockitoJUnitRunner.class) public class TrackRecordingServiceNotificationManagerTest { + private static final String CHANNEL_ID = "TrackRecordingServiceNotificationManagerTest"; + private final Context context = ApplicationProvider.getApplicationContext(); + private final NotificationManager notificationManager = context.getSystemService(NotificationManager.class); - @Mock - private NotificationCompat.Builder notificationCompatBuilder; - - @Mock - private NotificationManager notificationManager; + @After + public void tearDown() { + notificationManager.cancelAll(); + } @Test public void updateLocation_triggersAlertOnlyOnFirstInaccurateLocation() { - TrackPoint trackPointMock = new TrackPoint(TrackPoint.Type.TRACKPOINT, - new Position( - null, - null, null, Distance.of(999f), - Altitude.WGS84.of(10), null, - null, - Speed.ZERO) - ); + notificationManager.createNotificationChannel(new NotificationChannel(CHANNEL_ID, CHANNEL_ID, NotificationManager.IMPORTANCE_DEFAULT)); - when(notificationCompatBuilder.setOnlyAlertOnce(anyBoolean())) - .thenReturn(notificationCompatBuilder); - - TrackRecordingServiceNotificationManager subject = new TrackRecordingServiceNotificationManager(notificationManager, notificationCompatBuilder); + TrackingNotificationBuilder notificationBuilder = new TrackingNotificationBuilder(context, CHANNEL_ID); + TrackRecordingServiceNotificationManager subject = new TrackRecordingServiceNotificationManager(notificationManager, notificationBuilder); subject.setUnitSystem(UnitSystem.METRIC); Track track = new Track( @@ -68,16 +59,42 @@ public class TrackRecordingServiceNotificationManagerTest { Statistics.DEFAULT ); - RecordingData data = new RecordingData(track, trackPointMock, null, null); - - // when - subject.updateTrackPoint(context, data, Distance.of(100)); - subject.updateTrackPoint(context, data, Distance.of(100)); - subject.updateTrackPoint(context, data, Distance.of(1000)); - subject.updateTrackPoint(context, data, Distance.of(100)); - - // then - verify(notificationCompatBuilder, times(6)).setOnlyAlertOnce(true); - verify(notificationCompatBuilder, times(2)).setOnlyAlertOnce(false); + subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(100)); + subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(100)); + subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(1000)); + subject.updateTrackPoint(context, createRecordingData(track, Distance.of(999f)), Distance.of(100)); + assertEquals(java.util.Arrays.asList(false, true, true, true, true, true, false, true), notificationBuilder.onlyAlertOnceValues); } -} \ No newline at end of file + + private RecordingData createRecordingData(Track track, Distance horizontalAccuracy) { + TrackPoint trackPoint = new TrackPoint( + TrackPoint.Type.TRACKPOINT, + new Position( + null, + null, + null, + horizontalAccuracy, + Altitude.WGS84.of(10), + null, + null, + Speed.ZERO + ) + ); + return new RecordingData(track, trackPoint, null, null); + } + + private static final class TrackingNotificationBuilder extends NotificationCompat.Builder { + private final List onlyAlertOnceValues = new ArrayList<>(); + + private TrackingNotificationBuilder(Context context, String channelId) { + super(context, channelId); + setSmallIcon(R.drawable.ic_logo_color_24dp); + } + + @Override + public NotificationCompat.Builder setOnlyAlertOnce(boolean onlyAlertOnce) { + onlyAlertOnceValues.add(onlyAlertOnce); + return super.setOnlyAlertOnce(onlyAlertOnce); + } + } +} diff --git a/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceStateMachineTest.java b/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceStateMachineTest.java index a28099d66..1764da8a0 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceStateMachineTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceStateMachineTest.java @@ -15,6 +15,7 @@ */ package de.dennisguse.opentracks.services; +import static de.dennisguse.opentracks.util.LiveDataTestUtils.waitForValue; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; @@ -41,6 +42,7 @@ import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import java.time.Duration; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -56,6 +58,8 @@ import de.dennisguse.opentracks.sensors.GpsStatusValue; @RunWith(AndroidJUnit4.class) public class TrackRecordingServiceStateMachineTest { + private static final Duration WAIT_TIMEOUT = Duration.ofSeconds(2); + @Rule public final ServiceTestRule mServiceRule = ServiceTestRule.withTimeout(5, TimeUnit.SECONDS); @@ -123,6 +127,7 @@ public class TrackRecordingServiceStateMachineTest { // when service.tryStartSensors(); + waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED); // then assertEquals(TrackRecordingService.STATUS_DEFAULT, service.getRecordingStatusObservable().getValue()); @@ -131,6 +136,7 @@ public class TrackRecordingServiceStateMachineTest { // when service.stopSensors(); + waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE); // then assertEquals(TrackRecordingService.STATUS_DEFAULT, service.getRecordingStatusObservable().getValue()); @@ -146,6 +152,9 @@ public class TrackRecordingServiceStateMachineTest { // when Track.Id trackId = service.startNewTrack(); + waitUntilRecordingStatusIs(new RecordingStatus(trackId)); + waitUntilRecordingDataIsNotDefault(); + waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED); // then assertTrue(service.isRecording()); @@ -154,9 +163,13 @@ public class TrackRecordingServiceStateMachineTest { assertEquals(GpsStatusValue.GPS_ENABLED, service.getGpsStatusObservable().getValue()); service.endCurrentTrack(); + waitUntilRecordingStatusIs(TrackRecordingService.STATUS_DEFAULT); + waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE); // when service.resumeTrack(trackId); + waitUntilRecordingStatusIs(new RecordingStatus(trackId)); + waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED); // then assertTrue(service.isRecording()); @@ -167,6 +180,8 @@ public class TrackRecordingServiceStateMachineTest { // when service.endCurrentTrack(); + waitUntilRecordingStatusIs(TrackRecordingService.STATUS_DEFAULT); + waitUntilGpsStatusIs(GpsStatusValue.GPS_NONE); // then assertFalse(service.isRecording()); @@ -177,6 +192,8 @@ public class TrackRecordingServiceStateMachineTest { // when service.resumeTrack(trackId); + waitUntilRecordingStatusIs(new RecordingStatus(trackId)); + waitUntilGpsStatusIs(GpsStatusValue.GPS_ENABLED); // then assertTrue(service.isRecording()); @@ -254,4 +271,25 @@ public class TrackRecordingServiceStateMachineTest { assertNotNull(trackId); assertNull(newTrackId); } + + private void waitUntilRecordingDataIsNotDefault() { + waitForValue(service.getRecordingDataObservable(), + recordingData -> !RecordingData.NOT_RECORDING.equals(recordingData), + WAIT_TIMEOUT, + "recording data to leave NOT_RECORDING"); + } + + private void waitUntilGpsStatusIs(GpsStatusValue expected) { + waitForValue(service.getGpsStatusObservable(), + expected::equals, + WAIT_TIMEOUT, + "gps status to become " + expected); + } + + private void waitUntilRecordingStatusIs(RecordingStatus expected) { + waitForValue(service.getRecordingStatusObservable(), + expected::equals, + WAIT_TIMEOUT, + "recording status to become " + expected); + } } diff --git a/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceUITest.java b/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceUITest.java index c6f999260..9adc6748a 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceUITest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/services/TrackRecordingServiceUITest.java @@ -2,12 +2,12 @@ package de.dennisguse.opentracks.services; import static org.junit.Assert.assertEquals; import static de.dennisguse.opentracks.services.TrackRecordingService.RECORDING_DATA_UPDATE_INTERVAL; +import static de.dennisguse.opentracks.util.LiveDataTestUtils.waitForValue; import android.content.Context; import android.content.Intent; import android.os.Looper; -import androidx.core.location.LocationManagerCompat; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.rule.GrantPermissionRule; @@ -30,6 +30,7 @@ import de.dennisguse.opentracks.data.models.Speed; import de.dennisguse.opentracks.data.models.TrackPoint; import de.dennisguse.opentracks.io.file.importer.ExportImportTest; import de.dennisguse.opentracks.io.file.importer.TrackPointAssert; +import de.dennisguse.opentracks.sensors.GpsHandler; import de.dennisguse.opentracks.sensors.driver.GpsInternal; import de.dennisguse.opentracks.services.handlers.TrackPointCreator; @@ -51,7 +52,7 @@ public class TrackRecordingServiceUITest { } @Test - public void getDataForUI_gps() throws TimeoutException, InterruptedException { + public void getDataForUI_gps() throws TimeoutException { TrackRecordingService service = ((TrackRecordingService.Binder) mServiceRule.bindService(new Intent(context, TrackRecordingService.class))) .getService(); RecordingData recordingData; @@ -63,34 +64,23 @@ public class TrackRecordingServiceUITest { // when service.startNewTrack(); - //We do not want the real GPS to interfere. - GpsInternal gpsInternal = (GpsInternal)trackPointCreator.getSensorManager().getGpsHandler().driver; - LocationManagerCompat.removeUpdates(gpsInternal.locationManager, gpsInternal.locationListenerCompat); - Thread.sleep(RECORDING_DATA_UPDATE_INTERVAL.toMillis()); + disconnectRealGps(trackPointCreator.getSensorManager().getGpsHandler()); + trackPointCreator.getSensorManager().reset(); + RecordingData initialRecordingData = waitUntilLatestTrackPointMatches(service, Instant.parse("2020-02-02T02:02:02Z"), false); // then - assertEquals(new TrackPoint( - null, - TrackPoint.Type.TRACKPOINT, - Position.of( Instant.parse("2020-02-02T02:02:02Z")), - null, - null, - null, - null, - null, - null - ), - service.getRecordingDataObservable().getValue().latestTrackPoint()); + TrackPoint initialTrackPoint = initialRecordingData.latestTrackPoint(); + assertEquals(TrackPoint.Type.TRACKPOINT, initialTrackPoint.type()); + assertEquals(Instant.parse("2020-02-02T02:02:02Z"), initialTrackPoint.getTime()); // when // In this step, the data is saved (and Aggregator.resetAggregated called), but data should still be shown in the UI. trackPointCreator.setClock("2020-02-02T02:02:03Z"); ExportImportTest.sendLocation(trackPointCreator, "2020-02-02T02:02:03Z", 3.1234567, 14.0014567, 10, 13, 15, 1020.25, 1f); + recordingData = waitUntilLatestTrackPointMatches(service, Instant.parse("2020-02-02T02:02:03Z"), true); // then - Thread.sleep(RECORDING_DATA_UPDATE_INTERVAL.toMillis()); - recordingData = service.getRecordingDataObservable().getValue(); new TrackPointAssert().assertEquals(new TrackPoint( null, TrackPoint.Type.TRACKPOINT, @@ -109,4 +99,23 @@ public class TrackRecordingServiceUITest { ), recordingData.latestTrackPoint()); } -} \ No newline at end of file + + private void disconnectRealGps(GpsHandler gpsHandler) { + if (gpsHandler.driver != null) { + gpsHandler.driver.disconnect(); + } + gpsHandler.onConnected(null, GpsInternal.LOCATION_PROVIDER); + } + + private RecordingData waitUntilLatestTrackPointMatches(TrackRecordingService service, Instant expectedTime, boolean requiresLocation) { + return waitForValue(service.getRecordingDataObservable(), + recordingData -> { + TrackPoint latestTrackPoint = recordingData != null ? recordingData.latestTrackPoint() : null; + return latestTrackPoint != null + && expectedTime.equals(latestTrackPoint.getTime()) + && (!requiresLocation || latestTrackPoint.position().hasLocation()); + }, + RECORDING_DATA_UPDATE_INTERVAL.multipliedBy(3), + "latest track point to reach " + expectedTime + (requiresLocation ? " with location" : "")); + } +} diff --git a/src/androidTest/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtilsTest.java b/src/androidTest/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtilsTest.java index 9fa1a6400..a4c3fce90 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtilsTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/services/announcement/VoiceAnnouncementUtilsTest.java @@ -556,6 +556,7 @@ public class VoiceAnnouncementUtilsTest { PreferencesUtils.setVoiceAnnounceLapHeartRate(true); Pair data = buildTrackWithTrackPoints(); + int lapHeartRate = Math.round(data.second.avgHeartRate().getBPM()); SensorStatistics sensorStatistics = new SensorStatistics(HeartRate.of(180f), HeartRate.of(180f), null, null, null, null); @@ -573,7 +574,7 @@ public class VoiceAnnouncementUtilsTest { String announcement = VoiceAnnouncementUtils.createStatistics(context, data.first, dataSet, UnitSystem.METRIC, true, data.second, sensorStatistics).toString(); // then - assertEquals("12:16 AM. Total distance 14.2 kilometers. 16 minutes 39 seconds. Average moving speed 51.2 kilometers per hour. Lap speed 51.2 kilometers per hour. Average heart rate 180 bpm. Lap heart rate 133 bpm.", announcement); + assertEquals("12:16 AM. Total distance 14.2 kilometers. 16 minutes 39 seconds. Average moving speed 51.2 kilometers per hour. Lap speed 51.2 kilometers per hour. Average heart rate 180 bpm. Lap heart rate " + lapHeartRate + " bpm.", announcement); } @Test @@ -589,6 +590,7 @@ public class VoiceAnnouncementUtilsTest { PreferencesUtils.setVoiceAnnounceLapSpeedPace(false); Pair data = buildTrackWithTrackPoints(); + int lapHeartRate = Math.round(data.second.avgHeartRate().getBPM()); SensorStatistics sensorStatistics = new SensorStatistics(HeartRate.of(180f), HeartRate.of(180f), null, null, null, null); @@ -606,7 +608,7 @@ public class VoiceAnnouncementUtilsTest { String announcement = VoiceAnnouncementUtils.createStatistics(context, data.first, dataSet, UnitSystem.METRIC, true, data.second, sensorStatistics).toString(); // then - assertEquals("12:16 AM. Current heart rate 60 bpm. Average heart rate 180 bpm. Lap heart rate 133 bpm.", announcement); + assertEquals("12:16 AM. Current heart rate 60 bpm. Average heart rate 180 bpm. Lap heart rate " + lapHeartRate + " bpm.", announcement); } @Test diff --git a/src/androidTest/java/de/dennisguse/opentracks/ui/aggregatedStatistics/EspressoAggregatedFilterTest.java b/src/androidTest/java/de/dennisguse/opentracks/ui/aggregatedStatistics/EspressoAggregatedFilterTest.java index 5bbe07b5f..e6cee6626 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/ui/aggregatedStatistics/EspressoAggregatedFilterTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/ui/aggregatedStatistics/EspressoAggregatedFilterTest.java @@ -5,32 +5,36 @@ import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withParent; import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static de.dennisguse.opentracks.util.EspressoUtils.waitForView; +import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedView; import static org.hamcrest.Matchers.allOf; -import static de.dennisguse.opentracks.util.EspressoUtils.waitFor; import android.util.Pair; +import android.view.View; +import androidx.test.core.app.ActivityScenario; import androidx.test.core.app.ApplicationProvider; -import androidx.test.espresso.matcher.ViewMatchers; -import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import androidx.test.rule.GrantPermissionRule; import org.hamcrest.core.IsInstanceOf; +import org.hamcrest.Matcher; +import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import java.time.Duration; import java.util.List; import de.dennisguse.opentracks.R; import de.dennisguse.opentracks.TestUtil; -import de.dennisguse.opentracks.TrackListActivity; import de.dennisguse.opentracks.content.data.TestDataUtil; import de.dennisguse.opentracks.data.ContentProviderUtils; import de.dennisguse.opentracks.data.models.Track; @@ -41,33 +45,41 @@ import de.dennisguse.opentracks.data.models.TrackPoint; @RunWith(AndroidJUnit4.class) public class EspressoAggregatedFilterTest { - private final String ACTIVITY_TYPE_LOCALIZED = "activityTypeLocalized"; + private static final Duration UI_TIMEOUT = Duration.ofSeconds(10); - @Rule - public ActivityScenarioRule mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class); + private final String ACTIVITY_TYPE_LOCALIZED = "activityTypeLocalized"; @Rule public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule(); + private ActivityScenario scenario; + @Before public void setUp() { + ContentProviderUtils contentProviderUtils = new ContentProviderUtils(ApplicationProvider.getApplicationContext()); + contentProviderUtils.deleteAllTracks(ApplicationProvider.getApplicationContext()); + Pair> pair = TestDataUtil.createTrack(new Track.Id(System.currentTimeMillis()), 20); TrackBuilder trackBuilder = new TrackBuilder(pair.first); trackBuilder.setActivityTypeLocalized(ACTIVITY_TYPE_LOCALIZED); - TestDataUtil.insertTrackWithLocations(new ContentProviderUtils(ApplicationProvider.getApplicationContext()), trackBuilder.getTrack(), pair.second); + TestDataUtil.insertTrackWithLocations(contentProviderUtils, trackBuilder.getTrack(), pair.second); + + scenario = ActivityScenario.launch(AggregatedStatisticsActivity.class); + } + + @After + public void tearDown() { + if (scenario != null) { + scenario.close(); + } } @Test public void espressoAggregatedFilterTest() { - // open AggregatedStatisticsActivity through toolbar's menu item - onView(ViewMatchers.withId(R.id.bottom_app_bar)) - .check(matches(isDisplayed())); - onView(withId(R.id.bottom_app_bar)) - .check(matches(hasDescendant(withId(R.id.aggregated_stats_button)))); - onView(withId(R.id.aggregated_stats_button)) - .perform(click()); + waitUntilDisplayed(withId(R.id.bottom_app_bar)); + waitUntilDisplayed(withId(R.id.aggregated_statistics_filter)); - // open FilterDialogFragment through toolbar's menu item + // open FilterDialogFragment through the current bottom app bar action item onView(withId(R.id.bottom_app_bar)) .check(matches(isDisplayed())); onView(withId(R.id.bottom_app_bar)) @@ -76,11 +88,13 @@ public class EspressoAggregatedFilterTest { .perform(click()); // check there's a checkbox with ACTIVITY_TYPE_LOCALIZED text - onView(allOf(withId(R.id.filter_dialog_check_button), withText(ACTIVITY_TYPE_LOCALIZED), + Matcher activityTypeFilter = allOf(withId(R.id.filter_dialog_check_button), withText(ACTIVITY_TYPE_LOCALIZED), withParent(allOf(withId(R.id.filter_items), withParent(IsInstanceOf.instanceOf(android.view.ViewGroup.class)))), - isDisplayed())) - .perform(waitFor(2000)); + isDisplayed()); + waitUntilDisplayedInCurrentRoot(activityTypeFilter); + onView(activityTypeFilter) + .check(matches(isDisplayed())); // check there's an edit text for "from date" onView(withId(R.id.filter_date_edit_text_from)) @@ -90,4 +104,12 @@ public class EspressoAggregatedFilterTest { onView(withId(R.id.filter_date_edit_text_to)) .check(matches(isDisplayed())); } + + private void waitUntilDisplayed(Matcher viewMatcher) { + waitForResumedView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT); + } + + private void waitUntilDisplayedInCurrentRoot(Matcher viewMatcher) { + onView(isRoot()).perform(waitForView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT)); + } } diff --git a/src/androidTest/java/de/dennisguse/opentracks/util/ActivityTestUtils.java b/src/androidTest/java/de/dennisguse/opentracks/util/ActivityTestUtils.java new file mode 100644 index 000000000..4500a2695 --- /dev/null +++ b/src/androidTest/java/de/dennisguse/opentracks/util/ActivityTestUtils.java @@ -0,0 +1,106 @@ +package de.dennisguse.opentracks.util; + +import static org.junit.Assert.fail; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; +import androidx.test.runner.lifecycle.Stage; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +public final class ActivityTestUtils { + + private ActivityTestUtils() { + } + + public static T waitForResumedActivity(Class activityClass, Duration timeout) { + T resumedActivity = getResumedActivity(activityClass); + if (resumedActivity != null) { + return resumedActivity; + } + + Application application = ApplicationProvider.getApplicationContext(); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference resumedActivityRef = new AtomicReference<>(); + + Application.ActivityLifecycleCallbacks callbacks = new Application.ActivityLifecycleCallbacks() { + @Override + public void onActivityResumed(Activity activity) { + if (activityClass.isInstance(activity)) { + resumedActivityRef.set(activityClass.cast(activity)); + latch.countDown(); + } + } + + @Override + public void onActivityCreated(Activity activity, Bundle savedInstanceState) { + } + + @Override + public void onActivityStarted(Activity activity) { + } + + @Override + public void onActivityPaused(Activity activity) { + } + + @Override + public void onActivityStopped(Activity activity) { + } + + @Override + public void onActivitySaveInstanceState(Activity activity, Bundle outState) { + } + + @Override + public void onActivityDestroyed(Activity activity) { + } + }; + + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + application.registerActivityLifecycleCallbacks(callbacks); + + T currentResumedActivity = findResumedActivity(activityClass); + if (currentResumedActivity != null) { + resumedActivityRef.set(currentResumedActivity); + latch.countDown(); + } + }); + + try { + if (!latch.await(timeout.toMillis(), TimeUnit.MILLISECONDS)) { + fail("Timed out waiting for resumed activity " + activityClass.getSimpleName()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail("Interrupted while waiting for resumed activity " + activityClass.getSimpleName()); + } finally { + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> application.unregisterActivityLifecycleCallbacks(callbacks)); + } + + return resumedActivityRef.get(); + } + + private static T getResumedActivity(Class activityClass) { + AtomicReference resumedActivity = new AtomicReference<>(); + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> resumedActivity.set(findResumedActivity(activityClass))); + return resumedActivity.get(); + } + + private static T findResumedActivity(Class activityClass) { + for (Activity activity : ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED)) { + if (activityClass.isInstance(activity)) { + return activityClass.cast(activity); + } + } + return null; + } +} diff --git a/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java b/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java index 3b282de89..6c6d539cb 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java +++ b/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java @@ -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 childAtPosition( final Matcher 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 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 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 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 viewMatcher, final Duration timeout) { + return waitForViewCondition(viewMatcher, ignored -> true, "view to match " + viewMatcher, timeout); + } + + public static ViewAction waitForViewCondition(final Matcher viewMatcher, + final Predicate 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 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 viewMatcher, final Duration timeout) { + waitForResumedViewCondition(viewMatcher, ignored -> true, "view to match " + viewMatcher, timeout); + } + + public static void waitForResumedViewCondition(final Matcher viewMatcher, + final Predicate 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 diff --git a/src/androidTest/java/de/dennisguse/opentracks/util/LiveDataTestUtils.java b/src/androidTest/java/de/dennisguse/opentracks/util/LiveDataTestUtils.java new file mode 100644 index 000000000..2174d6753 --- /dev/null +++ b/src/androidTest/java/de/dennisguse/opentracks/util/LiveDataTestUtils.java @@ -0,0 +1,57 @@ +package de.dennisguse.opentracks.util; + +import static org.junit.Assert.fail; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.test.platform.app.InstrumentationRegistry; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; + +public final class LiveDataTestUtils { + + private LiveDataTestUtils() { + } + + public static T waitForValue(LiveData liveData, + Predicate predicate, + Duration timeout, + String description) { + AtomicReference lastValue = new AtomicReference<>(liveData.getValue()); + CountDownLatch latch = new CountDownLatch(1); + + Observer observer = value -> { + lastValue.set(value); + if (predicate.test(value)) { + latch.countDown(); + } + }; + + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + liveData.observeForever(observer); + + T currentValue = liveData.getValue(); + lastValue.set(currentValue); + if (predicate.test(currentValue)) { + latch.countDown(); + } + }); + + try { + if (!latch.await(timeout.toMillis(), TimeUnit.MILLISECONDS)) { + fail("Timed out waiting for " + description + ". Last value: " + lastValue.get()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail("Interrupted while waiting for " + description); + } finally { + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> liveData.removeObserver(observer)); + } + + return lastValue.get(); + } +} diff --git a/src/androidTest/java/de/dennisguse/opentracks/util/TrackRecordingServiceTestUtils.java b/src/androidTest/java/de/dennisguse/opentracks/util/TrackRecordingServiceTestUtils.java new file mode 100644 index 000000000..4c9799563 --- /dev/null +++ b/src/androidTest/java/de/dennisguse/opentracks/util/TrackRecordingServiceTestUtils.java @@ -0,0 +1,50 @@ +package de.dennisguse.opentracks.util; + +import static org.junit.Assert.fail; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.platform.app.InstrumentationRegistry; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import de.dennisguse.opentracks.services.TrackRecordingService; +import de.dennisguse.opentracks.services.TrackRecordingServiceConnection; + +public final class TrackRecordingServiceTestUtils { + + private TrackRecordingServiceTestUtils() { + } + + public static void waitForRecordingState(boolean isRecording, Duration timeout) { + Context context = ApplicationProvider.getApplicationContext(); + CountDownLatch connectedLatch = new CountDownLatch(1); + AtomicReference serviceRef = new AtomicReference<>(); + TrackRecordingServiceConnection connection = new TrackRecordingServiceConnection((service, self) -> { + serviceRef.set(service); + connectedLatch.countDown(); + }); + + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> connection.bind(context)); + + try { + if (!connectedLatch.await(timeout.toMillis(), TimeUnit.MILLISECONDS)) { + fail("Timed out waiting to bind TrackRecordingService"); + } + + LiveDataTestUtils.waitForValue(serviceRef.get().getRecordingStatusObservable(), + status -> status != null && status.isRecording() == isRecording, + timeout, + "recording state to become " + isRecording); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + fail("Interrupted while waiting for recording state " + isRecording); + } finally { + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> connection.unbind(context)); + } + } +} From b679f00cc5e59d738a836b4e61ac80bdb790308f Mon Sep 17 00:00:00 2001 From: "Marcatili, Manuel" Date: Thu, 16 Apr 2026 10:11:58 +0300 Subject: [PATCH 2/6] Improve clicking behaviour --- .../opentracks/EspressoDeleteTrackTest.java | 42 +++--- .../EspressoEditTrackRecordingTest.java | 21 +-- .../dennisguse/opentracks/EspressoUITest.java | 32 +++-- .../EspressoAggregatedFilterTest.java | 35 +++-- .../opentracks/util/EspressoUtils.java | 132 ++++++++++++++++++ 5 files changed, 208 insertions(+), 54 deletions(-) diff --git a/src/androidTest/java/de/dennisguse/opentracks/EspressoDeleteTrackTest.java b/src/androidTest/java/de/dennisguse/opentracks/EspressoDeleteTrackTest.java index 91417a656..c29fdfabc 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/EspressoDeleteTrackTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/EspressoDeleteTrackTest.java @@ -2,20 +2,22 @@ package de.dennisguse.opentracks; import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; -import static androidx.test.espresso.action.ViewActions.longClick; import static androidx.test.espresso.action.ViewActions.replaceText; import static androidx.test.espresso.action.ViewActions.scrollTo; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isRoot; import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static androidx.test.espresso.matcher.RootMatchers.isDialog; +import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup; import static de.dennisguse.opentracks.util.ActivityTestUtils.waitForResumedActivity; -import static de.dennisguse.opentracks.util.EspressoUtils.waitForView; +import static de.dennisguse.opentracks.util.EspressoUtils.performClick; +import static de.dennisguse.opentracks.util.EspressoUtils.performClickOnAncestor; +import static de.dennisguse.opentracks.util.EspressoUtils.performLongClick; +import static de.dennisguse.opentracks.util.EspressoUtils.performLongClickOnAncestor; import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedView; import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedViewCondition; import static de.dennisguse.opentracks.util.EspressoUtils.withRecyclerViewItemCount; @@ -66,11 +68,10 @@ public class EspressoDeleteTrackTest { // TrackStoppedActivity: discard the just-recorded track directly onView(withId(R.id.discard_button)) - .perform(click()); - waitUntilDisplayedInCurrentRoot(withText(android.R.string.ok)); - + .perform(performClick()); onView(withText(android.R.string.ok)) - .perform(click()); + .inRoot(isDialog()) + .perform(performClick()); waitForResumedActivity(TrackListActivity.class, UI_TIMEOUT); waitUntilDisplayed(withId(R.id.track_list)); waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0); @@ -89,20 +90,19 @@ public class EspressoDeleteTrackTest { .perform(scrollTo(), replaceText(uniqueTrackName), closeSoftKeyboard()); onView(withId(R.id.finish_button)) - .perform(click()); + .perform(performClick()); waitForResumedActivity(TrackListActivity.class, UI_TIMEOUT); waitUntilDisplayed(withId(R.id.track_list)); waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))); waitUntilRecyclerViewItemCount(withId(R.id.track_list), 1); onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))) - .perform(longClick()); + .perform(performLongClickOnAncestor()); clickDeleteSelectedTrackAction(); - waitUntilDisplayedInCurrentRoot(withText(android.R.string.ok)); - onView(withText(android.R.string.ok)) - .perform(click()); + .inRoot(isDialog()) + .perform(performClick()); waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0); onView(withId(R.id.track_list)) @@ -112,14 +112,14 @@ public class EspressoDeleteTrackTest { private void recordTrackAndOpenTrackStoppedActivity() { // TrackListActivity: start recording onView(withId(R.id.track_list_fab_action)) - .perform(click()); + .perform(performClick()); waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); waitForRecordingState(true, UI_TIMEOUT); waitUntilDisplayed(withId(R.id.track_recording_fab_action)); // TrackRecordingActivity: stop recording and open TrackStoppedActivity onView(withId(R.id.track_recording_fab_action)) - .perform(longClick()); + .perform(performLongClick()); waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); waitUntilDisplayed(withId(R.id.track_edit_name)); waitUntilDisplayed(withId(R.id.discard_button)); @@ -132,15 +132,15 @@ public class EspressoDeleteTrackTest { try { waitUntilDisplayed(withId(R.id.list_context_menu_delete)); onView(withId(R.id.list_context_menu_delete)) - .perform(click()); + .perform(performClick()); } catch (AssertionError ignored) { onView(allOf( withContentDescription(androidx.appcompat.R.string.abc_action_menu_overflow_description), isDescendantOfA(withId(androidx.appcompat.R.id.action_mode_bar)))) - .perform(click()); - waitUntilDisplayedInCurrentRoot(withText(R.string.menu_delete)); + .perform(performClick()); onView(withText(R.string.menu_delete)) - .perform(click()); + .inRoot(isPlatformPopup()) + .perform(performClickOnAncestor()); } } @@ -148,10 +148,6 @@ public class EspressoDeleteTrackTest { waitForResumedView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT); } - private void waitUntilDisplayedInCurrentRoot(Matcher viewMatcher) { - onView(isRoot()).perform(waitForView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT)); - } - private void waitUntilRecyclerViewItemCount(Matcher viewMatcher, int expectedCount) { waitForResumedViewCondition( viewMatcher, diff --git a/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java b/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java index a80b08ea9..fcfeabdd1 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java @@ -2,18 +2,20 @@ package de.dennisguse.opentracks; import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.Espresso.openContextualActionModeOverflowMenu; -import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; -import static androidx.test.espresso.action.ViewActions.longClick; import static androidx.test.espresso.action.ViewActions.replaceText; import static androidx.test.espresso.action.ViewActions.scrollTo; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; import static androidx.test.espresso.matcher.ViewMatchers.isRoot; +import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static de.dennisguse.opentracks.util.ActivityTestUtils.waitForResumedActivity; +import static de.dennisguse.opentracks.util.EspressoUtils.performClick; +import static de.dennisguse.opentracks.util.EspressoUtils.performClickOnAncestor; +import static de.dennisguse.opentracks.util.EspressoUtils.performLongClick; import static de.dennisguse.opentracks.util.EspressoUtils.waitForView; import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedView; import static de.dennisguse.opentracks.util.TrackRecordingServiceTestUtils.waitForRecordingState; @@ -67,7 +69,7 @@ public class EspressoEditTrackRecordingTest { { // TrackListActivity: start recording onView(withId(R.id.track_list_fab_action)) - .perform(click()); + .perform(performClick()); waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); waitForRecordingState(true, UI_TIMEOUT); waitUntilDisplayed(withId(R.id.track_recording_fab_action)); @@ -76,12 +78,15 @@ public class EspressoEditTrackRecordingTest { // TrackRecordingActivity // open menu - openContextualActionModeOverflowMenu(); + onView(allOf( + withContentDescription(androidx.appcompat.R.string.abc_action_menu_overflow_description), + isDescendantOfA(withId(R.id.bottom_app_bar)))) + .perform(performClick()); waitUntilDisplayedInCurrentRoot(withText(R.string.menu_edit)); // Click the item. onView(withText(R.string.menu_edit)) - .perform(click()); + .perform(performClickOnAncestor()); waitUntilDisplayed(withId(R.id.track_edit_name)); // change name for "New Name" @@ -93,12 +98,12 @@ public class EspressoEditTrackRecordingTest { // save edition onView(withId(R.id.track_edit_save)) - .perform(click()); + .perform(performClick()); waitUntilDisplayed(withId(R.id.track_recording_fab_action)); // stop; onView(withId(R.id.track_recording_fab_action)) - .perform(longClick()); + .perform(performLongClick()); waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); waitUntilDisplayed(withId(R.id.resume_button)); waitUntilDisplayed(withId(R.id.finish_button)); diff --git a/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java b/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java index fea4b26dc..b0206ac32 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java @@ -1,15 +1,16 @@ package de.dennisguse.opentracks; import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; -import static androidx.test.espresso.action.ViewActions.longClick; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.isClickable; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static de.dennisguse.opentracks.util.ActivityTestUtils.waitForResumedActivity; +import static de.dennisguse.opentracks.util.EspressoUtils.performClick; +import static de.dennisguse.opentracks.util.EspressoUtils.performLongClick; +import static de.dennisguse.opentracks.util.EspressoUtils.performLongClickOnAncestor; import static de.dennisguse.opentracks.util.EspressoUtils.selectTabAtIndex; import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedView; import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedViewCondition; @@ -34,6 +35,7 @@ import org.hamcrest.Matcher; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.RuleChain; import org.junit.runner.RunWith; import java.time.Duration; @@ -50,11 +52,12 @@ public class EspressoUITest { private static final Duration UI_TIMEOUT = Duration.ofSeconds(10); - @Rule - public ActivityScenarioRule mActivityTestRule = new ActivityScenarioRule<>(TrackListActivity.class); + private final ActivityScenarioRule activityRule = new ActivityScenarioRule<>(TrackListActivity.class); + private final GrantPermissionRule grantPermissionRule = TestUtil.createGrantPermissionRule(); @Rule - public GrantPermissionRule mGrantPermissionRule = TestUtil.createGrantPermissionRule(); + public RuleChain rules = RuleChain.outerRule(grantPermissionRule) + .around(activityRule); @Before public void setUp() { @@ -69,7 +72,7 @@ public class EspressoUITest { { // TrackListActivity: start recording onView(withId(R.id.track_list_fab_action)) - .perform(click()); + .perform(performClick()); waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); waitForRecordingState(true, UI_TIMEOUT); waitUntilDisplayed(withId(R.id.track_recording_fab_action)); @@ -77,7 +80,7 @@ public class EspressoUITest { { // TrackRecordingActivity: stop onView(withId(R.id.track_recording_fab_action)) - .perform(longClick()); + .perform(performLongClick()); waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); waitUntilDisplayed(withId(R.id.track_edit_name)); waitUntilDisplayed(withId(R.id.resume_button)); @@ -86,14 +89,14 @@ public class EspressoUITest { // TrackStoppedActivity: resume onView(allOf(withId(R.id.resume_button), isClickable())) - .perform(click()); + .perform(performClick()); waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); waitForRecordingState(true, UI_TIMEOUT); waitUntilDisplayed(withId(R.id.track_recording_fab_action)); // TrackRecordingActivity: stop again onView(withId(R.id.track_recording_fab_action)) - .perform(longClick()); + .perform(performLongClick()); waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); waitUntilDisplayed(withId(R.id.track_edit_name)); waitUntilDisplayed(withId(R.id.finish_button)); @@ -102,7 +105,7 @@ public class EspressoUITest { // TrackStoppedActivity onView(withId(R.id.finish_button)) - .perform(click()); + .perform(performClick()); waitForResumedActivity(TrackListActivity.class, UI_TIMEOUT); waitUntilDisplayed(withId(R.id.track_list)); } @@ -114,7 +117,7 @@ public class EspressoUITest { { // TrackListActivity: start recording onView(withId(R.id.track_list_fab_action)) - .perform(click()); + .perform(performClick()); waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); waitForRecordingState(true, UI_TIMEOUT); waitUntilDisplayed(withId(R.id.track_detail_activity_tablayout)); @@ -138,7 +141,7 @@ public class EspressoUITest { // stop onView(withId(R.id.track_recording_fab_action)) - .perform(longClick()); + .perform(performLongClick()); waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); waitUntilDisplayed(withId(R.id.resume_button)); } @@ -152,11 +155,12 @@ public class EspressoUITest { TrackBuilder trackBuilder = new TrackBuilder(pair.first); trackBuilder.setName(uniqueTrackName); TestDataUtil.insertTrackWithLocations(new ContentProviderUtils(ApplicationProvider.getApplicationContext()), trackBuilder.getTrack(), pair.second); - mActivityTestRule.getScenario().recreate(); + activityRule.getScenario().recreate(); waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))); onView(withId(R.id.track_list)).check(matches(isDisplayed())); - onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))).perform(longClick()); + onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))) + .perform(performLongClickOnAncestor()); } private void waitUntilDisplayed(Matcher viewMatcher) { diff --git a/src/androidTest/java/de/dennisguse/opentracks/ui/aggregatedStatistics/EspressoAggregatedFilterTest.java b/src/androidTest/java/de/dennisguse/opentracks/ui/aggregatedStatistics/EspressoAggregatedFilterTest.java index e6cee6626..355acbddc 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/ui/aggregatedStatistics/EspressoAggregatedFilterTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/ui/aggregatedStatistics/EspressoAggregatedFilterTest.java @@ -1,18 +1,21 @@ package de.dennisguse.opentracks.ui.aggregatedStatistics; import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.RootMatchers.isDialog; import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isRoot; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withParent; import static androidx.test.espresso.matcher.ViewMatchers.withText; -import static de.dennisguse.opentracks.util.EspressoUtils.waitForView; +import static de.dennisguse.opentracks.util.EspressoUtils.performClick; import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedView; +import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedViewCondition; +import static de.dennisguse.opentracks.util.EspressoUtils.withRecyclerViewItemCount; import static org.hamcrest.Matchers.allOf; +import android.content.Context; +import android.content.Intent; import android.util.Pair; import android.view.View; @@ -40,6 +43,7 @@ import de.dennisguse.opentracks.data.ContentProviderUtils; import de.dennisguse.opentracks.data.models.Track; import de.dennisguse.opentracks.data.models.TrackBuilder; import de.dennisguse.opentracks.data.models.TrackPoint; +import de.dennisguse.opentracks.services.TrackRecordingService; @LargeTest @RunWith(AndroidJUnit4.class) @@ -56,8 +60,11 @@ public class EspressoAggregatedFilterTest { @Before public void setUp() { - ContentProviderUtils contentProviderUtils = new ContentProviderUtils(ApplicationProvider.getApplicationContext()); - contentProviderUtils.deleteAllTracks(ApplicationProvider.getApplicationContext()); + Context context = ApplicationProvider.getApplicationContext(); + context.stopService(new Intent(context, TrackRecordingService.class)); + + ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context); + contentProviderUtils.deleteAllTracks(context); Pair> pair = TestDataUtil.createTrack(new Track.Id(System.currentTimeMillis()), 20); TrackBuilder trackBuilder = new TrackBuilder(pair.first); @@ -69,6 +76,8 @@ public class EspressoAggregatedFilterTest { @After public void tearDown() { + Context context = ApplicationProvider.getApplicationContext(); + context.stopService(new Intent(context, TrackRecordingService.class)); if (scenario != null) { scenario.close(); } @@ -78,6 +87,8 @@ public class EspressoAggregatedFilterTest { public void espressoAggregatedFilterTest() { waitUntilDisplayed(withId(R.id.bottom_app_bar)); waitUntilDisplayed(withId(R.id.aggregated_statistics_filter)); + waitUntilRecyclerViewItemCount(withId(R.id.aggregated_stats_list), 1); + waitUntilDisplayed(allOf(withId(R.id.aggregated_stats_type_label), withText(ACTIVITY_TYPE_LOCALIZED))); // open FilterDialogFragment through the current bottom app bar action item onView(withId(R.id.bottom_app_bar)) @@ -85,23 +96,25 @@ public class EspressoAggregatedFilterTest { onView(withId(R.id.bottom_app_bar)) .check(matches(hasDescendant(withId(R.id.aggregated_statistics_filter)))); onView(withId(R.id.aggregated_statistics_filter)) - .perform(click()); + .perform(performClick()); // check there's a checkbox with ACTIVITY_TYPE_LOCALIZED text Matcher activityTypeFilter = allOf(withId(R.id.filter_dialog_check_button), withText(ACTIVITY_TYPE_LOCALIZED), withParent(allOf(withId(R.id.filter_items), withParent(IsInstanceOf.instanceOf(android.view.ViewGroup.class)))), isDisplayed()); - waitUntilDisplayedInCurrentRoot(activityTypeFilter); onView(activityTypeFilter) + .inRoot(isDialog()) .check(matches(isDisplayed())); // check there's an edit text for "from date" onView(withId(R.id.filter_date_edit_text_from)) + .inRoot(isDialog()) .check(matches(isDisplayed())); // check there's an edit text for "to date" onView(withId(R.id.filter_date_edit_text_to)) + .inRoot(isDialog()) .check(matches(isDisplayed())); } @@ -109,7 +122,11 @@ public class EspressoAggregatedFilterTest { waitForResumedView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT); } - private void waitUntilDisplayedInCurrentRoot(Matcher viewMatcher) { - onView(isRoot()).perform(waitForView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT)); + private void waitUntilRecyclerViewItemCount(Matcher viewMatcher, int expectedCount) { + waitForResumedViewCondition( + allOf(viewMatcher, isDisplayed()), + view -> withRecyclerViewItemCount(expectedCount).matches(view), + "RecyclerView item count to be " + expectedCount, + UI_TIMEOUT); } } diff --git a/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java b/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java index 6c6d539cb..0ac4df014 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java +++ b/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java @@ -8,6 +8,7 @@ import android.os.SystemClock; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; +import android.widget.AdapterView; import android.widget.ListView; import androidx.recyclerview.widget.RecyclerView; @@ -221,4 +222,135 @@ public class EspressoUtils { }; } + public static ViewAction performClick() { + return new ViewAction() { + @Override + public String getDescription() { + return "invoke View.performClick()"; + } + + @Override + public Matcher getConstraints() { + return isDisplayed(); + } + + @Override + public void perform(UiController uiController, View view) { + if (!view.performClick()) { + throw new PerformException.Builder() + .withActionDescription(getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .build(); + } + uiController.loopMainThreadUntilIdle(); + } + }; + } + + public static ViewAction performClickOnAncestor() { + return new ViewAction() { + @Override + public String getDescription() { + return "invoke performClick() on the matched view or its ancestor"; + } + + @Override + public Matcher getConstraints() { + return isDisplayed(); + } + + @Override + public void perform(UiController uiController, View view) { + View current = view; + while (current != null) { + if (current.performClick()) { + uiController.loopMainThreadUntilIdle(); + return; + } + + ViewParent parent = current.getParent(); + current = parent instanceof View ? (View) parent : null; + } + + current = view; + while (current != null) { + ViewParent parent = current.getParent(); + if (parent instanceof AdapterView adapterView) { + int position = adapterView.getPositionForView(current); + if (position != AdapterView.INVALID_POSITION + && adapterView.performItemClick(current, position, adapterView.getAdapter().getItemId(position))) { + uiController.loopMainThreadUntilIdle(); + return; + } + } + + current = parent instanceof View ? (View) parent : null; + } + + throw new PerformException.Builder() + .withActionDescription(getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .build(); + } + }; + } + + public static ViewAction performLongClick() { + return new ViewAction() { + @Override + public String getDescription() { + return "invoke View.performLongClick()"; + } + + @Override + public Matcher getConstraints() { + return isDisplayed(); + } + + @Override + public void perform(UiController uiController, View view) { + if (!view.performLongClick()) { + throw new PerformException.Builder() + .withActionDescription(getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .build(); + } + uiController.loopMainThreadUntilIdle(); + } + }; + } + + public static ViewAction performLongClickOnAncestor() { + return new ViewAction() { + @Override + public String getDescription() { + return "invoke performLongClick() on the matched view or its ancestor"; + } + + @Override + public Matcher getConstraints() { + return isDisplayed(); + } + + @Override + public void perform(UiController uiController, View view) { + View current = view; + while (current != null) { + if (current.performLongClick()) { + uiController.loopMainThreadUntilIdle(); + return; + } + + ViewParent parent = current.getParent(); + current = parent instanceof View ? (View) parent : null; + } + + throw new PerformException.Builder() + .withActionDescription(getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .build(); + } + }; + } + } From def4f3a1b953201872b1e0ba30bc48cae38644e5 Mon Sep 17 00:00:00 2001 From: "Marcatili, Manuel" Date: Thu, 16 Apr 2026 10:26:11 +0300 Subject: [PATCH 3/6] Cleanup unused utils --- .../EspressoEditTrackRecordingTest.java | 11 +-- .../opentracks/util/EspressoUtils.java | 84 +------------------ 2 files changed, 4 insertions(+), 91 deletions(-) diff --git a/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java b/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java index fcfeabdd1..e613222cc 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java @@ -6,9 +6,9 @@ import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; import static androidx.test.espresso.action.ViewActions.replaceText; import static androidx.test.espresso.action.ViewActions.scrollTo; import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; -import static androidx.test.espresso.matcher.ViewMatchers.isRoot; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; @@ -16,7 +16,6 @@ import static de.dennisguse.opentracks.util.ActivityTestUtils.waitForResumedActi import static de.dennisguse.opentracks.util.EspressoUtils.performClick; import static de.dennisguse.opentracks.util.EspressoUtils.performClickOnAncestor; import static de.dennisguse.opentracks.util.EspressoUtils.performLongClick; -import static de.dennisguse.opentracks.util.EspressoUtils.waitForView; import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedView; import static de.dennisguse.opentracks.util.TrackRecordingServiceTestUtils.waitForRecordingState; import static org.hamcrest.Matchers.allOf; @@ -82,10 +81,8 @@ public class EspressoEditTrackRecordingTest { withContentDescription(androidx.appcompat.R.string.abc_action_menu_overflow_description), isDescendantOfA(withId(R.id.bottom_app_bar)))) .perform(performClick()); - waitUntilDisplayedInCurrentRoot(withText(R.string.menu_edit)); - - // Click the item. onView(withText(R.string.menu_edit)) + .inRoot(isPlatformPopup()) .perform(performClickOnAncestor()); waitUntilDisplayed(withId(R.id.track_edit_name)); @@ -119,8 +116,4 @@ public class EspressoEditTrackRecordingTest { private void waitUntilDisplayed(Matcher viewMatcher) { waitForResumedView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT); } - - private void waitUntilDisplayedInCurrentRoot(Matcher viewMatcher) { - onView(isRoot()).perform(waitForView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT)); - } } diff --git a/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java b/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java index 0ac4df014..fb969cab8 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java +++ b/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java @@ -1,15 +1,13 @@ package de.dennisguse.opentracks.util; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; -import static androidx.test.espresso.matcher.ViewMatchers.isRoot; +import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom; import android.app.Activity; import android.os.SystemClock; import android.view.View; -import android.view.ViewGroup; import android.view.ViewParent; import android.widget.AdapterView; -import android.widget.ListView; import androidx.recyclerview.widget.RecyclerView; import androidx.test.espresso.PerformException; @@ -30,46 +28,12 @@ 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 childAtPosition( - final Matcher parentMatcher, final int position) { - - return new TypeSafeMatcher<>() { - @Override - public void describeTo(Description description) { - description.appendText("Child at position " + position + " in parent "); - parentMatcher.describeTo(description); - } - - @Override - public boolean matchesSafely(View view) { - ViewParent parent = view.getParent(); - return parent instanceof ViewGroup && parentMatcher.matches(parent) - && view.equals(((ViewGroup) parent).getChildAt(position)); - } - }; - } - - public static Matcher withListSize (final int size) { - return new TypeSafeMatcher<>() { - @Override - public boolean matchesSafely(final View view) { - return ((ListView) view).getCount() == size; - } - - @Override - public void describeTo(final Description description) { - description.appendText("ListView should have " + size + " items"); - } - }; - } - public static Matcher withRecyclerViewItemCount(final int count) { return new TypeSafeMatcher<>() { @Override @@ -120,50 +84,6 @@ public class EspressoUtils { }; } - public static ViewAction waitForView(final Matcher viewMatcher, final Duration timeout) { - return waitForViewCondition(viewMatcher, ignored -> true, "view to match " + viewMatcher, timeout); - } - - public static ViewAction waitForViewCondition(final Matcher viewMatcher, - final Predicate condition, - final String conditionDescription, - final Duration timeout) { - return new ViewAction() { - @Override - public String getDescription() { - return "wait up to " + timeout + " ms for " + conditionDescription; - } - - @Override - public Matcher getConstraints() { - return isRoot(); - } - - @Override - 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 viewMatcher, final Duration timeout) { waitForResumedViewCondition(viewMatcher, ignored -> true, "view to match " + viewMatcher, timeout); } @@ -256,7 +176,7 @@ public class EspressoUtils { @Override public Matcher getConstraints() { - return isDisplayed(); + return isAssignableFrom(View.class); } @Override From 81081b0ee5762ede85878b09b16e31c21c511d02 Mon Sep 17 00:00:00 2001 From: "Marcatili, Manuel" Date: Thu, 16 Apr 2026 11:00:26 +0300 Subject: [PATCH 4/6] reduce code duplication and creation of more test utils methods --- .../opentracks/EspressoDeleteTrackTest.java | 74 ++++------------ .../EspressoEditTrackRecordingTest.java | 42 ++------- .../dennisguse/opentracks/EspressoUITest.java | 87 ++++--------------- .../EspressoAggregatedFilterTest.java | 40 +++------ .../opentracks/util/EspressoUtils.java | 13 +++ .../opentracks/util/TestEnvironmentUtils.java | 30 +++++++ .../opentracks/util/TrackUiTestUtils.java | 70 +++++++++++++++ 7 files changed, 168 insertions(+), 188 deletions(-) create mode 100644 src/androidTest/java/de/dennisguse/opentracks/util/TestEnvironmentUtils.java create mode 100644 src/androidTest/java/de/dennisguse/opentracks/util/TrackUiTestUtils.java diff --git a/src/androidTest/java/de/dennisguse/opentracks/EspressoDeleteTrackTest.java b/src/androidTest/java/de/dennisguse/opentracks/EspressoDeleteTrackTest.java index c29fdfabc..1a06a94a5 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/EspressoDeleteTrackTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/EspressoDeleteTrackTest.java @@ -2,46 +2,36 @@ package de.dennisguse.opentracks; import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; -import static androidx.test.espresso.action.ViewActions.replaceText; -import static androidx.test.espresso.action.ViewActions.scrollTo; import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static androidx.test.espresso.matcher.RootMatchers.isDialog; import static androidx.test.espresso.matcher.RootMatchers.isPlatformPopup; -import static de.dennisguse.opentracks.util.ActivityTestUtils.waitForResumedActivity; import static de.dennisguse.opentracks.util.EspressoUtils.performClick; import static de.dennisguse.opentracks.util.EspressoUtils.performClickOnAncestor; -import static de.dennisguse.opentracks.util.EspressoUtils.performLongClick; import static de.dennisguse.opentracks.util.EspressoUtils.performLongClickOnAncestor; -import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedView; -import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedViewCondition; +import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed; +import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilRecyclerViewItemCount; import static de.dennisguse.opentracks.util.EspressoUtils.withRecyclerViewItemCount; -import static de.dennisguse.opentracks.util.TrackRecordingServiceTestUtils.waitForRecordingState; +import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks; +import static de.dennisguse.opentracks.util.TrackUiTestUtils.renameStoppedTrack; +import static de.dennisguse.opentracks.util.TrackUiTestUtils.startRecordingFromTrackList; +import static de.dennisguse.opentracks.util.TrackUiTestUtils.stopRecordingToTrackStopped; import static org.hamcrest.Matchers.allOf; -import android.content.Context; -import android.content.Intent; -import android.view.View; - -import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import androidx.test.rule.GrantPermissionRule; -import org.hamcrest.Matcher; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import java.time.Duration; -import de.dennisguse.opentracks.data.ContentProviderUtils; @LargeTest @RunWith(AndroidJUnit4.class) @@ -57,9 +47,7 @@ public class EspressoDeleteTrackTest { @Before public void setUp() { - Context context = ApplicationProvider.getApplicationContext(); - context.stopService(new Intent(context, de.dennisguse.opentracks.services.TrackRecordingService.class)); - new ContentProviderUtils(context).deleteAllTracks(context); + resetTrackRecordingServiceAndDeleteTracks(); } @Test @@ -72,9 +60,8 @@ public class EspressoDeleteTrackTest { onView(withText(android.R.string.ok)) .inRoot(isDialog()) .perform(performClick()); - waitForResumedActivity(TrackListActivity.class, UI_TIMEOUT); - waitUntilDisplayed(withId(R.id.track_list)); - waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0); + waitUntilDisplayed(withId(R.id.track_list), UI_TIMEOUT); + waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0, UI_TIMEOUT); // the recycler is empty again after discarding the only recorded track onView(withId(R.id.track_list)) @@ -86,15 +73,13 @@ public class EspressoDeleteTrackTest { String uniqueTrackName = "DeleteFromTrackList-" + System.currentTimeMillis(); recordTrackAndOpenTrackStoppedActivity(); - onView(withId(R.id.track_edit_name)) - .perform(scrollTo(), replaceText(uniqueTrackName), closeSoftKeyboard()); + renameStoppedTrack(uniqueTrackName, UI_TIMEOUT); onView(withId(R.id.finish_button)) .perform(performClick()); - waitForResumedActivity(TrackListActivity.class, UI_TIMEOUT); - waitUntilDisplayed(withId(R.id.track_list)); - waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))); - waitUntilRecyclerViewItemCount(withId(R.id.track_list), 1); + waitUntilDisplayed(withId(R.id.track_list), UI_TIMEOUT); + waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)), UI_TIMEOUT); + waitUntilRecyclerViewItemCount(withId(R.id.track_list), 1, UI_TIMEOUT); onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))) .perform(performLongClickOnAncestor()); @@ -103,34 +88,20 @@ public class EspressoDeleteTrackTest { onView(withText(android.R.string.ok)) .inRoot(isDialog()) .perform(performClick()); - waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0); + waitUntilRecyclerViewItemCount(withId(R.id.track_list), 0, UI_TIMEOUT); onView(withId(R.id.track_list)) .check(matches(withRecyclerViewItemCount(0))); } private void recordTrackAndOpenTrackStoppedActivity() { - // TrackListActivity: start recording - onView(withId(R.id.track_list_fab_action)) - .perform(performClick()); - waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); - waitForRecordingState(true, UI_TIMEOUT); - waitUntilDisplayed(withId(R.id.track_recording_fab_action)); - - // TrackRecordingActivity: stop recording and open TrackStoppedActivity - onView(withId(R.id.track_recording_fab_action)) - .perform(performLongClick()); - waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); - waitUntilDisplayed(withId(R.id.track_edit_name)); - waitUntilDisplayed(withId(R.id.discard_button)); - waitUntilDisplayed(withId(R.id.finish_button)); - onView(withId(R.id.track_edit_name)) - .perform(closeSoftKeyboard()); + startRecordingFromTrackList(UI_TIMEOUT); + stopRecordingToTrackStopped(UI_TIMEOUT, R.id.discard_button, R.id.finish_button); } private void clickDeleteSelectedTrackAction() { try { - waitUntilDisplayed(withId(R.id.list_context_menu_delete)); + waitUntilDisplayed(withId(R.id.list_context_menu_delete), UI_TIMEOUT); onView(withId(R.id.list_context_menu_delete)) .perform(performClick()); } catch (AssertionError ignored) { @@ -144,15 +115,4 @@ public class EspressoDeleteTrackTest { } } - private void waitUntilDisplayed(Matcher viewMatcher) { - waitForResumedView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT); - } - - private void waitUntilRecyclerViewItemCount(Matcher viewMatcher, int expectedCount) { - waitForResumedViewCondition( - viewMatcher, - view -> withRecyclerViewItemCount(expectedCount).matches(view), - "RecyclerView item count to be " + expectedCount, - UI_TIMEOUT); - } } diff --git a/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java b/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java index e613222cc..f48c24e02 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/EspressoEditTrackRecordingTest.java @@ -12,25 +12,19 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; -import static de.dennisguse.opentracks.util.ActivityTestUtils.waitForResumedActivity; import static de.dennisguse.opentracks.util.EspressoUtils.performClick; import static de.dennisguse.opentracks.util.EspressoUtils.performClickOnAncestor; -import static de.dennisguse.opentracks.util.EspressoUtils.performLongClick; -import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedView; -import static de.dennisguse.opentracks.util.TrackRecordingServiceTestUtils.waitForRecordingState; +import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed; +import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks; +import static de.dennisguse.opentracks.util.TrackUiTestUtils.startRecordingFromTrackList; +import static de.dennisguse.opentracks.util.TrackUiTestUtils.stopRecordingToTrackStopped; import static org.hamcrest.Matchers.allOf; -import android.view.View; -import android.content.Context; -import android.content.Intent; - -import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import androidx.test.rule.GrantPermissionRule; -import org.hamcrest.Matcher; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -38,7 +32,6 @@ import org.junit.runner.RunWith; import java.time.Duration; import java.util.Locale; -import de.dennisguse.opentracks.data.ContentProviderUtils; @LargeTest @RunWith(AndroidJUnit4.class) @@ -57,22 +50,13 @@ public class EspressoEditTrackRecordingTest { @Before public void setUp() { - Context context = ApplicationProvider.getApplicationContext(); - context.stopService(new Intent(context, de.dennisguse.opentracks.services.TrackRecordingService.class)); - new ContentProviderUtils(context).deleteAllTracks(context); + resetTrackRecordingServiceAndDeleteTracks(); } @LargeTest @Test public void espressoEditTrackRecordingTest() { - { - // TrackListActivity: start recording - onView(withId(R.id.track_list_fab_action)) - .perform(performClick()); - waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); - waitForRecordingState(true, UI_TIMEOUT); - waitUntilDisplayed(withId(R.id.track_recording_fab_action)); - } + startRecordingFromTrackList(UI_TIMEOUT); { // TrackRecordingActivity @@ -84,7 +68,7 @@ public class EspressoEditTrackRecordingTest { onView(withText(R.string.menu_edit)) .inRoot(isPlatformPopup()) .perform(performClickOnAncestor()); - waitUntilDisplayed(withId(R.id.track_edit_name)); + waitUntilDisplayed(withId(R.id.track_edit_name), UI_TIMEOUT); // change name for "New Name" onView(withId(R.id.track_edit_name)) @@ -96,14 +80,10 @@ public class EspressoEditTrackRecordingTest { // save edition onView(withId(R.id.track_edit_save)) .perform(performClick()); - waitUntilDisplayed(withId(R.id.track_recording_fab_action)); + waitUntilDisplayed(withId(R.id.track_recording_fab_action), UI_TIMEOUT); // stop; - onView(withId(R.id.track_recording_fab_action)) - .perform(performLongClick()); - waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); - waitUntilDisplayed(withId(R.id.resume_button)); - waitUntilDisplayed(withId(R.id.finish_button)); + stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button, R.id.finish_button); // it's on track stopped activity and there are two buttons onView(withId(R.id.resume_button)) @@ -112,8 +92,4 @@ public class EspressoEditTrackRecordingTest { .check(matches(isDisplayed())); } } - - private void waitUntilDisplayed(Matcher viewMatcher) { - waitForResumedView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT); - } } diff --git a/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java b/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java index b0206ac32..ea1fa12e6 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java @@ -1,28 +1,24 @@ package de.dennisguse.opentracks; import static androidx.test.espresso.Espresso.onView; -import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; import static androidx.test.espresso.assertion.ViewAssertions.matches; -import static androidx.test.espresso.matcher.ViewMatchers.isClickable; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; -import static de.dennisguse.opentracks.util.ActivityTestUtils.waitForResumedActivity; -import static de.dennisguse.opentracks.util.EspressoUtils.performClick; -import static de.dennisguse.opentracks.util.EspressoUtils.performLongClick; import static de.dennisguse.opentracks.util.EspressoUtils.performLongClickOnAncestor; import static de.dennisguse.opentracks.util.EspressoUtils.selectTabAtIndex; -import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedView; +import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed; import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedViewCondition; import static de.dennisguse.opentracks.util.EspressoUtils.withSelectedTab; import static de.dennisguse.opentracks.util.EspressoUtils.withViewPagerCurrentItem; -import static de.dennisguse.opentracks.util.TrackRecordingServiceTestUtils.waitForRecordingState; +import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks; +import static de.dennisguse.opentracks.util.TrackUiTestUtils.finishStoppedTrack; +import static de.dennisguse.opentracks.util.TrackUiTestUtils.resumeStoppedTrack; +import static de.dennisguse.opentracks.util.TrackUiTestUtils.startRecordingFromTrackList; +import static de.dennisguse.opentracks.util.TrackUiTestUtils.stopRecordingToTrackStopped; import static org.hamcrest.Matchers.allOf; import android.util.Pair; -import android.content.Context; -import android.content.Intent; -import android.view.View; import androidx.test.core.app.ApplicationProvider; import androidx.test.espresso.ViewInteraction; @@ -40,6 +36,7 @@ import org.junit.runner.RunWith; import java.time.Duration; import java.util.List; + import de.dennisguse.opentracks.content.data.TestDataUtil; import de.dennisguse.opentracks.data.ContentProviderUtils; import de.dennisguse.opentracks.data.models.Track; @@ -61,68 +58,27 @@ public class EspressoUITest { @Before public void setUp() { - Context context = ApplicationProvider.getApplicationContext(); - context.stopService(new Intent(context, de.dennisguse.opentracks.services.TrackRecordingService.class)); - new ContentProviderUtils(context).deleteAllTracks(context); + resetTrackRecordingServiceAndDeleteTracks(); } @LargeTest @Test public void record_stop_resume_stop_finish() { + startRecordingFromTrackList(UI_TIMEOUT); { - // TrackListActivity: start recording - onView(withId(R.id.track_list_fab_action)) - .perform(performClick()); - waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); - waitForRecordingState(true, UI_TIMEOUT); - waitUntilDisplayed(withId(R.id.track_recording_fab_action)); - } - { - // TrackRecordingActivity: stop - onView(withId(R.id.track_recording_fab_action)) - .perform(performLongClick()); - waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); - waitUntilDisplayed(withId(R.id.track_edit_name)); - waitUntilDisplayed(withId(R.id.resume_button)); - onView(withId(R.id.track_edit_name)) - .perform(closeSoftKeyboard()); - - // TrackStoppedActivity: resume - onView(allOf(withId(R.id.resume_button), isClickable())) - .perform(performClick()); - waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); - waitForRecordingState(true, UI_TIMEOUT); - waitUntilDisplayed(withId(R.id.track_recording_fab_action)); - - // TrackRecordingActivity: stop again - onView(withId(R.id.track_recording_fab_action)) - .perform(performLongClick()); - waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); - waitUntilDisplayed(withId(R.id.track_edit_name)); - waitUntilDisplayed(withId(R.id.finish_button)); - onView(withId(R.id.track_edit_name)) - .perform(closeSoftKeyboard()); - - // TrackStoppedActivity - onView(withId(R.id.finish_button)) - .perform(performClick()); - waitForResumedActivity(TrackListActivity.class, UI_TIMEOUT); - waitUntilDisplayed(withId(R.id.track_list)); + stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button); + resumeStoppedTrack(UI_TIMEOUT); + stopRecordingToTrackStopped(UI_TIMEOUT, R.id.finish_button); + finishStoppedTrack(UI_TIMEOUT); } } @LargeTest @Test public void record_move_through_tabs() { - { - // TrackListActivity: start recording - onView(withId(R.id.track_list_fab_action)) - .perform(performClick()); - waitForResumedActivity(TrackRecordingActivity.class, UI_TIMEOUT); - waitForRecordingState(true, UI_TIMEOUT); - waitUntilDisplayed(withId(R.id.track_detail_activity_tablayout)); - waitUntilDisplayed(withId(R.id.track_detail_activity_view_pager)); - } + startRecordingFromTrackList(UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.track_detail_activity_tablayout), UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.track_detail_activity_view_pager), UI_TIMEOUT); { // TrackRecordingActivity ViewInteraction tabLayout = onView(withId(R.id.track_detail_activity_tablayout)); @@ -140,10 +96,7 @@ public class EspressoUITest { waitUntilTabSelectionSettlesAt(0); // stop - onView(withId(R.id.track_recording_fab_action)) - .perform(performLongClick()); - waitForResumedActivity(TrackStoppedActivity.class, UI_TIMEOUT); - waitUntilDisplayed(withId(R.id.resume_button)); + stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button); } } @@ -156,17 +109,13 @@ public class EspressoUITest { trackBuilder.setName(uniqueTrackName); TestDataUtil.insertTrackWithLocations(new ContentProviderUtils(ApplicationProvider.getApplicationContext()), trackBuilder.getTrack(), pair.second); activityRule.getScenario().recreate(); - waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))); + waitUntilDisplayed(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName)), UI_TIMEOUT); onView(withId(R.id.track_list)).check(matches(isDisplayed())); onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))) .perform(performLongClickOnAncestor()); } - private void waitUntilDisplayed(Matcher viewMatcher) { - waitForResumedView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT); - } - private void waitUntilTabSelectionSettlesAt(int index) { waitForResumedViewCondition( withId(R.id.track_detail_activity_tablayout), diff --git a/src/androidTest/java/de/dennisguse/opentracks/ui/aggregatedStatistics/EspressoAggregatedFilterTest.java b/src/androidTest/java/de/dennisguse/opentracks/ui/aggregatedStatistics/EspressoAggregatedFilterTest.java index 355acbddc..53da9770c 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/ui/aggregatedStatistics/EspressoAggregatedFilterTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/ui/aggregatedStatistics/EspressoAggregatedFilterTest.java @@ -9,18 +9,16 @@ import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withParent; import static androidx.test.espresso.matcher.ViewMatchers.withText; import static de.dennisguse.opentracks.util.EspressoUtils.performClick; -import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedView; -import static de.dennisguse.opentracks.util.EspressoUtils.waitForResumedViewCondition; -import static de.dennisguse.opentracks.util.EspressoUtils.withRecyclerViewItemCount; +import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed; +import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilRecyclerViewItemCount; +import static de.dennisguse.opentracks.util.TestEnvironmentUtils.resetTrackRecordingServiceAndDeleteTracks; +import static de.dennisguse.opentracks.util.TestEnvironmentUtils.stopTrackRecordingService; import static org.hamcrest.Matchers.allOf; -import android.content.Context; -import android.content.Intent; import android.util.Pair; import android.view.View; import androidx.test.core.app.ActivityScenario; -import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import androidx.test.rule.GrantPermissionRule; @@ -43,7 +41,6 @@ import de.dennisguse.opentracks.data.ContentProviderUtils; import de.dennisguse.opentracks.data.models.Track; import de.dennisguse.opentracks.data.models.TrackBuilder; import de.dennisguse.opentracks.data.models.TrackPoint; -import de.dennisguse.opentracks.services.TrackRecordingService; @LargeTest @RunWith(AndroidJUnit4.class) @@ -60,11 +57,8 @@ public class EspressoAggregatedFilterTest { @Before public void setUp() { - Context context = ApplicationProvider.getApplicationContext(); - context.stopService(new Intent(context, TrackRecordingService.class)); - - ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context); - contentProviderUtils.deleteAllTracks(context); + resetTrackRecordingServiceAndDeleteTracks(); + ContentProviderUtils contentProviderUtils = new ContentProviderUtils(androidx.test.core.app.ApplicationProvider.getApplicationContext()); Pair> pair = TestDataUtil.createTrack(new Track.Id(System.currentTimeMillis()), 20); TrackBuilder trackBuilder = new TrackBuilder(pair.first); @@ -76,8 +70,7 @@ public class EspressoAggregatedFilterTest { @After public void tearDown() { - Context context = ApplicationProvider.getApplicationContext(); - context.stopService(new Intent(context, TrackRecordingService.class)); + stopTrackRecordingService(); if (scenario != null) { scenario.close(); } @@ -85,10 +78,10 @@ public class EspressoAggregatedFilterTest { @Test public void espressoAggregatedFilterTest() { - waitUntilDisplayed(withId(R.id.bottom_app_bar)); - waitUntilDisplayed(withId(R.id.aggregated_statistics_filter)); - waitUntilRecyclerViewItemCount(withId(R.id.aggregated_stats_list), 1); - waitUntilDisplayed(allOf(withId(R.id.aggregated_stats_type_label), withText(ACTIVITY_TYPE_LOCALIZED))); + waitUntilDisplayed(withId(R.id.bottom_app_bar), UI_TIMEOUT); + waitUntilDisplayed(withId(R.id.aggregated_statistics_filter), UI_TIMEOUT); + waitUntilRecyclerViewItemCount(withId(R.id.aggregated_stats_list), 1, UI_TIMEOUT); + waitUntilDisplayed(allOf(withId(R.id.aggregated_stats_type_label), withText(ACTIVITY_TYPE_LOCALIZED)), UI_TIMEOUT); // open FilterDialogFragment through the current bottom app bar action item onView(withId(R.id.bottom_app_bar)) @@ -118,15 +111,4 @@ public class EspressoAggregatedFilterTest { .check(matches(isDisplayed())); } - private void waitUntilDisplayed(Matcher viewMatcher) { - waitForResumedView(allOf(viewMatcher, isDisplayed()), UI_TIMEOUT); - } - - private void waitUntilRecyclerViewItemCount(Matcher viewMatcher, int expectedCount) { - waitForResumedViewCondition( - allOf(viewMatcher, isDisplayed()), - view -> withRecyclerViewItemCount(expectedCount).matches(view), - "RecyclerView item count to be " + expectedCount, - UI_TIMEOUT); - } } diff --git a/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java b/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java index fb969cab8..459c55148 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java +++ b/src/androidTest/java/de/dennisguse/opentracks/util/EspressoUtils.java @@ -1,5 +1,6 @@ package de.dennisguse.opentracks.util; +import static org.hamcrest.Matchers.allOf; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom; @@ -88,6 +89,18 @@ public class EspressoUtils { waitForResumedViewCondition(viewMatcher, ignored -> true, "view to match " + viewMatcher, timeout); } + public static void waitUntilDisplayed(final Matcher viewMatcher, final Duration timeout) { + waitForResumedView(allOf(viewMatcher, isDisplayed()), timeout); + } + + public static void waitUntilRecyclerViewItemCount(final Matcher viewMatcher, final int expectedCount, final Duration timeout) { + waitForResumedViewCondition( + allOf(viewMatcher, isDisplayed()), + view -> withRecyclerViewItemCount(expectedCount).matches(view), + "RecyclerView item count to be " + expectedCount, + timeout); + } + public static void waitForResumedViewCondition(final Matcher viewMatcher, final Predicate condition, final String conditionDescription, diff --git a/src/androidTest/java/de/dennisguse/opentracks/util/TestEnvironmentUtils.java b/src/androidTest/java/de/dennisguse/opentracks/util/TestEnvironmentUtils.java new file mode 100644 index 000000000..3350ddc28 --- /dev/null +++ b/src/androidTest/java/de/dennisguse/opentracks/util/TestEnvironmentUtils.java @@ -0,0 +1,30 @@ +package de.dennisguse.opentracks.util; + +import android.content.Context; +import android.content.Intent; + +import androidx.test.core.app.ApplicationProvider; + +import de.dennisguse.opentracks.data.ContentProviderUtils; +import de.dennisguse.opentracks.services.TrackRecordingService; + +public final class TestEnvironmentUtils { + + private TestEnvironmentUtils() { + } + + public static void stopTrackRecordingService() { + Context context = ApplicationProvider.getApplicationContext(); + context.stopService(new Intent(context, TrackRecordingService.class)); + } + + public static void deleteAllTracks() { + Context context = ApplicationProvider.getApplicationContext(); + new ContentProviderUtils(context).deleteAllTracks(context); + } + + public static void resetTrackRecordingServiceAndDeleteTracks() { + stopTrackRecordingService(); + deleteAllTracks(); + } +} diff --git a/src/androidTest/java/de/dennisguse/opentracks/util/TrackUiTestUtils.java b/src/androidTest/java/de/dennisguse/opentracks/util/TrackUiTestUtils.java new file mode 100644 index 000000000..a63e100b1 --- /dev/null +++ b/src/androidTest/java/de/dennisguse/opentracks/util/TrackUiTestUtils.java @@ -0,0 +1,70 @@ +package de.dennisguse.opentracks.util; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.action.ViewActions.scrollTo; +import static androidx.test.espresso.matcher.ViewMatchers.isClickable; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static de.dennisguse.opentracks.util.ActivityTestUtils.waitForResumedActivity; +import static de.dennisguse.opentracks.util.EspressoUtils.performClick; +import static de.dennisguse.opentracks.util.EspressoUtils.performLongClick; +import static de.dennisguse.opentracks.util.EspressoUtils.waitUntilDisplayed; +import static de.dennisguse.opentracks.util.TrackRecordingServiceTestUtils.waitForRecordingState; +import static org.hamcrest.Matchers.allOf; + +import androidx.annotation.IdRes; + +import java.time.Duration; + +import de.dennisguse.opentracks.R; +import de.dennisguse.opentracks.TrackListActivity; +import de.dennisguse.opentracks.TrackRecordingActivity; +import de.dennisguse.opentracks.TrackStoppedActivity; + +public final class TrackUiTestUtils { + + private TrackUiTestUtils() { + } + + public static void startRecordingFromTrackList(Duration timeout) { + onView(withId(R.id.track_list_fab_action)) + .perform(performClick()); + waitForResumedActivity(TrackRecordingActivity.class, timeout); + waitForRecordingState(true, timeout); + waitUntilDisplayed(withId(R.id.track_recording_fab_action), timeout); + } + + public static void stopRecordingToTrackStopped(Duration timeout, @IdRes int... expectedVisibleViewIds) { + onView(withId(R.id.track_recording_fab_action)) + .perform(performLongClick()); + waitForResumedActivity(TrackStoppedActivity.class, timeout); + waitUntilDisplayed(withId(R.id.track_edit_name), timeout); + for (int viewId : expectedVisibleViewIds) { + waitUntilDisplayed(withId(viewId), timeout); + } + onView(withId(R.id.track_edit_name)) + .perform(closeSoftKeyboard()); + } + + public static void resumeStoppedTrack(Duration timeout) { + onView(allOf(withId(R.id.resume_button), isClickable())) + .perform(performClick()); + waitForResumedActivity(TrackRecordingActivity.class, timeout); + waitForRecordingState(true, timeout); + waitUntilDisplayed(withId(R.id.track_recording_fab_action), timeout); + } + + public static void finishStoppedTrack(Duration timeout) { + onView(withId(R.id.finish_button)) + .perform(performClick()); + waitForResumedActivity(TrackListActivity.class, timeout); + waitUntilDisplayed(withId(R.id.track_list), timeout); + } + + public static void renameStoppedTrack(String name, Duration timeout) { + waitUntilDisplayed(withId(R.id.track_edit_name), timeout); + onView(withId(R.id.track_edit_name)) + .perform(scrollTo(), replaceText(name), closeSoftKeyboard()); + } +} From 8b7d4fa8130b0609f892648099a1d3f9e9090791 Mon Sep 17 00:00:00 2001 From: "Marcatili, Manuel" Date: Thu, 16 Apr 2026 11:35:50 +0300 Subject: [PATCH 5/6] Added explicit assertions to tests --- .../dennisguse/opentracks/EspressoUITest.java | 13 ++- .../data/CustomContentProviderUtilsTest.java | 34 ++++--- .../IntervalStatisticsUpdaterTest.java | 89 ++++++++++++++----- 3 files changed, 99 insertions(+), 37 deletions(-) diff --git a/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java b/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java index ea1fa12e6..0589cb0cd 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/EspressoUITest.java @@ -70,6 +70,9 @@ public class EspressoUITest { resumeStoppedTrack(UI_TIMEOUT); stopRecordingToTrackStopped(UI_TIMEOUT, R.id.finish_button); finishStoppedTrack(UI_TIMEOUT); + + onView(withId(R.id.track_list)) + .check(matches(isDisplayed())); } } @@ -96,7 +99,12 @@ public class EspressoUITest { waitUntilTabSelectionSettlesAt(0); // stop - stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button); + stopRecordingToTrackStopped(UI_TIMEOUT, R.id.resume_button, R.id.finish_button); + + onView(withId(R.id.resume_button)) + .check(matches(isDisplayed())); + onView(withId(R.id.finish_button)) + .check(matches(isDisplayed())); } } @@ -114,6 +122,9 @@ public class EspressoUITest { onView(withId(R.id.track_list)).check(matches(isDisplayed())); onView(allOf(withId(R.id.track_list_item_name), withText(uniqueTrackName))) .perform(performLongClickOnAncestor()); + + onView(withId(androidx.appcompat.R.id.action_mode_bar)) + .check(matches(isDisplayed())); } private void waitUntilTabSelectionSettlesAt(int index) { diff --git a/src/androidTest/java/de/dennisguse/opentracks/data/CustomContentProviderUtilsTest.java b/src/androidTest/java/de/dennisguse/opentracks/data/CustomContentProviderUtilsTest.java index 53b897eba..ab6653736 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/data/CustomContentProviderUtilsTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/data/CustomContentProviderUtilsTest.java @@ -94,21 +94,21 @@ public class CustomContentProviderUtilsTest { @Test public void testLocationIterator_noPoints() { - testIterator(new Track.Id(1), 0); + assertEquals(0, testIterator(new Track.Id(1), 0).size()); } @Test public void testLocationIterator_noAscending() { - testIterator(new Track.Id(1), 50); - testIterator(new Track.Id(2), 50); + assertEquals(50, testIterator(new Track.Id(1), 50).size()); + assertEquals(50, testIterator(new Track.Id(2), 50).size()); } @Test public void testLocationIterator_largeTrack() { - testIterator(new Track.Id(1), 20000 / 2); + assertEquals(20000 / 2, testIterator(new Track.Id(1), 20000 / 2).size()); } - private void testIterator(Track.Id trackId, int numPoints) { + private List testIterator(Track.Id trackId, int numPoints) { TrackPoint.Id lastPointId = initializeTrack(trackId, numPoints); List locations = new ArrayList<>(numPoints); try (TrackPointIterator it = contentProviderUtils.getTrackPointIterator(trackId, null)) { @@ -121,6 +121,7 @@ public class CustomContentProviderUtilsTest { } assertEquals(numPoints, locations.size()); } + return locations; } private TrackPoint.Id initializeTrack(Track.Id id, int numPoints) { @@ -1194,7 +1195,7 @@ public class CustomContentProviderUtilsTest { assertEquals(sensorStatistics.avgPower().getW(), stats.avgPower, 0f); } - private void testGetSensorStats_randomData(int totalPoints, boolean withStartSegments) { + private Pair testGetSensorStats_randomData(int totalPoints, boolean withStartSegments) { // given Instant start = Instant.now(); TestSensorDataUtil sensorDataUtil = new TestSensorDataUtil(); @@ -1217,22 +1218,27 @@ public class CustomContentProviderUtilsTest { SensorStatistics sensorStatistics = contentProviderUtils.getSensorStats(trackId); TestSensorDataUtil.SensorDataStats stats = sensorDataUtil.computeStats(); - // then - assertEquals(sensorStatistics.avgHeartRate().getBPM(), stats.avgHr, 0.01f); - assertEquals(sensorStatistics.maxHeartRate().getBPM(), stats.maxHr, 0.01f); - assertEquals(sensorStatistics.avgCadence().getRPM(), stats.avgCadence, 0.01f); - assertEquals(sensorStatistics.maxCadence().getRPM(), stats.maxCadence, 0.01f); - assertEquals(sensorStatistics.avgPower().getW(), stats.avgPower, 0.01f); + return Pair.create(sensorStatistics, stats); } @Test public void testGetSensorStats_veryLongActivity12h() { - testGetSensorStats_randomData(43200 / 6, false); + Pair result = testGetSensorStats_randomData(43200 / 6, false); + assertEquals(result.first.avgHeartRate().getBPM(), result.second.avgHr, 0.01f); + assertEquals(result.first.maxHeartRate().getBPM(), result.second.maxHr, 0.01f); + assertEquals(result.first.avgCadence().getRPM(), result.second.avgCadence, 0.01f); + assertEquals(result.first.maxCadence().getRPM(), result.second.maxCadence, 0.01f); + assertEquals(result.first.avgPower().getW(), result.second.avgPower, 0.01f); } @Test public void testGetSensorStats_withSeveralRandomStartSegments() { - testGetSensorStats_randomData(5000, true); + Pair result = testGetSensorStats_randomData(5000, true); + assertEquals(result.first.avgHeartRate().getBPM(), result.second.avgHr, 0.01f); + assertEquals(result.first.maxHeartRate().getBPM(), result.second.maxHr, 0.01f); + assertEquals(result.first.avgCadence().getRPM(), result.second.avgCadence, 0.01f); + assertEquals(result.first.maxCadence().getRPM(), result.second.maxCadence, 0.01f); + assertEquals(result.first.avgPower().getW(), result.second.avgPower, 0.01f); } private TrackPoint getLastValidTrackPoint(Track.Id trackId) { diff --git a/src/androidTest/java/de/dennisguse/opentracks/ui/intervals/IntervalStatisticsUpdaterTest.java b/src/androidTest/java/de/dennisguse/opentracks/ui/intervals/IntervalStatisticsUpdaterTest.java index 8bab9be0f..c2abe6a75 100644 --- a/src/androidTest/java/de/dennisguse/opentracks/ui/intervals/IntervalStatisticsUpdaterTest.java +++ b/src/androidTest/java/de/dennisguse/opentracks/ui/intervals/IntervalStatisticsUpdaterTest.java @@ -35,7 +35,10 @@ public class IntervalStatisticsUpdaterTest { float distanceInterval = 1000f; // when and then - whenAndThen(50, distanceInterval); + IntervalStatisticsComputation computation = computeIntervalStatistics(50, distanceInterval); + assertIntervalStatisticsComputation(computation, 50, distanceInterval); + assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size()); + assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01); } /** @@ -49,7 +52,10 @@ public class IntervalStatisticsUpdaterTest { float distanceInterval = 1000f; // when and then - whenAndThen(200, distanceInterval); + IntervalStatisticsComputation computation = computeIntervalStatistics(200, distanceInterval); + assertIntervalStatisticsComputation(computation, 200, distanceInterval); + assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size()); + assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01); } /** @@ -63,7 +69,10 @@ public class IntervalStatisticsUpdaterTest { float distanceInterval = 3000f; // when and then - whenAndThen(3000, distanceInterval); + IntervalStatisticsComputation computation = computeIntervalStatistics(3000, distanceInterval); + assertIntervalStatisticsComputation(computation, 3000, distanceInterval); + assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size()); + assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01); } /** @@ -77,7 +86,10 @@ public class IntervalStatisticsUpdaterTest { float distanceInterval = 3000f; // when and then - whenAndThen(1000, distanceInterval); + IntervalStatisticsComputation computation = computeIntervalStatistics(1000, distanceInterval); + assertIntervalStatisticsComputation(computation, 1000, distanceInterval); + assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size()); + assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01); } /** @@ -91,7 +103,10 @@ public class IntervalStatisticsUpdaterTest { float distanceInterval = 1000f; // when and then - whenAndThen(10000, distanceInterval); + IntervalStatisticsComputation computation = computeIntervalStatistics(10000, distanceInterval); + assertIntervalStatisticsComputation(computation, 10000, distanceInterval); + assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size()); + assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01); } @Test @@ -122,15 +137,18 @@ public class IntervalStatisticsUpdaterTest { Pair> trackWithStats = new Pair<>(dummyTrack, trackPoints); // when and then - whenAndThen(trackWithStats.first, trackWithStats.second, numberOfPoints, distanceInterval); + IntervalStatisticsComputation computation = computeIntervalStatistics(trackWithStats.first, trackWithStats.second, distanceInterval); + assertIntervalStatisticsComputation(computation, numberOfPoints, distanceInterval); + assertEquals((int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval), computation.intervalList.size()); + assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01); } - private void whenAndThen(int numberOfPoints, float distanceInterval) { + private IntervalStatisticsComputation computeIntervalStatistics(int numberOfPoints, float distanceInterval) { Pair> trackWithStats = buildTrackWithTrackPoints(numberOfPoints); - whenAndThen(trackWithStats.first, trackWithStats.second, numberOfPoints, distanceInterval); + return computeIntervalStatistics(trackWithStats.first, trackWithStats.second, distanceInterval); } - private void whenAndThen(Track track, List trackPoints, int numberOfPoints, float distanceInterval) { + private IntervalStatisticsComputation computeIntervalStatistics(Track track, List trackPoints, float distanceInterval) { IntervalStatisticsUpdater intervalStatistics = new IntervalStatisticsUpdater(Distance.of(distanceInterval)); intervalStatistics.addTrackPoints(trackPoints.iterator()); @@ -157,23 +175,27 @@ public class IntervalStatisticsUpdaterTest { } } - // then - assertEquals(track.statistics().totalDuration().toSeconds(), totalTime, 0.01); - assertEquals(track.statistics().totalDistance().toM(), totalDistance.toM(), 0.01); - assertEquals(intervalList.size(), (int) Math.ceil(track.statistics().totalDistance().toM() / distanceInterval)); - if (totalGain != null && totalLoss != null) { - assertEquals(totalGain, numberOfPoints * TestDataUtil.ALTITUDE_GAIN, 0.1); - assertEquals(totalLoss, numberOfPoints * TestDataUtil.ALTITUDE_LOSS, 0.1); + return new IntervalStatisticsComputation(track, intervalList, totalDistance, totalTime, totalGain, totalLoss); + } + + private void assertIntervalStatisticsComputation(IntervalStatisticsComputation computation, int numberOfPoints, float distanceInterval) { + assertEquals(computation.track.statistics().totalDuration().toSeconds(), computation.totalTime, 0.01); + assertEquals(computation.track.statistics().totalDistance().toM(), computation.totalDistance.toM(), 0.01); + assertEquals(computation.intervalList.size(), (int) Math.ceil(computation.track.statistics().totalDistance().toM() / distanceInterval)); + if (computation.totalGain != null && computation.totalLoss != null) { + assertEquals(computation.totalGain, numberOfPoints * TestDataUtil.ALTITUDE_GAIN, 0.1); + assertEquals(computation.totalLoss, numberOfPoints * TestDataUtil.ALTITUDE_LOSS, 0.1); } else { - assertTrue(intervalStatistics.getIntervalList().stream().noneMatch(i -> i.altitudeGainLoss() != null)); + assertTrue(computation.intervalList.stream().noneMatch(i -> i.altitudeGainLoss() != null)); } - for (int i = 0; i < intervalList.size() - 1; i++) { - assertEquals(intervalList.get(i).totalDistance().toM(), distanceInterval, 0.001); - totalDistance = totalDistance.minus(intervalList.get(i).totalDistance()); + Distance remainingDistance = computation.totalDistance; + for (int i = 0; i < computation.intervalList.size() - 1; i++) { + assertEquals(computation.intervalList.get(i).totalDistance().toM(), distanceInterval, 0.001); + remainingDistance = remainingDistance.minus(computation.intervalList.get(i).totalDistance()); } - assertEquals(intervalList.get(intervalList.size() - 1).totalDistance().toM(), totalDistance.toM(), 0.01); + assertEquals(computation.intervalList.get(computation.intervalList.size() - 1).totalDistance().toM(), remainingDistance.toM(), 0.01); } private static Pair> buildTrackWithTrackPoints(int numberOfPoints) { @@ -195,4 +217,27 @@ public class IntervalStatisticsUpdaterTest { return new Pair<>(track, trackPoints); } -} \ No newline at end of file + + private static final class IntervalStatisticsComputation { + private final Track track; + private final List intervalList; + private final Distance totalDistance; + private final float totalTime; + private final Float totalGain; + private final Float totalLoss; + + private IntervalStatisticsComputation(Track track, + List intervalList, + Distance totalDistance, + float totalTime, + Float totalGain, + Float totalLoss) { + this.track = track; + this.intervalList = intervalList; + this.totalDistance = totalDistance; + this.totalTime = totalTime; + this.totalGain = totalGain; + this.totalLoss = totalLoss; + } + } +} From c2f59c11bf396127eea31d6ff341b8e16d390227 Mon Sep 17 00:00:00 2001 From: "Marcatili, Manuel" Date: Thu, 16 Apr 2026 17:03:17 +0300 Subject: [PATCH 6/6] removed the mockito from build.gradle --- build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9de1d1b47..bd5639569 100644 --- a/build.gradle +++ b/build.gradle @@ -162,7 +162,6 @@ dependencies { androidTestImplementation 'androidx.test:rules:1.7.0' androidTestImplementation 'androidx.test:runner:1.7.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' - androidTestImplementation 'org.mockito:mockito-android:5.23.0' androidTestUtil 'androidx.test:orchestrator:1.6.1' }