From 4455f0ecd3eb5434e0abbba7d0b511494d2dbd87 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 00:47:18 -0400 Subject: [PATCH] test: cover delete flows and guard re-entrant mutations --- .../app/boarddetail/BoardDetailViewModel.kt | 6 + .../boarddetail/BoardDetailViewModelTest.kt | 128 ++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt index 3540ec7..fa4368a 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt @@ -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) -> CardBatchMutationResult, ) { val preMutation = _uiState.value + if (preMutation.isMutating) { + return + } val selectedIds = preMutation.selectedCardIds if (selectedIds.isEmpty()) { return diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt index e52465c..6783da5 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt @@ -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() + 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() + 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 = BoardsApiResult.Success(Unit), + var moveGate: CompletableDeferred? = null, + var renameGate: CompletableDeferred? = 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, targetListId: String): CardBatchMutationResult { + moveCalls += 1 + moveGate?.await() return moveCardsResult } override suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult { + deleteCalls += 1 return deleteCardsResult } override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult { renameCalls += 1 + renameGate?.await() lastRenameListId = listId lastRenameTitle = newTitle return renameListResult