test: cover board detail create and filter user flows

This commit is contained in:
2026-03-16 15:38:34 -04:00
parent 1e5979f5c4
commit 3d4661cbfd
3 changed files with 402 additions and 5 deletions

View File

@@ -9,10 +9,12 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.longClick
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtra
@@ -28,6 +30,7 @@ import com.google.android.material.color.MaterialColors
import com.google.android.material.chip.Chip
import java.text.DateFormat
import java.time.LocalDate
import java.time.ZoneId
import java.util.ArrayDeque
import java.util.Date
import java.util.Locale
@@ -65,6 +68,7 @@ class BoardDetailFlowTest {
@Before
fun setUp() {
observedStates.clear()
MainActivity.dependencies.clear()
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/") }
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("api") }
@@ -357,6 +361,203 @@ class BoardDetailFlowTest {
onView(withText("Add new card")).inRoot(isDialog()).check(matches(isDisplayed()))
}
@Test
fun fabChooser_addList_createAndValidationFlow() {
launchBoardDetail()
onView(withId(R.id.boardDetailCreateFab)).perform(click())
onView(withText(R.string.add_new_list)).inRoot(isDialog()).perform(click())
onView(withText(R.string.add_new_list)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.add_list)).inRoot(isDialog()).perform(click())
onView(withText(R.string.list_title_required)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withId(R.id.addListTitleInput)).inRoot(isDialog()).perform(replaceText("Created List"))
onView(withText(R.string.add_list)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.createListCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
assertEquals(1, defaultDataSource.createListCalls)
assertEquals("board-1", defaultDataSource.lastCreateListBoardId)
assertEquals("Created List", defaultDataSource.lastCreateListTitle)
assertTrue(
observedStates.lastOrNull()
?.boardDetail
?.lists
?.any { it.title == "Created List" } == true,
)
}
@Test
fun fabChooser_addCard_createWithTagsAndClearDate() {
defaultDataSource.currentDetail = detailCreateCardDialogTagCatalog()
val scenario = launchBoardDetail()
onView(withId(R.id.boardDetailCreateFab)).perform(click())
onView(withText(R.string.add_new_card)).inRoot(isDialog()).perform(click())
onView(withId(R.id.addCardTitleInput)).inRoot(isDialog()).perform(replaceText("Created Card"))
onView(withId(R.id.addCardDescriptionInput)).inRoot(isDialog()).perform(replaceText("Body"))
onView(withText("Backend")).inRoot(isDialog()).perform(click())
onView(withId(R.id.addCardDueDateText)).inRoot(isDialog()).perform(click())
onView(withText(android.R.string.ok)).inRoot(isDialog()).perform(click())
awaitCondition {
observedStates.lastOrNull()?.addCardDueDate != null
}
scenario.onActivity { }
onView(withId(R.id.addCardClearDueDateAction)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withId(R.id.addCardClearDueDateAction)).inRoot(isDialog()).perform(click())
awaitCondition {
observedStates.lastOrNull()?.addCardDueDate == null
}
onView(withText(R.string.add_card)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.createCardCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
assertEquals(1, defaultDataSource.createCardCalls)
assertEquals("list-1", defaultDataSource.lastCreateCardListId)
assertEquals("Created Card", defaultDataSource.lastCreateCardTitle)
assertEquals("Body", defaultDataSource.lastCreateCardDescription)
assertEquals(setOf("tag-1"), defaultDataSource.lastCreateCardTagIds)
assertNull(defaultDataSource.lastCreateCardDueDate)
}
@Test
fun fabChooser_zeroLists_disablesAddCard_andShowsHelperMessage() {
defaultDataSource.currentDetail = BoardDetail(id = "board-1", title = "Board", lists = emptyList())
launchBoardDetail()
onView(withId(R.id.boardDetailCreateFab)).perform(click())
onView(withText(R.string.add_new_card)).inRoot(isDialog()).check(matches(not(isEnabled())))
onView(withText(R.string.create_a_list_first_to_add_cards)).inRoot(isDialog()).check(matches(isDisplayed()))
}
@Test
fun filters_applyTagAnyAndSearchAnd_keepAllListsVisible() {
defaultDataSource.currentDetail = detailFilterAndSearchThreeLists()
val scenario = launchBoardDetail()
onView(withContentDescription("Filter by tag")).perform(click())
onView(withText("Backend")).inRoot(isDialog()).perform(click())
onView(withText("Mobile")).inRoot(isDialog()).perform(click())
onView(withText(R.string.filter)).inRoot(isDialog()).perform(click())
onView(withContentDescription("Search")).perform(click())
onView(withId(R.id.searchTitleInput)).inRoot(isDialog()).perform(replaceText("Duke"))
onView(withText(R.string.search)).inRoot(isDialog()).perform(click())
onView(withText("Duke Backend")).check(matches(isDisplayed()))
scenario.onActivity { activity ->
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
}
onView(withText("Duke Mobile")).check(matches(isDisplayed()))
scenario.onActivity { activity ->
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(2, false)
}
onView(withText("Done")).check(matches(isDisplayed()))
onView(withText(R.string.board_detail_empty_list)).check(matches(isDisplayed()))
}
@Test
fun filterAndSearchActions_showActiveTintWhenCriteriaApplied_andResetOnClear() {
defaultDataSource.currentDetail = detailFilterAndSearchThreeLists()
val scenario = launchBoardDetail()
onView(withContentDescription("Filter by tag")).perform(click())
onView(withText("Backend")).inRoot(isDialog()).perform(click())
onView(withText(R.string.filter)).inRoot(isDialog()).perform(click())
onView(withContentDescription("Search")).perform(click())
onView(withId(R.id.searchTitleInput)).inRoot(isDialog()).perform(replaceText("Duke"))
onView(withText(R.string.search)).inRoot(isDialog()).perform(click())
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
val expectedActive = MaterialColors.getColor(
activity,
com.google.android.material.R.attr.colorPrimary,
Color.BLUE,
)
assertEquals(expectedActive, menu.findItem(R.id.actionFilterByTag).iconTintList?.defaultColor)
assertEquals(expectedActive, menu.findItem(R.id.actionSearch).iconTintList?.defaultColor)
}
onView(withContentDescription("Filter by tag")).perform(click())
onView(withContentDescription("Search")).perform(click())
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
val expectedInactive = MaterialColors.getColor(
activity,
com.google.android.material.R.attr.colorOnSurfaceVariant,
Color.BLACK,
)
assertEquals(expectedInactive, menu.findItem(R.id.actionFilterByTag).iconTintList?.defaultColor)
assertEquals(expectedInactive, menu.findItem(R.id.actionSearch).iconTintList?.defaultColor)
}
}
@Test
fun activeFilterOrSearchIconTap_clearsCriterionImmediately() {
defaultDataSource.currentDetail = detailFilterAndSearchThreeLists()
val scenario = launchBoardDetail()
onView(withContentDescription("Filter by tag")).perform(click())
onView(withText("Backend")).inRoot(isDialog()).perform(click())
onView(withText(R.string.filter)).inRoot(isDialog()).perform(click())
onView(withText("Duke Mobile")).check(doesNotExist())
onView(withContentDescription("Filter by tag")).perform(click())
scenario.onActivity { activity ->
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
}
onView(withText("Duke Mobile")).check(matches(isDisplayed()))
onView(withContentDescription("Search")).perform(click())
onView(withId(R.id.searchTitleInput)).inRoot(isDialog()).perform(replaceText("Backend"))
onView(withText(R.string.search)).inRoot(isDialog()).perform(click())
onView(withText(R.string.board_detail_empty_list)).check(matches(isDisplayed()))
onView(withContentDescription("Search")).perform(click())
scenario.onActivity { activity ->
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
}
onView(withText("Duke Mobile")).check(matches(isDisplayed()))
}
@Test
fun selectionMode_hidesFilterAndSearchActions() {
val scenario = launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
assertNull(menu.findItem(R.id.actionFilterByTag))
assertNull(menu.findItem(R.id.actionSearch))
assertNotNull(menu.findItem(R.id.actionSelectAll))
}
}
@Test
fun backArrowAndSystemBack_clearSelectionBeforeNavigation() {
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withId(R.id.actionSelectAll)).check(matches(isDisplayed()))
onView(withContentDescription(androidx.appcompat.R.string.abc_action_bar_up_description)).perform(click())
onView(withId(R.id.actionFilterByTag)).check(matches(isDisplayed()))
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withId(R.id.actionSelectAll)).check(matches(isDisplayed()))
pressBack()
onView(withId(R.id.actionFilterByTag)).check(matches(isDisplayed()))
}
@Test
fun missingBoardIdShowsBlockingDialogAndFinishes() {
val scenario = launchBoardDetail(boardId = null)
@@ -682,12 +883,37 @@ class BoardDetailFlowTest {
var deleteGate: CompletableDeferred<Unit>? = null
var moveCalls: Int = 0
var deleteCalls: Int = 0
var createListCalls: Int = 0
var createCardCalls: Int = 0
var lastMoveCardIds: Set<String> = emptySet()
var lastDeleteCardIds: Set<String> = emptySet()
var lastMoveTargetListId: String? = null
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: Set<String> = emptySet()
var createListResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("created-list"))
var createCardResult: BoardsApiResult<CreatedEntityRef> = BoardsApiResult.Success(CreatedEntityRef("created-card"))
override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult<CreatedEntityRef> {
return BoardsApiResult.Failure("Not implemented in test fake")
createListCalls += 1
lastCreateListBoardId = boardPublicId
lastCreateListTitle = title
val result = createListResult
if (result is BoardsApiResult.Success) {
val newId = result.value.publicId?.trim().orEmpty().ifBlank { "created-list-${createListCalls}" }
currentDetail = currentDetail.copy(
lists = currentDetail.lists + BoardListDetail(
id = newId,
title = title,
cards = emptyList(),
),
)
}
return result
}
override suspend fun createCard(
@@ -697,7 +923,47 @@ class BoardDetailFlowTest {
dueDate: LocalDate?,
tagPublicIds: Collection<String>,
): BoardsApiResult<CreatedEntityRef> {
return BoardsApiResult.Failure("Not implemented in test fake")
createCardCalls += 1
lastCreateCardListId = listPublicId
lastCreateCardTitle = title
lastCreateCardDescription = description
lastCreateCardDueDate = dueDate
lastCreateCardTagIds = tagPublicIds.toSet()
val result = createCardResult
if (result is BoardsApiResult.Success) {
val newId = result.value.publicId?.trim().orEmpty().ifBlank { "created-card-${createCardCalls}" }
val knownTagsById = currentDetail.lists
.asSequence()
.flatMap { list -> list.cards.asSequence() }
.flatMap { card -> card.tags.asSequence() }
.associateBy { tag -> tag.id }
val newTags = tagPublicIds.mapNotNull { knownTagsById[it] }
val dueAt = dueDate
?.atStartOfDay(ZoneId.systemDefault())
?.toInstant()
?.toEpochMilli()
currentDetail = currentDetail.copy(
lists = currentDetail.lists.map { list ->
if (list.id == listPublicId) {
list.copy(
cards = listOf(
BoardCardSummary(
id = newId,
title = title,
tags = newTags,
dueAtEpochMillis = dueAt,
),
) + list.cards,
)
} else {
list
}
},
)
}
return result
}
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
@@ -1026,5 +1292,74 @@ class BoardDetailFlowTest {
),
)
}
fun detailCreateCardDialogTagCatalog(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Existing",
tags = listOf(
BoardTagSummary("tag-1", "Backend", "#008080"),
BoardTagSummary("tag-2", "Mobile", "#990000"),
),
dueAtEpochMillis = null,
),
),
),
),
)
}
fun detailFilterAndSearchThreeLists(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "todo-duke-backend",
title = "Duke Backend",
tags = listOf(BoardTagSummary("tag-1", "Backend", "#008080")),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = listOf(
BoardCardSummary(
id = "doing-duke-mobile",
title = "Duke Mobile",
tags = listOf(BoardTagSummary("tag-2", "Mobile", "#009900")),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-3",
title = "Done",
cards = listOf(
BoardCardSummary(
id = "done-archived",
title = "Archived",
tags = listOf(BoardTagSummary("tag-3", "Ops", "#333333")),
dueAtEpochMillis = null,
),
),
),
),
)
}
}
}

