feat: implement board detail pager UI and card rendering
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
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.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
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.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 com.google.android.material.color.MaterialColors
|
||||
import java.text.DateFormat
|
||||
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.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.boards.BoardsApiResult
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BoardDetailFlowTest {
|
||||
|
||||
private lateinit var defaultDataSource: FakeBoardDetailDataSource
|
||||
private var originalLocale: Locale? = null
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
originalLocale = Locale.getDefault()
|
||||
defaultDataSource = FakeBoardDetailDataSource(initialDetail = detailOneList())
|
||||
BoardDetailActivity.testDataSourceFactory = { defaultDataSource }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
BoardDetailActivity.testDataSourceFactory = null
|
||||
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 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())
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchBoardDetail(): ActivityScenario<BoardDetailActivity> {
|
||||
val intent = Intent(
|
||||
androidx.test.core.app.ApplicationProvider.getApplicationContext(),
|
||||
BoardDetailActivity::class.java,
|
||||
).putExtra(BoardDetailActivity.EXTRA_BOARD_ID, "board-1")
|
||||
.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 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
|
||||
|
||||
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 {
|
||||
return CardBatchMutationResult.Success
|
||||
}
|
||||
|
||||
override suspend fun deleteCards(cardIds: Collection<String>): CardBatchMutationResult {
|
||||
return CardBatchMutationResult.Success
|
||||
}
|
||||
|
||||
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 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user