1367 lines
56 KiB
Kotlin
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
}
|