test: cover delete flows and guard re-entrant mutations

This commit is contained in:
2026-03-16 00:47:18 -04:00
parent 89537a57b7
commit 4455f0ecd3
2 changed files with 134 additions and 0 deletions

View File

@@ -153,6 +153,9 @@ class BoardDetailViewModel(
fun submitRenameList() { fun submitRenameList() {
val snapshot = _uiState.value val snapshot = _uiState.value
if (snapshot.isMutating) {
return
}
val editingListId = snapshot.editingListId ?: return val editingListId = snapshot.editingListId ?: return
val currentList = snapshot.boardDetail?.lists?.firstOrNull { it.id == editingListId } ?: return val currentList = snapshot.boardDetail?.lists?.firstOrNull { it.id == editingListId } ?: return
val trimmedTitle = snapshot.editingListTitle.trim() val trimmedTitle = snapshot.editingListTitle.trim()
@@ -238,6 +241,9 @@ class BoardDetailViewModel(
mutation: suspend (Set<String>) -> CardBatchMutationResult, mutation: suspend (Set<String>) -> CardBatchMutationResult,
) { ) {
val preMutation = _uiState.value val preMutation = _uiState.value
if (preMutation.isMutating) {
return
}
val selectedIds = preMutation.selectedCardIds val selectedIds = preMutation.selectedCardIds
if (selectedIds.isEmpty()) { if (selectedIds.isEmpty()) {
return return

View File

@@ -1,6 +1,7 @@
package space.hackenslacker.kanbn4droid.app.boarddetail package space.hackenslacker.kanbn4droid.app.boarddetail
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@@ -304,6 +305,99 @@ class BoardDetailViewModelTest {
assertEquals("Cannot move", (event as BoardDetailUiEvent.ShowServerError).message) 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 @Test
fun renameNoOpSkipsApiCallAndExitsEditMode() = runTest { fun renameNoOpSkipsApiCallAndExitsEditMode() = runTest {
val repository = FakeBoardDetailDataSource( val repository = FakeBoardDetailDataSource(
@@ -369,6 +463,32 @@ class BoardDetailViewModelTest {
assertEquals("Rename rejected", (event as BoardDetailUiEvent.ShowServerError).message) 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( private fun newLoadedViewModel(
scope: TestScope, scope: TestScope,
repository: FakeBoardDetailDataSource, repository: FakeBoardDetailDataSource,
@@ -389,7 +509,11 @@ class BoardDetailViewModelTest {
var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success, var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success, var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit), var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit),
var moveGate: CompletableDeferred<Unit>? = null,
var renameGate: CompletableDeferred<Unit>? = null,
) : BoardDetailDataSource { ) : BoardDetailDataSource {
var moveCalls: Int = 0
var deleteCalls: Int = 0
var renameCalls: Int = 0 var renameCalls: Int = 0
var lastRenameListId: String? = null var lastRenameListId: String? = null
var lastRenameTitle: String? = null var lastRenameTitle: String? = null
@@ -403,15 +527,19 @@ class BoardDetailViewModelTest {
} }
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult { override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
moveCalls += 1
moveGate?.await()
return moveCardsResult return moveCardsResult
} }
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult { override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
deleteCalls += 1
return deleteCardsResult return deleteCardsResult
} }
override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> { override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
renameCalls += 1 renameCalls += 1
renameGate?.await()
lastRenameListId = listId lastRenameListId = listId
lastRenameTitle = newTitle lastRenameTitle = newTitle
return renameListResult return renameListResult