fix: avoid redundant card activity reload and assert ordering

This commit is contained in:
2026-03-16 22:01:09 -04:00
parent f9625df828
commit 344c5a4faa
5 changed files with 48 additions and 17 deletions

View File

@@ -180,8 +180,9 @@ class CardDetailFlowTest {
onView(withText(R.string.add)).perform(click()) onView(withText(R.string.add)).perform(click())
awaitCondition { awaitCondition {
fakeDataSource.addCommentCalls.get() == 1 && fakeDataSource.listActivitiesCalls.get() >= 2 fakeDataSource.addCommentCalls.get() == 1
} }
assertEquals(1, fakeDataSource.listActivitiesCalls.get())
assertTrue(observedEvents.any { it is CardDetailUiEvent.ShowSnackbar }) assertTrue(observedEvents.any { it is CardDetailUiEvent.ShowSnackbar })
} }
@@ -196,6 +197,7 @@ class CardDetailFlowTest {
onView(withText(containsString("Someone commented"))).check(matches(isDisplayed())) onView(withText(containsString("Someone commented"))).check(matches(isDisplayed()))
onView(withText(containsString("Someone updated this card"))).check(matches(isDisplayed())) onView(withText(containsString("Someone updated this card"))).check(matches(isDisplayed()))
onView(withText(containsString("body"))).check(matches(isDisplayed())) onView(withText(containsString("body"))).check(matches(isDisplayed()))
assertEquals(listOf("a-new", "a-old"), observedStates.last().activities.map { it.id })
} }
@Test @Test
@@ -360,15 +362,20 @@ class CardDetailFlowTest {
return if (listActivitiesResults.isNotEmpty()) listActivitiesResults.removeFirst() else DataSourceResult.Success(activities) return if (listActivitiesResults.isNotEmpty()) listActivitiesResults.removeFirst() else DataSourceResult.Success(activities)
} }
override suspend fun addComment(cardId: String, comment: String): DataSourceResult<Unit> { override suspend fun addComment(cardId: String, comment: String): DataSourceResult<List<CardActivity>> {
addCommentCalls.incrementAndGet() addCommentCalls.incrementAndGet()
val result = if (addCommentResults.isNotEmpty()) addCommentResults.removeFirst() else DataSourceResult.Success(Unit) val result = if (addCommentResults.isNotEmpty()) addCommentResults.removeFirst() else DataSourceResult.Success(Unit)
if (result is DataSourceResult.Success) { if (result is DataSourceResult.Success) {
activities = listOf( activities = listOf(
CardActivity("comment-${addCommentCalls.get()}", "comment", comment, System.currentTimeMillis()), CardActivity("comment-${addCommentCalls.get()}", "comment", comment, System.currentTimeMillis()),
) + activities ) + activities
return DataSourceResult.Success(activities)
}
return when (result) {
is DataSourceResult.GenericError -> result
DataSourceResult.SessionExpired -> DataSourceResult.SessionExpired
is DataSourceResult.Success -> DataSourceResult.Success(activities)
} }
return result
} }
} }
} }

View File

