test: cover delete flows and guard re-entrant mutations
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user