feat: add board detail viewmodel state and selection logic

This commit is contained in:
2026-03-16 00:43:48 -04:00
parent 2c40892906
commit 89537a57b7
2 changed files with 900 additions and 0 deletions

View File

@@ -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}")
}
}
}

View File

@@ -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)),
),
),
)
}
}
}