@@ -97,7 +97,13 @@ class CardDetailActivity : AppCompatActivity() {
val id = cardId val id = cardId
val fakeFactory = testDataSourceFactory val fakeFactory = testDataSourceFactory
if (fakeFactory != null) { if (fakeFactory != null) {
CardDetailViewModel.Factory(id, fakeFactory.invoke(id)) CardDetailViewModel.Factory(
cardId = id,
dataSource = fakeFactory.invoke(id),
titleRequiredMessage = getString(R.string.card_title_required),
commentRequiredMessage = getString(R.string.card_detail_comment_required),
commentAddedMessage = getString(R.string.card_detail_comment_added),
)
} else { } else {
val repository = CardDetailRepository( val repository = CardDetailRepository(
sessionStore = sessionStore, sessionStore = sessionStore,
@@ -107,6 +113,9 @@ class CardDetailActivity : AppCompatActivity() {
CardDetailViewModel.Factory( CardDetailViewModel.Factory(
cardId = id, cardId = id,
dataSource = CardDetailRepositoryDataSource(repository, repository::loadCard), dataSource = CardDetailRepositoryDataSource(repository, repository::loadCard),
titleRequiredMessage = getString(R.string.card_title_required),
commentRequiredMessage = getString(R.string.card_detail_comment_required),
commentAddedMessage = getString(R.string.card_detail_comment_added),
) )
} }
} }

View File

@@ -58,7 +58,7 @@ interface CardDetailDataSource {
suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit> suspend fun updateDescription(cardId: String, description: String): DataSourceResult<Unit>
suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit> suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): DataSourceResult<Unit>
suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>> suspend fun listActivities(cardId: String): DataSourceResult<List<CardActivity>>
suspend fun addComment(cardId: String, comment: String): DataSourceResult<Unit> suspend fun addComment(cardId: String, comment: String): DataSourceResult<List<CardActivity>>
} }
internal class CardDetailRepositoryDataSource( internal class CardDetailRepositoryDataSource(
@@ -85,10 +85,10 @@ internal class CardDetailRepositoryDataSource(
return repository.listActivities(cardId).toDataSourceResult() return repository.listActivities(cardId).toDataSourceResult()
} }
override suspend fun addComment(cardId: String, comment: String): DataSourceResult<Unit> { override suspend fun addComment(cardId: String, comment: String): DataSourceResult<List<CardActivity>> {
val result = repository.addComment(cardId, comment) val result = repository.addComment(cardId, comment)
return when (result) { return when (result) {
is CardDetailRepository.Result.Success -> DataSourceResult.Success(Unit) is CardDetailRepository.Result.Success -> DataSourceResult.Success(result.value)
is CardDetailRepository.Result.Failure.SessionExpired -> DataSourceResult.SessionExpired is CardDetailRepository.Result.Failure.SessionExpired -> DataSourceResult.SessionExpired
is CardDetailRepository.Result.Failure.Generic -> { is CardDetailRepository.Result.Failure.Generic -> {
DataSourceResult.GenericError(result.message) DataSourceResult.GenericError(result.message)
@@ -101,6 +101,9 @@ class CardDetailViewModel(
private val cardId: String, private val cardId: String,
private val repository: CardDetailDataSource, private val repository: CardDetailDataSource,
private val descriptionDebounceMillis: Long = 800L, private val descriptionDebounceMillis: Long = 800L,
private val titleRequiredMessage: String = "Card title is required",
private val commentRequiredMessage: String = "Comment is required",
private val commentAddedMessage: String = "Comment added",
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(CardDetailUiState()) private val _uiState = MutableStateFlow(CardDetailUiState())
val uiState: StateFlow<CardDetailUiState> = _uiState.asStateFlow() val uiState: StateFlow<CardDetailUiState> = _uiState.asStateFlow()
@@ -284,7 +287,7 @@ class CardDetailViewModel(
val payload = _uiState.value.commentDraft.trim() val payload = _uiState.value.commentDraft.trim()
if (payload.isBlank()) { if (payload.isBlank()) {
_uiState.update { it.copy(commentErrorMessage = "Comment is required") } _uiState.update { it.copy(commentErrorMessage = commentRequiredMessage) }
return return
} }
@@ -299,10 +302,12 @@ class CardDetailViewModel(
isCommentDialogOpen = false, isCommentDialogOpen = false,
commentDraft = "", commentDraft = "",
commentDialogMode = CommentDialogMode.EDIT, commentDialogMode = CommentDialogMode.EDIT,
activities = result.value,
activitiesErrorMessage = null,
isActivitiesLoading = false,
) )
} }
_events.emit(CardDetailUiEvent.ShowSnackbar("Comment added")) _events.emit(CardDetailUiEvent.ShowSnackbar(commentAddedMessage))
loadActivities()
} }
is DataSourceResult.SessionExpired -> { is DataSourceResult.SessionExpired -> {
@@ -342,7 +347,7 @@ class CardDetailViewModel(
} }
val normalized = rawTitle.trim() val normalized = rawTitle.trim()
if (normalized.isBlank()) { if (normalized.isBlank()) {
_uiState.update { it.copy(titleErrorMessage = "Card title is required") } _uiState.update { it.copy(titleErrorMessage = titleRequiredMessage) }
return return
} }
if (!fromRetry && normalized == persistedTitle) { if (!fromRetry && normalized == persistedTitle) {
@@ -567,11 +572,20 @@ class CardDetailViewModel(
class Factory( class Factory(
private val cardId: String, private val cardId: String,
private val dataSource: CardDetailDataSource, private val dataSource: CardDetailDataSource,
private val titleRequiredMessage: String = "Card title is required",
private val commentRequiredMessage: String = "Comment is required",
private val commentAddedMessage: String = "Comment added",
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(CardDetailViewModel::class.java)) { if (modelClass.isAssignableFrom(CardDetailViewModel::class.java)) {
return CardDetailViewModel(cardId = cardId, repository = dataSource) as T return CardDetailViewModel(
cardId = cardId,
repository = dataSource,
titleRequiredMessage = titleRequiredMessage,
commentRequiredMessage = commentRequiredMessage,
commentAddedMessage = commentAddedMessage,
) as T
} }
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
} }

View File

@@ -85,4 +85,6 @@
<string name="card_detail_timeline_action_commented">commented</string> <string name="card_detail_timeline_action_commented">commented</string>
<string name="card_detail_timeline_action_updated">updated this card</string> <string name="card_detail_timeline_action_updated">updated this card</string>
<string name="add">Add</string> <string name="add">Add</string>
<string name="card_detail_comment_required">Comment is required</string>
<string name="card_detail_comment_added">Comment added</string>
</resources> </resources>

View File

@@ -227,10 +227,9 @@ class CardDetailViewModelTest {
listActivitiesResults = ArrayDeque( listActivitiesResults = ArrayDeque(
listOf( listOf(
DataSourceResult.Success(emptyList()), DataSourceResult.Success(emptyList()),
DataSourceResult.Success(sampleActivitiesShuffled()),
), ),
), ),
addCommentResult = DataSourceResult.Success(Unit), addCommentResult = DataSourceResult.Success(sampleActivitiesShuffled()),
) )
val viewModel = loadedViewModel(this, repository) val viewModel = loadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first { it is CardDetailUiEvent.ShowSnackbar } } val eventDeferred = async { viewModel.events.first { it is CardDetailUiEvent.ShowSnackbar } }
@@ -243,7 +242,7 @@ class CardDetailViewModelTest {
assertFalse(viewModel.uiState.value.isCommentDialogOpen) assertFalse(viewModel.uiState.value.isCommentDialogOpen)
assertEquals("", viewModel.uiState.value.commentDraft) assertEquals("", viewModel.uiState.value.commentDraft)
assertEquals(1, repository.addCommentCalls) assertEquals(1, repository.addCommentCalls)
assertEquals(2, repository.listActivitiesCalls) assertEquals(1, repository.listActivitiesCalls)
assertEquals(listOf("a-mid", "a-new", "a-old"), viewModel.uiState.value.activities.map { it.id }) assertEquals(listOf("a-mid", "a-new", "a-old"), viewModel.uiState.value.activities.map { it.id })
assertTrue(eventDeferred.await() is CardDetailUiEvent.ShowSnackbar) assertTrue(eventDeferred.await() is CardDetailUiEvent.ShowSnackbar)
} }
@@ -365,7 +364,7 @@ class CardDetailViewModelTest {
var updateTitleResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit), var updateTitleResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit),
var updateDescriptionResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit), var updateDescriptionResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit),
var updateDueDateResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit), var updateDueDateResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit),
var addCommentResult: DataSourceResult<Unit> = DataSourceResult.Success(Unit), var addCommentResult: DataSourceResult<List<CardActivity>> = DataSourceResult.Success(emptyList()),
var updateDescriptionGate: CompletableDeferred<Unit>? = null, var updateDescriptionGate: CompletableDeferred<Unit>? = null,
var updateDescriptionGates: ArrayDeque<CompletableDeferred<Unit>> = ArrayDeque(), var updateDescriptionGates: ArrayDeque<CompletableDeferred<Unit>> = ArrayDeque(),
) : CardDetailDataSource { ) : CardDetailDataSource {
@@ -416,7 +415,7 @@ class CardDetailViewModelTest {
return listActivitiesResult return listActivitiesResult
} }
override suspend fun addComment(cardId: String, comment: String): DataSourceResult<Unit> { override suspend fun addComment(cardId: String, comment: String): DataSourceResult<List<CardActivity>> {
addCommentCalls += 1 addCommentCalls += 1
addCommentPayloads += comment addCommentPayloads += comment
return addCommentResult return addCommentResult