test: cover delete flows and guard re-entrant mutations
This commit is contained in:
@@ -153,6 +153,9 @@ class BoardDetailViewModel(
|
||||
|
||||
fun submitRenameList() {
|
||||
val snapshot = _uiState.value
|
||||
if (snapshot.isMutating) {
|
||||
return
|
||||
}
|
||||
val editingListId = snapshot.editingListId ?: return
|
||||
val currentList = snapshot.boardDetail?.lists?.firstOrNull { it.id == editingListId } ?: return
|
||||
val trimmedTitle = snapshot.editingListTitle.trim()
|
||||
@@ -238,6 +241,9 @@ class BoardDetailViewModel(
|
||||
mutation: suspend (Set<String>) -> CardBatchMutationResult,
|
||||
) {
|
||||
val preMutation = _uiState.value
|
||||
if (preMutation.isMutating) {
|
||||
return
|
||||
}
|
||||
val selectedIds = preMutation.selectedCardIds
|
||||
if (selectedIds.isEmpty()) {
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package space.hackenslacker.kanbn4droid.app.boarddetail
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
@@ -304,6 +305,99 @@ class BoardDetailViewModelTest {
|
||||
assertEquals("Cannot move", (event as BoardDetailUiEvent.ShowServerError).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteSuccessWithReloadSuccessClearsSelection() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithTwoLists()),
|
||||
BoardsApiResult.Success(detailWithTwoLists()),
|
||||
),
|
||||
),
|
||||
deleteCardsResult = CardBatchMutationResult.Success,
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
viewModel.onCardLongPressed("card-1")
|
||||
|
||||
viewModel.deleteSelectedCards()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletePartialWithReloadSuccessReselectsFailedIdsStillVisibleAndWarns() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithTwoLists()),
|
||||
BoardsApiResult.Success(detailOnlyCardThree()),
|
||||
),
|
||||
),
|
||||
deleteCardsResult = CardBatchMutationResult.PartialSuccess(
|
||||
failedCardIds = setOf("card-2", "card-3"),
|
||||
message = "Some cards could not be deleted.",
|
||||
),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
viewModel.onCardLongPressed("card-1")
|
||||
viewModel.onCardLongPressed("card-2")
|
||||
viewModel.onCardLongPressed("card-3")
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.deleteSelectedCards()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(setOf("card-3"), viewModel.uiState.value.selectedCardIds)
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowWarning)
|
||||
assertEquals("Some cards could not be deleted.", (event as BoardDetailUiEvent.ShowWarning).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteFailurePreservesSelectionAndEmitsServerError() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithTwoLists()))),
|
||||
deleteCardsResult = CardBatchMutationResult.Failure("Delete blocked"),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
viewModel.onCardLongPressed("card-1")
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.deleteSelectedCards()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(setOf("card-1"), viewModel.uiState.value.selectedCardIds)
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowServerError)
|
||||
assertEquals("Delete blocked", (event as BoardDetailUiEvent.ShowServerError).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runMutationNoOpsWhenAlreadyMutating() = runTest {
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithTwoLists()),
|
||||
BoardsApiResult.Success(detailWithTwoLists()),
|
||||
),
|
||||
),
|
||||
moveCardsResult = CardBatchMutationResult.Success,
|
||||
moveGate = gate,
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
viewModel.onCardLongPressed("card-1")
|
||||
|
||||
viewModel.moveSelectedCards("list-2")
|
||||
advanceUntilIdle()
|
||||
viewModel.moveSelectedCards("list-2")
|
||||
gate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, repository.moveCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renameNoOpSkipsApiCallAndExitsEditMode() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
@@ -369,6 +463,32 @@ class BoardDetailViewModelTest {
|
||||
assertEquals("Rename rejected", (event as BoardDetailUiEvent.ShowServerError).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submitRenameNoOpsWhenAlreadyMutating() = runTest {
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithSingleList()),
|
||||
BoardsApiResult.Success(detailWithSingleList()),
|
||||
),
|
||||
),
|
||||
renameListResult = BoardsApiResult.Success(Unit),
|
||||
renameGate = gate,
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
|
||||
viewModel.startEditingList("list-1")
|
||||
viewModel.updateEditingTitle("Renamed")
|
||||
viewModel.submitRenameList()
|
||||
advanceUntilIdle()
|
||||
viewModel.submitRenameList()
|
||||
gate.complete(Unit)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, repository.renameCalls)
|
||||
}
|
||||
|
||||
private fun newLoadedViewModel(
|
||||
scope: TestScope,
|
||||
repository: FakeBoardDetailDataSource,
|
||||
@@ -389,7 +509,11 @@ class BoardDetailViewModelTest {
|
||||
var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
|
||||
var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
|
||||
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit),
|
||||
var moveGate: CompletableDeferred<Unit>? = null,
|
||||
var renameGate: CompletableDeferred<Unit>? = null,
|
||||
) : BoardDetailDataSource {
|
||||
var moveCalls: Int = 0
|
||||
var deleteCalls: Int = 0
|
||||
var renameCalls: Int = 0
|
||||
var lastRenameListId: String? = null
|
||||
var lastRenameTitle: String? = null
|
||||
@@ -403,15 +527,19 @@ class BoardDetailViewModelTest {
|
||||
}
|
||||
|
||||
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
||||
moveCalls += 1
|
||||
moveGate?.await()
|
||||
return moveCardsResult
|
||||
}
|
||||
|
||||
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
|
||||
deleteCalls += 1
|
||||
return deleteCardsResult
|
||||
}
|
||||
|
||||
override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
|
||||
renameCalls += 1
|
||||
renameGate?.await()
|
||||
lastRenameListId = listId
|
||||
lastRenameTitle = newTitle
|
||||
return renameListResult
|
||||
|
||||
Reference in New Issue
Block a user