Compare commits

...

4 Commits

Author SHA1 Message Date
Geoffroy Bonneville
6df720fb8c Update a package
Run tests in pipeline
2025-11-08 14:45:32 -05:00
Geoffroy Bonneville
92263bc4ec Update screenshots and metadata
Update pipeline to build new module
2025-11-08 14:33:39 -05:00
Geoffroy Bonneville
157b577397 Fix imports 2025-11-07 17:01:07 -05:00
Geoffroy Bonneville
c47ce57c31 Add remaining ViewModels Unit Tests
Update packages
Make Clock a dependency in TaskItemViewModel
2025-11-07 16:59:28 -05:00
38 changed files with 1402 additions and 100 deletions

View File

@@ -8,7 +8,8 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
BUILD_DIR: donext/build/outputs/bundle/release MODULE: donextv2
BUILD_DIR: donextv2/build/outputs/bundle/release
jobs: jobs:
build: build:
@@ -38,8 +39,11 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
- name: Run unit tests
run: ./gradlew :${{ env.MODULE }}:testDebugUnitTest --stacktrace
- name: Build AAB - name: Build AAB
run: ./gradlew bundleRelease run: ./gradlew :${{ env.MODULE }}:bundleRelease
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@
.DS_Store .DS_Store
/build /build
/captures /captures
/fastlane/keys/

View File

@@ -29,6 +29,32 @@
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
</value> </value>
</entry> </entry>
<entry key="-885367779">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Medium_Phone_API_36.0" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="676896396">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Medium_Phone_API_36.0" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="1337588336"> <entry key="1337588336">
<value> <value>
<AndroidTestResultsTableState> <AndroidTestResultsTableState>
@@ -42,6 +68,32 @@
</AndroidTestResultsTableState> </AndroidTestResultsTableState>
</value> </value>
</entry> </entry>
<entry key="1756619873">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Medium_Phone_API_36.0" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
<entry key="2129352037">
<value>
<AndroidTestResultsTableState>
<option name="preferredColumnWidths">
<map>
<entry key="Duration" value="90" />
<entry key="Medium_Phone_API_36.0" value="120" />
<entry key="Tests" value="360" />
</map>
</option>
</AndroidTestResultsTableState>
</value>
</entry>
</map> </map>
</option> </option>
</component> </component>

View File

@@ -100,6 +100,18 @@
<option name="screenX" value="1200" /> <option name="screenX" value="1200" />
<option name="screenY" value="1920" /> <option name="screenY" value="1920" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="samsung" />
<option name="codename" value="a06" />
<option name="id" value="a06" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy A06" />
<option name="screenDensity" value="300" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="samsung" /> <option name="brand" value="samsung" />
@@ -184,6 +196,18 @@
<option name="screenX" value="1080" /> <option name="screenX" value="1080" />
<option name="screenY" value="2340" /> <option name="screenY" value="2340" />
</PersistentDeviceSelectionData> </PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="35" />
<option name="brand" value="samsung" />
<option name="codename" value="a56x" />
<option name="id" value="a56x" />
<option name="labId" value="google" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="SM-A566E" />
<option name="screenDensity" value="450" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData> <PersistentDeviceSelectionData>
<option name="api" value="34" /> <option name="api" value="34" />
<option name="brand" value="google" /> <option name="brand" value="google" />

View File

@@ -8,12 +8,6 @@
<SelectionState runConfigName="donext"> <SelectionState runConfigName="donext">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
</SelectionState> </SelectionState>
<SelectionState runConfigName="DatabaseMigrationTest">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="migrate_v6_to_v7_preserves_data_and_transforms_columns()">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates> </selectionStates>
</component> </component>
</project> </project>

View File

