From aa987c9e0090dc041b40c13d1b47d73d94c80d5a Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Mon, 16 Mar 2026 20:52:04 -0400 Subject: [PATCH] feat: add card detail viewmodel live-save and debounce state --- .../app/carddetail/CardDetailModels.kt | 10 + .../app/carddetail/CardDetailViewModel.kt | 513 ++++++++++++++++++ .../app/carddetail/CardDetailViewModelTest.kt | 407 ++++++++++++++ 3 files changed, 930 insertions(+) create mode 100644 app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModel.kt create mode 100644 app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModelTest.kt diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailModels.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailModels.kt index 790fb8d..fd3463e 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailModels.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailModels.kt @@ -24,3 +24,13 @@ data class CardActivity( val text: String, val createdAtEpochMillis: Long, ) + +enum class DescriptionMode { + EDIT, + PREVIEW, +} + +enum class CommentDialogMode { + EDIT, + PREVIEW, +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModel.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModel.kt new file mode 100644 index 0000000..816533a --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModel.kt @@ -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 = emptyList(), + val activities: List = 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 + suspend fun updateTitle(cardId: String, title: String): CardDetailRepository.Result + suspend fun updateDescription(cardId: String, description: String): CardDetailRepository.Result + suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): CardDetailRepository.Result + suspend fun listActivities(cardId: String): CardDetailRepository.Result> + suspend fun addComment(cardId: String, comment: String): CardDetailRepository.Result +} + +class CardDetailViewModel( + private val cardId: String, + private val repository: CardDetailDataSource, + private val descriptionDebounceMillis: Long = 800L, +) : ViewModel() { + private val _uiState = MutableStateFlow(CardDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _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, + ) + } + } + } + } + } +} diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModelTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModelTest.kt new file mode 100644 index 0000000..6b3bfd5 --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/carddetail/CardDetailViewModelTest.kt @@ -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() + 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 = CardDetailRepository.Result.Success(sampleCardDetail()), + var listActivitiesResult: CardDetailRepository.Result> = CardDetailRepository.Result.Success(emptyList()), + var listActivitiesResults: ArrayDeque>> = ArrayDeque(), + var updateTitleResult: CardDetailRepository.Result = CardDetailRepository.Result.Success(Unit), + var updateDescriptionResult: CardDetailRepository.Result = CardDetailRepository.Result.Success(Unit), + var updateDueDateResult: CardDetailRepository.Result = CardDetailRepository.Result.Success(Unit), + var addCommentResult: CardDetailRepository.Result = CardDetailRepository.Result.Success(Unit), + var updateDescriptionGate: CompletableDeferred? = 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 = mutableListOf() + val updateDescriptionPayloads: MutableList = mutableListOf() + val updateDueDatePayloads: MutableList = mutableListOf() + val addCommentPayloads: MutableList = mutableListOf() + + override suspend fun loadCard(cardId: String): CardDetailRepository.Result { + loadCalls += 1 + return loadCardResult + } + + override suspend fun updateTitle(cardId: String, title: String): CardDetailRepository.Result { + updateTitleCalls += 1 + updateTitlePayloads += title + return updateTitleResult + } + + override suspend fun updateDescription(cardId: String, description: String): CardDetailRepository.Result { + updateDescriptionCalls += 1 + updateDescriptionPayloads += description + updateDescriptionGate?.await() + return updateDescriptionResult + } + + override suspend fun updateDueDate(cardId: String, dueDate: LocalDate?): CardDetailRepository.Result { + updateDueDateCalls += 1 + updateDueDatePayloads += dueDate + return updateDueDateResult + } + + override suspend fun listActivities(cardId: String): CardDetailRepository.Result> { + listActivitiesCalls += 1 + if (listActivitiesResults.isNotEmpty()) { + return listActivitiesResults.removeFirst() + } + return listActivitiesResult + } + + override suspend fun addComment(cardId: String, comment: String): CardDetailRepository.Result { + 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 { + 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), + ) + } + } +}