feat: add board detail viewmodel state and selection logic
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
package space.hackenslacker.kanbn4droid.app.boarddetail
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||
|
||||
data class BoardDetailUiState(
|
||||
val isInitialLoading: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val isMutating: Boolean = false,
|
||||
val boardDetail: BoardDetail? = null,
|
||||
val fullScreenErrorMessage: String? = null,
|
||||
val currentPageIndex: Int = 0,
|
||||
val selectedCardIds: Set<String> = emptySet(),
|
||||
val editingListId: String? = null,
|
||||
val editingListTitle: String = "",
|
||||
)
|
||||
|
||||
sealed interface BoardDetailUiEvent {
|
||||
data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent
|
||||
data class ShowServerError(val message: String) : BoardDetailUiEvent
|
||||
data class ShowWarning(val message: String) : BoardDetailUiEvent
|
||||
}
|
||||
|
||||
interface BoardDetailDataSource {
|
||||
suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail>
|
||||
suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult
|
||||
suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult
|
||||
suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit>
|
||||
}
|
||||
|
||||
internal class BoardDetailRepositoryDataSource(
|
||||
private val repository: BoardDetailRepository,
|
||||
) : BoardDetailDataSource {
|
||||
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
|
||||
return repository.getBoardDetail(boardId)
|
||||
}
|
||||
|
||||
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
||||
return repository.moveCards(cardIds, targetListId)
|
||||
}
|
||||
|
||||
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
|
||||
return repository.deleteCards(cardIds)
|
||||
}
|
||||
|
||||
override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
|
||||
return repository.renameList(listId, newTitle)
|
||||
}
|
||||
}
|
||||
|
||||
class BoardDetailViewModel(
|
||||
private val boardId: String,
|
||||
private val repository: BoardDetailDataSource,
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(BoardDetailUiState())
|
||||
val uiState: StateFlow<BoardDetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<BoardDetailUiEvent>()
|
||||
val events: SharedFlow<BoardDetailUiEvent> = _events.asSharedFlow()
|
||||
|
||||
fun loadBoardDetail() {
|
||||
fetchBoardDetail(initial = true)
|
||||
}
|
||||
|
||||
fun retryLoad() {
|
||||
fetchBoardDetail(initial = true)
|
||||
}
|
||||
|
||||
fun refreshBoardDetail() {
|
||||
fetchBoardDetail(initial = false, refresh = true)
|
||||
}
|
||||
|
||||
fun setCurrentPage(pageIndex: Int) {
|
||||
_uiState.update {
|
||||
it.copy(currentPageIndex = clampPageIndex(detail = it.boardDetail, pageIndex = pageIndex))
|
||||
}
|
||||
}
|
||||
|
||||
fun selectAllOnCurrentPage() {
|
||||
val current = _uiState.value
|
||||
val pageCards = current.boardDetail
|
||||
?.lists
|
||||
?.getOrNull(current.currentPageIndex)
|
||||
?.cards
|
||||
.orEmpty()
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
if (pageCards.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(selectedCardIds = it.selectedCardIds + pageCards)
|
||||
}
|
||||
}
|
||||
|
||||
fun onCardLongPressed(cardId: String) {
|
||||
toggleCardSelection(cardId)
|
||||
}
|
||||
|
||||
fun onCardTapped(cardId: String) {
|
||||
val hasSelection = _uiState.value.selectedCardIds.isNotEmpty()
|
||||
if (hasSelection) {
|
||||
toggleCardSelection(cardId)
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_events.emit(BoardDetailUiEvent.NavigateToCardPlaceholder(cardId))
|
||||
}
|
||||
}
|
||||
|
||||
fun onBackPressed(): Boolean {
|
||||
if (_uiState.value.selectedCardIds.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(selectedCardIds = emptySet()) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun moveSelectedCards(targetListId: String) {
|
||||
runMutation { selectedIds -> repository.moveCards(selectedIds, targetListId) }
|
||||
}
|
||||
|
||||
fun deleteSelectedCards() {
|
||||
runMutation(repository::deleteCards)
|
||||
}
|
||||
|
||||
fun startEditingList(listId: String) {
|
||||
val list = _uiState.value.boardDetail?.lists?.firstOrNull { it.id == listId } ?: return
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
editingListId = list.id,
|
||||
editingListTitle = list.title,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateEditingTitle(title: String) {
|
||||
_uiState.update { it.copy(editingListTitle = title) }
|
||||
}
|
||||
|
||||
fun submitRenameList() {
|
||||
val snapshot = _uiState.value
|
||||
val editingListId = snapshot.editingListId ?: return
|
||||
val currentList = snapshot.boardDetail?.lists?.firstOrNull { it.id == editingListId } ?: return
|
||||
val trimmedTitle = snapshot.editingListTitle.trim()
|
||||
|
||||
if (trimmedTitle == currentList.title.trim()) {
|
||||
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isMutating = true) }
|
||||
when (val result = repository.renameList(editingListId, trimmedTitle)) {
|
||||
is BoardsApiResult.Success -> {
|
||||
_uiState.update { it.copy(editingListId = null, editingListTitle = "") }
|
||||
val reloadFailureMessage = tryReloadDetailAndReconcile()
|
||||
_uiState.update { it.copy(isMutating = false) }
|
||||
if (reloadFailureMessage != null) {
|
||||
_events.emit(
|
||||
BoardDetailUiEvent.ShowWarning(
|
||||
"Changes applied, but refresh failed. Pull to refresh.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is BoardsApiResult.Failure -> {
|
||||
_uiState.update { it.copy(isMutating = false) }
|
||||
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchBoardDetail(initial: Boolean, refresh: Boolean = false) {
|
||||
if (_uiState.value.isMutating) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isInitialLoading = initial && it.boardDetail == null,
|
||||
isRefreshing = refresh,
|
||||
fullScreenErrorMessage = if (initial && it.boardDetail == null) null else it.fullScreenErrorMessage,
|
||||
)
|
||||
}
|
||||
|
||||
when (val result = repository.getBoardDetail(boardId)) {
|
||||
is BoardsApiResult.Success -> {
|
||||
_uiState.update {
|
||||
reconcileWithNewDetail(it, result.value).copy(
|
||||
isInitialLoading = false,
|
||||
isRefreshing = false,
|
||||
fullScreenErrorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is BoardsApiResult.Failure -> {
|
||||
_uiState.update {
|
||||
if (it.boardDetail == null) {
|
||||
it.copy(
|
||||
isInitialLoading = false,
|
||||
isRefreshing = false,
|
||||
fullScreenErrorMessage = result.message,
|
||||
)
|
||||
} else {
|
||||
it.copy(
|
||||
isInitialLoading = false,
|
||||
isRefreshing = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (_uiState.value.boardDetail != null) {
|
||||
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runMutation(
|
||||
mutation: suspend (Set<String>) -> CardBatchMutationResult,
|
||||
) {
|
||||
val preMutation = _uiState.value
|
||||
val selectedIds = preMutation.selectedCardIds
|
||||
if (selectedIds.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isMutating = true) }
|
||||
|
||||
when (val result = mutation(selectedIds)) {
|
||||
is CardBatchMutationResult.Success -> {
|
||||
_uiState.update { it.copy(selectedCardIds = emptySet()) }
|
||||
val reloadFailureMessage = tryReloadDetailAndReconcile()
|
||||
_uiState.update { it.copy(isMutating = false) }
|
||||
if (reloadFailureMessage != null) {
|
||||
_events.emit(
|
||||
BoardDetailUiEvent.ShowWarning(
|
||||
"Changes applied, but refresh failed. Pull to refresh.",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is CardBatchMutationResult.PartialSuccess -> {
|
||||
val reloadFailureMessage = tryReloadDetailAndReconcile()
|
||||
if (reloadFailureMessage == null) {
|
||||
val visibleIds = allVisibleCardIds(_uiState.value.boardDetail)
|
||||
_uiState.update {
|
||||
it.copy(selectedCardIds = result.failedCardIds.intersect(visibleIds))
|
||||
}
|
||||
_events.emit(BoardDetailUiEvent.ShowWarning(result.message))
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
selectedCardIds = preMutation.selectedCardIds,
|
||||
currentPageIndex = preMutation.currentPageIndex,
|
||||
)
|
||||
}
|
||||
_events.emit(
|
||||
BoardDetailUiEvent.ShowWarning(
|
||||
"Some changes were applied, but refresh failed. Pull to refresh.",
|
||||
),
|
||||
)
|
||||
}
|
||||
_uiState.update { it.copy(isMutating = false) }
|
||||
}
|
||||
|
||||
is CardBatchMutationResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isMutating = false,
|
||||
selectedCardIds = preMutation.selectedCardIds,
|
||||
currentPageIndex = preMutation.currentPageIndex,
|
||||
)
|
||||
}
|
||||
_events.emit(BoardDetailUiEvent.ShowServerError(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun tryReloadDetailAndReconcile(): String? {
|
||||
return when (val result = repository.getBoardDetail(boardId)) {
|
||||
is BoardsApiResult.Success -> {
|
||||
_uiState.update { reconcileWithNewDetail(it, result.value) }
|
||||
null
|
||||
}
|
||||
|
||||
is BoardsApiResult.Failure -> result.message
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleCardSelection(cardId: String) {
|
||||
_uiState.update {
|
||||
val next = it.selectedCardIds.toMutableSet()
|
||||
if (!next.add(cardId)) {
|
||||
next.remove(cardId)
|
||||
}
|
||||
it.copy(selectedCardIds = next)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconcileWithNewDetail(current: BoardDetailUiState, detail: BoardDetail): BoardDetailUiState {
|
||||
val clampedPage = clampPageIndex(detail, current.currentPageIndex)
|
||||
val visibleIds = allVisibleCardIds(detail)
|
||||
val prunedSelection = current.selectedCardIds.intersect(visibleIds)
|
||||
val hasEditedList = current.editingListId?.let { id -> detail.lists.any { it.id == id } } ?: false
|
||||
|
||||
return current.copy(
|
||||
boardDetail = detail,
|
||||
currentPageIndex = clampedPage,
|
||||
selectedCardIds = prunedSelection,
|
||||
editingListId = if (hasEditedList) current.editingListId else null,
|
||||
editingListTitle = if (hasEditedList) current.editingListTitle else "",
|
||||
)
|
||||
}
|
||||
|
||||
private fun clampPageIndex(detail: BoardDetail?, pageIndex: Int): Int {
|
||||
val lastIndex = detail?.lists?.lastIndex ?: -1
|
||||
if (lastIndex < 0) {
|
||||
return 0
|
||||
}
|
||||
return pageIndex.coerceIn(0, lastIndex)
|
||||
}
|
||||
|
||||
private fun allVisibleCardIds(detail: BoardDetail?): Set<String> {
|
||||
return detail?.lists
|
||||
.orEmpty()
|
||||
.flatMap { list -> list.cards }
|
||||
.map { card -> card.id }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val boardId: String,
|
||||
private val repository: BoardDetailRepository,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(BoardDetailViewModel::class.java)) {
|
||||
return BoardDetailViewModel(
|
||||
boardId = boardId,
|
||||
repository = BoardDetailRepositoryDataSource(repository),
|
||||
) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
package space.hackenslacker.kanbn4droid.app.boarddetail
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class BoardDetailViewModelTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
kotlinx.coroutines.Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
kotlinx.coroutines.Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectAllIsAdditiveAcrossPages() = runTest {
|
||||
val viewModel = newLoadedViewModel(
|
||||
scope = this,
|
||||
repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithTwoLists()))),
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.selectAllOnCurrentPage()
|
||||
viewModel.setCurrentPage(1)
|
||||
viewModel.selectAllOnCurrentPage()
|
||||
|
||||
assertEquals(setOf("card-1", "card-2", "card-3"), viewModel.uiState.value.selectedCardIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backPressClearsSelectionWhenSelectionActive() = runTest {
|
||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
|
||||
|
||||
viewModel.onCardLongPressed("card-1")
|
||||
|
||||
assertTrue(viewModel.onBackPressed())
|
||||
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardTapWithoutSelectionEmitsNavigateEvent() = runTest {
|
||||
val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists())
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.onCardTapped("card-1")
|
||||
advanceUntilIdle()
|
||||
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.NavigateToCardPlaceholder)
|
||||
assertEquals("card-1", (event as BoardDetailUiEvent.NavigateToCardPlaceholder).cardId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reloadClampsPageIndex() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithThreeLists()),
|
||||
BoardsApiResult.Success(detailWithSingleList()),
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
|
||||
viewModel.setCurrentPage(2)
|
||||
viewModel.refreshBoardDetail()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(0, viewModel.uiState.value.currentPageIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reloadPrunesSelectedIdsAgainstVisibleCards() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithTwoLists()),
|
||||
BoardsApiResult.Success(detailOnlyCardThree()),
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
|
||||
viewModel.onCardLongPressed("card-1")
|
||||
viewModel.onCardLongPressed("card-3")
|
||||
viewModel.refreshBoardDetail()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(setOf("card-3"), viewModel.uiState.value.selectedCardIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reloadClearsEditStateWhenEditedListDisappears() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithTwoLists()),
|
||||
BoardsApiResult.Success(detailWithoutListOne()),
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
|
||||
viewModel.startEditingList("list-1")
|
||||
viewModel.refreshBoardDetail()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(viewModel.uiState.value.editingListId)
|
||||
assertEquals("", viewModel.uiState.value.editingListTitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialLoadFailureShowsFullScreenErrorAndRetryRecovers() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Failure("No network"),
|
||||
BoardsApiResult.Success(detailWithSingleList()),
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = BoardDetailViewModel(boardId = "board-1", repository = repository)
|
||||
|
||||
viewModel.loadBoardDetail()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(viewModel.uiState.value.boardDetail)
|
||||
assertEquals("No network", viewModel.uiState.value.fullScreenErrorMessage)
|
||||
|
||||
viewModel.retryLoad()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("board-1", viewModel.uiState.value.boardDetail?.id)
|
||||
assertNull(viewModel.uiState.value.fullScreenErrorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshFailureKeepsStaleContentAndEmitsServerError() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithSingleList()),
|
||||
BoardsApiResult.Failure("Refresh failed"),
|
||||
),
|
||||
),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.refreshBoardDetail()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("board-1", viewModel.uiState.value.boardDetail?.id)
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowServerError)
|
||||
assertEquals("Refresh failed", (event as BoardDetailUiEvent.ShowServerError).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mutationSuccessWithReloadSuccessClearsSelection() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithTwoLists()),
|
||||
BoardsApiResult.Success(detailWithTwoLists()),
|
||||
),
|
||||
),
|
||||
moveCardsResult = CardBatchMutationResult.Success,
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
viewModel.onCardLongPressed("card-1")
|
||||
|
||||
viewModel.moveSelectedCards("list-2")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mutationSuccessWithReloadFailureClearsSelectionAndWarns() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithTwoLists()),
|
||||
BoardsApiResult.Failure("Reload failed"),
|
||||
),
|
||||
),
|
||||
moveCardsResult = CardBatchMutationResult.Success,
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
viewModel.onCardLongPressed("card-1")
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.moveSelectedCards("list-2")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty())
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowWarning)
|
||||
assertEquals(
|
||||
"Changes applied, but refresh failed. Pull to refresh.",
|
||||
(event as BoardDetailUiEvent.ShowWarning).message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mutationPartialWithReloadSuccessReselectsFailedIdsStillVisibleAndWarns() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithTwoLists()),
|
||||
BoardsApiResult.Success(detailOnlyCardThree()),
|
||||
),
|
||||
),
|
||||
moveCardsResult = CardBatchMutationResult.PartialSuccess(
|
||||
failedCardIds = setOf("card-2", "card-3"),
|
||||
message = "Some cards failed.",
|
||||
),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
viewModel.onCardLongPressed("card-1")
|
||||
viewModel.onCardLongPressed("card-2")
|
||||
viewModel.onCardLongPressed("card-3")
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.moveSelectedCards("list-2")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(setOf("card-3"), viewModel.uiState.value.selectedCardIds)
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowWarning)
|
||||
assertEquals("Some cards failed.", (event as BoardDetailUiEvent.ShowWarning).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mutationPartialWithReloadFailurePreservesPreMutationSelectionAndWarns() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithTwoLists()),
|
||||
BoardsApiResult.Failure("Reload failed"),
|
||||
),
|
||||
),
|
||||
moveCardsResult = CardBatchMutationResult.PartialSuccess(
|
||||
failedCardIds = setOf("card-2"),
|
||||
message = "Some cards failed.",
|
||||
),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
viewModel.onCardLongPressed("card-1")
|
||||
viewModel.onCardLongPressed("card-2")
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.moveSelectedCards("list-2")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(setOf("card-1", "card-2"), viewModel.uiState.value.selectedCardIds)
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowWarning)
|
||||
assertEquals(
|
||||
"Some changes were applied, but refresh failed. Pull to refresh.",
|
||||
(event as BoardDetailUiEvent.ShowWarning).message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mutationFailurePreservesSelectionAndPageAndShowsServerError() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithThreeLists()))),
|
||||
moveCardsResult = CardBatchMutationResult.Failure("Cannot move"),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
viewModel.setCurrentPage(2)
|
||||
viewModel.onCardLongPressed("card-4")
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.moveSelectedCards("list-2")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(2, viewModel.uiState.value.currentPageIndex)
|
||||
assertEquals(setOf("card-4"), viewModel.uiState.value.selectedCardIds)
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowServerError)
|
||||
assertEquals("Cannot move", (event as BoardDetailUiEvent.ShowServerError).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renameNoOpSkipsApiCallAndExitsEditMode() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
|
||||
viewModel.startEditingList("list-1")
|
||||
viewModel.updateEditingTitle(" To Do ")
|
||||
viewModel.submitRenameList()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(0, repository.renameCalls)
|
||||
assertNull(viewModel.uiState.value.editingListId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renameSuccessReloadsExitsEditAndPreservesPageAndSelectionSemantics() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(
|
||||
listOf(
|
||||
BoardsApiResult.Success(detailWithTwoLists()),
|
||||
BoardsApiResult.Success(detailWithRenamedListOne()),
|
||||
),
|
||||
),
|
||||
renameListResult = BoardsApiResult.Success(Unit),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
|
||||
viewModel.setCurrentPage(1)
|
||||
viewModel.onCardLongPressed("card-3")
|
||||
viewModel.startEditingList("list-1")
|
||||
viewModel.updateEditingTitle("Renamed")
|
||||
viewModel.submitRenameList()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, repository.renameCalls)
|
||||
assertEquals("list-1", repository.lastRenameListId)
|
||||
assertEquals("Renamed", repository.lastRenameTitle)
|
||||
assertNull(viewModel.uiState.value.editingListId)
|
||||
assertEquals(1, viewModel.uiState.value.currentPageIndex)
|
||||
assertEquals(setOf("card-3"), viewModel.uiState.value.selectedCardIds)
|
||||
assertEquals("Renamed", viewModel.uiState.value.boardDetail?.lists?.first()?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renameFailureKeepsEditModeAndEmitsServerError() = runTest {
|
||||
val repository = FakeBoardDetailDataSource(
|
||||
boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))),
|
||||
renameListResult = BoardsApiResult.Failure("Rename rejected"),
|
||||
)
|
||||
val viewModel = newLoadedViewModel(this, repository)
|
||||
val eventDeferred = async { viewModel.events.first() }
|
||||
|
||||
viewModel.startEditingList("list-1")
|
||||
viewModel.updateEditingTitle("Renamed")
|
||||
viewModel.submitRenameList()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("list-1", viewModel.uiState.value.editingListId)
|
||||
val event = eventDeferred.await()
|
||||
assertTrue(event is BoardDetailUiEvent.ShowServerError)
|
||||
assertEquals("Rename rejected", (event as BoardDetailUiEvent.ShowServerError).message)
|
||||
}
|
||||
|
||||
private fun newLoadedViewModel(
|
||||
scope: TestScope,
|
||||
repository: FakeBoardDetailDataSource,
|
||||
initialDetail: BoardDetail = detailWithTwoLists(),
|
||||
): BoardDetailViewModel {
|
||||
if (repository.boardDetailResults.isEmpty()) {
|
||||
repository.boardDetailResults.add(BoardsApiResult.Success(initialDetail))
|
||||
}
|
||||
|
||||
return BoardDetailViewModel(boardId = "board-1", repository = repository).also {
|
||||
it.loadBoardDetail()
|
||||
scope.advanceUntilIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeBoardDetailDataSource(
|
||||
val boardDetailResults: ArrayDeque<BoardsApiResult<BoardDetail>> = ArrayDeque(),
|
||||
var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
|
||||
var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
|
||||
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit),
|
||||
) : BoardDetailDataSource {
|
||||
var renameCalls: Int = 0
|
||||
var lastRenameListId: String? = null
|
||||
var lastRenameTitle: String? = null
|
||||
|
||||
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
|
||||
return if (boardDetailResults.isNotEmpty()) {
|
||||
boardDetailResults.removeFirst()
|
||||
} else {
|
||||
BoardsApiResult.Success(detailWithSingleList())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
||||
return moveCardsResult
|
||||
}
|
||||
|
||||
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
|
||||
return deleteCardsResult
|
||||
}
|
||||
|
||||
override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
|
||||
renameCalls += 1
|
||||
lastRenameListId = listId
|
||||
lastRenameTitle = newTitle
|
||||
return renameListResult
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun detailWithSingleList(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
title = "Board",
|
||||
lists = listOf(
|
||||
BoardListDetail(
|
||||
id = "list-1",
|
||||
title = "To Do",
|
||||
cards = listOf(BoardCardSummary("card-1", "Card 1", emptyList(), null)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun detailWithTwoLists(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
title = "Board",
|
||||
lists = listOf(
|
||||
BoardListDetail(
|
||||
id = "list-1",
|
||||
title = "To Do",
|
||||
cards = listOf(
|
||||
BoardCardSummary("card-1", "Card 1", emptyList(), null),
|
||||
BoardCardSummary("card-2", "Card 2", emptyList(), null),
|
||||
),
|
||||
),
|
||||
BoardListDetail(
|
||||
id = "list-2",
|
||||
title = "Doing",
|
||||
cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun detailWithThreeLists(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
title = "Board",
|
||||
lists = listOf(
|
||||
BoardListDetail(
|
||||
id = "list-1",
|
||||
title = "To Do",
|
||||
cards = listOf(BoardCardSummary("card-1", "Card 1", emptyList(), null)),
|
||||
),
|
||||
BoardListDetail(
|
||||
id = "list-2",
|
||||
title = "Doing",
|
||||
cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)),
|
||||
),
|
||||
BoardListDetail(
|
||||
id = "list-3",
|
||||
title = "Done",
|
||||
cards = listOf(BoardCardSummary("card-4", "Card 4", emptyList(), null)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun detailOnlyCardThree(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
title = "Board",
|
||||
lists = listOf(
|
||||
BoardListDetail(
|
||||
id = "list-2",
|
||||
title = "Doing",
|
||||
cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun detailWithoutListOne(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
title = "Board",
|
||||
lists = listOf(
|
||||
BoardListDetail(
|
||||
id = "list-2",
|
||||
title = "Doing",
|
||||
cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun detailWithRenamedListOne(): BoardDetail {
|
||||
return BoardDetail(
|
||||
id = "board-1",
|
||||
title = "Board",
|
||||
lists = listOf(
|
||||
BoardListDetail(
|
||||
id = "list-1",
|
||||
title = "Renamed",
|
||||
cards = listOf(
|
||||
BoardCardSummary("card-1", "Card 1", emptyList(), null),
|
||||
BoardCardSummary("card-2", "Card 2", emptyList(), null),
|
||||
),
|
||||
),
|
||||
BoardListDetail(
|
||||
id = "list-2",
|
||||
title = "Doing",
|
||||
cards = listOf(BoardCardSummary("card-3", "Card 3", emptyList(), null)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user