fix: preserve pending description flush during in-flight save

This commit is contained in:
2026-03-16 20:55:43 -04:00
parent aa987c9e00
commit beab9006a3
2 changed files with 56 additions and 4 deletions

View File

@@ -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)
}
}
}

View File

@@ -181,6 +181,44 @@ class CardDetailViewModelTest {
assertFalse(viewModel.uiState.value.isDescriptionDirty)
}
@Test
fun onStopDuringInflightSave_preservesPendingLatest_andDirtyUntilLatestCompletes() = runTest {
val firstGate = CompletableDeferred<Unit>()
val secondGate = CompletableDeferred<Unit>()
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<Unit> = CardDetailRepository.Result.Success(Unit),
var addCommentResult: CardDetailRepository.Result<Unit> = CardDetailRepository.Result.Success(Unit),
var updateDescriptionGate: CompletableDeferred<Unit>? = null,
var updateDescriptionGates: ArrayDeque<CompletableDeferred<Unit>> = 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<Unit> {
updateDescriptionCalls += 1
updateDescriptionPayloads += description
if (updateDescriptionGates.isNotEmpty()) {
updateDescriptionGates.removeFirst().await()
}
updateDescriptionGate?.await()
return updateDescriptionResult
}