From 5cfa0451713cdc5555dc7955c4309da28bcf15ba Mon Sep 17 00:00:00 2001 From: Iu Date: Fri, 16 Oct 2026 14:12:58 +0300 Subject: [PATCH] =?UTF-8?q?test(android):=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=BE=D1=82=D0=B5=D1=80=D1=8E=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit-тесты планировщика, координатора, политики и восстановления, Compose- тесты маркера и шита, instrumented-проверка удаления превью с устройства и E2E-сценарии потери/возврата серверной копии. E2E не прогонялись: нужен эмулятор и сервер на 10.0.2.2:8088. --- .../com/galo/android/DeviceSyncE2ETest.kt | 89 ++++++++++++++++++ .../androidTest/java/com/galo/android/E2E.kt | 12 +++ .../galo/android/OffloadInstrumentedTest.kt | 17 ++++ .../android/data/sync/RestoreManagerTest.kt | 9 ++ .../android/data/sync/SyncCoordinatorTest.kt | 91 +++++++++++++++++++ .../galo/android/data/sync/SyncPlannerTest.kt | 42 +++++++++ .../attributes/PhotoAttributeResolverTest.kt | 8 ++ .../domain/model/LocalAssetStateTest.kt | 25 +++++ .../domain/sync/GallerySyncPolicyTest.kt | 9 ++ .../ui/devicealbum/DeviceAlbumContentTest.kt | 17 ++++ .../devicealbum/DeviceAlbumOriginalsTest.kt | 18 ++++ .../ui/photo/PhotoAttributesSheetTest.kt | 22 +++++ 12 files changed, 359 insertions(+) create mode 100644 android/app/src/test/java/com/galo/android/domain/model/LocalAssetStateTest.kt diff --git a/android/app/src/androidTest/java/com/galo/android/DeviceSyncE2ETest.kt b/android/app/src/androidTest/java/com/galo/android/DeviceSyncE2ETest.kt index 953d734..072237f 100644 --- a/android/app/src/androidTest/java/com/galo/android/DeviceSyncE2ETest.kt +++ b/android/app/src/androidTest/java/com/galo/android/DeviceSyncE2ETest.kt @@ -293,6 +293,95 @@ class DeviceSyncE2ETest { ) } + @Test + fun deletedServerCopyMarksThePreviewAsOrphaned() = runBlocking { + val app = ApplicationProvider.getApplicationContext() + val container = app.container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val serverAlbum = E2E.createAlbum(token, "Orphan ${System.currentTimeMillis()}") + + val (uri, bucket) = insertImage() + val mediaId = ContentUris.parseId(uri) + val key = DeviceAssetEntity.key(bucket, mediaId) + container.syncRepository.link(bucket, "O", serverAlbum, SyncMode.MOVE) + assertTrue(container.syncCoordinator.reconcile(bucket)) + assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1) + assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW)) + + // Somebody removes the photo from the server album. + val photo = E2E.listPhotos(token, serverAlbum).first() + E2E.deletePhoto(token, photo.id) + + container.syncCheck.checkGallery(bucket, recoverIndex = true) + + assertTrue( + "the offloaded preview should be marked as orphaned", + waitForState(container, bucket, key, LocalAssetState.ORPHANED), + ) + + // The grid derives the crossed-out cloud from the view model set. + val viewModel = DeviceAlbumViewModel(container, bucket) + val collector = launch { viewModel.state.collect { } } + try { + var orphaned = false + val deadline = System.currentTimeMillis() + 8_000 + while (System.currentTimeMillis() < deadline) { + orphaned = mediaId in viewModel.state.value.orphanedMediaIds + if (orphaned) break + delay(200) + } + assertTrue("the lost preview should be flagged for the grid", orphaned) + } finally { + collector.cancel() + } + } + + @Test + fun returnedOriginalIsUploadedAgainAndOffloaded() = runBlocking { + val app = ApplicationProvider.getApplicationContext() + val container = app.container + E2E.configure(container) + val token = E2E.registerAuthenticated(container) + val serverAlbum = E2E.createAlbum(token, "Return ${System.currentTimeMillis()}") + + val (uri, bucket) = insertImage() + val mediaId = ContentUris.parseId(uri) + val key = DeviceAssetEntity.key(bucket, mediaId) + val originalBytes = resolver.openInputStream(uri)!!.use { it.readBytes() } + + container.syncRepository.link(bucket, "R", serverAlbum, SyncMode.MOVE) + assertTrue(container.syncCoordinator.reconcile(bucket)) + assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1) + assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW)) + + val photo = E2E.listPhotos(token, serverAlbum).first() + E2E.deletePhoto(token, photo.id) + container.syncCheck.checkGallery(bucket, recoverIndex = true) + assertTrue(waitForState(container, bucket, key, LocalAssetState.ORPHANED)) + + // The original comes back to the device: upload it again, then offload. + resolver.openOutputStream(uri)!!.use { it.write(originalBytes) } + + var offloadedAgain = false + val deadline = System.currentTimeMillis() + 60_000 + while (System.currentTimeMillis() < deadline) { + container.syncCheck.checkGallery(bucket, recoverIndex = true) + delay(1_000) + val state = container.syncRepository.assets(bucket) + .firstOrNull { it.assetKey == key }?.state + if (state == LocalAssetState.PREVIEW.name) { + offloadedAgain = true + break + } + } + assertTrue("the returned original should be uploaded and offloaded again", offloadedAgain) + assertTrue( + "the server album should hold a photo again", + E2E.listPhotos(token, serverAlbum).isNotEmpty(), + ) + } + private suspend fun waitForLink( container: com.galo.android.di.AppContainer, bucket: String, diff --git a/android/app/src/androidTest/java/com/galo/android/E2E.kt b/android/app/src/androidTest/java/com/galo/android/E2E.kt index a5fb5e3..17fc21d 100644 --- a/android/app/src/androidTest/java/com/galo/android/E2E.kt +++ b/android/app/src/androidTest/java/com/galo/android/E2E.kt @@ -184,6 +184,18 @@ object E2E { } } + /** Deletes a photo on the server (drives a live `photo.deleted`). */ + fun deletePhoto(token: String, photoId: String) { + val request = Request.Builder() + .url("$BASE_URL/api/photos/$photoId") + .header("Authorization", "Bearer $token") + .delete() + .build() + client.newCall(request).execute().use { response -> + check(response.isSuccessful) { "delete photo failed: HTTP ${response.code}" } + } + } + fun awaitPhotoWithPlayback(token: String, albumId: String, timeoutMs: Long): PhotoDto { val deadline = System.currentTimeMillis() + timeoutMs while (System.currentTimeMillis() < deadline) { diff --git a/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt b/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt index 4f592e6..ce842aa 100644 --- a/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt +++ b/android/app/src/androidTest/java/com/galo/android/OffloadInstrumentedTest.kt @@ -16,6 +16,7 @@ import com.galo.android.data.db.GaloDatabase import com.galo.android.data.db.entity.DeviceAssetEntity import com.galo.android.data.db.entity.SyncedAlbumEntity import com.galo.android.data.media.MediaStoreDeviceSource +import com.galo.android.data.repo.SyncRepository import com.galo.android.data.sync.OffloadManager import com.galo.android.data.sync.OffloadResult import com.galo.android.data.sync.OriginalFetcher @@ -198,6 +199,22 @@ class OffloadInstrumentedTest { assertEquals("srvX", db.syncDao().getSyncedAlbum(bucket)?.serverAlbumId) } + @Test + fun removingALostPreviewDeletesTheFileAndTheIndexRow() = runBlocking { + val (uri, bucket) = insertImage(800, 600) + val asset = assetOf(uri, bucket, "lost.jpg", "image/jpeg") + .copy(state = LocalAssetState.ORPHANED.name) + db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity(bucket, "Test", "srvX", SyncMode.MOVE.name, 1L)) + db.syncDao().upsertAssets(listOf(asset)) + val repository = SyncRepository(db.syncDao(), MediaStoreDeviceSource(context)) + + val removed = repository.removeDeviceAsset(asset.assetKey, uri) + + assertTrue("the preview file should be gone", removed) + assertTrue("the media row should be gone", !exists(uri)) + assertTrue("the index row should be gone", db.syncDao().getAsset(asset.assetKey) == null) + } + @Test fun restoreWritesTheOriginalBackOverThePreview() = runBlocking { val (uri, bucket) = insertImage(2400, 1800) diff --git a/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt b/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt index 3854dc6..cece30e 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/RestoreManagerTest.kt @@ -50,4 +50,13 @@ class RestoreManagerTest { assertEquals(listOf("b1/1"), targets.map { it.assetKey }) } + + @Test + fun `orphaned previews are not restored`() { + val orphaned = asset("b1/1", state = LocalAssetState.ORPHANED) + + val targets = RestoreManager.restoreTargets(listOf(orphaned)) + + assertTrue("a lost server copy has nothing to restore", targets.isEmpty()) + } } diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt index c1a4081..41e426c 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncCoordinatorTest.kt @@ -310,6 +310,97 @@ class SyncCoordinatorTest { assertEquals(listOf("b1/1"), offloader.offloaded) } + @Test + fun `a preview whose server copy is gone becomes orphaned`() = runBlocking { + link(SyncMode.MOVE) + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "orig", "p1", 1L, LocalAssetState.PREVIEW.name, + ), + ), + ) + provider.items = listOf(asset("b1/1", hash = "preview")) + + coordinator.reconcile("b1") + + assertEquals(LocalAssetState.ORPHANED.name, db.syncDao().getAsset("b1/1")?.state) + assertTrue("an orphaned preview must not be uploaded", queue.enqueued.isEmpty()) + assertTrue("the server copy is already gone", remote.deletes.isEmpty()) + } + + @Test + fun `an orphaned preview whose photo returns becomes a preview again`() = runBlocking { + link(SyncMode.MOVE) + remote.serverMap = mapOf("orig" to "p1") + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "orig", "p1", 1L, LocalAssetState.ORPHANED.name, + ), + ), + ) + provider.items = listOf(asset("b1/1", hash = "preview")) + + coordinator.reconcile("b1") + + assertEquals(LocalAssetState.PREVIEW.name, db.syncDao().getAsset("b1/1")?.state) + } + + @Test + fun `a returned original is uploaded again`() = runBlocking { + link(SyncMode.MOVE) + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "orig", "p1", 1L, LocalAssetState.ORPHANED.name, + ), + ), + ) + provider.items = listOf(asset("b1/1", hash = "orig")) + + coordinator.reconcile("b1") + + assertEquals(1, queue.enqueued.size) + assertEquals("b1/1", queue.enqueued.single().third.assetKey) + } + + @Test + fun `after a returned original is uploaded it is offloaded again`() = runBlocking { + link(SyncMode.MOVE) + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "orig", "p1", 1L, LocalAssetState.ORPHANED.name, + ), + ), + ) + provider.items = listOf(asset("b1/1", hash = "orig")) + + coordinator.reconcile("b1") + assertEquals(1, queue.enqueued.size) + + // The upload finished: the row points at a live photo again. + db.syncDao().upsertAssets( + listOf( + DeviceAssetEntity( + "b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L, + "orig", "p2", 1L, LocalAssetState.AWAITING_FREE.name, + ), + ), + ) + remote.serverMap = mapOf("orig" to "p2") + offloader.result = OffloadResult.Done + + coordinator.reconcile("b1") + + assertEquals(listOf("b1/1"), offloader.offloaded) + } + @Test fun `move delegates to the server`() = runBlocking { coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2") diff --git a/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt b/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt index 4f456bf..2b7367b 100644 --- a/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt +++ b/android/app/src/test/java/com/galo/android/data/sync/SyncPlannerTest.kt @@ -104,6 +104,48 @@ class SyncPlannerTest { assertTrue("an offloaded asset must not be uploaded again", actions.isEmpty()) } + @Test + fun `an orphaned preview is not re-uploaded`() { + val asset = local("b1/1", hash = "preview-hash") + val actions = SyncPlanner.plan( + local = listOf(asset), + index = listOf( + IndexedAsset("b1/1", "p1", "original-hash", "a.jpg", LocalAssetState.ORPHANED.name), + ), + serverChecksums = emptyMap(), + hashesPresentElsewhere = emptySet(), + ) + assertTrue("an orphaned preview must not be uploaded as an original", actions.isEmpty()) + } + + @Test + fun `a returned original is uploaded again`() { + val asset = local("b1/1", hash = "original-hash") + val actions = SyncPlanner.plan( + local = listOf(asset), + index = listOf( + IndexedAsset("b1/1", "p1", "original-hash", "a.jpg", LocalAssetState.ORPHANED.name), + ), + serverChecksums = emptyMap(), + hashesPresentElsewhere = emptySet(), + ) + assertEquals(listOf(SyncAction.Upload(asset)), actions) + } + + @Test + fun `a returned original already on the server is linked`() { + val asset = local("b1/1", hash = "original-hash") + val actions = SyncPlanner.plan( + local = listOf(asset), + index = listOf( + IndexedAsset("b1/1", "p1", "original-hash", "a.jpg", LocalAssetState.ORPHANED.name), + ), + serverChecksums = mapOf("original-hash" to "p9"), + hashesPresentElsewhere = emptySet(), + ) + assertEquals(listOf(SyncAction.Link(asset, "p9")), actions) + } + @Test fun `edited content is re-uploaded and the old upload removed`() { val asset = local("b1/1", name = "a.jpg", hash = "h2") diff --git a/android/app/src/test/java/com/galo/android/domain/attributes/PhotoAttributeResolverTest.kt b/android/app/src/test/java/com/galo/android/domain/attributes/PhotoAttributeResolverTest.kt index fc6f1e5..bcf9a04 100644 --- a/android/app/src/test/java/com/galo/android/domain/attributes/PhotoAttributeResolverTest.kt +++ b/android/app/src/test/java/com/galo/android/domain/attributes/PhotoAttributeResolverTest.kt @@ -46,4 +46,12 @@ class PhotoAttributeResolverTest { assertTrue(source is PhotoAttributeSource.Local) } + + @Test + fun `orphaned media resolves to the orphaned source even with a stale mapping`() { + val source = PhotoAttributeResolver.resolve(media(4L), serverPhotoId = "p42", orphaned = true) + + assertTrue(source is PhotoAttributeSource.Orphaned) + assertEquals(4L, (source as PhotoAttributeSource.Orphaned).media.id) + } } diff --git a/android/app/src/test/java/com/galo/android/domain/model/LocalAssetStateTest.kt b/android/app/src/test/java/com/galo/android/domain/model/LocalAssetStateTest.kt new file mode 100644 index 0000000..98c0e88 --- /dev/null +++ b/android/app/src/test/java/com/galo/android/domain/model/LocalAssetStateTest.kt @@ -0,0 +1,25 @@ +package com.galo.android.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LocalAssetStateTest { + + @Test + fun `every state round-trips through its stored name`() { + for (state in LocalAssetState.entries) { + assertEquals(state, LocalAssetState.from(state.name)) + } + } + + @Test + fun `orphaned round-trips`() { + assertEquals(LocalAssetState.ORPHANED, LocalAssetState.from(LocalAssetState.ORPHANED.name)) + } + + @Test + fun `from returns local for unknown values`() { + assertEquals(LocalAssetState.LOCAL, LocalAssetState.from("NOPE")) + assertEquals(LocalAssetState.LOCAL, LocalAssetState.from(null)) + } +} diff --git a/android/app/src/test/java/com/galo/android/domain/sync/GallerySyncPolicyTest.kt b/android/app/src/test/java/com/galo/android/domain/sync/GallerySyncPolicyTest.kt index 6af0b49..f1e63a0 100644 --- a/android/app/src/test/java/com/galo/android/domain/sync/GallerySyncPolicyTest.kt +++ b/android/app/src/test/java/com/galo/android/domain/sync/GallerySyncPolicyTest.kt @@ -29,6 +29,15 @@ class GallerySyncPolicyTest { assertEquals(LocalFix.NONE, decide(SyncMode.MOVE, LocalAssetState.PREVIEW, true)) } + @Test + fun `orphaned is never fixed locally in any mode`() { + for (mode in SyncMode.entries) { + for (linked in listOf(true, false)) { + assertEquals(LocalFix.NONE, decide(mode, LocalAssetState.ORPHANED, linked)) + } + } + } + @Test fun `uploaded and awaiting-free are offloaded only in move mode`() { for (state in listOf(LocalAssetState.UPLOADED, LocalAssetState.AWAITING_FREE)) { diff --git a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt index d836f6a..77bfdf2 100644 --- a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumContentTest.kt @@ -323,6 +323,23 @@ class DeviceAlbumContentTest { compose.onNodeWithTag(TAG_TRANSFERRED_PREFIX + 1, useUnmergedTree = true).assertDoesNotExist() } + @Test + fun `orphaned media shows a crossed-out cloud badge`() { + compose.setContent { + content( + DeviceAlbumViewModel.UiState( + name = "Camera", + items = listOf(media(1), media(2)), + orphanedMediaIds = setOf(1L), + ), + ) + } + compose.onNodeWithTag(TAG_ORPHANED_PREFIX + 1, useUnmergedTree = true).assertExists() + compose.onNodeWithTag(TAG_ORPHANED_PREFIX + 2, useUnmergedTree = true).assertDoesNotExist() + // A lost copy is not shown with the plain cloud. + compose.onNodeWithTag(TAG_TRANSFERRED_PREFIX + 1, useUnmergedTree = true).assertDoesNotExist() + } + @Test fun `settings panel offers the three sync modes`() { compose.setContent { settings(DeviceAlbumViewModel.UiState(name = "Camera")) } diff --git a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt index 72a1427..93a61ca 100644 --- a/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/devicealbum/DeviceAlbumOriginalsTest.kt @@ -46,4 +46,22 @@ class DeviceAlbumOriginalsTest { val map = serverOriginalsByMediaId(listOf(asset(1, LocalAssetState.PREVIEW, null))) assertTrue(map.isEmpty()) } + + @Test + fun `orphaned preview never uses the server original`() { + val map = serverOriginalsByMediaId(listOf(asset(1, LocalAssetState.ORPHANED, "p1"))) + assertTrue("a lost server copy has no original to fetch", map.isEmpty()) + } + + @Test + fun `orphaned media ids are collected for the grid marker`() { + val ids = orphanedMediaIds( + listOf( + asset(1, LocalAssetState.ORPHANED, "p1"), + asset(2, LocalAssetState.PREVIEW, "p2"), + asset(3, LocalAssetState.ORPHANED, "p3"), + ), + ) + assertEquals(setOf(1L, 3L), ids) + } } diff --git a/android/app/src/test/java/com/galo/android/ui/photo/PhotoAttributesSheetTest.kt b/android/app/src/test/java/com/galo/android/ui/photo/PhotoAttributesSheetTest.kt index 4e8cbcb..77b1366 100644 --- a/android/app/src/test/java/com/galo/android/ui/photo/PhotoAttributesSheetTest.kt +++ b/android/app/src/test/java/com/galo/android/ui/photo/PhotoAttributesSheetTest.kt @@ -157,4 +157,26 @@ class PhotoAttributesSheetTest { compose.onNodeWithTag(TAG_SHEET_DOWNLOAD).assertDoesNotExist() compose.onNodeWithTag(TAG_SHEET_DELETE).assertDoesNotExist() } + + @Test + fun `orphaned media offers only deleting from the device`() { + var deleted = false + compose.setContent { + GaloTheme(dynamicColor = false) { + PhotoAttributesSheet( + model = PhotoSheetModel(title = "IMG.jpg", isServer = false), + onDelete = { deleted = true }, + deleteLabel = "Удалить с устройства", + ) + } + } + + compose.onNodeWithText("Удалить с устройства").performScrollTo().assertIsDisplayed() + compose.onNodeWithTag(TAG_SHEET_DELETE).performScrollTo().performClick() + compose.onNodeWithTag(TAG_SHEET_DOWNLOAD).assertDoesNotExist() + compose.onNodeWithTag(TAG_SHEET_MOVE).assertDoesNotExist() + compose.onNodeWithTag(TAG_SHEET_COVER).assertDoesNotExist() + + assertTrue(deleted) + } }