feat: implement card detail activity UI and timeline

This commit is contained in:
2026-03-16 21:48:35 -04:00
parent a0255c2487
commit f9625df828
13 changed files with 1599 additions and 11 deletions

View File

@@ -55,6 +55,7 @@ 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
@@ -788,14 +789,14 @@ class BoardDetailFlowTest {
}
@Test
fun cardTapNavigatesToCardPlaceholderWithExtras() {
fun cardTapNavigatesToCardDetailWithExtras() {
launchBoardDetail()
onView(withText("Card 1")).perform(click())
Intents.intended(hasComponent(CardDetailPlaceholderActivity::class.java.name))
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, "card-1"))
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, "Card 1"))
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
@@ -807,9 +808,9 @@ class BoardDetailFlowTest {
val expectedFallback = ApplicationProvider.getApplicationContext<android.content.Context>()
.getString(R.string.card_detail_placeholder_fallback_title)
Intents.intended(hasComponent(CardDetailPlaceholderActivity::class.java.name))
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_ID, "card-1"))
Intents.intended(hasExtra(CardDetailPlaceholderActivity.EXTRA_CARD_TITLE, expectedFallback))
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> {

View File

@@ -0,0 +1,374 @@
package space.hackenslacker.kanbn4droid.app
import android.content.Intent
import android.graphics.Color
import android.view.View
import android.widget.TextView
import android.view.inputmethod.EditorInfo
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.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
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.time.LocalDate
import java.util.ArrayDeque
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CompletableDeferred
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.not
import org.hamcrest.TypeSafeMatcher
import org.junit.After
import org.junit.Assert.assertEquals
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.carddetail.CardActivity
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetail
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailActivity
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailDataSource
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailTag
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailUiEvent
import space.hackenslacker.kanbn4droid.app.carddetail.CardDetailUiState
import space.hackenslacker.kanbn4droid.app.carddetail.DataSourceResult
@RunWith(AndroidJUnit4::class)
class CardDetailFlowTest {
private lateinit var fakeDataSource: FakeCardDetailDataSource
private val observedStates = mutableListOf<CardDetailUiState>()
private val observedEvents = mutableListOf<CardDetailUiEvent>()
@Before
fun setUp() {
observedStates.clear()
observedEvents.clear()
Intents.init()
MainActivity.dependencies.clear()
fakeDataSource = FakeCardDetailDataSource()
CardDetailActivity.testDataSourceFactory = { fakeDataSource }
CardDetailActivity.testUiStateObserver = { observedStates += it }
CardDetailActivity.testEventObserver = { observedEvents += it }
}
@After
fun tearDown() {
Intents.release()
MainActivity.dependencies.clear()
CardDetailActivity.testDataSourceFactory = null
CardDetailActivity.testUiStateObserver = null
CardDetailActivity.testEventObserver = null
}
@Test
fun missingCardIdShowsBlockingDialogAndFinishes() {
val scenario = launchCardDetail(cardId = null)
onView(withText(R.string.card_detail_unable_to_open_card)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.ok)).inRoot(isDialog()).perform(click())
scenario.onActivity { assertTrue(it.isFinishing) }
}
@Test
fun loadingContentAndErrorSectionsRenderDeterministically() {
val gate = CompletableDeferred<Unit>()
fakeDataSource.loadGate = gate
val scenario = launchCardDetail(waitForContent = false)
onView(withId(R.id.cardDetailInitialProgress)).check(matches(isDisplayed()))
gate.complete(Unit)
scenario.onActivity { }
onView(withId(R.id.cardDetailContentScroll)).check(matches(isDisplayed()))
fakeDataSource.loadResults.add(DataSourceResult.GenericError("Load failed"))
scenario.onActivity { it.recreate() }
onView(withText("Load failed")).check(matches(isDisplayed()))
}
@Test
fun titleLiveSaveErrorAndRetryFlow() {
fakeDataSource.updateTitleResults.add(DataSourceResult.GenericError("Title rejected"))
fakeDataSource.updateTitleResults.add(DataSourceResult.Success(Unit))
val scenario = launchCardDetail()
onView(withId(R.id.cardDetailTitleInput)).perform(replaceText("Renamed"), closeSoftKeyboard())
scenario.onActivity {
val input = it.findViewById<TextView>(R.id.cardDetailTitleInput)
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
awaitCondition { fakeDataSource.updateTitleCalls.get() == 1 }
onView(withText("Title rejected")).check(matches(isDisplayed()))
onView(withId(R.id.cardDetailTitleRetryButton)).perform(click())
awaitCondition { fakeDataSource.updateTitleCalls.get() == 2 }
}
@Test
fun dueDateSetClearAndExpiredStyling() {
fakeDataSource.currentCard = fakeDataSource.currentCard.copy(dueDate = LocalDate.now().minusDays(1))
var scenario = launchCardDetail()
var expectedErrorColor = Color.RED
scenario.onActivity {
expectedErrorColor = MaterialColors.getColor(
it.findViewById(android.R.id.content),
com.google.android.material.R.attr.colorError,
Color.RED,
)
}
onView(withId(R.id.cardDetailDueDateText)).check(matches(withCurrentTextColor(expectedErrorColor)))
scenario.close()
fakeDataSource.currentCard = fakeDataSource.currentCard.copy(dueDate = null)
scenario = launchCardDetail()
onView(withId(R.id.cardDetailDueDateText)).perform(click())
onView(withText(android.R.string.ok)).inRoot(isDialog()).perform(click())
awaitCondition { fakeDataSource.updateDueDateCalls.get() == 1 }
onView(withId(R.id.cardDetailDueDateClearButton)).perform(click())
awaitCondition { fakeDataSource.updateDueDateCalls.get() == 2 }
assertNull(fakeDataSource.lastDueDate)
}
@Test
fun descriptionLiveSaveRetryAndEditPreviewToggle() {
fakeDataSource.updateDescriptionResults.add(DataSourceResult.GenericError("Desc failed"))
fakeDataSource.updateDescriptionResults.add(DataSourceResult.Success(Unit))
val scenario = launchCardDetail()
onView(withId(R.id.cardDetailDescriptionInput)).perform(replaceText("**hello**"), closeSoftKeyboard())
scenario.onActivity { it.findViewById<View>(R.id.cardDetailDescriptionInput).clearFocus() }
awaitCondition { fakeDataSource.updateDescriptionCalls.get() == 1 }
onView(withText("Desc failed")).check(matches(isDisplayed()))
onView(withId(R.id.cardDetailDescriptionRetryButton)).perform(click())
awaitCondition { fakeDataSource.updateDescriptionCalls.get() == 2 }
onView(withId(R.id.cardDetailDescriptionPreviewButton)).perform(click())
onView(withId(R.id.cardDetailDescriptionPreviewText)).check(matches(isDisplayed()))
onView(withId(R.id.cardDetailDescriptionEditButton)).perform(click())
onView(withId(R.id.cardDetailDescriptionInputLayout)).check(matches(isDisplayed()))
}
@Test
fun addCommentDialogSupportsEditPreviewAndAddCancel() {
launchCardDetail()
onView(withId(R.id.cardDetailAddCommentFab)).perform(click())
onView(withId(android.R.id.button2)).check(matches(isDisplayed()))
onView(withId(android.R.id.button2)).perform(click())
onView(withId(R.id.cardDetailAddCommentFab)).perform(click())
onView(withId(R.id.commentDialogInput)).check(matches(isDisplayed()))
onView(withId(R.id.commentDialogInput)).perform(replaceText("new comment"), closeSoftKeyboard())
onView(withId(R.id.commentDialogPreviewButton)).perform(click())
onView(withId(R.id.commentDialogPreviewText)).check(matches(isDisplayed()))
onView(withText(R.string.add)).perform(click())
awaitCondition {
fakeDataSource.addCommentCalls.get() == 1 && fakeDataSource.listActivitiesCalls.get() >= 2
}
assertTrue(observedEvents.any { it is CardDetailUiEvent.ShowSnackbar })
}
@Test
fun timelineRowsUseGivenOrderAndCommentBodyOnly() {
fakeDataSource.activities = listOf(
CardActivity(id = "a-new", type = "comment", text = "**body**", createdAtEpochMillis = 2_000L),
CardActivity(id = "a-old", type = "update", text = "ignored", createdAtEpochMillis = 1_000L),
)
launchCardDetail()
onView(withText(containsString("Someone commented"))).check(matches(isDisplayed()))
onView(withText(containsString("Someone updated this card"))).check(matches(isDisplayed()))
onView(withText(containsString("body"))).check(matches(isDisplayed()))
}
@Test
fun sessionExpiredEventShowsDialogAndNavigatesToMainClearTask() {
fakeDataSource.loadResults.add(DataSourceResult.SessionExpired)
launchCardDetail()
onView(withText(R.string.card_detail_session_expired)).inRoot(isDialog()).check(matches(isDisplayed()))
onView(withText(R.string.ok)).inRoot(isDialog()).perform(click())
assertTrue(
Intents.getIntents().any { intent ->
intent.component?.className == MainActivity::class.java.name &&
(intent.flags and (Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)) ==
(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
},
)
}
@Test
fun deterministicHarnessUsesFakeDataSourceAndSynchronizationHooks() {
val titleGate = CompletableDeferred<Unit>()
fakeDataSource.updateTitleGate = titleGate
val scenario = launchCardDetail()
onView(withId(R.id.cardDetailTitleInput)).perform(replaceText("Blocked title"), closeSoftKeyboard())
scenario.onActivity {
val input = it.findViewById<TextView>(R.id.cardDetailTitleInput)
input.onEditorAction(EditorInfo.IME_ACTION_DONE)
}
awaitCondition { fakeDataSource.updateTitleCalls.get() == 1 }
awaitCondition { observedStates.lastOrNull()?.isTitleSaving == true }
onView(withId(R.id.cardDetailTitleSavingText)).check(matches(isDisplayed()))
titleGate.complete(Unit)
awaitCondition { observedStates.lastOrNull()?.isTitleSaving == false }
onView(withId(R.id.cardDetailTitleSavingText)).check(matches(not(isDisplayed())))
}
private fun launchCardDetail(
cardId: String? = "card-1",
waitForContent: Boolean = true,
): ActivityScenario<CardDetailActivity> {
val intent = Intent(
androidx.test.core.app.ApplicationProvider.getApplicationContext(),
CardDetailActivity::class.java,
)
if (cardId != null) {
intent.putExtra(CardDetailActivity.EXTRA_CARD_ID, cardId)
}
intent.putExtra(CardDetailActivity.EXTRA_CARD_TITLE, "Card 1")
return ActivityScenario.launch<CardDetailActivity>(intent).also {
if (waitForContent && cardId != null) {
awaitCondition {
observedStates.lastOrNull()?.isInitialLoading == false &&
observedStates.lastOrNull()?.loadErrorMessage == null
}
}
}
}
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 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 FakeCardDetailDataSource : CardDetailDataSource {
var currentCard = CardDetail(
id = "card-1",
title = "Card 1",
description = "Seed description",
dueDate = null,
listPublicId = "list-1",
index = 0,
tags = listOf(CardDetailTag("tag-1", "Backend", "#008080")),
)
var activities = listOf(
CardActivity("activity-1", "update", "", 1_000L),
)
val loadResults: ArrayDeque<DataSourceResult<CardDetail>> = ArrayDeque()
val updateTitleResults: ArrayDeque<DataSourceResult<Unit>> = ArrayDeque()
val updateDescriptionResults: ArrayDeque<DataSourceResult<Unit>> = ArrayDeque()
val updateDueDateResults: ArrayDeque<DataSourceResult<Unit>> = ArrayDeque()
val listActivitiesResults: ArrayDeque<DataSourceResult<List<CardActivity>>> = ArrayDeque()
val addCommentResults: ArrayDeque<DataSourceResult<Unit>> = ArrayDeque()
var loadGate: CompletableDeferred<Unit>? = null
var updateTitleGate: CompletableDeferred<Unit>? = null
var updateDescriptionGate: CompletableDeferred<Unit>? = null
var updateDueDateGate: CompletableDeferred<Unit>? = null
val updateTitleCalls = AtomicInteger(0)
val updateDescriptionCalls = AtomicInteger(0)
val updateDueDateCalls = AtomicInteger(0)
val listActivitiesCalls = AtomicInteger(0)
val addCommentCalls = AtomicInteger(0)
var lastDueDate: LocalDate? = null
override suspend fun loadCard(cardId: String): DataSourceResult<CardDetail> {
loadGate?.await()
return if (loadResults.isNotEmpty()) loadResults.removeFirst() else DataSourceResult.Success(currentCard)
}
override suspend fun updateTitle(cardId: String, title: String): DataSourceResult<Unit> {
updateTitleCalls.incrementAndGet()
updateTitleGate?.await()
val result = if (updateTitleResults.isNotEmpty()) updateTitleResults.removeFirst() else DataSourceResult.Success(Unit)
if (result is DataSourceResult.Success) {
currentCard = currentCard.copy(title = title)
}
return result
}
override suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit> {
updateDescriptionCalls.incrementAndGet()
updateDescriptionGate?.await()
val result = if (updateDescriptionResults.isNotEmpty()) updateDescriptionResults.removeFirst() else DataSourceResult.Success(Unit)
if (result is DataSourceResult.Success) {
currentCard = currentCard.copy(description = description)
}
return result
}
override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit> {
updateDueDateCalls.incrementAndGet()
updateDueDateGate?.await()
val result = if (updateDueDateResults.isNotEmpty()) updateDueDateResults.removeFirst() else DataSourceResult.Success(Unit)
if (result is DataSourceResult.Success) {
lastDueDate = dueDate
currentCard = currentCard.copy(dueDate = dueDate)
}
return result
}
override suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>> {
listActivitiesCalls.incrementAndGet()
return if (listActivitiesResults.isNotEmpty()) listActivitiesResults.removeFirst() else DataSourceResult.Success(activities)
}
override suspend fun addComment(cardId: String, comment: String): DataSourceResult<Unit> {
addCommentCalls.incrementAndGet()
val result = if (addCommentResults.isNotEmpty()) addCommentResults.removeFirst() else DataSourceResult.Success(Unit)
if (result is DataSourceResult.Success) {
activities = listOf(
CardActivity("comment-${addCommentCalls.get()}", "comment", comment, System.currentTimeMillis()),
) + activities
}
return result
}
}
}