feat: implement board detail pager UI and card rendering

This commit is contained in:
2026-03-16 01:19:15 -04:00
parent 4455f0ecd3
commit 5f5a273d7f
10 changed files with 1155 additions and 0 deletions

View File

@@ -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,
),
),
),
),
)
}
}
}