From 5d4213c01cab1cbe506a473b8155881fceb14f5b Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Mon, 27 Oct 2025 19:02:50 +0100 Subject: [PATCH 1/4] Testing slideshow deletion for localOnly Testing the case of localOnly files. As the deletion logic differs per scenario, tests for the online scenario will also need to be added. Signed-off-by: Philipp Hasper --- .../test/ConnectivityServiceOfflineMock.kt | 23 +++ .../com/nextcloud/test/LoopFailureHandler.kt | 37 +++++ .../ui/preview/PreviewImageActivityIT.kt | 134 ++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 app/src/androidTest/java/com/nextcloud/test/ConnectivityServiceOfflineMock.kt create mode 100644 app/src/androidTest/java/com/nextcloud/test/LoopFailureHandler.kt create mode 100644 app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt diff --git a/app/src/androidTest/java/com/nextcloud/test/ConnectivityServiceOfflineMock.kt b/app/src/androidTest/java/com/nextcloud/test/ConnectivityServiceOfflineMock.kt new file mode 100644 index 000000000000..560b94ff6172 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/ConnectivityServiceOfflineMock.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.test + +import com.nextcloud.client.network.Connectivity +import com.nextcloud.client.network.ConnectivityService + +/** A mocked connectivity service returning that the device is offline **/ +class ConnectivityServiceOfflineMock : ConnectivityService { + override fun isNetworkAndServerAvailable(callback: ConnectivityService.GenericCallback) { + callback.onComplete(false) + } + + override fun isConnected(): Boolean = false + + override fun isInternetWalled(): Boolean = false + + override fun getConnectivity(): Connectivity = Connectivity.CONNECTED_WIFI +} diff --git a/app/src/androidTest/java/com/nextcloud/test/LoopFailureHandler.kt b/app/src/androidTest/java/com/nextcloud/test/LoopFailureHandler.kt new file mode 100644 index 000000000000..48baf2cec9b0 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/LoopFailureHandler.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.test + +import android.content.Context +import android.view.View +import androidx.test.espresso.FailureHandler +import androidx.test.espresso.base.DefaultFailureHandler +import org.hamcrest.Matcher + +/** + * When testing inside of a loop, test failures are hard to attribute. For that, wrap them in an outer + * exception detailing more about the context. + * + * Set the failure handler via + * ``` + * Espresso.setFailureHandler( + * LoopFailureHandler(targetContext, "Test failed in iteration $yourTestIterationCounter") + * ) + * ``` + * and set it back to the default afterwards via + * ``` + * Espresso.setFailureHandler(DefaultFailureHandler(targetContext)) + * ``` + */ +class LoopFailureHandler(targetContext: Context, private val loopMessage: String) : FailureHandler { + private val delegate: FailureHandler = DefaultFailureHandler(targetContext) + + override fun handle(error: Throwable?, viewMatcher: Matcher?) { + // Wrap in additional Exception + delegate.handle(Exception(loopMessage, error), viewMatcher) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt new file mode 100644 index 000000000000..717fc3fff1c4 --- /dev/null +++ b/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt @@ -0,0 +1,134 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.ui.preview + +import androidx.appcompat.widget.ActionBarContainer +import androidx.test.core.app.launchActivity +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.IdlingRegistry +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.base.DefaultFailureHandler +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom +import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.nextcloud.test.ConnectivityServiceOfflineMock +import com.nextcloud.test.LoopFailureHandler +import com.owncloud.android.AbstractOnServerIT +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import org.hamcrest.Matchers.allOf +import org.junit.Test +import java.io.File + +class PreviewImageActivityIT : AbstractOnServerIT() { + lateinit var testFiles: List + + fun createMockedImageFiles(count: Int, localOnly: Boolean) { + val srcPngFile = getFile("imageFile.png") + testFiles = (0 until count).map { i -> + val pngFile = File(srcPngFile.parent ?: ".", "image$i.png") + srcPngFile.copyTo(pngFile, overwrite = true) + + OCFile("/${pngFile.name}").apply { + storagePath = pngFile.absolutePath + mimeType = "image/png" + modificationTimestamp = 1000000 + permissions = "D" // OCFile.PERMISSION_CAN_DELETE_OR_LEAVE_SHARE. Required for deletion button to show + remoteId = if (localOnly) null else "abc-mocked-remote-id" // mocking the file to be on the server + }.also { + storageManager.saveNewFile(it) + } + } + } + + fun veryImageThenDelete(index: Int) { + val currentFileName = testFiles[index].fileName + Espresso.setFailureHandler( + LoopFailureHandler(targetContext, "Test failed with image file index $index, $currentFileName") + ) + + onView(withId(R.id.image)) + .check(matches(isDisplayed())) + + // Check that the Action Bar shows the file name as title + onView( + allOf( + isDescendantOfA(isAssignableFrom(ActionBarContainer::class.java)), + withText(currentFileName) + ) + ).check(matches(isDisplayed())) + + // Open the Action Bar's overflow menu. + // The official way would be: + // openActionBarOverflowOrOptionsMenu(targetContext) + // But this doesn't find the view. Presumably because Espresso.OVERFLOW_BUTTON_MATCHER looks for the description + // "More options", whereas it actually says "More menu". + // selecting by this would also work: + // onView(withContentDescription("More menu")).perform(ViewActions.click()) + // For now, we identify it by the ID we know it to be + onView(withId(R.id.custom_menu_placeholder_item)).perform(ViewActions.click()) + + // Click the "Remove" button + onView(withText(R.string.common_remove)).perform(ViewActions.click()) + + // Check confirmation dialog and then confirm the deletion by clicking the main button of the dialog + val expectedText = targetContext.getString(R.string.confirmation_remove_file_alert, currentFileName) + onView(withId(android.R.id.message)) + .inRoot(isDialog()) + .check(matches(withText(expectedText))) + + onView(withId(android.R.id.button1)) + .inRoot(isDialog()) + .check(matches(withText(R.string.file_delete))) + .perform(ViewActions.click()) + + Espresso.setFailureHandler(DefaultFailureHandler(targetContext)) + } + + @Test + fun deleteFromSlideshow_localOnly_online() { + // Prepare local test data + val imageCount = 5 + createMockedImageFiles(imageCount, localOnly = true) + + // Launch the activity with the first image + val intent = PreviewImageActivity.previewFileIntent(targetContext, user, testFiles[0]) + launchActivity(intent).use { + onView(isRoot()).check(matches(isDisplayed())) + + for (i in 0 until imageCount) { + veryImageThenDelete(i) + } + } + } + + @Test + fun deleteFromSlideshow_localOnly_offline() { + // Prepare local test data + val imageCount = 5 + createMockedImageFiles(imageCount, localOnly = true) + + // Launch the activity with the first image + val intent = PreviewImageActivity.previewFileIntent(targetContext, user, testFiles[0]) + launchActivity(intent).use { scenario -> + scenario.onActivity { activity -> + activity.connectivityService = ConnectivityServiceOfflineMock() + } + onView(isRoot()).check(matches(isDisplayed())) + + for (i in 0 until imageCount) { + veryImageThenDelete(i) + } + } + } +} From cef0b78167563d2a67928f03955f32f81ccdea2b Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Mon, 15 Dec 2025 18:44:08 +0100 Subject: [PATCH 2/4] Fix: After file deletion, properly refresh view pager Otherwise, the localOnly file deletion will not update the UI. Signed-off-by: Philipp Hasper --- .../android/ui/dialog/RemoveFilesDialogFragment.kt | 4 ++++ .../owncloud/android/ui/preview/PreviewImageActivity.kt | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/RemoveFilesDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/RemoveFilesDialogFragment.kt index c27956afe82e..437cbed483d6 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/RemoveFilesDialogFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/RemoveFilesDialogFragment.kt @@ -24,6 +24,7 @@ import com.owncloud.android.datamodel.OCFile import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.ui.activity.FileDisplayActivity import com.owncloud.android.ui.dialog.ConfirmationDialogFragment.ConfirmationDialogFragmentListener +import com.owncloud.android.ui.preview.PreviewImageActivity import javax.inject.Inject /** @@ -96,6 +97,7 @@ class RemoveFilesDialogFragment : val fileActivity = getTypedActivity(FileActivity::class.java) val fda = getTypedActivity(FileDisplayActivity::class.java) + val pia = getTypedActivity(PreviewImageActivity::class.java) fileActivity?.connectivityService?.isNetworkAndServerAvailable { result -> if (result) { fileActivity.showLoadingDialog(fileActivity.getString(R.string.wait_a_moment)) @@ -110,6 +112,7 @@ class RemoveFilesDialogFragment : if (offlineFiles.isNotEmpty()) { fda?.refreshCurrentDirectory() + pia?.initViewPager() } fileActivity.dismissLoadingDialog() @@ -123,6 +126,7 @@ class RemoveFilesDialogFragment : } fda?.refreshCurrentDirectory() + pia?.initViewPager() } finishActionMode() diff --git a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt index 505951c20993..c8cf0c7b0dd3 100644 --- a/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/preview/PreviewImageActivity.kt @@ -212,7 +212,13 @@ class PreviewImageActivity : } } - private fun updateViewPagerAfterDeletionAndAdvanceForward() { + fun initViewPager() { + if (user.isPresent) { + initViewPager(user.get()) + } + } + + fun updateViewPagerAfterDeletionAndAdvanceForward() { val deletePosition = viewPager?.currentItem ?: return previewImagePagerAdapter?.let { adapter -> val nextPosition = min(deletePosition, adapter.itemCount - 1) From 8ab58331ea903859883e6e3ba2646fc55c6754b7 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Thu, 18 Dec 2025 19:47:19 +0100 Subject: [PATCH 3/4] Testing slideshow deletion for remote files The remote + offline test case had to be ignored due to two reasons 1) Broken App behavior - The UX is indeed broken, as from a user perspective, nothing happens with the file when deleting it. The offlineOperation is put on the worker stack, but the user doesn't see anything from it - Even when coming back online, it is completely unreliable when the deletion will be finally done. It might happen 5 or 10 minutes later 2) Broken test mock - The mocked connectivityService doesn't work as expected, because the OfflineOperationsWorker has its own service, and thus might still execute the deletion, but just at an unforseable time during the test execution - see problem 1). Signed-off-by: Philipp Hasper --- .../test/FileRemovedIdlingResource.kt | 51 +++++++ .../ui/preview/PreviewImageActivityIT.kt | 138 ++++++++++++++---- 2 files changed, 160 insertions(+), 29 deletions(-) create mode 100644 app/src/androidTest/java/com/nextcloud/test/FileRemovedIdlingResource.kt diff --git a/app/src/androidTest/java/com/nextcloud/test/FileRemovedIdlingResource.kt b/app/src/androidTest/java/com/nextcloud/test/FileRemovedIdlingResource.kt new file mode 100644 index 000000000000..8d52cc303047 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/FileRemovedIdlingResource.kt @@ -0,0 +1,51 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Philipp Hasper + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.test + +import androidx.test.espresso.IdlingResource +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference + +/** + * IdlingResource that can be reused to watch the removal of different file ids sequentially. + * + * Use setFileId(fileId) before triggering the deletion. The resource will call the Espresso callback + * once the file no longer exists. Call unregister from IdlingRegistry in @After. + */ +class FileRemovedIdlingResource(private val storageManager: FileDataStorageManager) : IdlingResource { + private var resourceCallback: IdlingResource.ResourceCallback? = null + + // null means "no file set" + private var currentFile = AtomicReference(null) + + override fun getName(): String = "${this::class.java.simpleName}" + + override fun isIdleNow(): Boolean { + val file = currentFile.get() + // If no file set, consider idle. If file set, idle only if it doesn't exist. + val idle = file == null || (!storageManager.fileExists(file.fileId) && !file.exists()) + if (idle && file != null) { + // if we detect it's already removed, notify and clear + resourceCallback?.onTransitionToIdle() + currentFile.set(null) + } + return idle + } + + override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) { + this.resourceCallback = callback + } + + /** + * Start watching the given file. Call this right before performing the UI action that triggers deletion. + */ + fun setFile(file: OCFile) { + currentFile.set(file) + } +} diff --git a/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt index 717fc3fff1c4..d99f6d0ae6a8 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt @@ -22,20 +22,34 @@ import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import com.nextcloud.test.ConnectivityServiceOfflineMock +import com.nextcloud.test.FileRemovedIdlingResource import com.nextcloud.test.LoopFailureHandler import com.owncloud.android.AbstractOnServerIT import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.db.OCUpload +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation import org.hamcrest.Matchers.allOf +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Ignore import org.junit.Test import java.io.File class PreviewImageActivityIT : AbstractOnServerIT() { - lateinit var testFiles: List + companion object { + private const val REMOTE_FOLDER: String = "/PreviewImageActivityIT/" + } + + var fileRemovedIdlingResource = FileRemovedIdlingResource(storageManager) - fun createMockedImageFiles(count: Int, localOnly: Boolean) { + @Suppress("SameParameterValue") + private fun createLocalMockedImageFiles(count: Int): List { val srcPngFile = getFile("imageFile.png") - testFiles = (0 until count).map { i -> + return (0 until count).map { i -> val pngFile = File(srcPngFile.parent ?: ".", "image$i.png") srcPngFile.copyTo(pngFile, overwrite = true) @@ -44,19 +58,48 @@ class PreviewImageActivityIT : AbstractOnServerIT() { mimeType = "image/png" modificationTimestamp = 1000000 permissions = "D" // OCFile.PERMISSION_CAN_DELETE_OR_LEAVE_SHARE. Required for deletion button to show - remoteId = if (localOnly) null else "abc-mocked-remote-id" // mocking the file to be on the server }.also { storageManager.saveNewFile(it) } } } - fun veryImageThenDelete(index: Int) { - val currentFileName = testFiles[index].fileName + /** + * Create image files and upload them to the connected server. + * + * This function relies on the images not existing beforehand, as AbstractOnServerIT#deleteAllFilesOnServer() + * should clean up. If it does fail, likely because that clean up didn't work and there are leftovers from + * a previous run + * @param count Number of files to create + * @param folder Parent folder to which to upload. Must start and end with a slash + */ + private fun createAndUploadImageFiles(count: Int, folder: String = REMOTE_FOLDER): List { + val srcPngFile = getFile("imageFile.png") + return (0 until count).map { i -> + val pngFile = File(srcPngFile.parent ?: ".", "image$i.png") + srcPngFile.copyTo(pngFile, overwrite = true) + + val ocUpload = OCUpload( + pngFile.absolutePath, + folder + pngFile.name, + account.name + ).apply { + nameCollisionPolicy = NameCollisionPolicy.OVERWRITE + } + uploadOCUpload(ocUpload) + + fileDataStorageManager.getFileByDecryptedRemotePath(folder + pngFile.name)!! + } + } + + private fun veryImageThenDelete(testFile: OCFile) { Espresso.setFailureHandler( - LoopFailureHandler(targetContext, "Test failed with image file index $index, $currentFileName") + LoopFailureHandler(targetContext, "Test failed with image file ${testFile.fileName}") ) + assertTrue(testFile.exists()) + assertTrue(testFile.fileExists()) + onView(withId(R.id.image)) .check(matches(isDisplayed())) @@ -64,7 +107,7 @@ class PreviewImageActivityIT : AbstractOnServerIT() { onView( allOf( isDescendantOfA(isAssignableFrom(ActionBarContainer::class.java)), - withText(currentFileName) + withText(testFile.fileName) ) ).check(matches(isDisplayed())) @@ -82,7 +125,7 @@ class PreviewImageActivityIT : AbstractOnServerIT() { onView(withText(R.string.common_remove)).perform(ViewActions.click()) // Check confirmation dialog and then confirm the deletion by clicking the main button of the dialog - val expectedText = targetContext.getString(R.string.confirmation_remove_file_alert, currentFileName) + val expectedText = targetContext.getString(R.string.confirmation_remove_file_alert, testFile.fileName) onView(withId(android.R.id.message)) .inRoot(isDialog()) .check(matches(withText(expectedText))) @@ -92,43 +135,80 @@ class PreviewImageActivityIT : AbstractOnServerIT() { .check(matches(withText(R.string.file_delete))) .perform(ViewActions.click()) + // Register the idling resource to wait for successful deletion + fileRemovedIdlingResource.setFile(testFile) + + // Wait for idle, then verify that the file is gone. Somehow waitForIdleSync() doesn't work and we need onIdle() + Espresso.onIdle() + assertFalse("test file still exists: ${testFile.fileName}", testFile.exists()) + Espresso.setFailureHandler(DefaultFailureHandler(targetContext)) } - @Test - fun deleteFromSlideshow_localOnly_online() { + @Before + fun bringUp() { + IdlingRegistry.getInstance().register(fileRemovedIdlingResource) + } + + @After + fun tearDown() { + IdlingRegistry.getInstance().unregister(fileRemovedIdlingResource) + } + + private fun testDeleteFromSlideshow_impl(localOnly: Boolean, offline: Boolean) { // Prepare local test data val imageCount = 5 - createMockedImageFiles(imageCount, localOnly = true) + val testFiles = if (localOnly) { + createLocalMockedImageFiles( + imageCount + ) + } else { + createAndUploadImageFiles(imageCount) + } // Launch the activity with the first image val intent = PreviewImageActivity.previewFileIntent(targetContext, user, testFiles[0]) - launchActivity(intent).use { + launchActivity(intent).use { scenario -> + if (offline) { + scenario.onActivity { activity -> + activity.connectivityService = ConnectivityServiceOfflineMock() + } + } onView(isRoot()).check(matches(isDisplayed())) - for (i in 0 until imageCount) { - veryImageThenDelete(i) + for (testFile in testFiles) { + veryImageThenDelete(testFile) + assertTrue( + "Test file still exists on the server: ${testFile.remotePath}", + ExistenceCheckRemoteOperation(testFile.remotePath, true).execute(client).isSuccess + ) } } } + @Test + fun deleteFromSlideshow_localOnly_online() { + testDeleteFromSlideshow_impl(localOnly = true, offline = false) + } + @Test fun deleteFromSlideshow_localOnly_offline() { - // Prepare local test data - val imageCount = 5 - createMockedImageFiles(imageCount, localOnly = true) + testDeleteFromSlideshow_impl(localOnly = true, offline = true) + } - // Launch the activity with the first image - val intent = PreviewImageActivity.previewFileIntent(targetContext, user, testFiles[0]) - launchActivity(intent).use { scenario -> - scenario.onActivity { activity -> - activity.connectivityService = ConnectivityServiceOfflineMock() - } - onView(isRoot()).check(matches(isDisplayed())) + @Test + fun deleteFromSlideshow_remote_online() { + testDeleteFromSlideshow_impl(localOnly = false, offline = false) + } - for (i in 0 until imageCount) { - veryImageThenDelete(i) - } - } + @Test + @Ignore( + "Offline deletion is following a different UX and it is also brittle: Deletion might happen 10 minutes later" + ) + fun deleteFromSlideshow_remote_offline() { + // Note: the offline mock doesn't actually do what it is supposed to. The OfflineOperationsWorker uses its + // own connectivityService, which is online, and may still execute the server deletion. + // You'll need to address this, should you activate that test. Otherwise it might not catch all error cases + testDeleteFromSlideshow_impl(localOnly = false, offline = true) } } From 0fa80398e491388f76a093c2ee97eb37a8813697 Mon Sep 17 00:00:00 2001 From: Philipp Hasper Date: Mon, 22 Dec 2025 15:27:14 +0100 Subject: [PATCH 4/4] Testing slideshow deletion: start in beginning, end, and middle of the list The existing test cases varied by configuration: local vs. remote, online vs. offline. This is now extended by also starting at different entry points: beginning, end and middle of the list. Signed-off-by: Philipp Hasper --- .../ui/preview/PreviewImageActivityIT.kt | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt index d99f6d0ae6a8..3ce7a9333b6d 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt @@ -145,18 +145,11 @@ class PreviewImageActivityIT : AbstractOnServerIT() { Espresso.setFailureHandler(DefaultFailureHandler(targetContext)) } - @Before - fun bringUp() { - IdlingRegistry.getInstance().register(fileRemovedIdlingResource) - } - - @After - fun tearDown() { - IdlingRegistry.getInstance().unregister(fileRemovedIdlingResource) - } - - private fun testDeleteFromSlideshow_impl(localOnly: Boolean, offline: Boolean) { - // Prepare local test data + private fun executeDeletionTestScenario( + localOnly: Boolean, + offline: Boolean, + fileListTransformation: (List) -> List + ) { val imageCount = 5 val testFiles = if (localOnly) { createLocalMockedImageFiles( @@ -165,9 +158,9 @@ class PreviewImageActivityIT : AbstractOnServerIT() { } else { createAndUploadImageFiles(imageCount) } + val expectedFileOrder = fileListTransformation(testFiles) - // Launch the activity with the first image - val intent = PreviewImageActivity.previewFileIntent(targetContext, user, testFiles[0]) + val intent = PreviewImageActivity.previewFileIntent(targetContext, user, expectedFileOrder.first()) launchActivity(intent).use { scenario -> if (offline) { scenario.onActivity { activity -> @@ -176,7 +169,7 @@ class PreviewImageActivityIT : AbstractOnServerIT() { } onView(isRoot()).check(matches(isDisplayed())) - for (testFile in testFiles) { + for (testFile in expectedFileOrder) { veryImageThenDelete(testFile) assertTrue( "Test file still exists on the server: ${testFile.remotePath}", @@ -186,6 +179,27 @@ class PreviewImageActivityIT : AbstractOnServerIT() { } } + private fun testDeleteFromSlideshow_impl(localOnly: Boolean, offline: Boolean) { + // Case 1: start at first image + executeDeletionTestScenario(localOnly, offline) { list -> list } + // Case 2: start at last image (reversed) + executeDeletionTestScenario(localOnly, offline) { list -> list.reversed() } + // Case 3: Start in the middle. From middle to the end, then backwards through remaining files of the first half + executeDeletionTestScenario(localOnly, offline) { list -> + list.subList(list.size / 2, list.size) + list.subList(0, list.size / 2).reversed() + } + } + + @Before + fun bringUp() { + IdlingRegistry.getInstance().register(fileRemovedIdlingResource) + } + + @After + fun tearDown() { + IdlingRegistry.getInstance().unregister(fileRemovedIdlingResource) + } + @Test fun deleteFromSlideshow_localOnly_online() { testDeleteFromSlideshow_impl(localOnly = true, offline = false)