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