Files
Kanbn4droid/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/BoardDetailFlowTest.kt

1367 lines
56 KiB
Kotlin

package space.hackenslacker.kanbn4droid.app
import android.content.Intent
import android.graphics.Color
import android.view.inputmethod.EditorInfo
import android.view.View
import android.widget.TextView
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
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.viewpager2.widget.ViewPager2
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
import kotlinx.coroutines.CompletableDeferred
import org.hamcrest.Matchers.not
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.TypeSafeMatcher
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardCardSummary
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailActivity
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailDataSource
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardListDetail
import space.hackenslacker.kanbn4droid.app.boarddetail.BoardTagSummary
import space.hackenslacker.kanbn4droid.app.boarddetail.CardBatchMutationResult
import space.hackenslacker.kanbn4droid.app.boarddetail.CreatedEntityRef
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailActivity
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardsApiResult
@RunWith(AndroidJUnit4::class)
class BoardDetailFlowTest {
private lateinit var defaultDataSource: FakeBoardDetailDataSource
private var originalLocale: Locale? = null
private val observedStates = mutableListOf<space.hackenslacker.kanbn4droid.app.boarddetail.BoardDetailUiState>()
@Before
fun setUp() {
observedStates.clear()
MainActivity.dependencies.clear()
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore("https://kan.bn/") }
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("api") }
originalLocale = Locale.getDefault()
Intents.init()
defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList())
BoardDetailActivity.testDataSourceFactory = { defaultDataSource }
BoardDetailActivity.testUiStateObserver = { state -> observedStates += state }
}
@After
fun tearDown() {
Intents.release()
BoardDetailActivity.testDataSourceFactory = null
BoardDetailActivity.testUiStateObserver = null
MainActivity.dependencies.clear()
originalLocale?.let { Locale.setDefault(it) }
}
@Test
fun boardDetailShowsListTitleAndCards() {
launchBoardDetail()
onView(withText("To Do")).check(matches(isDisplayed()))
onView(withText("Card 1")).check(matches(isDisplayed()))
}
@Test
fun initialLoadShowsProgressThenContent() {
val gate = CompletableDeferred<Unit>()
defaultDataSource.loadGate = gate
val scenario = launchBoardDetail()
onView(withId(R.id.boardDetailInitialProgress)).check(matches(isDisplayed()))
gate.complete(Unit)
scenario.onActivity { }
onView(withText("Card 1")).check(matches(isDisplayed()))
}
@Test
fun emptyBoardShowsNoListsYetMessage() {
defaultDataSource.currentDetail = BoardDetail(id = "board-1", title = "Board", lists = emptyList())
launchBoardDetail()
onView(withText(R.string.board_detail_empty_board)).check(matches(isDisplayed()))
}
@Test
fun initialLoadFailureShowsRetryAndRetryReloads() {
defaultDataSource.loadResults.add(BoardsApiResult.Failure("Load failed"))
defaultDataSource.loadResults.add(BoardsApiResult.Success(detailOneList()))
launchBoardDetail()
onView(withText("Load failed")).check(matches(isDisplayed()))
onView(withId(R.id.boardDetailRetryButton)).perform(click())
onView(withText("Card 1")).check(matches(isDisplayed()))
}
@Test
fun expiredDueDateUsesErrorColor() {
defaultDataSource.currentDetail = detailWithDueDate(System.currentTimeMillis() - 3_600_000)
val scenario = launchBoardDetail()
var expectedColor: Int? = null
scenario.onActivity { activity ->
expectedColor = MaterialColors.getColor(activity.findViewById(android.R.id.content), com.google.android.material.R.attr.colorError)
}
onView(withId(R.id.cardDueDateText)).check(matches(withCurrentTextColor(expectedColor ?: Color.RED)))
}
@Test
fun validDueDateUsesOnSurfaceColor() {
defaultDataSource.currentDetail = detailWithDueDate(System.currentTimeMillis() + 86_400_000)
val scenario = launchBoardDetail()
var expectedColor: Int? = null
scenario.onActivity { activity ->
expectedColor = MaterialColors.getColor(activity.findViewById(android.R.id.content), com.google.android.material.R.attr.colorOnSurface)
}
onView(withId(R.id.cardDueDateText)).check(matches(withCurrentTextColor(expectedColor ?: Color.BLACK)))
}
@Test
fun invalidTagColorFallsBackToOnSurfaceColor() {
defaultDataSource.currentDetail = detailWithInvalidTagColor()
val scenario = launchBoardDetail()
var expectedColor: Int? = null
scenario.onActivity { activity ->
expectedColor = MaterialColors.getColor(
activity.findViewById(android.R.id.content),
com.google.android.material.R.attr.colorOnSurface,
)
}
onView(withText("Backend")).check(matches(withChipStrokeColor(expectedColor ?: Color.DKGRAY)))
}
@Test
fun dueDateUsesSystemLocaleFormatting() {
Locale.setDefault(Locale.FRANCE)
val due = 1_735_776_000_000L
defaultDataSource.currentDetail = detailWithDueDate(due)
launchBoardDetail()
val expected = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()).format(Date(due))
onView(withText(expected)).check(matches(isDisplayed()))
}
@Test
fun inlineListTitleEdit_savesOnImeDone() {
defaultDataSource.currentDetail = detailOneList()
val scenario = launchBoardDetail()
onView(withId(R.id.listTitleText)).perform(click())
scenario.onActivity { activity ->
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
input.setText("Renamed")
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
assertEquals(1, defaultDataSource.renameCalls)
assertEquals("Renamed", defaultDataSource.lastRenameTitle)
}
@Test
fun inlineListTitleEdit_savesOnFocusLoss() {
launchBoardDetail()
onView(withId(R.id.listTitleText)).perform(click())
onView(withId(R.id.listTitleEditInput)).perform(replaceText("Renamed again"))
onView(withId(R.id.listCardsRecycler)).perform(click())
assertEquals(1, defaultDataSource.renameCalls)
assertEquals("Renamed again", defaultDataSource.lastRenameTitle)
}
@Test
fun inlineListTitleEdit_trimmedNoOpSkipsRenameCall() {
val scenario = launchBoardDetail()
onView(withId(R.id.listTitleText)).perform(click())
scenario.onActivity { activity ->
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
input.setText(" To Do ")
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
assertEquals(0, defaultDataSource.renameCalls)
}
@Test
fun inlineListTitleEdit_rejectsBlankTitle() {
val scenario = launchBoardDetail()
onView(withId(R.id.listTitleText)).perform(click())
scenario.onActivity { activity ->
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
input.setText(" ")
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
onView(withText(R.string.list_title_required)).check(matches(isDisplayed()))
assertEquals(0, defaultDataSource.renameCalls)
}
@Test
fun inlineListTitleEdit_renameFailure_keepsEditModeAndShowsError() {
defaultDataSource.renameResult = BoardsApiResult.Failure("Rename rejected")
val scenario = launchBoardDetail()
onView(withId(R.id.listTitleText)).perform(click())
scenario.onActivity { activity ->
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
input.setText("X")
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
onView(withId(R.id.listTitleEditInput)).check(matches(isDisplayed()))
onView(withText("Rename rejected")).check(matches(isDisplayed()))
}
@Test
fun inlineListTitleEdit_saveDisabledWhileMutating() {
defaultDataSource.renameGate = CompletableDeferred()
val scenario = launchBoardDetail()
onView(withId(R.id.listTitleText)).perform(click())
scenario.onActivity { activity ->
val input = activity.findViewById<TextView>(R.id.listTitleEditInput)
input.setText("New")
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
onView(withId(R.id.listTitleEditInput)).check(matches(not(isEnabled())))
defaultDataSource.renameGate?.complete(Unit)
}
@Test
fun selectionModeActionsShowTooltipsOnLongPress() {
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
assertEquals(activity.getString(R.string.select_all), menu.findItem(R.id.actionSelectAll)?.tooltipText?.toString())
assertEquals(activity.getString(R.string.move_cards), menu.findItem(R.id.actionMoveCards)?.tooltipText?.toString())
assertEquals(activity.getString(R.string.delete_cards), menu.findItem(R.id.actionDeleteCards)?.tooltipText?.toString())
}
}
@Test
fun selectionActionIconsMatchExpectedResources() {
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
assertNotNull(menu.findItem(R.id.actionSelectAll)?.icon)
assertNotNull(menu.findItem(R.id.actionMoveCards)?.icon)
assertNotNull(menu.findItem(R.id.actionDeleteCards)?.icon)
assertEquals(
AppCompatResources.getDrawable(activity, R.drawable.ic_select_all_grid_24)?.constantState,
menu.findItem(R.id.actionSelectAll)?.icon?.constantState,
)
assertEquals(
AppCompatResources.getDrawable(activity, R.drawable.ic_move_cards_horizontal_24)?.constantState,
menu.findItem(R.id.actionMoveCards)?.icon?.constantState,
)
assertEquals(
AppCompatResources.getDrawable(activity, R.drawable.ic_delete_24)?.constantState,
menu.findItem(R.id.actionDeleteCards)?.icon?.constantState,
)
}
}
@Test
fun toolbarNormalMode_showsFilterAndSearchActions() {
val scenario = launchBoardDetail()
onView(withText("Card 1")).check(matches(isDisplayed()))
awaitCondition {
var present = false
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
present = menu.findItem(R.id.actionFilterByTag) != null && menu.findItem(R.id.actionSearch) != null
}
present
}
scenario.onActivity { activity ->
val menu = activity.findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.boardDetailToolbar).menu
assertNotNull(menu.findItem(R.id.actionFilterByTag))
assertNotNull(menu.findItem(R.id.actionSearch))
}
}
@Test
fun selectionMode_replacesFilterSearchWithSelectionActions() {
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
assertNotNull(menu.findItem(R.id.actionSelectAll))
assertNotNull(menu.findItem(R.id.actionMoveCards))
assertNotNull(menu.findItem(R.id.actionDeleteCards))
assertNull(menu.findItem(R.id.actionFilterByTag))
assertNull(menu.findItem(R.id.actionSearch))
}
}
@Test
fun fab_isVisible_andOpensAddChooser() {
launchBoardDetail()
onView(withId(R.id.boardDetailCreateFab)).check(matches(isDisplayed()))
onView(withId(R.id.boardDetailCreateFab)).perform(click())
onView(withText("Add new list")).inRoot(isDialog()).check(matches(isDisplayed()))
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)
onView(withText(R.string.board_detail_unable_to_open_board)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.ok)).inRoot(isDialog()).perform(click())
scenario.onActivity { activity ->
assertTrue(activity.isFinishing)
}
}
@Test
fun missingSessionShowsBlockingDialogAndFinishes() {
BoardDetailActivity.testDataSourceFactory = null
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore(null) }
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore(null) }
val scenario = launchBoardDetail()
onView(withText(R.string.board_detail_session_expired)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.ok)).inRoot(isDialog()).perform(click())
scenario.onActivity { activity ->
assertTrue(activity.isFinishing)
}
}
@Test
fun moveDialogShowsListSelector() {
defaultDataSource.currentDetail = detailTwoLists()
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Move cards")).perform(click())
onView(withText(R.string.move_cards_to_list)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText("To Do")).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText("Doing")).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.cancel)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.move_cards)).inRoot(isDialog()).check(matches(isDisplayed()))
}
@Test
fun moveDisablesWhenTargetAlreadyContainsAllSelected() {
defaultDataSource.currentDetail = detailTwoLists()
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Move cards")).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).check(matches(not(isEnabled())))
}
@Test
fun moveExcludesNoOpCardsFromPayload() {
defaultDataSource.currentDetail = detailTwoListsWithCardsOnBothPages()
val scenario = launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
scenario.onActivity { activity ->
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
}
onView(withText("Card 2")).perform(click())
onView(withContentDescription("Move cards")).perform(click())
onView(withText("Doing")).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
scenario.onActivity {
assertEquals(1, defaultDataSource.moveCalls)
assertEquals("list-2", defaultDataSource.lastMoveTargetListId)
assertEquals(setOf("card-1"), defaultDataSource.lastMoveCardIds)
}
}
@Test
fun moveConfirmDisabledWhileMutating() {
defaultDataSource.currentDetail = detailTwoLists()
defaultDataSource.moveGate = CompletableDeferred()
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Move cards")).perform(click())
onView(withText("Doing")).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).check(matches(not(isEnabled())))
defaultDataSource.moveGate?.complete(Unit)
}
@Test
fun movePartialSuccessShowsWarningAndReselectsFailedCards() {
defaultDataSource.currentDetail = detailSingleListTwoCards()
defaultDataSource.moveResult = CardBatchMutationResult.PartialSuccess(
failedCardIds = setOf("card-2"),
message = "Some cards could not be moved. Please try again.",
)
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withText("Card 2")).perform(click())
onView(withContentDescription("Move cards")).perform(click())
onView(withText("Doing")).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
onView(withText("Some cards could not be moved. Please try again.")).check(matches(isDisplayed()))
val last = observedStates.last()
assertEquals(setOf("card-2"), last.selectedCardIds)
}
@Test
fun moveFullFailurePreservesSelectionAndShowsError() {
defaultDataSource.currentDetail = detailTwoLists()
defaultDataSource.moveResult = CardBatchMutationResult.Failure("Move failed")
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Move cards")).perform(click())
onView(withText("Doing")).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.moveCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
onView(withText("Move failed")).check(matches(isDisplayed()))
val last = observedStates.last()
assertEquals(setOf("card-1"), last.selectedCardIds)
}
@Test
fun deleteRequiresSecondConfirmation() {
val scenario = launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Delete cards")).perform(click())
onView(withText(R.string.delete)).inRoot(isDialog()).perform(click())
onView(withText(R.string.delete_cards_second_confirmation)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click())
scenario.onActivity {
assertEquals(1, defaultDataSource.deleteCalls)
}
}
@Test
fun deleteConfirmDisabledWhileMutating() {
defaultDataSource.deleteGate = CompletableDeferred()
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Delete cards")).perform(click())
onView(withText(R.string.delete)).inRoot(isDialog()).perform(click())
onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).check(matches(not(isEnabled())))
defaultDataSource.deleteGate?.complete(Unit)
}
@Test
fun deletePartialSuccessShowsWarningAndReselectsFailedCards() {
defaultDataSource.currentDetail = detailSingleListTwoCards()
defaultDataSource.deleteResult = CardBatchMutationResult.PartialSuccess(
failedCardIds = setOf("card-2"),
message = "Some cards could not be deleted. Please try again.",
)
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withText("Card 2")).perform(click())
onView(withContentDescription("Delete cards")).perform(click())
onView(withText(R.string.delete)).inRoot(isDialog()).perform(click())
onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
onView(withText("Some cards could not be deleted. Please try again.")).check(matches(isDisplayed()))
val last = observedStates.last()
assertEquals(setOf("card-2"), last.selectedCardIds)
}
@Test
fun deleteFullFailurePreservesSelectionAndShowsError() {
defaultDataSource.deleteResult = CardBatchMutationResult.Failure("Delete failed")
launchBoardDetail()
onView(withText("Card 1")).perform(longClick())
onView(withContentDescription("Delete cards")).perform(click())
onView(withText(R.string.delete)).inRoot(isDialog()).perform(click())
onView(withText(R.string.im_sure)).inRoot(isDialog()).perform(click())
awaitCondition {
defaultDataSource.deleteCalls == 1 && observedStates.lastOrNull()?.isMutating == false
}
onView(withText("Delete failed")).check(matches(isDisplayed()))
val last = observedStates.last()
assertEquals(setOf("card-1"), last.selectedCardIds)
}
@Test
fun selectionPersistsAcrossPagesAndSelectAllIsPageScoped() {
defaultDataSource.currentDetail = detailTwoListsTwoCardsEach()
val scenario = launchBoardDetail()
onView(withText("Todo A")).perform(longClick())
scenario.onActivity { activity ->
activity.findViewById<ViewPager2>(R.id.boardDetailPager).setCurrentItem(1, false)
}
onView(withContentDescription("Select all")).perform(click())
onView(withContentDescription("Move cards")).perform(click())
onView(withText("To Do")).inRoot(isDialog()).perform(click())
onView(withId(android.R.id.button1)).inRoot(isDialog()).perform(click())
scenario.onActivity {
assertEquals(1, defaultDataSource.moveCalls)
assertEquals("list-1", defaultDataSource.lastMoveTargetListId)
assertEquals(setOf("doing-a", "doing-b"), defaultDataSource.lastMoveCardIds)
}
}
@Test
fun cardTapNavigatesToCardDetailWithExtras() {
launchBoardDetail()
onView(withText("Card 1")).perform(click())
Intents.intended(hasComponent(CardDetailActivity::class.java.name))
Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_ID, "card-1"))
Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_TITLE, "Card 1"))
}
@Test
fun cardTapBlankTitle_usesCardFallbackInPlaceholderExtra() {
defaultDataSource.currentDetail = detailWithCardTitle(" ")
launchBoardDetail()
onView(withId(R.id.cardItemRoot)).perform(click())
val expectedFallback = ApplicationProvider.getApplicationContext<android.content.Context>()
.getString(R.string.card_detail_placeholder_fallback_title)
Intents.intended(hasComponent(CardDetailActivity::class.java.name))
Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_ID, "card-1"))
Intents.intended(hasExtra(CardDetailActivity.EXTRA_CARD_TITLE, expectedFallback))
}
private fun launchBoardDetail(boardId: String? = "board-1"): ActivityScenario<BoardDetailActivity> {
val intent = Intent(
androidx.test.core.app.ApplicationProvider.getApplicationContext(),
BoardDetailActivity::class.java,
)
if (boardId != null) {
intent.putExtra(BoardDetailActivity.EXTRA_BOARD_ID, boardId)
}
intent.putExtra(BoardDetailActivity.EXTRA_BOARD_TITLE, "Board")
return ActivityScenario.launch(intent)
}
private fun withCurrentTextColor(expectedColor: Int): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("with text color: $expectedColor")
}
override fun matchesSafely(item: View): Boolean {
if (item !is TextView) {
return false
}
return item.currentTextColor == expectedColor
}
}
}
private fun withChipStrokeColor(expectedColor: Int): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("with chip stroke color: $expectedColor")
}
override fun matchesSafely(item: View): Boolean {
if (item !is Chip) {
return false
}
return item.chipStrokeColor?.defaultColor == expectedColor
}
}
}
private fun awaitCondition(timeoutMs: Long = 4_000L, condition: () -> Boolean) {
val instrumentation = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation()
val start = System.currentTimeMillis()
while (System.currentTimeMillis() - start < timeoutMs) {
instrumentation.waitForIdleSync()
if (condition()) {
return
}
Thread.sleep(50)
}
throw AssertionError("Condition not met within ${timeoutMs}ms")
}
private class FakeBoardDetailDataSource(
initialDetail: BoardDetail,
) : BoardDetailDataSource {
var currentDetail: BoardDetail = initialDetail
val loadResults: ArrayDeque<BoardsApiResult<BoardDetail>> = ArrayDeque()
var loadGate: CompletableDeferred<Unit>? = null
var renameResult: BoardsApiResult<Unit> = BoardsApiResult.Success(Unit)
var renameGate: CompletableDeferred<Unit>? = null
var renameCalls: Int = 0
var lastRenameTitle: String? = null
var moveResult: CardBatchMutationResult = CardBatchMutationResult.Success
var deleteResult: CardBatchMutationResult = CardBatchMutationResult.Success
var moveGate: CompletableDeferred<Unit>? = null
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> {
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(
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.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> {
loadGate?.await()
return if (loadResults.isNotEmpty()) {
loadResults.removeFirst()
} else {
BoardsApiResult.Success(currentDetail)
}
}
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
moveCalls += 1
lastMoveCardIds = cardIds.toSet()
lastMoveTargetListId = targetListId
moveGate?.await()
val result = moveResult
if (result is CardBatchMutationResult.Success || result is CardBatchMutationResult.PartialSuccess) {
val failedIds = if (result is CardBatchMutationResult.PartialSuccess) result.failedCardIds else emptySet()
val movedIds = cardIds.filterNot { failedIds.contains(it) }.toSet()
if (movedIds.isNotEmpty()) {
val targetList = currentDetail.lists.firstOrNull { it.id == targetListId }
if (targetList != null) {
val movedCards = currentDetail.lists.flatMap { it.cards }.filter { movedIds.contains(it.id) }
currentDetail = currentDetail.copy(
lists = currentDetail.lists.map { list ->
if (list.id == targetList.id) {
list.copy(cards = list.cards + movedCards.filter { moved -> list.cards.none { it.id == moved.id } })
} else {
list.copy(cards = list.cards.filterNot { movedIds.contains(it.id) })
}
},
)
}
}
}
return result
}
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
deleteCalls += 1
lastDeleteCardIds = cardIds.toSet()
deleteGate?.await()
val result = deleteResult
if (result is CardBatchMutationResult.Success || result is CardBatchMutationResult.PartialSuccess) {
val failedIds = if (result is CardBatchMutationResult.PartialSuccess) result.failedCardIds else emptySet()
val deletedIds = cardIds.filterNot { failedIds.contains(it) }.toSet()
if (deletedIds.isNotEmpty()) {
currentDetail = currentDetail.copy(
lists = currentDetail.lists.map { list ->
list.copy(cards = list.cards.filterNot { deletedIds.contains(it.id) })
},
)
}
}
return result
}
override suspend fun renameList(listId: String, newTitle: String): BoardsApiResult<Unit> {
renameCalls += 1
lastRenameTitle = newTitle
renameGate?.await()
val result = renameResult
if (result is BoardsApiResult.Success) {
currentDetail = currentDetail.copy(
lists = currentDetail.lists.map { list ->
if (list.id == listId) list.copy(title = newTitle) else list
},
)
}
return result
}
}
private class InMemorySessionStore(
private var baseUrl: String?,
) : SessionStore {
override fun getBaseUrl(): String? = baseUrl
override fun saveBaseUrl(url: String) {
baseUrl = url
}
override fun clearBaseUrl() {
baseUrl = null
}
override fun getWorkspaceId(): String? = "ws-1"
override fun saveWorkspaceId(workspaceId: String) {
}
override fun clearWorkspaceId() {
}
}
private class InMemoryApiKeyStore(
private var key: String?,
) : ApiKeyStore {
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
key = apiKey
return Result.success(Unit)
}
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key)
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
key = null
return Result.success(Unit)
}
}
private companion object {
fun detailOneList(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Card 1",
tags = listOf(BoardTagSummary("tag-1", "Backend", "#008080")),
dueAtEpochMillis = null,
),
),
),
),
)
}
fun detailWithDueDate(dueAtEpochMillis: Long): BoardDetail {
return detailOneList().copy(
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Card 1",
tags = emptyList(),
dueAtEpochMillis = dueAtEpochMillis,
),
),
),
),
)
}
fun detailWithInvalidTagColor(): BoardDetail {
return detailOneList().copy(
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Card 1",
tags = listOf(BoardTagSummary("tag-1", "Backend", "bad-color")),
dueAtEpochMillis = null,
),
),
),
),
)
}
fun detailWithCardTitle(title: String): BoardDetail {
return detailOneList().copy(
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = title,
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
),
)
}
fun detailTwoLists(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Card 1",
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = emptyList(),
),
),
)
}
fun detailTwoListsWithCardsOnBothPages(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Card 1",
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = listOf(
BoardCardSummary(
id = "card-2",
title = "Card 2",
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
),
)
}
fun detailSingleListTwoCards(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "card-1",
title = "Card 1",
tags = emptyList(),
dueAtEpochMillis = null,
),
BoardCardSummary(
id = "card-2",
title = "Card 2",
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = emptyList(),
),
),
)
}
fun detailTwoListsTwoCardsEach(): BoardDetail {
return BoardDetail(
id = "board-1",
title = "Board",
lists = listOf(
BoardListDetail(
id = "list-1",
title = "To Do",
cards = listOf(
BoardCardSummary(
id = "todo-a",
title = "Todo A",
tags = emptyList(),
dueAtEpochMillis = null,
),
BoardCardSummary(
id = "todo-b",
title = "Todo B",
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
BoardListDetail(
id = "list-2",
title = "Doing",
cards = listOf(
BoardCardSummary(
id = "doing-a",
title = "Doing A",
tags = emptyList(),
dueAtEpochMillis = null,
),
BoardCardSummary(
id = "doing-b",
title = "Doing B",
tags = emptyList(),
dueAtEpochMillis = null,
),
),
),
),
)
}
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,
),
),
),
),
)
}
}
}