feat: add card detail viewmodel live-save and debounce state

This commit is contained in:
2026-03-16 20:52:04 -04:00
parent 7132123ccf
commit aa987c9e00
3 changed files with 930 additions and 0 deletions

View File

@@ -24,3 +24,13 @@ data class CardActivity(
val text: String,
val createdAtEpochMillis: Long,
)
enum class DescriptionMode {
EDIT,
PREVIEW,
}
enum class CommentDialogMode {
EDIT,
PREVIEW,
}

View File

@@ -0,0 +1,513 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.time.LocalDate
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class CardDetailUiState(
val isInitialLoading: Boolean = false,
val loadErrorMessage: String? = null,
val isSessionExpired: Boolean = false,
val title: String = "",
val titleErrorMessage: String? = null,
val isTitleSaving: Boolean = false,
val description: String = "",
val descriptionErrorMessage: String? = null,
val isDescriptionSaving: Boolean = false,
val isDescriptionDirty: Boolean = false,
val descriptionMode: DescriptionMode = DescriptionMode.EDIT,
val dueDate: LocalDate? = null,
val dueDateErrorMessage: String? = null,
val isDueDateSaving: Boolean = false,
val tags: List<CardDetailTag> = emptyList(),
val activities: List<CardActivity> = emptyList(),
val isActivitiesLoading: Boolean = false,
val activitiesErrorMessage: String? = null,
val isCommentDialogOpen: Boolean = false,
val commentDialogMode: CommentDialogMode = CommentDialogMode.EDIT,
val commentDraft: String = "",
val isCommentSubmitting: Boolean = false,
val commentErrorMessage: String? = null,
)
sealed interface CardDetailUiEvent {
data object SessionExpired : CardDetailUiEvent
data class ShowSnackbar(val message: String) : CardDetailUiEvent
}
interface CardDetailDataSource {
suspend fun loadCard(cardId: String): CardDetailRepository.Result<CardDetail>
suspend fun updateTitle(cardId: String, title: String): CardDetailRepository.Result<Unit>
suspend fun updateDescription(cardId: String, description: String): CardDetailRepository.Result<Unit>
suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): CardDetailRepository.Result<Unit>
suspend fun listActivities(cardId: String): CardDetailRepository.Result<List<CardActivity>>
suspend fun addComment(cardId: String, comment: String): CardDetailRepository.Result<Unit>
}
class CardDetailViewModel(
private val cardId: String,
private val repository: CardDetailDataSource,
private val descriptionDebounceMillis: Long = 800L,
) : ViewModel() {
private val _uiState = MutableStateFlow(CardDetailUiState())
val uiState: StateFlow<CardDetailUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<CardDetailUiEvent>()
val events: SharedFlow<CardDetailUiEvent> = _events.asSharedFlow()
private var persistedTitle: String = ""
private var persistedDescription: String = ""
private var persistedDueDate: LocalDate? = null
private var lastAttemptedTitle: String? = null
private var lastAttemptedDescription: String? = null
private var hasLastAttemptedDueDate: Boolean = false
private var lastAttemptedDueDate: LocalDate? = null
private var lastAttemptedComment: String? = null
private var inFlightDescriptionPayload: String? = null
private var descriptionDebounceJob: Job? = null
fun load() {
if (_uiState.value.isSessionExpired) {
return
}
viewModelScope.launch {
_uiState.update {
it.copy(
isInitialLoading = true,
loadErrorMessage = null,
)
}
when (val result = repository.loadCard(cardId)) {
is CardDetailRepository.Result.Success -> {
val detail = result.value
persistedTitle = detail.title
persistedDescription = detail.description
persistedDueDate = detail.dueDate
_uiState.update {
it.copy(
isInitialLoading = false,
loadErrorMessage = null,
title = detail.title,
description = detail.description,
dueDate = detail.dueDate,
tags = detail.tags,
titleErrorMessage = null,
descriptionErrorMessage = null,
dueDateErrorMessage = null,
)
}
loadActivities()
}
is CardDetailRepository.Result.Failure.SessionExpired -> {
_uiState.update {
it.copy(
isInitialLoading = false,
isSessionExpired = true,
)
}
_events.emit(CardDetailUiEvent.SessionExpired)
}
is CardDetailRepository.Result.Failure.Generic -> {
_uiState.update {
it.copy(
isInitialLoading = false,
loadErrorMessage = result.message,
)
}
}
}
}
}
fun retryLoad() {
load()
}
fun onTitleChanged(value: String) {
_uiState.update { it.copy(title = value, titleErrorMessage = null) }
}
fun onTitleFocusLost() {
saveTitle(_uiState.value.title)
}
fun retryTitleSave() {
val payload = lastAttemptedTitle ?: return
saveTitle(payload, fromRetry = true)
}
fun onDescriptionChanged(value: String) {
if (_uiState.value.isSessionExpired) {
return
}
_uiState.update {
it.copy(
description = value,
descriptionErrorMessage = null,
isDescriptionDirty = value != persistedDescription,
)
}
scheduleDescriptionSave()
}
fun onDescriptionFocusLost() {
flushDescriptionImmediately()
}
fun onStop() {
flushDescriptionImmediately()
}
fun retryDescriptionSave() {
val payload = lastAttemptedDescription ?: return
saveDescription(payload, fromRetry = true)
}
fun setDescriptionMode(mode: DescriptionMode) {
_uiState.update { it.copy(descriptionMode = mode) }
}
fun setDueDate(dueDate: LocalDate) {
saveDueDate(dueDate)
}
fun clearDueDate() {
saveDueDate(null)
}
fun retryDueDateSave() {
if (!hasLastAttemptedDueDate) {
return
}
saveDueDate(lastAttemptedDueDate, fromRetry = true)
}
fun retryActivities() {
loadActivities()
}
fun openCommentDialog() {
_uiState.update {
it.copy(
isCommentDialogOpen = true,
commentDialogMode = CommentDialogMode.EDIT,
commentErrorMessage = null,
)
}
}
fun closeCommentDialog() {
_uiState.update {
it.copy(
isCommentDialogOpen = false,
commentDraft = "",
commentDialogMode = CommentDialogMode.EDIT,
commentErrorMessage = null,
isCommentSubmitting = false,
)
}
}
fun onCommentChanged(value: String) {
_uiState.update { it.copy(commentDraft = value, commentErrorMessage = null) }
}
fun setCommentDialogMode(mode: CommentDialogMode) {
_uiState.update { it.copy(commentDialogMode = mode) }
}
fun submitComment() {
if (_uiState.value.isSessionExpired || _uiState.value.isCommentSubmitting) {
return
}
val payload = _uiState.value.commentDraft.trim()
if (payload.isBlank()) {
_uiState.update { it.copy(commentErrorMessage = "Comment is required") }
return
}
lastAttemptedComment = payload
viewModelScope.launch {
_uiState.update { it.copy(isCommentSubmitting = true, commentErrorMessage = null) }
when (val result = repository.addComment(cardId, payload)) {
is CardDetailRepository.Result.Success -> {
_uiState.update {
it.copy(
isCommentSubmitting = false,
isCommentDialogOpen = false,
commentDraft = "",
commentDialogMode = CommentDialogMode.EDIT,
)
}
_events.emit(CardDetailUiEvent.ShowSnackbar("Comment added"))
loadActivities()
}
is CardDetailRepository.Result.Failure.SessionExpired -> {
_uiState.update {
it.copy(
isCommentSubmitting = false,
isSessionExpired = true,
)
}
_events.emit(CardDetailUiEvent.SessionExpired)
}
is CardDetailRepository.Result.Failure.Generic -> {
_uiState.update {
it.copy(
isCommentSubmitting = false,
commentErrorMessage = result.message,
)
}
}
}
}
}
fun retryAddComment() {
if (_uiState.value.isSessionExpired) {
return
}
val payload = lastAttemptedComment ?: return
_uiState.update { it.copy(commentDraft = payload) }
submitComment()
}
private fun saveTitle(rawTitle: String, fromRetry: Boolean = false) {
if (_uiState.value.isSessionExpired || _uiState.value.isTitleSaving) {
return
}
val normalized = rawTitle.trim()
if (normalized.isBlank()) {
_uiState.update { it.copy(titleErrorMessage = "Card title is required") }
return
}
if (!fromRetry && normalized == persistedTitle) {
return
}
lastAttemptedTitle = normalized
viewModelScope.launch {
_uiState.update { it.copy(isTitleSaving = true, titleErrorMessage = null) }
when (val result = repository.updateTitle(cardId, normalized)) {
is CardDetailRepository.Result.Success -> {
persistedTitle = normalized
_uiState.update {
it.copy(
title = normalized,
isTitleSaving = false,
titleErrorMessage = null,
)
}
}
is CardDetailRepository.Result.Failure.SessionExpired -> {
_uiState.update {
it.copy(
isTitleSaving = false,
isSessionExpired = true,
)
}
_events.emit(CardDetailUiEvent.SessionExpired)
}
is CardDetailRepository.Result.Failure.Generic -> {
_uiState.update {
it.copy(
isTitleSaving = false,
titleErrorMessage = result.message,
)
}
}
}
}
}
private fun scheduleDescriptionSave() {
if (_uiState.value.isSessionExpired) {
return
}
descriptionDebounceJob?.cancel()
descriptionDebounceJob = viewModelScope.launch {
delay(descriptionDebounceMillis)
saveDescription(_uiState.value.description)
}
}
private fun flushDescriptionImmediately() {
if (_uiState.value.isSessionExpired || !_uiState.value.isDescriptionDirty) {
return
}
descriptionDebounceJob?.cancel()
saveDescription(_uiState.value.description)
}
private fun saveDescription(rawDescription: String, fromRetry: Boolean = false) {
if (_uiState.value.isSessionExpired) {
return
}
val normalized = rawDescription
if (!fromRetry && normalized == persistedDescription) {
_uiState.update { it.copy(isDescriptionDirty = false) }
return
}
if (!fromRetry && normalized == inFlightDescriptionPayload) {
return
}
if (_uiState.value.isDescriptionSaving) {
return
}
lastAttemptedDescription = normalized
viewModelScope.launch {
inFlightDescriptionPayload = normalized
_uiState.update { it.copy(isDescriptionSaving = true, descriptionErrorMessage = null) }
when (val result = repository.updateDescription(cardId, normalized)) {
is CardDetailRepository.Result.Success -> {
persistedDescription = normalized
_uiState.update {
it.copy(
description = normalized,
isDescriptionSaving = false,
isDescriptionDirty = false,
descriptionErrorMessage = null,
)
}
}
is CardDetailRepository.Result.Failure.SessionExpired -> {
_uiState.update {
it.copy(
isDescriptionSaving = false,
isSessionExpired = true,
)
}
_events.emit(CardDetailUiEvent.SessionExpired)
}
is CardDetailRepository.Result.Failure.Generic -> {
_uiState.update {
it.copy(
isDescriptionSaving = false,
isDescriptionDirty = true,
descriptionErrorMessage = result.message,
)
}
}
}
inFlightDescriptionPayload = null
}
}
private fun saveDueDate(dueDate: LocalDate?, fromRetry: Boolean = false) {
if (_uiState.value.isSessionExpired || _uiState.value.isDueDateSaving) {
return
}
if (!fromRetry && dueDate == persistedDueDate) {
_uiState.update { it.copy(dueDate = dueDate, dueDateErrorMessage = null) }
return
}
hasLastAttemptedDueDate = true
lastAttemptedDueDate = dueDate
viewModelScope.launch {
_uiState.update {
it.copy(
dueDate = dueDate,
isDueDateSaving = true,
dueDateErrorMessage = null,
)
}
when (val result = repository.updateDueDate(cardId, dueDate)) {
is CardDetailRepository.Result.Success -> {
persistedDueDate = dueDate
_uiState.update {
it.copy(
isDueDateSaving = false,
dueDateErrorMessage = null,
)
}
}
is CardDetailRepository.Result.Failure.SessionExpired -> {
_uiState.update {
it.copy(
isDueDateSaving = false,
isSessionExpired = true,
)
}
_events.emit(CardDetailUiEvent.SessionExpired)
}
is CardDetailRepository.Result.Failure.Generic -> {
_uiState.update {
it.copy(
isDueDateSaving = false,
dueDateErrorMessage = result.message,
)
}
}
}
}
}
private fun loadActivities() {
if (_uiState.value.isSessionExpired) {
return
}
viewModelScope.launch {
_uiState.update { it.copy(isActivitiesLoading = true, activitiesErrorMessage = null) }
when (val result = repository.listActivities(cardId)) {
is CardDetailRepository.Result.Success -> {
_uiState.update {
it.copy(
isActivitiesLoading = false,
activities = result.value.sortedByDescending { activity -> activity.createdAtEpochMillis },
activitiesErrorMessage = null,
)
}
}
is CardDetailRepository.Result.Failure.SessionExpired -> {
_uiState.update {
it.copy(
isActivitiesLoading = false,
isSessionExpired = true,
)
}
_events.emit(CardDetailUiEvent.SessionExpired)
}
is CardDetailRepository.Result.Failure.Generic -> {
_uiState.update {
it.copy(
isActivitiesLoading = false,
activitiesErrorMessage = result.message,
)
}
}
}
}
}
}

