feat: add board detail batch move and delete workflows
This commit is contained in:
@@ -17,11 +17,13 @@ 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.hasDescendant
|
||||
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 java.text.DateFormat
|
||||
import java.util.ArrayDeque
|
||||
@@ -53,6 +55,7 @@ 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() {
|
||||
@@ -60,12 +63,14 @@ class BoardDetailFlowTest {
|
||||
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
|
||||
originalLocale?.let { Locale.setDefault(it) }
|
||||
}
|
||||
|
||||
@@ -251,6 +256,205 @@ class BoardDetailFlowTest {
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 cardTapNavigatesToCardPlaceholderWithExtras() {
|
||||
launchBoardDetail()
|
||||
@@ -300,6 +504,19 @@ class BoardDetailFlowTest {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -310,6 +527,15 @@ class BoardDetailFlowTest {
|
||||
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 lastMoveCardIds: Set<String> = emptySet()
|
||||
var lastDeleteCardIds: Set<String> = emptySet()
|
||||
var lastMoveTargetListId: String? = null
|
||||
|
||||
override suspend fun getBoardDetail(boardId: String): BoardsApiResult<BoardDetail> {
|
||||
loadGate?.await()
|
||||
@@ -321,11 +547,52 @@ class BoardDetailFlowTest {
|
||||
}
|
||||
|
||||
override suspend fun moveCards(cardIds: Collection<String>, targetListId: String): CardBatchMutationResult {
|
||||
return CardBatchMutationResult.Success
|
||||
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 {
|
||||
return CardBatchMutationResult.Success
|
||||
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> {
|
||||
@@ -403,5 +670,141 @@ class BoardDetailFlowTest {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user