diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
index 242d664..a3f4145 100644
--- a/.idea/androidTestResultsUserPreferences.xml
+++ b/.idea/androidTestResultsUserPreferences.xml
@@ -29,6 +29,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -42,6 +68,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml
index a2c5a7a..e5f298b 100644
--- a/.idea/caches/deviceStreaming.xml
+++ b/.idea/caches/deviceStreaming.xml
@@ -100,6 +100,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -184,6 +196,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index afdc8f1..7ca90ea 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -8,12 +8,6 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/donextv2/build.gradle.kts b/donextv2/build.gradle.kts
index 0aeda86..89b4d9d 100644
--- a/donextv2/build.gradle.kts
+++ b/donextv2/build.gradle.kts
@@ -70,23 +70,24 @@ android {
dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
implementation("androidx.activity:activity-compose:1.11.0")
- implementation(platform("androidx.compose:compose-bom:2025.10.01"))
+ implementation(platform("androidx.compose:compose-bom:2025.11.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material3:material3-window-size-class:1.4.0")
implementation("androidx.compose.material:material-icons-extended:1.7.8")
- implementation("androidx.navigation:navigation-compose:2.9.5")
+ implementation("androidx.navigation:navigation-compose:2.9.6")
implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
implementation("sh.calvin.reorderable:reorderable:3.0.0")
testImplementation("junit:junit:4.13.2")
- testImplementation("io.mockk:mockk:1.13.12")
- testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
+ testImplementation("io.mockk:mockk:1.14.6")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
+ testImplementation("com.google.truth:truth:1.4.5")
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
- androidTestImplementation(platform("androidx.compose:compose-bom:2025.10.01"))
+ androidTestImplementation(platform("androidx.compose:compose-bom:2025.11.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
- androidTestImplementation("com.google.truth:truth:1.4.4")
+ androidTestImplementation("com.google.truth:truth:1.4.5")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
diff --git a/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/dao/TaskDaoTest.kt b/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/dao/TaskDaoTest.kt
index ce75d5b..6e04955 100644
--- a/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/dao/TaskDaoTest.kt
+++ b/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/dao/TaskDaoTest.kt
@@ -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")
}
}
diff --git a/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDaoTest.kt b/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDaoTest.kt
index 8818345..b1d6f8e 100644
--- a/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDaoTest.kt
+++ b/donextv2/src/androidTest/java/com/wismna/geoffroy/donext/data/local/dao/TaskListDaoTest.kt
@@ -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")
}
}
\ No newline at end of file
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/extension/Date.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/extension/Date.kt
index b7abec7..74cb414 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/extension/Date.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/extension/Date.kt
@@ -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()
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt
index e83e9c8..14edd30 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt
@@ -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 ->
diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModel.kt
index 491905d..90badff 100644
--- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModel.kt
+++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModel.kt
@@ -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"
diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/DueTodayViewModelTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/DueTodayViewModelTest.kt
index 1428e93..34a3310 100644
--- a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/DueTodayViewModelTest.kt
+++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/DueTodayViewModelTest.kt
@@ -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
diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModelTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModelTest.kt
new file mode 100644
index 0000000..4c25fd2
--- /dev/null
+++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModelTest.kt
@@ -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>
+ 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 {
+ 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"))
+ }
+}
diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/ManageListsViewModelTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/ManageListsViewModelTest.kt
new file mode 100644
index 0000000..3f69258
--- /dev/null
+++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/ManageListsViewModelTest.kt
@@ -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>
+ 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) }
+ }
+}
diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/MenuViewModelTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/MenuViewModelTest.kt
new file mode 100644
index 0000000..19b8ca8
--- /dev/null
+++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/MenuViewModelTest.kt
@@ -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>
+ private lateinit var dueTodayTasksFlow: MutableSharedFlow>
+ 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)
+ }
+
+}
diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/RecycleBinViewModelTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/RecycleBinViewModelTest.kt
new file mode 100644
index 0000000..6f70b3d
--- /dev/null
+++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/RecycleBinViewModelTest.kt
@@ -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>
+ 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()
+
+ // 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) }
+ }
+
+}
diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModelTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModelTest.kt
new file mode 100644
index 0000000..0beca06
--- /dev/null
+++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskItemViewModelTest.kt
@@ -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)
+ }
+}
diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModelTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModelTest.kt
new file mode 100644
index 0000000..0eb29a4
--- /dev/null
+++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskListViewModelTest.kt
@@ -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>
+ 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()
+
+ 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()
+
+ 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) }
+ }
+}
diff --git a/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModelTest.kt b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModelTest.kt
new file mode 100644
index 0000000..30c7e19
--- /dev/null
+++ b/donextv2/src/test/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModelTest.kt
@@ -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
+
+ 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()
+ }
+}
\ No newline at end of file