@@ -24,7 +24,7 @@ buildscript {
plugins { plugins {
id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
id("com.google.dagger.hilt.android") version "2.57.1" apply false id("com.google.dagger.hilt.android") version "2.57.2" apply false
} }
allprojects { allprojects {

View File

@@ -70,23 +70,24 @@ android {
dependencies { dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
implementation("androidx.activity:activity-compose:1.11.0") 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")
implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material3:material3-window-size-class:1.4.0") implementation("androidx.compose.material3:material3-window-size-class:1.4.0")
implementation("androidx.compose.material:material-icons-extended:1.7.8") 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("androidx.hilt:hilt-navigation-compose:1.3.0")
implementation("sh.calvin.reorderable:reorderable:3.0.0") implementation("sh.calvin.reorderable:reorderable:3.0.0")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.12") testImplementation("io.mockk:mockk:1.14.6")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") 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("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("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-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")

View File

@@ -3,15 +3,11 @@ package com.wismna.geoffroy.donext.data.local.dao
import androidx.room.Room import androidx.room.Room
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 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.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.data.local.AppDatabase import com.wismna.geoffroy.donext.data.local.AppDatabase
import com.wismna.geoffroy.donext.domain.model.Priority 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.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.After import org.junit.After
@@ -19,6 +15,7 @@ import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.time.Instant import java.time.Instant
import kotlin.collections.first
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class TaskDaoTest { class TaskDaoTest {
@@ -61,9 +58,9 @@ class TaskDaoTest {
val inserted = taskDao.getTasksForList(listId).first().first() val inserted = taskDao.getTasksForList(listId).first().first()
val fetched = taskDao.getTaskById(inserted.id) val fetched = taskDao.getTaskById(inserted.id)
assertNotNull(fetched) assertThat(fetched).isNotNull()
assertEquals("Do laundry", fetched!!.name) assertThat(fetched!!.name).isEqualTo("Do laundry")
assertEquals(listId, fetched.taskListId) assertThat(fetched.taskListId).isEqualTo(listId)
} }
@Test @Test
@@ -77,8 +74,8 @@ class TaskDaoTest {
taskDao.insertTask(done) taskDao.insertTask(done)
taskDao.insertTask(high) taskDao.insertTask(high)
val tasks = taskDao.getTasksForList(listId).first() val taskPriorities = taskDao.getTasksForList(listId).first().map { it.name }
assertEquals(listOf("High", "Normal", "Done"), tasks.map { it.name }) assertThat(taskPriorities).containsExactly("High", "Normal", "Done").inOrder()
} }
@Test @Test
@@ -92,7 +89,7 @@ class TaskDaoTest {
taskDao.updateTask(updated) taskDao.updateTask(updated)
val fetched = taskDao.getTaskById(inserted.id) val fetched = taskDao.getTaskById(inserted.id)
assertEquals("Updated", fetched!!.name) assertThat(fetched!!.name).isEqualTo("Updated")
} }
@Test @Test
@@ -103,10 +100,10 @@ class TaskDaoTest {
val inserted = taskDao.getTasksForList(listId).first().first() val inserted = taskDao.getTasksForList(listId).first().first()
taskDao.toggleTaskDone(inserted.id, true) taskDao.toggleTaskDone(inserted.id, true)
assertTrue(taskDao.getTaskById(inserted.id)!!.isDone) assertThat(taskDao.getTaskById(inserted.id)!!.isDone).isTrue()
taskDao.toggleTaskDone(inserted.id, false) taskDao.toggleTaskDone(inserted.id, false)
assertFalse(taskDao.getTaskById(inserted.id)!!.isDone) assertThat(taskDao.getTaskById(inserted.id)!!.isDone).isFalse()
} }
@Test @Test
@@ -118,7 +115,7 @@ class TaskDaoTest {
taskDao.toggleTaskDeleted(inserted.id, true) taskDao.toggleTaskDeleted(inserted.id, true)
val deletedTask = taskDao.getTaskById(inserted.id) val deletedTask = taskDao.getTaskById(inserted.id)
assertTrue(deletedTask!!.isDeleted) assertThat(deletedTask!!.isDeleted).isTrue()
} }
@Test @Test
@@ -132,11 +129,10 @@ class TaskDaoTest {
taskDao.toggleAllTasksFromListDeleted(listId, true) taskDao.toggleAllTasksFromListDeleted(listId, true)
val fetched = taskDao.getTasksForList(listId).first() val fetched = taskDao.getTasksForList(listId).first()
assertTrue(fetched.isEmpty()) // filtered by deleted = 0 assertThat(fetched).isEmpty()
// confirm soft deletion // confirm soft deletion
val softDeleted = fetched.size < 2 assertThat(fetched).hasSize(0)
assertTrue(softDeleted)
} }
@Test @Test
@@ -147,7 +143,7 @@ class TaskDaoTest {
val inserted = taskDao.getTasksForList(listId).first().first() val inserted = taskDao.getTasksForList(listId).first().first()
taskDao.permanentDeleteTask(inserted.id) taskDao.permanentDeleteTask(inserted.id)
assertNull(taskDao.getTaskById(inserted.id)) assertThat(taskDao.getTaskById(inserted.id)).isNull()
} }
@Test @Test
@@ -160,8 +156,8 @@ class TaskDaoTest {
taskDao.permanentDeleteAllDeletedTasks() taskDao.permanentDeleteAllDeletedTasks()
val remaining = taskDao.getTasksForList(listId).first() val remaining = taskDao.getTasksForList(listId).first()
assertEquals(1, remaining.size) assertThat(remaining).hasSize(1)
assertEquals("Active", remaining.first().name) assertThat(remaining.first().name).isEqualTo("Active")
} }
@Test @Test
@@ -171,9 +167,9 @@ class TaskDaoTest {
taskDao.insertTask(deleted) taskDao.insertTask(deleted)
val results = taskDao.getDeletedTasksWithListName().first() val results = taskDao.getDeletedTasksWithListName().first()
assertEquals(1, results.size) assertThat(results).hasSize(1)
assertEquals("Trash", results.first().task.name) assertThat(results.first().task.name).isEqualTo("Trash")
assertEquals("Work", results.first().listName) assertThat(results.first().listName).isEqualTo("Work")
} }
@Test @Test
@@ -209,7 +205,7 @@ class TaskDaoTest {
taskDao.insertTask(tomorrow) taskDao.insertTask(tomorrow)
val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd).first() val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd).first()
assertEquals(1, tasks.size) assertThat(tasks).hasSize(1)
assertEquals("Today", tasks.first().name) 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.room.Room
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 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.TaskEntity
import com.wismna.geoffroy.donext.data.entities.TaskListEntity import com.wismna.geoffroy.donext.data.entities.TaskListEntity
import com.wismna.geoffroy.donext.data.local.AppDatabase import com.wismna.geoffroy.donext.data.local.AppDatabase
import com.wismna.geoffroy.donext.domain.model.Priority 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.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.After import org.junit.After
@@ -17,6 +15,7 @@ import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.time.Instant import java.time.Instant
import kotlin.collections.first
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class TaskListDaoTest { class TaskListDaoTest {
@@ -46,8 +45,8 @@ class TaskListDaoTest {
listDao.insertTaskList(taskList) listDao.insertTaskList(taskList)
val lists = listDao.getTaskLists().first() val lists = listDao.getTaskLists().first()
assertEquals(1, lists.size) assertThat(lists).hasSize(1)
assertEquals("Personal", lists.first().name) assertThat(lists.first().name).isEqualTo("Personal")
} }
@Test @Test
@@ -58,9 +57,9 @@ class TaskListDaoTest {
val inserted = listDao.getTaskLists().first().first() val inserted = listDao.getTaskLists().first().first()
val fetched = listDao.getTaskListById(inserted.id) val fetched = listDao.getTaskListById(inserted.id)
assertNotNull(fetched) assertThat(fetched).isNotNull()
assertEquals("Groceries", fetched!!.name) assertThat(fetched!!.name).isEqualTo("Groceries")
assertEquals(inserted.id, fetched.id) assertThat(fetched.id).isEqualTo(inserted.id)
} }
@Test @Test
@@ -73,7 +72,7 @@ class TaskListDaoTest {
listDao.updateTaskList(updated) listDao.updateTaskList(updated)
val fetched = listDao.getTaskListById(inserted.id) val fetched = listDao.getTaskListById(inserted.id)
assertEquals("Updated Work", fetched!!.name) assertThat(fetched!!.name).isEqualTo("Updated Work")
} }
@Test @Test
@@ -86,12 +85,12 @@ class TaskListDaoTest {
// getTaskLists() filters deleted = 0, so result should be empty // getTaskLists() filters deleted = 0, so result should be empty
val activeLists = listDao.getTaskLists().first() val activeLists = listDao.getTaskLists().first()
assertTrue(activeLists.isEmpty()) assertThat(activeLists).isEmpty()
// But the entity still exists in DB // But the entity still exists in DB
val softDeleted = listDao.getTaskListById(inserted.id) val softDeleted = listDao.getTaskListById(inserted.id)
assertNotNull(softDeleted) assertThat(softDeleted).isNotNull()
assertTrue(softDeleted!!.isDeleted) assertThat(softDeleted!!.isDeleted).isTrue()
} }
@Test @Test
@@ -103,8 +102,8 @@ class TaskListDaoTest {
listDao.insertTaskList(second) listDao.insertTaskList(second)
listDao.insertTaskList(third) listDao.insertTaskList(third)
val lists = listDao.getTaskLists().first() val listNames = listDao.getTaskLists().first().map { it.name }
assertEquals(listOf("Alpha", "Beta", "Zeta"), lists.map { it.name }) assertThat(listNames).containsExactly("Alpha", "Beta", "Zeta").inOrder()
} }
@Test @Test
@@ -152,7 +151,7 @@ class TaskListDaoTest {
val lists = listDao.getTaskListsWithOverdue(now) val lists = listDao.getTaskListsWithOverdue(now)
assertEquals(1, lists.first().first().overdueCount) assertThat(lists.first().first().overdueCount).isEqualTo(1)
} }
@Test @Test
@@ -222,7 +221,7 @@ class TaskListDaoTest {
val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd) val tasks = taskDao.getDueTodayTasks(todayStart, todayEnd)
assertEquals(1, tasks.first().count()) assertThat(tasks.first()).hasSize(1)
assertEquals("Today", tasks.first().first().name) assertThat(tasks.first().first().name).isEqualTo("Today")
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package com.wismna.geoffroy.donext.presentation.viewmodel 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.R
import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task 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.UiEvent
import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus import com.wismna.geoffroy.donext.presentation.ui.events.UiEventBus
import io.mockk.* import io.mockk.*
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -60,7 +60,7 @@ class DueTodayViewModelTest {
tasksFlow.emit(taskList) tasksFlow.emit(taskList)
advanceUntilIdle() advanceUntilIdle()
assertEquals(taskList, viewModel.dueTodayTasks) assertThat(viewModel.dueTodayTasks).isEqualTo(taskList)
} }
@Test @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()
}
}