View File

@@ -1,5 +1,6 @@
package space.hackenslacker.kanbn4droid.app.boarddetail
import android.app.DatePickerDialog
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
@@ -29,6 +30,9 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import com.google.android.material.snackbar.Snackbar
import java.text.DateFormat
import java.time.ZoneId
import java.util.Date
import kotlinx.coroutines.launch
import space.hackenslacker.kanbn4droid.app.MainActivity
import space.hackenslacker.kanbn4droid.app.CardDetailPlaceholderActivity
@@ -175,7 +179,7 @@ class BoardDetailActivity : AppCompatActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = intent.getStringExtra(EXTRA_BOARD_TITLE).orEmpty()
toolbar.setNavigationOnClickListener {
onBackPressedDispatcher.onBackPressed()
onBackPressed()
}
retryButton.setOnClickListener {
viewModel.retryLoad()
@@ -286,7 +290,7 @@ class BoardDetailActivity : AppCompatActivity() {
}
initialProgress.visibility = if (state.isInitialLoading && state.boardDetail == null) View.VISIBLE else View.GONE
val boardLists = state.boardDetail?.lists.orEmpty()
val boardLists = state.filteredBoardDetail?.lists.orEmpty()
val applyPagerState = {
pagerAdapter.submit(
lists = boardLists,
@@ -366,9 +370,15 @@ class BoardDetailActivity : AppCompatActivity() {
val titleLayout = dialog.findViewById<TextInputLayout>(R.id.addCardTitleLayout)
val titleInput = dialog.findViewById<TextInputEditText>(R.id.addCardTitleInput)
val descriptionInput = dialog.findViewById<TextInputEditText>(R.id.addCardDescriptionInput)
val dueDateText = dialog.findViewById<TextView>(R.id.addCardDueDateText)
val clearDueDateAction = dialog.findViewById<TextView>(R.id.addCardClearDueDateAction)
titleLayout?.error = state.addCardTitleError
titleInput?.isEnabled = !state.isMutating
descriptionInput?.isEnabled = !state.isMutating
dueDateText?.text = formatDueDateForDialog(state.addCardDueDate)
dueDateText?.isEnabled = !state.isMutating
clearDueDateAction?.visibility = if (state.addCardDueDate != null) View.VISIBLE else View.GONE
clearDueDateAction?.isEnabled = !state.isMutating
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = !state.isMutating
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.isEnabled = !state.isMutating
if (!state.isAddCardDialogOpen) {
@@ -516,6 +526,7 @@ class BoardDetailActivity : AppCompatActivity() {
}
DrawableCompat.setTint(wrapped, tintColor)
item?.icon = wrapped
item?.iconTintList = android.content.res.ColorStateList.valueOf(tintColor)
}
private fun showFabChooserDialog(state: BoardDetailUiState) {
@@ -591,10 +602,37 @@ class BoardDetailActivity : AppCompatActivity() {
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_add_card, null)
val titleInput: TextInputEditText = dialogView.findViewById(R.id.addCardTitleInput)
val descriptionInput: TextInputEditText = dialogView.findViewById(R.id.addCardDescriptionInput)
val dueDateText: TextView = dialogView.findViewById(R.id.addCardDueDateText)
val clearDueDateAction: TextView = dialogView.findViewById(R.id.addCardClearDueDateAction)
val tagsPlaceholderText: TextView = dialogView.findViewById(R.id.addCardTagsPlaceholderText)
val tagsContainer: LinearLayout = dialogView.findViewById(R.id.addCardTagsContainer)
val tags = state.boardDetail
?.lists
.orEmpty()
.flatMap { it.cards }
.flatMap { it.tags }
.distinctBy { it.id }
.sortedBy { it.name.lowercase() }
titleInput.setText(state.addCardTitleDraft)
descriptionInput.setText(state.addCardDescriptionDraft)
titleInput.doAfterTextChanged { viewModel.updateAddCardTitle(it?.toString().orEmpty()) }
descriptionInput.doAfterTextChanged { viewModel.updateAddCardDescription(it?.toString().orEmpty()) }
dueDateText.text = formatDueDateForDialog(state.addCardDueDate)
clearDueDateAction.visibility = if (state.addCardDueDate != null) View.VISIBLE else View.GONE
dueDateText.setOnClickListener { openAddCardDatePicker(state.addCardDueDate) }
clearDueDateAction.setOnClickListener { viewModel.clearDueDate() }
tagsPlaceholderText.visibility = if (tags.isEmpty()) View.VISIBLE else View.GONE
tags.forEach { tag ->
val checkBox = CheckBox(this).apply {
text = tag.name
isChecked = state.addCardSelectedTagIds.contains(tag.id)
setOnCheckedChangeListener { _, _ ->
viewModel.toggleAddCardTag(tag.id)
}
}
tagsContainer.addView(checkBox)
}
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.add_new_card)
@@ -617,6 +655,30 @@ class BoardDetailActivity : AppCompatActivity() {
dialog.show()
}
private fun openAddCardDatePicker(currentDate: java.time.LocalDate?) {
val seed = currentDate ?: java.time.LocalDate.now()
DatePickerDialog(
this,
{ _, year, month, dayOfMonth ->
viewModel.setDueDate(java.time.LocalDate.of(year, month + 1, dayOfMonth))
},
seed.year,
seed.monthValue - 1,
seed.dayOfMonth,
).show()
}
private fun formatDueDateForDialog(dueDate: java.time.LocalDate?): String {
if (dueDate == null) {
return getString(R.string.due_date)
}
val epochMillis = dueDate
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
return DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(epochMillis))
}
private fun showFilterDialog(state: BoardDetailUiState) {
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_filter_tags, null)
val container: LinearLayout = dialogView.findViewById(R.id.filterTagsContainer)