feat: implement card detail activity UI and timeline
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user