feat: add board detail create and local filter viewmodel state

This commit is contained in:
2026-03-16 14:04:10 -04:00
parent 8022647047
commit 5e0eff37a6
2 changed files with 934 additions and 11 deletions

View File

@@ -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<String> = emptySet(),
val editingListId: String? = null,
val editingListTitle: String = "",
)
val pendingTagFilterIds: Set<String> = emptySet(),
val pendingTitleQuery: String = "",
val activeTagFilterIds: Set<String> = 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<String> = 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<BoardDetail>
suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef>
suspend fun createCard(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef>
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>
@@ -45,6 +81,26 @@ internal class BoardDetailRepositoryDataSource(
return repository.getBoardDetail(boardId)
}
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
return repository.createList(boardPublicId, title)
}
override suspend fun createCard(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef> {
return repository.createCard(
listPublicId = listPublicId,
title = title,
description = description,
dueDate = dueDate,
tagPublicIds = tagPublicIds,
)
}
override suspend fun moveCards(cardIds: Collection<String>, 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<String>) {
_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<BoardDetail> {
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<String> {
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<String>, 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
},
)
},
)
}

View File

@@ -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<BoardsApiResult<BoardDetail>> = ArrayDeque(),
var createListResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("list-created")),
var createCardResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("card-created")),
var moveCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
var deleteCardsResult: CardBatchMutationResult = CardBatchMutationResult.Success,
var renameListResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit),
var moveGate: CompletableDeferred<Unit>? = null,
var renameGate: CompletableDeferred<Unit>? = 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<String> = emptyList()
var lastRenameListId: String? = null
var lastRenameTitle: String? = null
@@ -526,6 +871,29 @@ class BoardDetailViewModelTest {
}
}
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
createListCalls += 1
lastCreateListBoardId = boardPublicId
lastCreateListTitle = title
return createListResult
}
override suspend fun createCard(
listPublicId: String,
title: String,
description: String?,
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef> {
createCardCalls += 1
lastCreateCardListId = listPublicId
lastCreateCardTitle = title
lastCreateCardDescription = description
lastCreateCardDueDate = dueDate
lastCreateCardTagIds = tagPublicIds.toList()
return createCardResult
}
override suspend fun moveCards(cardIds: Collection<String>, 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",