View File

@@ -0,0 +1,407 @@
package space.hackenslacker.kanbn4droid.app.carddetail
import java.time.LocalDate
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class CardDetailViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
kotlinx.coroutines.Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
kotlinx.coroutines.Dispatchers.resetMain()
}
@Test
fun initialLoad_fillsEditableFieldsAndActivities() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(sampleActivitiesShuffled()),
)
val viewModel = CardDetailViewModel(
cardId = "card-1",
repository = repository,
descriptionDebounceMillis = 800,
)
viewModel.load()
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals("Title", state.title)
assertEquals("Description", state.description)
assertEquals(LocalDate.of(2026, 3, 16), state.dueDate)
assertEquals(listOf("tag-1", "tag-2"), state.tags.map { it.id })
assertEquals(listOf("a-new", "a-mid", "a-old"), state.activities.map { it.id })
assertNull(state.loadErrorMessage)
}
@Test
fun loadSessionExpired_emitsEvent_andRetryPathsDoNotRun() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Failure.SessionExpired(),
)
val viewModel = CardDetailViewModel(cardId = "card-1", repository = repository)
val eventDeferred = async { viewModel.events.first() }
viewModel.load()
advanceUntilIdle()
val event = eventDeferred.await()
assertTrue(event is CardDetailUiEvent.SessionExpired)
assertTrue(viewModel.uiState.value.isSessionExpired)
viewModel.retryLoad()
viewModel.retryTitleSave()
viewModel.retryDescriptionSave()
viewModel.retryDueDateSave()
viewModel.retryActivities()
advanceUntilIdle()
assertEquals(1, repository.loadCalls)
assertEquals(0, repository.updateTitleCalls)
assertEquals(0, repository.updateDescriptionCalls)
assertEquals(0, repository.updateDueDateCalls)
assertEquals(0, repository.listActivitiesCalls)
}
@Test
fun titleTrimRejectsBlank_andIsolatedFromDescription() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
)
val viewModel = loadedViewModel(this, repository)
viewModel.onDescriptionChanged("pending description")
viewModel.onTitleChanged(" ")
viewModel.onTitleFocusLost()
runCurrent()
assertEquals(0, repository.updateTitleCalls)
assertEquals("Card title is required", viewModel.uiState.value.titleErrorMessage)
assertEquals("pending description", viewModel.uiState.value.description)
assertEquals(" ", viewModel.uiState.value.title)
}
@Test
fun dueDateSetAndClear_areIndependentAndSaved() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
)
val viewModel = loadedViewModel(this, repository)
val nextDate = LocalDate.of(2026, 4, 1)
viewModel.setDueDate(nextDate)
advanceUntilIdle()
viewModel.clearDueDate()
advanceUntilIdle()
assertEquals(listOf(nextDate, null), repository.updateDueDatePayloads)
assertNull(viewModel.uiState.value.dueDate)
assertEquals("Title", viewModel.uiState.value.title)
assertEquals("Description", viewModel.uiState.value.description)
}
@Test
fun descriptionDebounce_savesLatestOnly_andSuppressesDuplicateInflight() = runTest {
val gate = CompletableDeferred<Unit>()
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
updateDescriptionGate = gate,
)
val viewModel = loadedViewModel(this, repository)
viewModel.onDescriptionChanged("a")
advanceTimeBy(799)
runCurrent()
assertEquals(0, repository.updateDescriptionCalls)
viewModel.onDescriptionChanged("ab")
advanceTimeBy(400)
runCurrent()
viewModel.onDescriptionChanged("abc")
advanceTimeBy(800)
runCurrent()
assertEquals(1, repository.updateDescriptionCalls)
assertEquals(listOf("abc"), repository.updateDescriptionPayloads)
viewModel.onDescriptionChanged("abc")
viewModel.onDescriptionFocusLost()
runCurrent()
assertEquals(1, repository.updateDescriptionCalls)
gate.complete(Unit)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isDescriptionSaving)
}
@Test
fun descriptionFocusLossAndOnStop_flushLatestDirtyImmediately() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
)
val viewModel = loadedViewModel(this, repository)
viewModel.onDescriptionChanged("first")
viewModel.onDescriptionFocusLost()
advanceUntilIdle()
viewModel.onDescriptionChanged("second")
viewModel.onStop()
advanceUntilIdle()
assertEquals(listOf("first", "second"), repository.updateDescriptionPayloads)
assertFalse(viewModel.uiState.value.isDescriptionDirty)
}
@Test
fun addComment_success_closesDialog_showsSnackbar_refreshesActivities() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResults = ArrayDeque(
listOf(
CardDetailRepository.Result.Success(emptyList()),
CardDetailRepository.Result.Success(sampleActivitiesShuffled()),
),
),
addCommentResult = CardDetailRepository.Result.Success(Unit),
)
val viewModel = loadedViewModel(this, repository)
val eventDeferred = async { viewModel.events.first { it is CardDetailUiEvent.ShowSnackbar } }
viewModel.openCommentDialog()
viewModel.onCommentChanged("hello")
viewModel.submitComment()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isCommentDialogOpen)
assertEquals("", viewModel.uiState.value.commentDraft)
assertEquals(1, repository.addCommentCalls)
assertEquals(2, repository.listActivitiesCalls)
assertEquals(listOf("a-new", "a-mid", "a-old"), viewModel.uiState.value.activities.map { it.id })
assertTrue(eventDeferred.await() is CardDetailUiEvent.ShowSnackbar)
}
@Test
fun addComment_failure_keepsDialogOpen_andStoresRetryPayload() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
addCommentResult = CardDetailRepository.Result.Failure.Generic("Cannot add comment"),
)
val viewModel = loadedViewModel(this, repository)
viewModel.openCommentDialog()
viewModel.onCommentChanged("hello")
viewModel.submitComment()
advanceUntilIdle()
viewModel.retryAddComment()
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isCommentDialogOpen)
assertEquals("Cannot add comment", viewModel.uiState.value.commentErrorMessage)
assertEquals(2, repository.addCommentCalls)
assertEquals(listOf("hello", "hello"), repository.addCommentPayloads)
}
@Test
fun activitiesRetry_recoversFromFailure() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResults = ArrayDeque(
listOf(
CardDetailRepository.Result.Failure.Generic("Network error"),
CardDetailRepository.Result.Success(sampleActivitiesShuffled()),
),
),
)
val viewModel = loadedViewModel(this, repository)
assertEquals("Network error", viewModel.uiState.value.activitiesErrorMessage)
viewModel.retryActivities()
advanceUntilIdle()
assertNull(viewModel.uiState.value.activitiesErrorMessage)
assertEquals(listOf("a-new", "a-mid", "a-old"), viewModel.uiState.value.activities.map { it.id })
}
@Test
fun descriptionAndCommentModes_toggleBetweenEditAndPreview() = runTest {
val viewModel = loadedViewModel(
this,
FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
),
)
viewModel.setDescriptionMode(DescriptionMode.PREVIEW)
viewModel.openCommentDialog()
viewModel.setCommentDialogMode(CommentDialogMode.PREVIEW)
assertEquals(DescriptionMode.PREVIEW, viewModel.uiState.value.descriptionMode)
assertEquals(CommentDialogMode.PREVIEW, viewModel.uiState.value.commentDialogMode)
}
@Test
fun fieldSpecificRetry_usesLastAttemptedPayload() = runTest {
val repository = FakeCardDetailDataSource(
loadCardResult = CardDetailRepository.Result.Success(sampleCardDetail()),
listActivitiesResult = CardDetailRepository.Result.Success(emptyList()),
updateTitleResult = CardDetailRepository.Result.Failure.Generic("title failed"),
updateDueDateResult = CardDetailRepository.Result.Failure.Generic("due failed"),
updateDescriptionResult = CardDetailRepository.Result.Failure.Generic("desc failed"),
)
val viewModel = loadedViewModel(this, repository)
viewModel.onTitleChanged("attempt title")
viewModel.onTitleFocusLost()
viewModel.onTitleChanged("different title")
viewModel.retryTitleSave()
val dueAttempt = LocalDate.of(2026, 6, 1)
viewModel.setDueDate(dueAttempt)
viewModel.setDueDate(LocalDate.of(2026, 7, 1))
viewModel.retryDueDateSave()
advanceUntilIdle()
viewModel.onDescriptionChanged("attempt desc")
advanceTimeBy(800)
runCurrent()
viewModel.onDescriptionChanged("different desc")
viewModel.retryDescriptionSave()
runCurrent()
assertEquals(listOf("attempt title", "attempt title"), repository.updateTitlePayloads)
assertEquals(listOf(dueAttempt, LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 1)), repository.updateDueDatePayloads)
assertEquals(listOf("attempt desc", "attempt desc"), repository.updateDescriptionPayloads)
}
private fun loadedViewModel(
scope: kotlinx.coroutines.test.TestScope,
repository: FakeCardDetailDataSource,
): CardDetailViewModel {
return CardDetailViewModel(
cardId = "card-1",
repository = repository,
descriptionDebounceMillis = 800,
).also {
it.load()
scope.advanceUntilIdle()
}
}
private class FakeCardDetailDataSource(
var loadCardResult: CardDetailRepository.Result<CardDetail> = CardDetailRepository.Result.Success(sampleCardDetail()),
var listActivitiesResult: CardDetailRepository.Result<List<CardActivity>> = CardDetailRepository.Result.Success(emptyList()),
var listActivitiesResults: ArrayDeque<CardDetailRepository.Result<List<CardActivity>>> = ArrayDeque(),
var updateTitleResult: CardDetailRepository.Result<Unit> = CardDetailRepository.Result.Success(Unit),
var updateDescriptionResult: CardDetailRepository.Result<Unit> = CardDetailRepository.Result.Success(Unit),
var updateDueDateResult: CardDetailRepository.Result<Unit> = CardDetailRepository.Result.Success(Unit),
var addCommentResult: CardDetailRepository.Result<Unit> = CardDetailRepository.Result.Success(Unit),
var updateDescriptionGate: CompletableDeferred<Unit>? = null,
) : CardDetailDataSource {
var loadCalls: Int = 0
var updateTitleCalls: Int = 0
var updateDescriptionCalls: Int = 0
var updateDueDateCalls: Int = 0
var listActivitiesCalls: Int = 0
var addCommentCalls: Int = 0
val updateTitlePayloads: MutableList<String> = mutableListOf()
val updateDescriptionPayloads: MutableList<String?> = mutableListOf()
val updateDueDatePayloads: MutableList<LocalDate?> = mutableListOf()
val addCommentPayloads: MutableList<String> = mutableListOf()
override suspend fun loadCard(cardId: String): CardDetailRepository.Result<CardDetail> {
loadCalls += 1
return loadCardResult
}
override suspend fun updateTitle(cardId: String, title: String): CardDetailRepository.Result<Unit> {
updateTitleCalls += 1
updateTitlePayloads += title
return updateTitleResult
}
override suspend fun updateDescription(cardId: String, description: String): CardDetailRepository.Result<Unit> {
updateDescriptionCalls += 1
updateDescriptionPayloads += description
updateDescriptionGate?.await()
return updateDescriptionResult
}
override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): CardDetailRepository.Result<Unit> {
updateDueDateCalls += 1
updateDueDatePayloads += dueDate
return updateDueDateResult
}
override suspend fun listActivities(cardId: String): CardDetailRepository.Result<List<CardActivity>> {
listActivitiesCalls += 1
if (listActivitiesResults.isNotEmpty()) {
return listActivitiesResults.removeFirst()
}
return listActivitiesResult
}
override suspend fun addComment(cardId: String, comment: String): CardDetailRepository.Result<Unit> {
addCommentCalls += 1
addCommentPayloads += comment
return addCommentResult
}
}
private companion object {
fun sampleCardDetail(): CardDetail {
return CardDetail(
id = "card-1",
title = "Title",
description = "Description",
dueDate = LocalDate.of(2026, 3, 16),
listPublicId = "list-1",
index = 0,
tags = listOf(
CardDetailTag(id = "tag-1", name = "Tag 1", colorHex = "#111111"),
CardDetailTag(id = "tag-2", name = "Tag 2", colorHex = "#222222"),
),
)
}
fun sampleActivitiesShuffled(): List<CardActivity> {
return listOf(
CardActivity(id = "a-mid", type = "comment", text = "mid", createdAtEpochMillis = 2L),
CardActivity(id = "a-old", type = "comment", text = "old", createdAtEpochMillis = 1L),
CardActivity(id = "a-new", type = "comment", text = "new", createdAtEpochMillis = 3L),
)
}
}
}