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() @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() 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(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(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(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(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(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(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(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(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(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(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(R.id.boardDetailPager).setCurrentItem(1, false) } onView(withText("Duke Mobile")).check(matches(isDisplayed())) scenario.onActivity { activity -> activity.findViewById(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(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(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(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(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(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(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(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() .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 { 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 { return object : TypeSafeMatcher() { 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 { return object : TypeSafeMatcher() { 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> = ArrayDeque() var loadGate: CompletableDeferred? = null var renameResult: BoardsApiResult = BoardsApiResult.Success(Unit) var renameGate: CompletableDeferred? = null var renameCalls: Int = 0 var lastRenameTitle: String? = null var moveResult: CardBatchMutationResult = CardBatchMutationResult.Success var deleteResult: CardBatchMutationResult = CardBatchMutationResult.Success var moveGate: CompletableDeferred? = null var deleteGate: CompletableDeferred? = null var moveCalls: Int = 0 var deleteCalls: Int = 0 var createListCalls: Int = 0 var createCardCalls: Int = 0 var lastMoveCardIds: Set = emptySet() var lastDeleteCardIds: Set = 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 = emptySet() var createListResult: BoardsApiResult = BoardsApiResult.Success(CreatedEntityRef("created-list")) var createCardResult: BoardsApiResult = BoardsApiResult.Success(CreatedEntityRef("created-card")) override suspend fun createList(boardPublicId: String, title: String): BoardsApiResult { 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, ): BoardsApiResult { 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 { loadGate?.await() return if (loadResults.isNotEmpty()) { loadResults.removeFirst() } else { BoardsApiResult.Success(currentDetail) } } override suspend fun moveCards(cardIds: Collection, 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): 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 { 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 { key = apiKey return Result.success(Unit) } override suspend fun getApiKey(baseUrl: String): Result = Result.success(key) override suspend fun invalidateApiKey(baseUrl: String): Result { 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, ), ), ), ), ) } } }