fix: preserve pending description flush during in-flight save
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user