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 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