Add remaining ViewModels Unit Tests

Update packages
Make Clock a dependency in TaskItemViewModel
This commit is contained in:
Geoffroy Bonneville
2025-11-07 16:59:28 -05:00
parent fc3672b17b
commit c47ce57c31
17 changed files with 1365 additions and 64 deletions

View File

@@ -3,15 +3,11 @@ package com.wismna.geoffroy.donext.data.local.dao
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.data.local.AppDatabase
import com.wismna.geoffroy.donext.domain.model.Priority
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertNull
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
@@ -19,6 +15,7 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
import kotlin.collections.first
@RunWith(AndroidJUnit4::class)
class TaskDaoTest {
@@ -61,9 +58,9 @@ class TaskDaoTest {
val inserted = taskDao.getTasksForList(listId).first().first()
val fetched = taskDao.getTaskById(inserted.id)
assertNotNull(fetched)
assertEquals("Do laundry", fetched!!.name)
assertEquals(listId, fetched.taskListId)
assertThat(fetched).isNotNull()
assertThat(fetched!!.name).isEqualTo("Do laundry")
assertThat(fetched.taskListId).isEqualTo(listId)
}
@Test
@@ -77,8 +74,8 @@ class TaskDaoTest {
taskDao.insertTask(done)
taskDao.insertTask(high)
val tasks = taskDao.getTasksForList(listId).first()
assertEquals(listOf("High", "Normal", "Done"), tasks.map { it.name })
val taskPriorities = taskDao.getTasksForList(listId).first().map { it.name }
assertThat(taskPriorities).containsExactly("High", "Normal", "Done").inOrder()
}
@Test
@@ -92,7 +89,7 @@ class TaskDaoTest {
taskDao.updateTask(updated)
val fetched = taskDao.getTaskById(inserted.id)
assertEquals("Updated", fetched!!.name)
assertThat(fetched!!.name).isEqualTo("Updated")
}
@Test
@@ -103,10 +100,10 @@ class TaskDaoTest {
val inserted = taskDao.getTasksForList(listId).first().first()
taskDao.toggleTaskDone(inserted.id, true)
assertTrue(taskDao.getTaskById(inserted.id)!!.isDone)
assertThat(taskDao.getTaskById(inserted.id)!!.isDone).isTrue()
taskDao.toggleTaskDone(inserted.id, false)
assertFalse(taskDao.getTaskById(inserted.id)!!.isDone)
assertThat(taskDao.getTaskById(inserted.id)!!.isDone).isFalse()
}
@Test
@@ -118,7 +115,7 @@ class TaskDaoTest {
taskDao.toggleTaskDeleted(inserted.id, true)
val deletedTask = taskDao.getTaskById(inserted.id)
assertTrue(deletedTask!!.isDeleted)
assertThat(deletedTask!!.isDeleted).isTrue()
}
@Test
@@ -132,11 +129,10 @@ class TaskDaoTest {
taskDao.toggleAllTasksFromListDeleted(listId, true)
val fetched = taskDao.getTasksForList(listId).first()
assertTrue(fetched.isEmpty()) // filtered by deleted = 0
assertThat(fetched).isEmpty()
// confirm soft deletion
val softDeleted = fetched.size < 2
assertTrue(softDeleted)
assertThat(fetched).hasSize(0)
}
@Test
@@ -147,7 +143,7 @@ class TaskDaoTest {
val inserted = taskDao.getTasksForList(listId).first().first()
taskDao.permanentDeleteTask(inserted.id)
assertNull(taskDao.getTaskById(inserted.id))
assertThat(taskDao.getTaskById(inserted.id)).isNull()
}
@Test
@@ -160,8 +156,8 @@ class TaskDaoTest {
taskDao.permanentDeleteAllDeletedTasks()
val remaining = taskDao.getTasksForList(listId).first()
assertEquals(1, remaining.size)
assertEquals("Active", remaining.first().name)
assertThat(remaining).hasSize(1)
assertThat(remaining.first().name).isEqualTo("Active")
}
@Test
@@ -171,9 +167,9 @@ class TaskDaoTest {
taskDao.insertTask(deleted)
val results = taskDao.getDeletedTasksWithListName().first()
assertEquals(1, results.size)
assertEquals("Trash", results.first().task.name)
assertEquals("Work", results.first().listName)
assertThat(results).hasSize(1)
assertThat(results.first().task.name).isEqualTo("Trash")
assertThat(results.first().listName).isEqualTo("Work")
}
@Test
@@ -209,7 +205,7 @@ class TaskDaoTest {
taskDao.insertTask(tomorrow)
val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd).first()
assertEquals(1, tasks.size)
assertEquals("Today", tasks.first().name)
assertThat(tasks).hasSize(1)
assertThat(tasks.first().name).isEqualTo("Today")
}
}

View File

