feat: add card detail viewmodel live-save and debounce state
This commit is contained in:
@@ -24,3 +24,13 @@ data class CardActivity(
|
|||||||
val text: String,
|
val text: String,
|
||||||
val createdAtEpochMillis: Long,
|
val createdAtEpochMillis: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class DescriptionMode {
|
||||||
|
EDIT,
|
||||||
|
PREVIEW,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class CommentDialogMode {
|
||||||
|
EDIT,
|
||||||
|
PREVIEW,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user