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