@@ -3,13 +3,11 @@ package com.wismna.geoffroy.donext.data.local.dao
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.data.entities.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.data.local.AppDatabase
import com.wismna.geoffroy.donext.domain.model.Priority
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
@@ -17,6 +15,7 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
import kotlin.collections.first
@RunWith(AndroidJUnit4::class)
class TaskListDaoTest {
@@ -46,8 +45,8 @@ class TaskListDaoTest {
listDao.insertTaskList(taskList)
val lists = listDao.getTaskLists().first()
assertEquals(1, lists.size)
assertEquals("Personal", lists.first().name)
assertThat(lists).hasSize(1)
assertThat(lists.first().name).isEqualTo("Personal")
}
@Test
@@ -58,9 +57,9 @@ class TaskListDaoTest {
val inserted = listDao.getTaskLists().first().first()
val fetched = listDao.getTaskListById(inserted.id)
assertNotNull(fetched)
assertEquals("Groceries", fetched!!.name)
assertEquals(inserted.id, fetched.id)
assertThat(fetched).isNotNull()
assertThat(fetched!!.name).isEqualTo("Groceries")
assertThat(fetched.id).isEqualTo(inserted.id)
}
@Test
@@ -73,7 +72,7 @@ class TaskListDaoTest {
listDao.updateTaskList(updated)
val fetched = listDao.getTaskListById(inserted.id)
assertEquals("Updated Work", fetched!!.name)
assertThat(fetched!!.name).isEqualTo("Updated Work")
}
@Test
@@ -86,12 +85,12 @@ class TaskListDaoTest {
// getTaskLists() filters deleted = 0, so result should be empty
val activeLists = listDao.getTaskLists().first()
assertTrue(activeLists.isEmpty())
assertThat(activeLists).isEmpty()
// But the entity still exists in DB
val softDeleted = listDao.getTaskListById(inserted.id)
assertNotNull(softDeleted)
assertTrue(softDeleted!!.isDeleted)
assertThat(softDeleted).isNotNull()
assertThat(softDeleted!!.isDeleted).isTrue()
}
@Test
@@ -103,8 +102,8 @@ class TaskListDaoTest {
listDao.insertTaskList(second)
listDao.insertTaskList(third)
val lists = listDao.getTaskLists().first()
assertEquals(listOf("Alpha", "Beta", "Zeta"), lists.map { it.name })
val listNames = listDao.getTaskLists().first().map { it.name }
assertThat(listNames).containsExactly("Alpha", "Beta", "Zeta").inOrder()
}
@Test
@@ -152,7 +151,7 @@ class TaskListDaoTest {
val lists = listDao.getTaskListsWithOverdue(now)
assertEquals(1, lists.first().first().overdueCount)
assertThat(lists.first().first().overdueCount).isEqualTo(1)
}
@Test
@@ -222,7 +221,7 @@ class TaskListDaoTest {
val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd)
assertEquals(1, tasks.first().count())
assertEquals("Today", tasks.first().first().name)
assertThat(tasks.first()).hasSize(1)
assertThat(tasks.first().first().name).isEqualTo("Today")
}
}

View File

@@ -1,10 +1,11 @@
package com.wismna.geoffroy.donext.domain.extension
import java.time.Clock
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
fun Long.toLocalDate(): LocalDate =
fun Long.toLocalDate(clock: Clock = Clock.systemDefaultZone()): LocalDate =
Instant.ofEpochMilli(this)
.atZone(ZoneId.systemDefault())
.atZone(clock.zone)
.toLocalDate()

View File

