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 lastAttemptedComment: String? = null
private var inFlightDescriptionPayload: String? = null private var inFlightDescriptionPayload: String? = null
private var pendingDescriptionPayload: String? = null
private var descriptionDebounceJob: Job? = null private var descriptionDebounceJob: Job? = null
fun load() { fun load() {
@@ -367,16 +368,20 @@ class CardDetailViewModel(
} }
val normalized = rawDescription val normalized = rawDescription
if (!fromRetry && normalized == persistedDescription) { if (!fromRetry && normalized == persistedDescription) {
_uiState.update { it.copy(isDescriptionDirty = false) } _uiState.update { it.copy(isDescriptionDirty = it.description != persistedDescription) }
return return
} }
if (!fromRetry && normalized == inFlightDescriptionPayload) { if (!fromRetry && normalized == inFlightDescriptionPayload) {
return return
} }
if (_uiState.value.isDescriptionSaving) { if (_uiState.value.isDescriptionSaving) {
if (normalized != inFlightDescriptionPayload) {
pendingDescriptionPayload = normalized
}
return return
} }
lastAttemptedDescription = normalized lastAttemptedDescription = normalized
pendingDescriptionPayload = null
viewModelScope.launch { viewModelScope.launch {
inFlightDescriptionPayload = normalized inFlightDescriptionPayload = normalized
@@ -385,10 +390,10 @@ class CardDetailViewModel(
is CardDetailRepository.Result.Success -> { is CardDetailRepository.Result.Success -> {
persistedDescription = normalized persistedDescription = normalized
_uiState.update { _uiState.update {
val stillDirty = it.description != persistedDescription
it.copy( it.copy(
description = normalized,
isDescriptionSaving = false, isDescriptionSaving = false,
isDescriptionDirty = false, isDescriptionDirty = stillDirty,
descriptionErrorMessage = null, descriptionErrorMessage = null,
) )
} }
@@ -408,13 +413,18 @@ class CardDetailViewModel(
_uiState.update { _uiState.update {
it.copy( it.copy(
isDescriptionSaving = false, isDescriptionSaving = false,
isDescriptionDirty = true, isDescriptionDirty = it.description != persistedDescription,
descriptionErrorMessage = result.message, descriptionErrorMessage = result.message,
) )
} }
} }
} }
inFlightDescriptionPayload = null 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) 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 @Test
fun addComment_success_closesDialog_showsSnackbar_refreshesActivities() = runTest { fun addComment_success_closesDialog_showsSnackbar_refreshesActivities() = runTest {
val repository = FakeCardDetailDataSource( val repository = FakeCardDetailDataSource(
@@ -328,6 +366,7 @@ class CardDetailViewModelTest {
var updateDueDateResult: CardDetailRepository.Result<Unit> = CardDetailRepository.Result.Success(Unit), var updateDueDateResult: CardDetailRepository.Result<Unit> = CardDetailRepository.Result.Success(Unit),
var addCommentResult: CardDetailRepository.Result<Unit> = CardDetailRepository.Result.Success(Unit), var addCommentResult: CardDetailRepository.Result<Unit> = CardDetailRepository.Result.Success(Unit),
var updateDescriptionGate: CompletableDeferred<Unit>? = null, var updateDescriptionGate: CompletableDeferred<Unit>? = null,
var updateDescriptionGates: ArrayDeque<CompletableDeferred<Unit>> = ArrayDeque(),
) : CardDetailDataSource { ) : CardDetailDataSource {
var loadCalls: Int = 0 var loadCalls: Int = 0
var updateTitleCalls: Int = 0 var updateTitleCalls: Int = 0
@@ -355,6 +394,9 @@ class CardDetailViewModelTest {
override suspend fun updateDescription(cardId: String, description: String): CardDetailRepository.Result<Unit> { override suspend fun updateDescription(cardId: String, description: String): CardDetailRepository.Result<Unit> {
updateDescriptionCalls += 1 updateDescriptionCalls += 1
updateDescriptionPayloads += description updateDescriptionPayloads += description
if (updateDescriptionGates.isNotEmpty()) {
updateDescriptionGates.removeFirst().await()
}
updateDescriptionGate?.await() updateDescriptionGate?.await()
return updateDescriptionResult return updateDescriptionResult
} }