diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModel.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModel.kt index 816533a..f52de9f 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModel.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModel.kt @@ -76,6 +76,7 @@ class CardDetailViewModel( private var lastAttemptedComment: String? = null private var inFlightDescriptionPayload: String? = null + private var pendingDescriptionPayload: String? = null private var descriptionDebounceJob: Job? = null fun load() { @@ -367,16 +368,20 @@ class CardDetailViewModel( } val normalized = rawDescription if (!fromRetry && normalized == persistedDescription) { - _uiState.update { it.copy(isDescriptionDirty = false) } + _uiState.update { it.copy(isDescriptionDirty = it.description != persistedDescription) } return } if (!fromRetry && normalized == inFlightDescriptionPayload) { return } if (_uiState.value.isDescriptionSaving) { + if (normalized != inFlightDescriptionPayload) { + pendingDescriptionPayload = normalized + } return } lastAttemptedDescription = normalized + pendingDescriptionPayload = null viewModelScope.launch { inFlightDescriptionPayload = normalized @@ -385,10 +390,10 @@ class CardDetailViewModel( is CardDetailRepository.Result.Success -> { persistedDescription = normalized _uiState.update { + val stillDirty = it.description != persistedDescription it.copy( - description = normalized, isDescriptionSaving = false, - isDescriptionDirty = false, + isDescriptionDirty = stillDirty, descriptionErrorMessage = null, ) } @@ -408,13 +413,18 @@ class CardDetailViewModel( _uiState.update { it.copy( isDescriptionSaving = false, - isDescriptionDirty = true, + isDescriptionDirty = it.description != persistedDescription, descriptionErrorMessage = result.message, ) } } } inFlightDescriptionPayload = null + + val pending = pendingDescriptionPayload + if (!_uiState.value.isSessionExpired && pending != null && pending != persistedDescription) { + saveDescription(pending) + } } } diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModelTest.kt index 6b3bfd5..f3ff836 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModelTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModelTest.kt @@ -181,6 +181,44 @@ class CardDetailViewModelTest { assertFalse(viewModel.uiState.value.isDescriptionDirty) } + @Test + fun onStopDuringInflightSave_preservesPendingLatest_andDirtyUntilLatestCompletes() = runTest { + val firstGate = CompletableDeferred() + val secondGate = CompletableDeferred() + val repository = FakeCardDetailDataSource( + loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()), + listActivitiesResult = CardDetailRepository.Result.Success(emptyList()), + updateDescriptionGates = ArrayDeque(listOf(firstGate, secondGate)), + ) + val viewModel = loadedViewModel(this, repository) + + viewModel.onDescriptionChanged("first") + advanceTimeBy(800) + runCurrent() + assertEquals(listOf("first"), repository.updateDescriptionPayloads) + + viewModel.onDescriptionChanged("second") + viewModel.onStop() + runCurrent() + assertEquals(1, repository.updateDescriptionCalls) + + firstGate.complete(Unit) + runCurrent() + + assertEquals(listOf("first", "second"), repository.updateDescriptionPayloads) + assertTrue(viewModel.uiState.value.isDescriptionDirty) + assertTrue(viewModel.uiState.value.isDescriptionSaving) + assertEquals("second", viewModel.uiState.value.description) + + secondGate.complete(Unit) + advanceUntilIdle() + + assertEquals(listOf("first", "second"), repository.updateDescriptionPayloads) + assertFalse(viewModel.uiState.value.isDescriptionDirty) + assertFalse(viewModel.uiState.value.isDescriptionSaving) + assertEquals("second", viewModel.uiState.value.description) + } + @Test fun addComment_success_closesDialog_showsSnackbar_refreshesActivities() = runTest { val repository = FakeCardDetailDataSource( @@ -328,6 +366,7 @@ class CardDetailViewModelTest { var updateDueDateResult: CardDetailRepository.Result = CardDetailRepository.Result.Success(Unit), var addCommentResult: CardDetailRepository.Result = CardDetailRepository.Result.Success(Unit), var updateDescriptionGate: CompletableDeferred? = null, + var updateDescriptionGates: ArrayDeque> = ArrayDeque(), ) : CardDetailDataSource { var loadCalls: Int = 0 var updateTitleCalls: Int = 0 @@ -355,6 +394,9 @@ class CardDetailViewModelTest { override suspend fun updateDescription(cardId: String, description: String): CardDetailRepository.Result { updateDescriptionCalls += 1 updateDescriptionPayloads += description + if (updateDescriptionGates.isNotEmpty()) { + updateDescriptionGates.removeFirst().await() + } updateDescriptionGate?.await() return updateDescriptionResult }