@@ -283,7 +283,7 @@ fun AppContent(
is AppDestination.RecycleBin -> {
EmptyRecycleBinAction()
}
else -> null
else -> Unit
}
}
)
@@ -297,7 +297,7 @@ fun AppContent(
text = { Text(stringResource(R.string.action_create_list)) },
)
}
else -> null
else -> Unit
}
}
) { contentPadding ->

View File

@@ -3,6 +3,7 @@ package com.wismna.geoffroy.donext.presentation.viewmodel
import com.wismna.geoffroy.donext.domain.extension.toLocalDate
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -10,7 +11,9 @@ import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.Locale
class TaskItemViewModel(task: Task) {
class TaskItemViewModel(
task: Task,
private val clock: Clock = Clock.systemDefaultZone()) {
val id: Long = task.id!!
val name: String = task.name
val description: String? = task.description
@@ -18,17 +21,17 @@ class TaskItemViewModel(task: Task) {
val isDeleted: Boolean = task.isDeleted
val priority: Priority = task.priority
val today: LocalDate = LocalDate.now(ZoneId.systemDefault())
val today: LocalDate = LocalDate.now(clock)
val isOverdue: Boolean = task.dueDate?.let { millis ->
val dueDate = millis.toLocalDate()
val dueDate = millis.toLocalDate(clock)
dueDate.isBefore(today)
} ?: false
val dueDateText: String? = task.dueDate?.let { formatDueDate(it) }
private fun formatDueDate(dueMillis: Long): String {
val dueDate = dueMillis.toLocalDate()
val dueDate = dueMillis.toLocalDate(clock)
return when {
dueDate.isEqual(today) -> "Today"

View File

@@ -1,5 +1,6 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
@@ -9,7 +10,6 @@ import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import io.mockk.*
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -60,7 +60,7 @@ class DueTodayViewModelTest {
tasksFlow.emit(taskList)
advanceUntilIdle()
assertEquals(taskList, viewModel.dueTodayTasks)
assertThat(viewModel.dueTodayTasks).isEqualTo(taskList)
}
@Test

View File

@@ -0,0 +1,138 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.navigation.NavBackStackEntry
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val uiEventBus: UiEventBus = mockk(relaxUnitFun = true)
private lateinit var getTaskListsFlow: MutableSharedFlow<List<TaskList>>
private lateinit var getTaskListsUseCase: GetTaskListsUseCase
private lateinit var viewModel: MainViewModel
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
getTaskListsFlow = MutableSharedFlow()
getTaskListsUseCase = mockk {
every { this@mockk.invoke() } returns getTaskListsFlow
}
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `initially isLoading is true and destinations are empty`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
assertThat(viewModel.isLoading).isTrue()
assertThat(viewModel.destinations).isEmpty()
}
@Test
fun `when task lists are emitted they populate destinations and isLoading becomes false`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
advanceUntilIdle()
val lists = listOf(
TaskList(id = 1L, name = "Work", isDeleted = false, order = 0),
TaskList(id = 2L, name = "Personal", isDeleted = false, order = 1)
)
getTaskListsFlow.emit(lists)
advanceUntilIdle()
val expectedTaskDestinations = lists.map {
AppDestination.TaskList(it.id!!, it.name)
}
assertThat(viewModel.destinations).containsAtLeastElementsIn(expectedTaskDestinations)
assertThat(viewModel.destinations).containsAtLeast(
AppDestination.ManageLists,
AppDestination.RecycleBin,
AppDestination.DueTodayList
)
assertThat(viewModel.isLoading).isFalse()
}
@Test
fun `navigateBack sends UiEvent_NavigateBack`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
viewModel.navigateBack()
advanceUntilIdle()
coVerify { uiEventBus.send(UiEvent.NavigateBack) }
}
@Test
fun `onNewTaskButtonClicked sets showTaskSheet true and sends CreateNewTask`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
val taskListId = 42L
viewModel.onNewTaskButtonClicked(taskListId)
advanceUntilIdle()
assertThat(viewModel.showTaskSheet).isTrue()
coVerify { uiEventBus.send(UiEvent.CreateNewTask(taskListId)) }
}
@Test
fun `onDismissTaskSheet sets showTaskSheet false and clears sticky`() = runTest {
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
viewModel.showTaskSheet = true
viewModel.onDismissTaskSheet()
advanceUntilIdle()
assertThat(viewModel.showTaskSheet).isFalse()
coVerify { uiEventBus.send(UiEvent.CloseTask) }
coVerify { uiEventBus.clearSticky() }
}
@Test
fun `doesListExist returns true when taskListId is present`() = runTest {
val lists = listOf(TaskList(id = 1L, name = "Work", isDeleted = false, order = 0))
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
assertThat(viewModel.doesListExist(1L)).isTrue()
assertThat(viewModel.doesListExist(99L)).isFalse()
}
@Test
fun `setCurrentDestination sets currentDestination based on navBackStackEntry`() = runTest {
val lists = listOf(TaskList(id = 1L, name = "Work", isDeleted = false, order = 0))
viewModel = MainViewModel(getTaskListsUseCase, uiEventBus)
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
val entry = mockk<NavBackStackEntry> {
every { destination.route } returns AppDestination.TaskList(1L, "Work").route
every { arguments?.getLong("taskListId") } returns 1L
}
viewModel.setCurrentDestination(entry)
assertThat(viewModel.currentDestination).isEqualTo(AppDestination.TaskList(1L, "Work"))
}
}

View File

@@ -0,0 +1,162 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.TaskList
import com.wismna.geoffroy.donext.domain.usecase.AddTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.DeleteTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskListUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import com.wismna.geoffroy.donext.R
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ManageListsViewModelTest {
private lateinit var getTaskListsUseCase: GetTaskListsUseCase
private lateinit var addTaskListUseCase: AddTaskListUseCase
private lateinit var updateTaskListUseCase: UpdateTaskListUseCase
private lateinit var deleteTaskListUseCase: DeleteTaskListUseCase
private lateinit var uiEventBus: UiEventBus
private lateinit var getTaskListsFlow: MutableSharedFlow<List<TaskList>>
private lateinit var viewModel: ManageListsViewModel
@Before
fun setUp() {
MockKAnnotations.init(this, relaxed = true)
Dispatchers.setMain(StandardTestDispatcher())
getTaskListsUseCase = mockk()
addTaskListUseCase = mockk(relaxed = true)
updateTaskListUseCase = mockk(relaxed = true)
deleteTaskListUseCase = mockk(relaxed = true)
uiEventBus = mockk(relaxed = true)
getTaskListsFlow = MutableSharedFlow()
every { getTaskListsUseCase() } returns getTaskListsFlow
viewModel = ManageListsViewModel(
getTaskListsUseCase,
addTaskListUseCase,
updateTaskListUseCase,
deleteTaskListUseCase,
uiEventBus
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `initially has empty task list`() = runTest {
assertThat(viewModel.taskLists).isEmpty()
assertThat(viewModel.taskCount).isEqualTo(0)
}
@Test
fun `emitting lists updates taskLists and taskCount`() = runTest {
val lists = listOf(
TaskList(id = 1L, name = "Work", isDeleted = false, order = 0),
TaskList(id = 2L, name = "Home", isDeleted = false, order = 1)
)
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
assertThat(viewModel.taskLists).isEqualTo(lists)
assertThat(viewModel.taskCount).isEqualTo(2)
}
@Test
fun `createTaskList calls use case`() = runTest {
val title = "Groceries"
val order = 1
viewModel.createTaskList(title, order)
advanceUntilIdle()
coVerify { addTaskListUseCase(title, order) }
}
@Test
fun `updateTaskListName calls use case`() = runTest {
val taskList = TaskList(id = 1L, name = "Updated", isDeleted = false, order = 0)
viewModel.updateTaskListName(taskList)
advanceUntilIdle()
coVerify { updateTaskListUseCase(1L, "Updated", 0) }
}
@Test
fun `deleteTaskList calls use case and sends snackbar`() = runTest {
val taskListId = 10L
viewModel.deleteTaskList(taskListId)
advanceUntilIdle()
coVerify { deleteTaskListUseCase(taskListId, true) }
coVerify {
uiEventBus.send(
match {
it is UiEvent.ShowUndoSnackbar &&
it.message == R.string.snackbar_message_task_list_recycle
}
)
}
}
@Test
fun `moveTaskList reorders the task list correctly`() = runTest {
val lists = listOf(
TaskList(id = 1L, name = "A", isDeleted = false, order = 0),
TaskList(id = 2L, name = "B", isDeleted = false, order = 1),
TaskList(id = 3L, name = "C", isDeleted = false, order = 2)
)
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
viewModel.moveTaskList(fromIndex = 0, toIndex = 2)
assertThat(viewModel.taskLists.map { it.id }).isEqualTo(listOf(2L, 3L, 1L))
}
@Test
fun `commitTaskListOrder updates only reordered lists`() = runTest {
val lists = listOf(
TaskList(id = 1L, name = "A", isDeleted = false, order = 0),
TaskList(id = 2L, name = "B", isDeleted = false, order = 1),
TaskList(id = 3L, name = "C", isDeleted = false, order = 2)
)
advanceUntilIdle()
getTaskListsFlow.emit(lists)
advanceUntilIdle()
// Simulate reordering
viewModel.moveTaskList(fromIndex = 2, toIndex = 0)
viewModel.commitTaskListOrder()
advanceUntilIdle()
coVerify { updateTaskListUseCase(3L, "C", 0) }
coVerify { updateTaskListUseCase(1L, "A", 1) }
coVerify { updateTaskListUseCase(2L, "B", 2) }
}
}

View File

@@ -0,0 +1,152 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskListWithOverdue
import com.wismna.geoffroy.donext.domain.usecase.GetDueTodayTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsWithOverdueUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import io.mockk.MockKAnnotations
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MenuViewModelTest {
private lateinit var getTaskListsWithOverdueUseCase: GetTaskListsWithOverdueUseCase
private lateinit var getDueTodayTasksUseCase: GetDueTodayTasksUseCase
private lateinit var uiEventBus: UiEventBus
private lateinit var taskListsFlow: MutableSharedFlow<List<TaskListWithOverdue>>
private lateinit var dueTodayTasksFlow: MutableSharedFlow<List<Task>>
private lateinit var viewModel: MenuViewModel
@Before
fun setUp() {
MockKAnnotations.init(this, relaxed = true)
Dispatchers.setMain(StandardTestDispatcher())
getTaskListsWithOverdueUseCase = mockk()
getDueTodayTasksUseCase = mockk()
uiEventBus = mockk(relaxed = true)
taskListsFlow = MutableSharedFlow()
dueTodayTasksFlow = MutableSharedFlow()
every { getTaskListsWithOverdueUseCase() } returns taskListsFlow
every { getDueTodayTasksUseCase() } returns dueTodayTasksFlow
viewModel = MenuViewModel(
getTaskListsWithOverdueUseCase,
getDueTodayTasksUseCase,
uiEventBus
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
// --- TESTS ---
@Test
fun `initially has empty lists and zero due today`() = runTest {
assertThat(viewModel.taskLists).isEmpty()
assertThat(viewModel.dueTodayTasksCount).isEqualTo(0)
}
@Test
fun `emitting task lists updates taskLists`() = runTest {
val lists = listOf(
TaskListWithOverdue(id = 1L, name = "Work", overdueCount = 2),
TaskListWithOverdue(id = 2L, name = "Home", overdueCount = 0)
)
advanceUntilIdle()
taskListsFlow.emit(lists)
advanceUntilIdle()
assertThat(viewModel.taskLists).isEqualTo(lists)
}
@Test
fun `emitting due today tasks updates count`() = runTest {
val tasks = listOf(
Task(id = 1L, name = "Task A", taskListId = 1L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false),
Task(id = 2L, name = "Task B", taskListId = 1L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false)
)
advanceUntilIdle()
dueTodayTasksFlow.emit(tasks)
advanceUntilIdle()
assertThat(viewModel.dueTodayTasksCount).isEqualTo(2)
}
@Test
fun `navigateTo sends UiEvent when route is different`() = runTest {
val route = "tasks"
val currentRoute = "home"
viewModel.navigateTo(route, currentRoute)
advanceUntilIdle()
coVerify {
uiEventBus.send(
match {
it is UiEvent.Navigate && it.route == route
}
)
}
}
@Test
fun `navigateTo does nothing when route is the same`() = runTest {
val route = "tasks"
viewModel.navigateTo(route, route)
advanceUntilIdle()
coVerify(exactly = 0) { uiEventBus.send(any()) }
}
@Test
fun `emitting both task lists and due today tasks updates both states`() = runTest {
val lists = listOf(
TaskListWithOverdue(id = 1L, name = "Work", overdueCount = 3),
TaskListWithOverdue(id = 2L, name = "Personal", overdueCount = 1)
)
val tasks = listOf(
Task(id = 10L, name = "Buy groceries", taskListId = 2L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false),
Task(id = 11L, name = "Finish report", taskListId = 1L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false)
)
// Let the ViewModel collectors start
advanceUntilIdle()
// Emit from both flows (simulating data updates happening nearly simultaneously)
taskListsFlow.emit(lists)
dueTodayTasksFlow.emit(tasks)
advanceUntilIdle()
// Verify both internal states are updated independently and correctly
assertThat(viewModel.taskLists).isEqualTo(lists)
assertThat(viewModel.dueTodayTasksCount).isEqualTo(2)
}
}

View File

@@ -0,0 +1,214 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.lifecycle.SavedStateHandle
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.model.TaskWithListName
import com.wismna.geoffroy.donext.domain.usecase.EmptyRecycleBinUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetDeletedTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.PermanentlyDeleteTaskUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import com.wismna.geoffroy.donext.R
import io.mockk.MockKAnnotations
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class RecycleBinViewModelTest {
private lateinit var getDeletedTasksUseCase: GetDeletedTasksUseCase
private lateinit var toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase
private lateinit var permanentlyDeleteTaskUseCase: PermanentlyDeleteTaskUseCase
private lateinit var emptyRecycleBinUseCase: EmptyRecycleBinUseCase
private lateinit var uiEventBus: UiEventBus
private lateinit var savedStateHandle: SavedStateHandle
private lateinit var getDeletedTasksFlow: MutableSharedFlow<List<TaskWithListName>>
private lateinit var viewModel: RecycleBinViewModel
@Before
fun setUp() {
MockKAnnotations.init(this, relaxed = true)
Dispatchers.setMain(StandardTestDispatcher())
getDeletedTasksUseCase = mockk()
toggleTaskDeletedUseCase = mockk(relaxed = true)
permanentlyDeleteTaskUseCase = mockk(relaxed = true)
emptyRecycleBinUseCase = mockk(relaxed = true)
uiEventBus = mockk(relaxed = true)
savedStateHandle = SavedStateHandle()
getDeletedTasksFlow = MutableSharedFlow()
every { getDeletedTasksUseCase() } returns getDeletedTasksFlow
viewModel = RecycleBinViewModel(
getDeletedTasksUseCase,
toggleTaskDeletedUseCase,
permanentlyDeleteTaskUseCase,
emptyRecycleBinUseCase,
uiEventBus,
savedStateHandle
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
// --- TESTS ---
@Test
fun `initial state is empty`() = runTest {
assertThat(viewModel.deletedTasks).isEmpty()
assertThat(viewModel.taskToDeleteFlow.value).isNull()
assertThat(viewModel.emptyRecycleBinFlow.value).isFalse()
}
@Test
fun `emitting deleted tasks updates deletedTasks list`() = runTest {
val tasks = listOf(
TaskWithListName(Task(id = 1L, name = "Old task", taskListId = 0L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false), listName = "Work"),
TaskWithListName(Task(id = 2L, name = "Done task", taskListId = 0L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false), listName = "Home")
)
advanceUntilIdle()
getDeletedTasksFlow.emit(tasks)
advanceUntilIdle()
assertThat(viewModel.deletedTasks).isEqualTo(tasks)
}
@Test
fun `restore toggles deletion and shows undo snackbar`() = runTest {
val taskId = 5L
viewModel.restore(taskId)
advanceUntilIdle()
coVerify { toggleTaskDeletedUseCase(taskId, false) }
coVerify {
uiEventBus.send(
match {
it is UiEvent.ShowUndoSnackbar &&
it.message == R.string.snackbar_message_task_restore
}
)
}
}
@Test
fun `onTaskClicked sends EditTask UiEvent`() = runTest {
val task = Task(id = 1L, name = "T", taskListId = 1L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false)
viewModel.onTaskClicked(task)
advanceUntilIdle()
coVerify { uiEventBus.send(UiEvent.EditTask(task)) }
}
@Test
fun `onEmptyRecycleBinRequest sets flag to true`() = runTest {
viewModel.onEmptyRecycleBinRequest()
assertThat(viewModel.emptyRecycleBinFlow.value).isTrue()
}
@Test
fun `onCancelEmptyRecycleBinRequest sets flag to false`() = runTest {
savedStateHandle["emptyRecycleBin"] = true
viewModel.onCancelEmptyRecycleBinRequest()
assertThat(viewModel.emptyRecycleBinFlow.value).isFalse()
}
@Test
fun `emptyRecycleBin calls use case and resets flag`() = runTest {
savedStateHandle["emptyRecycleBin"] = true
viewModel.emptyRecycleBin()
advanceUntilIdle()
coVerify { emptyRecycleBinUseCase() }
assertThat(viewModel.emptyRecycleBinFlow.value).isFalse()
}
@Test
fun `onTaskDeleteRequest sets taskToDelete id`() = runTest {
viewModel.onTaskDeleteRequest(42L)
assertThat(viewModel.taskToDeleteFlow.value).isEqualTo(42L)
}
@Test
fun `onConfirmDelete calls use case and clears task id`() = runTest {
savedStateHandle["taskToDeleteId"] = 7L
viewModel.onConfirmDelete()
advanceUntilIdle()
coVerify { permanentlyDeleteTaskUseCase(7L) }
assertThat(viewModel.taskToDeleteFlow.value).isNull()
}
@Test
fun `onCancelDelete clears task id`() = runTest {
savedStateHandle["taskToDeleteId"] = 10L
viewModel.onCancelDelete()
assertThat(viewModel.taskToDeleteFlow.value).isNull()
}
@Test
fun `simultaneous flow emissions update deleted tasks and flags independently`() = runTest {
val tasks = listOf(TaskWithListName(Task(id = 1L, name = "Trash", taskListId = 0L, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false), listName = "Work"))
advanceUntilIdle()
// Emit tasks while also updating the recycle bin flag
getDeletedTasksFlow.emit(tasks)
savedStateHandle["emptyRecycleBin"] = true
advanceUntilIdle()
assertThat(viewModel.deletedTasks).isEqualTo(tasks)
assertThat(viewModel.emptyRecycleBinFlow.value).isTrue()
}
@Test
fun `restore snackbar undoAction re-deletes the task`() = runTest {
val taskId = 99L
val eventSlot = slot<UiEvent>()
// Intercept UiEvent.ShowUndoSnackbar to get the undoAction
coEvery { uiEventBus.send(capture(eventSlot)) } just Runs
viewModel.restore(taskId)
advanceUntilIdle()
// Ensure the event is a ShowUndoSnackbar
val snackbarEvent = eventSlot.captured as UiEvent.ShowUndoSnackbar
// Run the undo lambda
snackbarEvent.undoAction.invoke()
advanceUntilIdle()
// Verify that it re-deletes the task (sets deleted = true again)
coVerify { toggleTaskDeletedUseCase(taskId, true) }
}
}

View File

@@ -0,0 +1,134 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import org.junit.Before
import org.junit.Test
import java.time.*
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.*
class TaskItemViewModelTest {
private val fixedClock: Clock = Clock.fixed(
LocalDate.of(2025, 1, 10)
.atStartOfDay(ZoneId.systemDefault())
.toInstant(),
ZoneId.systemDefault()
)
private val today: LocalDate = LocalDate.now(fixedClock)
private lateinit var baseTask: Task
@Before
fun setup() {
baseTask = Task(
id = 1L,
taskListId = 1L,
name = "Test Task",
description = "Description",
priority = Priority.NORMAL,
isDone = false,
isDeleted = false,
dueDate = null
)
}
private fun millisForDaysFromFixedToday(daysOffset: Long): Long {
val targetDate = today.plusDays(daysOffset)
return targetDate
.atStartOfDay(fixedClock.zone)
.toInstant()
.toEpochMilli()
}
@Test
fun `initializes fields from Task`() {
val viewModel = TaskItemViewModel(baseTask)
assertThat(viewModel.id).isEqualTo(baseTask.id)
assertThat(viewModel.name).isEqualTo(baseTask.name)
assertThat(viewModel.description).isEqualTo(baseTask.description)
assertThat(viewModel.isDone).isFalse()
assertThat(viewModel.isDeleted).isFalse()
assertThat(viewModel.priority).isEqualTo(Priority.NORMAL)
}
@Test
fun `isOverdue is true when due date is before today`() {
val overdueTask = baseTask.copy(dueDate = millisForDaysFromFixedToday(-1))
val viewModel = TaskItemViewModel(overdueTask)
assertThat(viewModel.isOverdue).isTrue()
}
@Test
fun `isOverdue is false when due date is today`() {
val dueToday = baseTask.copy(dueDate = millisForDaysFromFixedToday(0))
val viewModel = TaskItemViewModel(dueToday, fixedClock)
assertThat(viewModel.isOverdue).isFalse()
}
@Test
fun `isOverdue is false when due date is null`() {
val viewModel = TaskItemViewModel(baseTask.copy(dueDate = null))
assertThat(viewModel.isOverdue).isFalse()
}
@Test
fun `dueDateText is Today when due date is today`() {
val dueToday = baseTask.copy(dueDate = millisForDaysFromFixedToday(0))
val viewModel = TaskItemViewModel(dueToday, fixedClock)
assertThat(viewModel.dueDateText).isEqualTo("Today")
}
@Test
fun `dueDateText is Tomorrow when due date is tomorrow`() {
val dueTomorrow = baseTask.copy(dueDate = millisForDaysFromFixedToday(1))
val viewModel = TaskItemViewModel(dueTomorrow, fixedClock)
assertThat(viewModel.dueDateText).isEqualTo("Tomorrow")
}
@Test
fun `dueDateText is Yesterday when due date was yesterday`() {
val dueYesterday = baseTask.copy(dueDate = millisForDaysFromFixedToday(-1))
val viewModel = TaskItemViewModel(dueYesterday, fixedClock)
assertThat(viewModel.dueDateText).isEqualTo("Yesterday")
}
@Test
fun `dueDateText is day of week when within next 7 days`() {
val dueIn3Days = baseTask.copy(dueDate = millisForDaysFromFixedToday(3))
val viewModel = TaskItemViewModel(dueIn3Days, fixedClock)
val expected = today
.plusDays(3)
.dayOfWeek
.getDisplayName(TextStyle.SHORT, Locale.getDefault())
assertThat(viewModel.dueDateText).isEqualTo(expected)
}
@Test
fun `dueDateText is formatted date when more than 7 days away`() {
val dueIn10Days = baseTask.copy(dueDate = millisForDaysFromFixedToday(10))
val viewModel = TaskItemViewModel(dueIn10Days)
val expected = today
.plusDays(10)
.format(
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.getDefault())
)
assertThat(viewModel.dueDateText).isEqualTo(expected)
}
}

View File

@@ -0,0 +1,177 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import androidx.lifecycle.SavedStateHandle
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.GetTasksForListUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.Priority
import io.mockk.MockKAnnotations
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class TaskListViewModelTest {
private lateinit var getTasksForListUseCase: GetTasksForListUseCase
private lateinit var toggleTaskDoneUseCase: ToggleTaskDoneUseCase
private lateinit var toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase
private lateinit var uiEventBus: UiEventBus
private lateinit var savedStateHandle: SavedStateHandle
private lateinit var getTasksFlow: MutableSharedFlow<List<Task>>
private lateinit var viewModel: TaskListViewModel
private val testTaskListId = 100L
@Before
fun setUp() {
MockKAnnotations.init(this, relaxed = true)
Dispatchers.setMain(StandardTestDispatcher())
getTasksForListUseCase = mockk()
toggleTaskDoneUseCase = mockk(relaxed = true)
toggleTaskDeletedUseCase = mockk(relaxed = true)
uiEventBus = mockk(relaxed = true)
savedStateHandle = SavedStateHandle(mapOf("taskListId" to testTaskListId))
getTasksFlow = MutableSharedFlow()
every { getTasksForListUseCase(testTaskListId) } returns getTasksFlow
viewModel = TaskListViewModel(
savedStateHandle,
getTasksForListUseCase,
toggleTaskDoneUseCase,
toggleTaskDeletedUseCase,
uiEventBus
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
// --- TESTS ---
@Test
fun `initial state is loading and tasks empty`() = runTest {
assertThat(viewModel.isLoading).isTrue()
assertThat(viewModel.tasks).isEmpty()
}
@Test
fun `emitting tasks updates list and stops loading`() = runTest {
val tasks = listOf(
Task(id = 1L, name = "Write docs", taskListId = testTaskListId, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false),
Task(id = 2L, name = "Code review", taskListId = testTaskListId, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false)
)
advanceUntilIdle()
getTasksFlow.emit(tasks)
advanceUntilIdle()
assertThat(viewModel.isLoading).isFalse()
assertThat(viewModel.tasks).isEqualTo(tasks)
}
@Test
fun `onTaskClicked sends EditTask event`() = runTest {
val task = Task(id = 1L, name = "Test task", taskListId = testTaskListId, description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false)
viewModel.onTaskClicked(task)
advanceUntilIdle()
coVerify { uiEventBus.send(UiEvent.EditTask(task)) }
}
@Test
fun `updateTaskDone marks task done and sends snackbar with undo`() = runTest {
val taskId = 3L
viewModel.updateTaskDone(taskId, true)
advanceUntilIdle()
coVerify { toggleTaskDoneUseCase(taskId, true) }
coVerify {
uiEventBus.send(
match {
it is UiEvent.ShowUndoSnackbar &&
it.message == R.string.snackbar_message_task_done
}
)
}
}
@Test
fun `updateTaskDone undoAction marks task undone`() = runTest {
val taskId = 7L
val eventSlot = slot<UiEvent>()
coEvery { uiEventBus.send(capture(eventSlot)) } just Runs
viewModel.updateTaskDone(taskId, true)
advanceUntilIdle()
val snackbar = eventSlot.captured as UiEvent.ShowUndoSnackbar
snackbar.undoAction.invoke()
advanceUntilIdle()
coVerify { toggleTaskDoneUseCase(taskId, false) }
}
@Test
fun `deleteTask marks task deleted and sends snackbar`() = runTest {
val taskId = 9L
viewModel.deleteTask(taskId)
advanceUntilIdle()
coVerify { toggleTaskDeletedUseCase(taskId, true) }
coVerify {
uiEventBus.send(
match {
it is UiEvent.ShowUndoSnackbar &&
it.message == R.string.snackbar_message_task_recycle
}
)
}
}
@Test
fun `deleteTask undoAction restores task`() = runTest {
val taskId = 10L
val eventSlot = slot<UiEvent>()
coEvery { uiEventBus.send(capture(eventSlot)) } just Runs
viewModel.deleteTask(taskId)
advanceUntilIdle()
val snackbar = eventSlot.captured as UiEvent.ShowUndoSnackbar
snackbar.undoAction.invoke()
advanceUntilIdle()
coVerify { toggleTaskDeletedUseCase(taskId, false) }
}
}

View File

@@ -0,0 +1,254 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.AddTaskUseCase
import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskUseCase
import com.wismna.geoffroy.donext.presentation.ui.events.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import io.mockk.MockKAnnotations
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
@OptIn(ExperimentalCoroutinesApi::class)
class TaskViewModelTest {
private lateinit var createTaskUseCase: AddTaskUseCase
private lateinit var updateTaskUseCase: UpdateTaskUseCase
private lateinit var uiEventBus: UiEventBus
private lateinit var stickyEventsFlow: MutableSharedFlow<UiEvent>
private lateinit var viewModel: TaskViewModel
@Before
fun setUp() {
MockKAnnotations.init(this, relaxed = true)
Dispatchers.setMain(StandardTestDispatcher())
createTaskUseCase = mockk(relaxed = true)
updateTaskUseCase = mockk(relaxed = true)
uiEventBus = mockk(relaxed = true)
stickyEventsFlow = MutableSharedFlow()
every { uiEventBus.stickyEvents } returns stickyEventsFlow
viewModel = TaskViewModel(
createTaskUseCase,
updateTaskUseCase,
uiEventBus
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
// --- TESTS ---
@Test
fun `initial state is blank and not editing`() = runTest {
assertThat(viewModel.title).isEmpty()
assertThat(viewModel.description).isEmpty()
assertThat(viewModel.priority).isEqualTo(Priority.NORMAL)
assertThat(viewModel.dueDate).isNull()
assertThat(viewModel.isDone).isFalse()
assertThat(viewModel.isDeleted).isFalse()
assertThat(viewModel.taskListId).isNull()
assertThat(viewModel.isEditing()).isFalse()
}
@Test
fun `CreateNewTask event resets fields and sets taskListId`() = runTest {
stickyEventsFlow.emit(UiEvent.CreateNewTask(42L))
advanceUntilIdle()
assertThat(viewModel.isEditing()).isFalse()
assertThat(viewModel.taskListId).isEqualTo(42L)
assertThat(viewModel.title).isEmpty()
assertThat(viewModel.description).isEmpty()
assertThat(viewModel.priority).isEqualTo(Priority.NORMAL)
assertThat(viewModel.dueDate).isNull()
assertThat(viewModel.isDeleted).isFalse()
}
@Test
fun `EditTask event populates fields from existing task`() = runTest {
val task = Task(
id = 7L,
taskListId = 9L,
name = "Fix bug",
description = "Null pointer issue",
priority = Priority.HIGH,
dueDate = Instant.parse("2025-10-01T12:00:00Z").toEpochMilli(),
isDone = true,
isDeleted = false
)
stickyEventsFlow.emit(UiEvent.EditTask(task))
advanceUntilIdle()
assertThat(viewModel.isEditing()).isTrue()
assertThat(viewModel.editingTaskId).isEqualTo(7L)
assertThat(viewModel.taskListId).isEqualTo(9L)
assertThat(viewModel.title).isEqualTo("Fix bug")
assertThat(viewModel.description).isEqualTo("Null pointer issue")
assertThat(viewModel.priority).isEqualTo(Priority.HIGH)
assertThat(viewModel.dueDate).isEqualTo(task.dueDate)
assertThat(viewModel.isDone).isTrue()
assertThat(viewModel.isDeleted).isFalse()
}
@Test
fun `CloseTask event resets state`() = runTest {
// set up as editing
stickyEventsFlow.emit(
UiEvent.EditTask(
Task(id = 1L, taskListId = 2L, name = "T", description = "D", priority = Priority.HIGH, isDone = false, isDeleted = false)
)
)
advanceUntilIdle()
stickyEventsFlow.emit(UiEvent.CloseTask)
advanceUntilIdle()
assertThat(viewModel.title).isEmpty()
assertThat(viewModel.description).isEmpty()
assertThat(viewModel.priority).isEqualTo(Priority.NORMAL)
assertThat(viewModel.editingTaskId).isNull()
assertThat(viewModel.taskListId).isNull()
}
@Test
fun `onTitleChanged updates title`() {
viewModel.onTitleChanged("New title")
assertThat(viewModel.title).isEqualTo("New title")
}
@Test
fun `onDescriptionChanged updates description`() {
viewModel.onDescriptionChanged("Some description")
assertThat(viewModel.description).isEqualTo("Some description")
}
@Test
fun `onPriorityChanged updates priority`() {
viewModel.onPriorityChanged(Priority.HIGH)
assertThat(viewModel.priority).isEqualTo(Priority.HIGH)
}
@Test
fun `onDueDateChanged normalizes date to start of day in system timezone`() {
val utcMidday = Instant.parse("2025-10-01T12:00:00Z").toEpochMilli()
viewModel.onDueDateChanged(utcMidday)
val expectedStartOfDay =
Instant.ofEpochMilli(utcMidday)
.atZone(ZoneOffset.UTC)
.toLocalDate()
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
assertThat(viewModel.dueDate).isEqualTo(expectedStartOfDay)
}
@Test
fun `save with blank title does nothing`() = runTest {
stickyEventsFlow.emit(UiEvent.CreateNewTask(1L))
advanceUntilIdle()
viewModel.save()
advanceUntilIdle()
coVerify(exactly = 0) { createTaskUseCase(any(), any(), any(), any(), any()) }
coVerify(exactly = 0) { updateTaskUseCase(any(), any(), any(), any(), any(), any(), any()) }
}
@Test
fun `save creates task when not editing`() = runTest {stickyEventsFlow.emit(UiEvent.CreateNewTask(3L))
advanceUntilIdle()
viewModel.onTitleChanged("New Task")
viewModel.onDescriptionChanged("Description")
viewModel.onPriorityChanged(Priority.HIGH)
val due = Instant.parse("2025-10-01T12:00:00Z").toEpochMilli()
viewModel.onDueDateChanged(due)
viewModel.save()
advanceUntilIdle()
coVerify {
createTaskUseCase(
3L,
"New Task",
"Description",
Priority.HIGH,
viewModel.dueDate
)
}
}
@Test
fun `save updates task when editing`() = runTest {
val task = Task(
id = 10L,
taskListId = 5L,
name = "Old Task",
description = "Old desc",
priority = Priority.NORMAL,
dueDate = null,
isDone = false,
isDeleted = false
)
stickyEventsFlow.emit(UiEvent.EditTask(task))
advanceUntilIdle()
viewModel.onTitleChanged("Updated Task")
viewModel.onDescriptionChanged("Updated desc")
viewModel.save()
advanceUntilIdle()
coVerify {
updateTaskUseCase(
10L,
5L,
"Updated Task",
"Updated desc",
Priority.NORMAL,
null,
false
)
}
}
@Test
fun `save calls onDone callback after save completes`() = runTest {
var doneCalled = false
stickyEventsFlow.emit(UiEvent.CreateNewTask(2L))
advanceUntilIdle()
viewModel.onTitleChanged("Task")
viewModel.save { doneCalled = true }
advanceUntilIdle()
assertThat(doneCalled).isTrue()
}
}