From 5e0eff37a617be7c4d6c1a5f03cb3ab3bcad52c8 Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 14:04:10 -0400 Subject: [PATCH] feat: add board detail create and local filter viewmodel state --- .../app/boarddetail/BoardDetailViewModel.kt | 456 +++++++++++++++- .../boarddetail/BoardDetailViewModelTest.kt | 489 +++++++++++++++++- 2 files changed, 934 insertions(+), 11 deletions(-) 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 index 2641eca..fc49471 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModel.kt @@ -3,6 +3,7 @@ package space.hackenslacker.kanbn4droid.app.boarddetail import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import java.time.LocalDate import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -23,7 +24,33 @@ data class BoardDetailUiState( val selectedCardIds: Set = emptySet(), val editingListId: String? = null, val editingListTitle: String = "", -) + val pendingTagFilterIds: Set = emptySet(), + val pendingTitleQuery: String = "", + val activeTagFilterIds: Set = emptySet(), + val activeTitleQuery: String = "", + val isFabChooserOpen: Boolean = false, + val isAddListDialogOpen: Boolean = false, + val isAddCardDialogOpen: Boolean = false, + val isFilterDialogOpen: Boolean = false, + val isSearchDialogOpen: Boolean = false, + val addListTitleDraft: String = "", + val addCardTitleDraft: String = "", + val addCardDescriptionDraft: String = "", + val addCardDueDate: LocalDate? = null, + val addCardSelectedTagIds: Set = emptySet(), +) { + val canAddCard: Boolean + get() = boardDetail?.lists?.isNotEmpty() == true + + val addCardDisabledMessage: String? + get() = if (canAddCard) null else "Add a list before creating cards." + + val filteredBoardDetail: BoardDetail? + get() = boardDetail?.withFilteredCards( + tagFilterIds = activeTagFilterIds, + titleQuery = activeTitleQuery, + ) +} sealed interface BoardDetailUiEvent { data class NavigateToCardPlaceholder(val cardId: String) : BoardDetailUiEvent @@ -33,6 +60,15 @@ sealed interface BoardDetailUiEvent { interface BoardDetailDataSource { suspend fun getBoardDetail(boardId: String): BoardsApiResult + suspend fun createList(boardPublicId: String, title: String): BoardsApiResult + suspend fun createCard( + listPublicId: String, + title: String, + description: String?, + dueDate: LocalDate?, + tagPublicIds: Collection, + ): BoardsApiResult + suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult suspend fun deleteCards(cardIds: Collection): CardBatchMutationResult suspend fun renameList(listId: String, newTitle: String): BoardsApiResult @@ -45,6 +81,26 @@ internal class BoardDetailRepositoryDataSource( return repository.getBoardDetail(boardId) } + override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult { + return repository.createList(boardPublicId, title) + } + + override suspend fun createCard( + listPublicId: String, + title: String, + description: String?, + dueDate: LocalDate?, + tagPublicIds: Collection, + ): BoardsApiResult { + return repository.createCard( + listPublicId = listPublicId, + title = title, + description = description, + dueDate = dueDate, + tagPublicIds = tagPublicIds, + ) + } + override suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult { return repository.moveCards(cardIds, targetListId) } @@ -129,6 +185,304 @@ class BoardDetailViewModel( return true } + fun openFabChooser() { + _uiState.update { it.copy(isFabChooserOpen = true) } + } + + fun closeFabChooser() { + _uiState.update { it.copy(isFabChooserOpen = false) } + } + + fun openAddListDialog() { + _uiState.update { + it.copy( + isFabChooserOpen = false, + isAddListDialogOpen = true, + ) + } + } + + fun updateAddListTitle(title: String) { + _uiState.update { it.copy(addListTitleDraft = title) } + } + + fun cancelAddListDialog() { + _uiState.update { + it.copy( + isAddListDialogOpen = false, + addListTitleDraft = "", + ) + } + } + + fun createList() { + val snapshot = _uiState.value + if (snapshot.isMutating) { + return + } + val detail = snapshot.boardDetail ?: return + val title = snapshot.addListTitleDraft.trim() + if (title.isBlank()) { + viewModelScope.launch { + _events.emit(BoardDetailUiEvent.ShowServerError("List title is required")) + } + return + } + val expectedIndex = detail.lists.size + + viewModelScope.launch { + _uiState.update { it.copy(isMutating = true) } + when (val result = repository.createList(detail.id, title)) { + is BoardsApiResult.Success -> { + _uiState.update { + it.copy( + isAddListDialogOpen = false, + addListTitleDraft = "", + ) + } + when (val reload = reloadDetailAndReconcile()) { + is BoardsApiResult.Success -> { + _uiState.update { it.copy(isMutating = false) } + val warning = verifyCreatedList(result.value.publicId, expectedIndex, reload.value) + if (warning != null) { + _events.emit(BoardDetailUiEvent.ShowWarning(warning)) + } + } + + is BoardsApiResult.Failure -> { + _uiState.update { it.copy(isMutating = false) } + _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)) + } + } + } + } + + fun openAddCardDialog() { + val snapshot = _uiState.value + if (!snapshot.canAddCard) { + _uiState.update { it.copy(isFabChooserOpen = false, isAddCardDialogOpen = false) } + return + } + + _uiState.update { + it.copy( + isFabChooserOpen = false, + isAddCardDialogOpen = true, + ) + } + } + + fun updateAddCardTitle(title: String) { + _uiState.update { it.copy(addCardTitleDraft = title) } + } + + fun updateAddCardDescription(description: String) { + _uiState.update { it.copy(addCardDescriptionDraft = description) } + } + + fun setDueDate(dueDate: LocalDate) { + _uiState.update { it.copy(addCardDueDate = dueDate) } + } + + fun clearDueDate() { + _uiState.update { it.copy(addCardDueDate = null) } + } + + fun toggleAddCardTag(tagId: String) { + val normalizedTagId = tagId.trim() + if (normalizedTagId.isBlank()) { + return + } + + _uiState.update { + val next = it.addCardSelectedTagIds.toMutableSet() + if (!next.add(normalizedTagId)) { + next.remove(normalizedTagId) + } + it.copy(addCardSelectedTagIds = next) + } + } + + fun cancelAddCardDialog() { + _uiState.update { it.resetAddCardDrafts().copy(isAddCardDialogOpen = false) } + } + + fun createCard() { + val snapshot = _uiState.value + if (snapshot.isMutating) { + return + } + val detail = snapshot.boardDetail ?: return + val currentList = detail.lists.getOrNull(snapshot.currentPageIndex) ?: return + val title = snapshot.addCardTitleDraft.trim() + if (title.isBlank()) { + viewModelScope.launch { + _events.emit(BoardDetailUiEvent.ShowServerError("Card title is required")) + } + return + } + + viewModelScope.launch { + _uiState.update { it.copy(isMutating = true) } + when ( + val result = repository.createCard( + listPublicId = currentList.id, + title = title, + description = snapshot.addCardDescriptionDraft, + dueDate = snapshot.addCardDueDate, + tagPublicIds = snapshot.addCardSelectedTagIds, + ) + ) { + is BoardsApiResult.Success -> { + _uiState.update { + it.resetAddCardDrafts().copy(isAddCardDialogOpen = false) + } + when (val reload = reloadDetailAndReconcile()) { + is BoardsApiResult.Success -> { + _uiState.update { it.copy(isMutating = false) } + val warning = verifyCreatedCard( + createdCardId = result.value.publicId, + targetListId = currentList.id, + detail = reload.value, + ) + if (warning != null) { + _events.emit(BoardDetailUiEvent.ShowWarning(warning)) + } + } + + is BoardsApiResult.Failure -> { + _uiState.update { it.copy(isMutating = false) } + _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)) + } + } + } + } + + fun openFilterDialog() { + _uiState.update { + it.copy( + isFilterDialogOpen = true, + pendingTagFilterIds = it.activeTagFilterIds, + ) + } + } + + fun updatePendingTagFilterIds(tagIds: Set) { + _uiState.update { + it.copy( + pendingTagFilterIds = tagIds + .map { id -> id.trim() } + .filter { id -> id.isNotBlank() } + .toSet(), + ) + } + } + + fun applyFilterDialog() { + _uiState.update { + it.copy( + activeTagFilterIds = it.pendingTagFilterIds, + isFilterDialogOpen = false, + ) + } + } + + fun cancelFilterDialog() { + _uiState.update { + it.copy( + pendingTagFilterIds = it.activeTagFilterIds, + isFilterDialogOpen = false, + ) + } + } + + fun onTagFilterIconTapped() { + val snapshot = _uiState.value + if (snapshot.activeTagFilterIds.isNotEmpty()) { + _uiState.update { + it.copy( + activeTagFilterIds = emptySet(), + pendingTagFilterIds = emptySet(), + isFilterDialogOpen = false, + ) + } + return + } + + openFilterDialog() + } + + fun openSearchDialog() { + _uiState.update { + it.copy( + isSearchDialogOpen = true, + pendingTitleQuery = it.activeTitleQuery, + ) + } + } + + fun updatePendingTitleQuery(query: String) { + _uiState.update { it.copy(pendingTitleQuery = query) } + } + + fun applySearchDialog() { + _uiState.update { + val trimmed = it.pendingTitleQuery.trim() + it.copy( + activeTitleQuery = trimmed, + pendingTitleQuery = trimmed, + isSearchDialogOpen = false, + ) + } + } + + fun cancelSearchDialog() { + _uiState.update { + it.copy( + pendingTitleQuery = it.activeTitleQuery, + isSearchDialogOpen = false, + ) + } + } + + fun onSearchIconTapped() { + val snapshot = _uiState.value + if (snapshot.activeTitleQuery.isNotBlank()) { + _uiState.update { + it.copy( + activeTitleQuery = "", + pendingTitleQuery = "", + isSearchDialogOpen = false, + ) + } + return + } + + openSearchDialog() + } + fun moveSelectedCards(targetListId: String) { val snapshot = _uiState.value if (snapshot.isMutating) { @@ -191,9 +545,9 @@ class BoardDetailViewModel( when (val result = repository.renameList(editingListId, trimmedTitle)) { is BoardsApiResult.Success -> { _uiState.update { it.copy(editingListId = null, editingListTitle = "") } - val reloadFailureMessage = tryReloadDetailAndReconcile() + val reloadResult = reloadDetailAndReconcile() _uiState.update { it.copy(isMutating = false) } - if (reloadFailureMessage != null) { + if (reloadResult is BoardsApiResult.Failure) { _events.emit( BoardDetailUiEvent.ShowWarning( "Changes applied, but refresh failed. Pull to refresh.", @@ -276,9 +630,9 @@ class BoardDetailViewModel( when (val result = mutation(selectedIds)) { is CardBatchMutationResult.Success -> { _uiState.update { it.copy(selectedCardIds = emptySet()) } - val reloadFailureMessage = tryReloadDetailAndReconcile() + val reloadResult = reloadDetailAndReconcile() _uiState.update { it.copy(isMutating = false) } - if (reloadFailureMessage != null) { + if (reloadResult is BoardsApiResult.Failure) { _events.emit( BoardDetailUiEvent.ShowWarning( "Changes applied, but refresh failed. Pull to refresh.", @@ -288,8 +642,8 @@ class BoardDetailViewModel( } is CardBatchMutationResult.PartialSuccess -> { - val reloadFailureMessage = tryReloadDetailAndReconcile() - if (reloadFailureMessage == null) { + val reloadResult = reloadDetailAndReconcile() + if (reloadResult is BoardsApiResult.Success) { val visibleIds = allVisibleCardIds(_uiState.value.boardDetail) _uiState.update { it.copy(selectedCardIds = result.failedCardIds.intersect(visibleIds)) @@ -325,14 +679,14 @@ class BoardDetailViewModel( } } - private suspend fun tryReloadDetailAndReconcile(): String? { + private suspend fun reloadDetailAndReconcile(): BoardsApiResult { return when (val result = repository.getBoardDetail(boardId)) { is BoardsApiResult.Success -> { _uiState.update { reconcileWithNewDetail(it, result.value) } - null + result } - is BoardsApiResult.Failure -> result.message + is BoardsApiResult.Failure -> result } } @@ -358,6 +712,8 @@ class BoardDetailViewModel( selectedCardIds = prunedSelection, editingListId = if (hasEditedList) current.editingListId else null, editingListTitle = if (hasEditedList) current.editingListTitle else "", + pendingTagFilterIds = current.pendingTagFilterIds.intersect(availableTagIds(detail)), + activeTagFilterIds = current.activeTagFilterIds.intersect(availableTagIds(detail)), ) } @@ -377,6 +733,50 @@ class BoardDetailViewModel( .toSet() } + private fun availableTagIds(detail: BoardDetail): Set { + return detail.lists + .asSequence() + .flatMap { list -> list.cards.asSequence() } + .flatMap { card -> card.tags.asSequence() } + .map { tag -> tag.id } + .toSet() + } + + private fun verifyCreatedList(createdListId: String?, expectedIndex: Int, detail: BoardDetail): String? { + val normalizedId = createdListId?.trim().orEmpty() + if (normalizedId.isBlank()) { + return CREATE_NON_DETERMINISTIC_WARNING + } + val actualIndex = detail.lists.indexOfFirst { list -> list.id == normalizedId } + if (actualIndex < 0) { + return CREATE_NON_DETERMINISTIC_WARNING + } + return if (actualIndex != expectedIndex) CREATE_LIST_PLACEMENT_WARNING else null + } + + private fun verifyCreatedCard(createdCardId: String?, targetListId: String, detail: BoardDetail): String? { + val normalizedId = createdCardId?.trim().orEmpty() + if (normalizedId.isBlank()) { + return CREATE_NON_DETERMINISTIC_WARNING + } + val list = detail.lists.firstOrNull { it.id == targetListId } + ?: return CREATE_NON_DETERMINISTIC_WARNING + val actualIndex = list.cards.indexOfFirst { card -> card.id == normalizedId } + if (actualIndex < 0) { + return CREATE_NON_DETERMINISTIC_WARNING + } + return if (actualIndex != 0) CREATE_CARD_PLACEMENT_WARNING else null + } + + private companion object { + const val CREATE_LIST_PLACEMENT_WARNING = + "List created, but server ordering differed. Pull to refresh if needed." + const val CREATE_CARD_PLACEMENT_WARNING = + "Card created, but server ordering differed. Pull to refresh if needed." + const val CREATE_NON_DETERMINISTIC_WARNING = + "Created item could not be deterministically verified. Pull to refresh if needed." + } + class Factory( private val boardId: String, private val repository: BoardDetailRepository, @@ -393,3 +793,39 @@ class BoardDetailViewModel( } } } + +private fun BoardDetailUiState.resetAddCardDrafts(): BoardDetailUiState { + return copy( + addCardTitleDraft = "", + addCardDescriptionDraft = "", + addCardDueDate = null, + addCardSelectedTagIds = emptySet(), + ) +} + +private fun BoardDetail.withFilteredCards(tagFilterIds: Set, titleQuery: String): BoardDetail { + val normalizedTitleQuery = titleQuery.trim() + if (tagFilterIds.isEmpty() && normalizedTitleQuery.isEmpty()) { + return this + } + + return copy( + lists = lists.map { list -> + list.copy( + cards = list.cards.filter { card -> + val matchesTags = if (tagFilterIds.isEmpty()) { + true + } else { + card.tags.any { tag -> tag.id in tagFilterIds } + } + val matchesTitle = if (normalizedTitleQuery.isEmpty()) { + true + } else { + card.title.contains(normalizedTitleQuery, ignoreCase = true) + } + matchesTags && matchesTitle + }, + ) + }, + ) +} 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 index 6783da5..d81117b 100644 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/boarddetail/BoardDetailViewModelTest.kt @@ -10,8 +10,10 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import java.time.LocalDate import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before @@ -49,7 +51,7 @@ class BoardDetailViewModelTest { } @Test - fun backPressClearsSelectionWhenSelectionActive() = runTest { + fun backPressWithSelection_clearsSelectionAndReturnsTrue() = runTest { val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists()) viewModel.onCardLongPressed("card-1") @@ -58,6 +60,338 @@ class BoardDetailViewModelTest { assertTrue(viewModel.uiState.value.selectedCardIds.isEmpty()) } + @Test + fun openAddCardWhenNoLists_setsCanAddCardFalse_andMessage() = runTest { + val viewModel = newLoadedViewModel( + this, + FakeBoardDetailDataSource(), + detailWithoutLists(), + ) + + viewModel.openAddCardDialog() + + assertFalse(viewModel.uiState.value.canAddCard) + assertEquals("Add a list before creating cards.", viewModel.uiState.value.addCardDisabledMessage) + assertFalse(viewModel.uiState.value.isAddCardDialogOpen) + } + + @Test + fun applyFilters_tagAnyAndQueryAnd_keepsAllLists_filtersCardsOnly() = runTest { + val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture()) + + viewModel.openFilterDialog() + viewModel.updatePendingTagFilterIds(setOf("tag-b")) + viewModel.applyFilterDialog() + viewModel.openSearchDialog() + viewModel.updatePendingTitleQuery("duke") + viewModel.applySearchDialog() + + val filtered = viewModel.uiState.value.filteredBoardDetail + assertEquals(2, filtered?.lists?.size) + assertEquals(listOf("card-2"), filtered?.lists?.get(0)?.cards?.map { it.id }) + assertTrue(filtered?.lists?.get(1)?.cards?.isEmpty() == true) + } + + @Test + fun tapActiveTagFilterIcon_clearsTagFilter_withoutOpeningDialog() = runTest { + val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture()) + + viewModel.openFilterDialog() + viewModel.updatePendingTagFilterIds(setOf("tag-a")) + viewModel.applyFilterDialog() + viewModel.onTagFilterIconTapped() + + assertTrue(viewModel.uiState.value.activeTagFilterIds.isEmpty()) + assertTrue(viewModel.uiState.value.pendingTagFilterIds.isEmpty()) + assertFalse(viewModel.uiState.value.isFilterDialogOpen) + } + + @Test + fun cancelFilterDialog_restoresPendingFromActive() = runTest { + val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture()) + + viewModel.openFilterDialog() + viewModel.updatePendingTagFilterIds(setOf("tag-a")) + viewModel.applyFilterDialog() + viewModel.openFilterDialog() + viewModel.updatePendingTagFilterIds(setOf("tag-b")) + viewModel.cancelFilterDialog() + + assertEquals(setOf("tag-a"), viewModel.uiState.value.activeTagFilterIds) + assertEquals(setOf("tag-a"), viewModel.uiState.value.pendingTagFilterIds) + assertFalse(viewModel.uiState.value.isFilterDialogOpen) + } + + @Test + fun cancelAndSuccess_resetAddDrafts() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithSingleList()), + BoardsApiResult.Success(detailWithSingleListAndAppendedList()), + BoardsApiResult.Success(detailWithSingleListAndNewTopCard()), + ), + ), + createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")), + createCardResult = BoardsApiResult.Success(CreatedEntityRef("card-new")), + ) + val viewModel = newLoadedViewModel(this, repository) + + viewModel.openAddListDialog() + viewModel.updateAddListTitle("Temp list") + viewModel.cancelAddListDialog() + assertEquals("", viewModel.uiState.value.addListTitleDraft) + assertFalse(viewModel.uiState.value.isAddListDialogOpen) + + viewModel.openAddCardDialog() + viewModel.updateAddCardTitle("Temp card") + viewModel.updateAddCardDescription("temp") + viewModel.toggleAddCardTag("tag-a") + viewModel.setDueDate(LocalDate.of(2026, 1, 2)) + viewModel.cancelAddCardDialog() + assertEquals("", viewModel.uiState.value.addCardTitleDraft) + assertEquals("", viewModel.uiState.value.addCardDescriptionDraft) + assertTrue(viewModel.uiState.value.addCardSelectedTagIds.isEmpty()) + assertNull(viewModel.uiState.value.addCardDueDate) + assertFalse(viewModel.uiState.value.isAddCardDialogOpen) + + viewModel.openAddListDialog() + viewModel.updateAddListTitle("New list") + viewModel.createList() + advanceUntilIdle() + assertEquals("", viewModel.uiState.value.addListTitleDraft) + assertFalse(viewModel.uiState.value.isAddListDialogOpen) + + viewModel.openAddCardDialog() + viewModel.updateAddCardTitle("New card") + viewModel.updateAddCardDescription("desc") + viewModel.toggleAddCardTag("tag-a") + viewModel.setDueDate(LocalDate.of(2026, 2, 3)) + viewModel.createCard() + advanceUntilIdle() + assertEquals("", viewModel.uiState.value.addCardTitleDraft) + assertEquals("", viewModel.uiState.value.addCardDescriptionDraft) + assertTrue(viewModel.uiState.value.addCardSelectedTagIds.isEmpty()) + assertNull(viewModel.uiState.value.addCardDueDate) + assertFalse(viewModel.uiState.value.isAddCardDialogOpen) + } + + @Test + fun createFailure_keepsAddDialogsOpenWithDrafts() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))), + createListResult = BoardsApiResult.Failure("List create failed"), + createCardResult = BoardsApiResult.Failure("Card create failed"), + ) + val viewModel = newLoadedViewModel(this, repository) + + viewModel.openAddListDialog() + viewModel.updateAddListTitle("My list") + viewModel.createList() + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isAddListDialogOpen) + assertEquals("My list", viewModel.uiState.value.addListTitleDraft) + + viewModel.openAddCardDialog() + viewModel.updateAddCardTitle("My card") + viewModel.updateAddCardDescription("desc") + viewModel.toggleAddCardTag("tag-a") + viewModel.setDueDate(LocalDate.of(2026, 3, 4)) + viewModel.createCard() + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isAddCardDialogOpen) + assertEquals("My card", viewModel.uiState.value.addCardTitleDraft) + assertEquals("desc", viewModel.uiState.value.addCardDescriptionDraft) + assertEquals(setOf("tag-a"), viewModel.uiState.value.addCardSelectedTagIds) + assertEquals(LocalDate.of(2026, 3, 4), viewModel.uiState.value.addCardDueDate) + } + + @Test + fun createList_blankTitle_rejectsBeforeRepositoryCall() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))), + ) + val viewModel = newLoadedViewModel(this, repository) + val eventDeferred = async { viewModel.events.first() } + + viewModel.openAddListDialog() + viewModel.updateAddListTitle(" ") + viewModel.createList() + advanceUntilIdle() + + assertEquals(0, repository.createListCalls) + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.ShowServerError) + assertEquals("List title is required", (event as BoardDetailUiEvent.ShowServerError).message) + } + + @Test + fun createCard_blankTitle_rejectsBeforeRepositoryCall() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque(listOf(BoardsApiResult.Success(detailWithSingleList()))), + ) + val viewModel = newLoadedViewModel(this, repository) + val eventDeferred = async { viewModel.events.first() } + + viewModel.openAddCardDialog() + viewModel.updateAddCardTitle(" ") + viewModel.createCard() + advanceUntilIdle() + + assertEquals(0, repository.createCardCalls) + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.ShowServerError) + assertEquals("Card title is required", (event as BoardDetailUiEvent.ShowServerError).message) + } + + @Test + fun setDueDate_updatesCardDraftDueDate() = runTest { + val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithSingleList()) + + viewModel.openAddCardDialog() + viewModel.setDueDate(LocalDate.of(2026, 4, 5)) + + assertEquals(LocalDate.of(2026, 4, 5), viewModel.uiState.value.addCardDueDate) + } + + @Test + fun clearDueDate_resetsCardDraftDueDate() = runTest { + val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithSingleList()) + + viewModel.openAddCardDialog() + viewModel.setDueDate(LocalDate.of(2026, 4, 5)) + viewModel.clearDueDate() + + assertNull(viewModel.uiState.value.addCardDueDate) + } + + @Test + fun createListPlacementMismatch_emitsExactWarning() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithSingleList()), + BoardsApiResult.Success(detailWithListPlacementMismatch()), + ), + ), + createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")), + ) + val viewModel = newLoadedViewModel(this, repository) + val eventDeferred = async { viewModel.events.first() } + + viewModel.openAddListDialog() + viewModel.updateAddListTitle("New list") + viewModel.createList() + advanceUntilIdle() + + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.ShowWarning) + assertEquals( + "List created, but server ordering differed. Pull to refresh if needed.", + (event as BoardDetailUiEvent.ShowWarning).message, + ) + } + + @Test + fun createCardPlacementMismatch_emitsExactWarning() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithSingleList()), + BoardsApiResult.Success(detailWithCardPlacementMismatch()), + ), + ), + createCardResult = BoardsApiResult.Success(CreatedEntityRef("card-new")), + ) + val viewModel = newLoadedViewModel(this, repository) + val eventDeferred = async { viewModel.events.first() } + + viewModel.openAddCardDialog() + viewModel.updateAddCardTitle("New card") + viewModel.createCard() + advanceUntilIdle() + + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.ShowWarning) + assertEquals( + "Card created, but server ordering differed. Pull to refresh if needed.", + (event as BoardDetailUiEvent.ShowWarning).message, + ) + } + + @Test + fun createFallbackNonDeterministic_emitsExactWarning() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithSingleList()), + BoardsApiResult.Success(detailWithSingleListAndAppendedList()), + ), + ), + createListResult = BoardsApiResult.Success(CreatedEntityRef(null)), + ) + val viewModel = newLoadedViewModel(this, repository) + val eventDeferred = async { viewModel.events.first() } + + viewModel.openAddListDialog() + viewModel.updateAddListTitle("New list") + viewModel.createList() + advanceUntilIdle() + + val event = eventDeferred.await() + assertTrue(event is BoardDetailUiEvent.ShowWarning) + assertEquals( + "Created item could not be deterministically verified. Pull to refresh if needed.", + (event as BoardDetailUiEvent.ShowWarning).message, + ) + } + + @Test + fun createSuccess_refreshFailure_emitsExactWarning() = runTest { + val repository = FakeBoardDetailDataSource( + boardDetailResults = ArrayDeque( + listOf( + BoardsApiResult.Success(detailWithSingleList()), + BoardsApiResult.Failure("refresh failed"), + ), + ), + createListResult = BoardsApiResult.Success(CreatedEntityRef("list-new")), + ) + val viewModel = newLoadedViewModel(this, repository) + val eventDeferred = async { viewModel.events.first() } + + viewModel.openAddListDialog() + viewModel.updateAddListTitle("New list") + viewModel.createList() + advanceUntilIdle() + + 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 applySearch_withWhitespaceOnlyQuery_trimsToEmptyAndClearsActiveState() = runTest { + val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithFilterFixture()) + + viewModel.openSearchDialog() + viewModel.updatePendingTitleQuery("Duke") + viewModel.applySearchDialog() + viewModel.openSearchDialog() + viewModel.updatePendingTitleQuery(" ") + viewModel.applySearchDialog() + + assertEquals("", viewModel.uiState.value.activeTitleQuery) + assertEquals("", viewModel.uiState.value.pendingTitleQuery) + val filtered = viewModel.uiState.value.filteredBoardDetail + assertEquals(2, filtered?.lists?.size) + assertEquals(2, filtered?.lists?.get(0)?.cards?.size) + assertEquals(1, filtered?.lists?.get(1)?.cards?.size) + } + @Test fun cardTapWithoutSelectionEmitsNavigateEvent() = runTest { val viewModel = newLoadedViewModel(this, FakeBoardDetailDataSource(), detailWithTwoLists()) @@ -506,15 +840,26 @@ class BoardDetailViewModelTest { private class FakeBoardDetailDataSource( val boardDetailResults: ArrayDeque> = ArrayDeque(), + var createListResult: BoardsApiResult = BoardsApiResult.Success(CreatedEntityRef("list-created")), + var createCardResult: BoardsApiResult = BoardsApiResult.Success(CreatedEntityRef("card-created")), var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success, var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success, var renameListResult: BoardsApiResult = BoardsApiResult.Success(Unit), var moveGate: CompletableDeferred? = null, var renameGate: CompletableDeferred? = null, ) : BoardDetailDataSource { + var createListCalls: Int = 0 + var createCardCalls: Int = 0 var moveCalls: Int = 0 var deleteCalls: Int = 0 var renameCalls: Int = 0 + var lastCreateListBoardId: String? = null + var lastCreateListTitle: String? = null + var lastCreateCardListId: String? = null + var lastCreateCardTitle: String? = null + var lastCreateCardDescription: String? = null + var lastCreateCardDueDate: LocalDate? = null + var lastCreateCardTagIds: List = emptyList() var lastRenameListId: String? = null var lastRenameTitle: String? = null @@ -526,6 +871,29 @@ class BoardDetailViewModelTest { } } + override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult { + createListCalls += 1 + lastCreateListBoardId = boardPublicId + lastCreateListTitle = title + return createListResult + } + + override suspend fun createCard( + listPublicId: String, + title: String, + description: String?, + dueDate: LocalDate?, + tagPublicIds: Collection, + ): BoardsApiResult { + createCardCalls += 1 + lastCreateCardListId = listPublicId + lastCreateCardTitle = title + lastCreateCardDescription = description + lastCreateCardDueDate = dueDate + lastCreateCardTagIds = tagPublicIds.toList() + return createCardResult + } + override suspend fun moveCards(cardIds: Collection, targetListId: String): CardBatchMutationResult { moveCalls += 1 moveGate?.await() @@ -561,6 +929,14 @@ class BoardDetailViewModelTest { ) } + fun detailWithoutLists(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = emptyList(), + ) + } + fun detailWithTwoLists(): BoardDetail { return BoardDetail( id = "board-1", @@ -583,6 +959,117 @@ class BoardDetailViewModelTest { ) } + fun detailWithFilterFixture(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf( + BoardCardSummary( + id = "card-1", + title = "Alpha", + tags = listOf(BoardTagSummary("tag-a", "A", "#111111")), + dueAtEpochMillis = null, + ), + BoardCardSummary( + id = "card-2", + title = "Duke Nukem", + tags = listOf(BoardTagSummary("tag-b", "B", "#222222")), + dueAtEpochMillis = null, + ), + ), + ), + BoardListDetail( + id = "list-2", + title = "Doing", + cards = listOf( + BoardCardSummary( + id = "card-3", + title = "Bravo", + tags = listOf(BoardTagSummary("tag-b", "B", "#222222")), + dueAtEpochMillis = null, + ), + ), + ), + ), + ) + } + + fun detailWithSingleListAndAppendedList(): 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-new", + title = "New list", + cards = emptyList(), + ), + ), + ) + } + + fun detailWithSingleListAndNewTopCard(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf( + BoardCardSummary("card-new", "New card", emptyList(), null), + BoardCardSummary("card-1", "Card 1", emptyList(), null), + ), + ), + ), + ) + } + + fun detailWithListPlacementMismatch(): BoardDetail { + return BoardDetail( + id = "board-1", + title = "Board", + lists = listOf( + BoardListDetail( + id = "list-new", + title = "New list", + cards = emptyList(), + ), + BoardListDetail( + id = "list-1", + title = "To Do", + cards = listOf(BoardCardSummary("card-1", "Card 1", emptyList(), null)), + ), + ), + ) + } + + fun detailWithCardPlacementMismatch(): 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-new", "New card", emptyList(), null), + ), + ), + ), + ) + } + fun detailWithThreeLists(): BoardDetail { return BoardDetail( id = "board-1",