From 823184d99e4bac506234acba83f346827f334510 Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 18 Sep 2026 07:59:43 +0200 Subject: [PATCH] Test terminal generation resume boundary --- .../SyncGenerationForegroundCoordinator.kt | 8 ++-- .../data/SyncGenerationServiceTest.kt | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt index 5ed2252..a4f7de9 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationForegroundCoordinator.kt @@ -17,7 +17,7 @@ internal sealed interface ForegroundGenerationResult { internal class SyncGenerationForegroundCoordinator(private val repository: TrainlogRepository) { private val service = SyncGenerationService(repository) - private fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? = + internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? = repository.inSyncGenerationTransaction { db -> db.rawQuery( "SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " + @@ -209,9 +209,9 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train * CONTRACT: an explicit retry for the same run republishes that * exact generation and resumes ACK handling; it never recaptures * mutable application state under an existing identity. - * INVARIANT: only an active generation for the correlated desktop - * consumer is resumable; acknowledged or archived runs remain - * excluded from new foreground work. */ + * INVARIANT: a completed inbound ledger makes the run ineligible; + * an acknowledged outbound generation remains resumable only to + * finish its missing inbound half. */ val captured = resumableGeneration( request.getString("run_id"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index cca9603..02b82c9 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -26,6 +26,54 @@ import org.robolectric.annotation.Config class SyncGenerationServiceTest { private val context: Context = ApplicationProvider.getApplicationContext() + @Test + fun acknowledgedOutboundRemainsResumableUntilInboundIsTerminal() { + val sourceName = "generation-resume-source-${UUID.randomUUID()}.db" + val destinationName = "generation-resume-destination-${UUID.randomUUID()}.db" + val root = Files.createTempDirectory("trainlog-generation-resume-").toFile() + val source = TrainlogRepository(context, sourceName) + val destination = TrainlogRepository(context, destinationName) + try { + val sourceService = SyncGenerationService(source) + val destinationService = SyncGenerationService(destination) + val runId = "sy_${UUID.randomUUID()}" + val outbound = + sourceService.capture( + java.io.File(root, "source-owned"), + destinationService.peerId(), + runId, + ) + val outboundDirectory = + sourceService.publish(outbound, java.io.File(root, "source-objects")) + sourceService.acceptAcknowledgement( + destinationService.consume(outboundDirectory).toByteArray() + ) + + val coordinator = SyncGenerationForegroundCoordinator(source) + assertEquals( + outbound.generationId, + coordinator.resumableGeneration(runId, destinationService.peerId())?.generationId, + ) + + val inbound = + destinationService.capture( + java.io.File(root, "destination-owned"), + sourceService.peerId(), + runId, + ) + sourceService.consume( + destinationService.publish(inbound, java.io.File(root, "destination-objects")) + ) + assertEquals(null, coordinator.resumableGeneration(runId, destinationService.peerId())) + } finally { + source.close() + destination.close() + context.deleteDatabase(sourceName) + context.deleteDatabase(destinationName) + root.deleteRecursively() + } + } + @Test fun v19MigrationPreservesDataAndInventsNoGenerationState() { val name = "generation-migration-${UUID.randomUUID()}.db"