From 89537a57b7fc113caa024f9320d8cb2f56413717 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 00:43:48 -0400 Subject: [PATCH] feat: add board detail viewmodel state and selection logic --- .../app/boarddetail/BoardDetailViewModel.kt | 368 ++++++++++++ .../boarddetail/BoardDetailViewModelTest.kt | 532 ++++++++++++++++++ 2 files changed, 900 insertions(+) create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt create mode 100644 app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt 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 new file mode 100644 index 0000000..3540ec7 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt @@ -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 = 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 + suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult + suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult + suspend fun renameList(listId: String, newTitle: String): BoardsApiResult +} + +internal class BoardDetailRepositoryDataSource( + private val repository: BoardDetailRepository, +) : BoardDetailDataSource { + override suspend fun getBoardDetail(boardId: String): BoardsApiResult { + return repository.getBoardDetail(boardId) + } + + override suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult { + return repository.moveCards(cardIds, targetListId) + } + + override suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult { + return repository.deleteCards(cardIds) + } + + override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult { + return repository.renameList(listId, newTitle) + } +} + +class BoardDetailViewModel( + private val boardId: String, + private val repository: BoardDetailDataSource, +) : ViewModel() { + private val _uiState = MutableStateFlow(BoardDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _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) -> 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 { + 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 create(modelClass: Class): T { + if (modelClass.isAssignableFrom(BoardDetailViewModel::class.java)) { + return BoardDetailViewModel( + boardId = boardId, + repository = BoardDetailRepositoryDataSource(repository), + ) as T + } + throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } + } +} 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 new file mode 100644 index 0000000..e52465c --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt @@ -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> = ArrayDeque(), + var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success, + var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success, + var renameListResult: BoardsApiResult = BoardsApiResult.Success(Unit), + ) : BoardDetailDataSource { + var renameCalls: Int = 0 + var lastRenameListId: String? = null + var lastRenameTitle: String? = null + + override suspend fun getBoardDetail(boardId: String): BoardsApiResult { + return if (boardDetailResults.isNotEmpty()) { + boardDetailResults.removeFirst() + } else { + BoardsApiResult.Success(detailWithSingleList()) + } + } + + override suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult { + return moveCardsResult + } + + override suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult { + return deleteCardsResult + } + + override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult { + 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)), + ), + ), + ) + } + } +}