View File

@@ -1,2 +1,2 @@
json_key_file("../..//Downloads/donext-f9e67-1184ae400b09.json") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one json_key_file("./fastlane/keys/donext-f9e67-5038064982b0")
package_name("com.wismna.geoffroy.donext") # e.g. com.krausefx.app package_name("com.wismna.geoffroy.donext")

View File

@@ -1,38 +1,26 @@
# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
# https://docs.fastlane.tools/actions
#
# For a list of all available plugins, check out
#
# https://docs.fastlane.tools/plugins/available-plugins
#
# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane
default_platform(:android) default_platform(:android)
platform :android do platform :android do
desc "Runs all the tests" desc "Run all unit tests"
lane :test do lane :test do
gradle(task: "test") gradle(task: "testDebugUnitTest")
end end
desc "Submit a new Beta Build to Crashlytics Beta" desc "Build a release APK or AAB for beta testing"
lane :beta do lane :beta do
gradle(task: "clean assembleRelease") gradle(task: "clean bundleRelease")
crashlytics upload_to_play_store(
track: "beta",
# sh "your_script.sh" aab: "app/build/outputs/bundle/release/app-release.aab"
# You can also use other beta testing services here )
end end
desc "Deploy a new version to the Google Play" desc "Deploy the production version to Google Play"
lane :deploy do lane :deploy do
gradle(task: "clean assembleRelease") gradle(task: "clean bundleRelease")
upload_to_play_store upload_to_play_store(
track: "production",
aab: "app/build/outputs/bundle/release/app-release.aab"
)
end end
end end

View File

@@ -1 +1,11 @@
Update to SDK version 35 Complete UI overhaul that gives DoNext a nice, modern look.
Complete code rewrite to Kotlin and Android Jetpack, with a better architecture.
Task lists are now displayed in a navigation menu
Tasks are now ordered by priority and not cycles
All tasks can be Done or Removed instead of only the first one
A Recycle Bin was added
The whole Next and cycles mechanism was removed
The History page was removed and its features split between the regular task lists and the Recycle Bin
The Today view is not a separate concept now, it's based on the tasks due date
No more Settings page as it's not needed anymore (but it may come back later if required)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -1 +1 @@
A new way to manage your tasks! A simple and fast app to manage your tasks!

View File

@@ -1 +1 @@
DoNext DoNext v2