Compare commits

...

2 Commits

Author SHA1 Message Date
Geoffroy Bonneville
fc3672b17b Add DueTodayViewModel Unit Tests 2025-11-06 20:49:46 -05:00
Geoffroy Bonneville
85f1e66a62 Test database v6 -> v7 migration (finally!) 2025-11-06 18:22:03 -05:00
10 changed files with 553 additions and 16 deletions

View File

@@ -3,6 +3,32 @@
<component name="AndroidTestResultsUserPreferences">
<option name="androidTestResultsTableState">
<map>
<entry key="-2002158262">
<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="-1209641426">
<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">
<value>
<AndroidTestResultsTableState>

View File

@@ -5,10 +5,13 @@
<SelectionState runConfigName="donextv2">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="overdueCount_correctlyCalculated()">
<SelectionState runConfigName="donext">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="donext">
<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>

View File

@@ -14,7 +14,6 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.3'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21'
classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.21'
@@ -37,6 +36,4 @@ allprojects {
tasks.register('clean', Delete) {
delete rootProject.layout.buildDirectory
}
apply plugin: 'org.sonarqube'
}

BIN
donext.backup Normal file

Binary file not shown.

View File

@@ -22,15 +22,6 @@ android {
targetCompatibility JavaVersion.VERSION_17
}
}
sonarqube {
properties {
property 'sonar.host.url', '#{sonar.host.url}'
property 'sonar.login', '#{sonar.login}'
property 'sonar.organization', '#{sonar.organization}'
property 'sonar.projectKey', '#{sonar.projectkey}'
property 'sonar.branch', 'master'
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])

View File

@@ -6,6 +6,10 @@ plugins {
id("com.google.dagger.hilt.android")
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
android {
namespace = "com.wismna.geoffroy.donext"
compileSdk = 36
@@ -20,6 +24,10 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
sourceSets {
getByName("debug").assets.srcDirs(files("$projectDir/schemas"))
}
buildTypes {
release {
// Enables code-related app optimization.
@@ -46,6 +54,17 @@ android {
composeOptions {
kotlinCompilerExtensionVersion = "1.1.1"
}
configurations.all {
resolutionStrategy {
eachDependency {
when (requested.module.toString()) {
// Required for forcing the serialization lib version used by MigrationTestHelper
"org.jetbrains.kotlinx:kotlinx-serialization-core-jvm" -> useVersion("1.8.0")
}
}
}
}
}
dependencies {
@@ -60,15 +79,20 @@ dependencies {
implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation("androidx.navigation:navigation-compose:2.9.5")
implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
implementation("androidx.test.ext:junit-ktx: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")
androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0")
androidTestImplementation(platform("androidx.compose:compose-bom:2025.10.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("com.google.truth:truth:1.4.4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
val roomVersion = "2.8.3"
implementation("androidx.room:room-runtime:$roomVersion")
androidTestImplementation("androidx.room:room-testing:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
val hiltVersion = "2.57.2"

View File

@@ -0,0 +1,171 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "a911cf75d24949c0b24bd212cacc860c",
"entities": [
{
"tableName": "tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `description` TEXT, `cycle` INTEGER NOT NULL, `priority` INTEGER NOT NULL, `done` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `displayorder` INTEGER NOT NULL, `todayorder` INTEGER NOT NULL, `list` INTEGER NOT NULL, `duedate` TEXT, `todaydate` TEXT, FOREIGN KEY(`list`) REFERENCES `tasklist`(`_id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "_id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "cycle",
"columnName": "cycle",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "done",
"columnName": "done",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "displayorder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "todayOrder",
"columnName": "todayorder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskList",
"columnName": "list",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dueDate",
"columnName": "duedate",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "todayDate",
"columnName": "todaydate",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"_id"
]
},
"indices": [
{
"name": "index_tasks_list",
"unique": false,
"columnNames": [
"list"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list` ON `${TABLE_NAME}` (`list`)"
}
],
"foreignKeys": [
{
"table": "tasklist",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"list"
],
"referencedColumns": [
"_id"
]
}
]
},
{
"tableName": "tasklist",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `visible` INTEGER NOT NULL, `displayorder` INTEGER NOT NULL, `taskCount` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "_id",
"columnName": "_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visible",
"columnName": "visible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "displayorder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskCount",
"columnName": "taskCount",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [
{
"viewName": "TodayTasksView",
"createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM tasks WHERE todaydate = date('now','localtime')"
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a911cf75d24949c0b24bd212cacc860c')"
]
}
}

View File

@@ -0,0 +1,107 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "adb2abaced32bebf52bab45ae8069f40",
"entities": [
{
"tableName": "tasks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `priority` INTEGER NOT NULL, `done` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `task_list_id` INTEGER NOT NULL, `due_date` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT"
},
{
"fieldPath": "priority",
"columnName": "priority",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDone",
"columnName": "done",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDeleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskListId",
"columnName": "task_list_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dueDate",
"columnName": "due_date",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "task_lists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `display_order` INTEGER NOT NULL, `deleted` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "order",
"columnName": "display_order",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDeleted",
"columnName": "deleted",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'adb2abaced32bebf52bab45ae8069f40')"
]
}
}

View File

@@ -0,0 +1,107 @@
package com.wismna.geoffroy.donext.data.local
import android.content.ContentValues
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import com.wismna.geoffroy.donext.domain.model.Priority
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
@RunWith(AndroidJUnit4::class)
class DatabaseMigrationTest {
private val TEST_DB = "migration-test.db"
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
listOf(),
FrameworkSQLiteOpenHelperFactory()
)
/**
* This test recreates the old SQLite schema (v6 from DatabaseHelper),
* inserts sample legacy rows, runs AppDatabase.MIGRATION_6_7 and then
* validates the migrated data by calling the real DAOs you provided.
*/
@Test
@Throws(IOException::class)
fun migrate_v6_to_v7_preserves_data_and_transforms_columns() {
// Arrange
val context = InstrumentationRegistry.getInstrumentation().targetContext
val db: SupportSQLiteDatabase = helper.createDatabase(TEST_DB, 6)
val listValues = ContentValues().apply {
put("_id", 1)
put("name", "Legacy List")
put("displayorder", 10)
put("visible", 1)
put("taskCount", 0)
}
db.insert("tasklist", 0, listValues)
val taskValues = ContentValues().apply {
put("_id", 1) // explicit id
put("name", "Legacy Task")
put("description", "Old task description")
put("priority", 2)
put("cycle", 0)
put("done", 0)
put("deleted", 0)
put("displayorder", 5)
put("todayorder", 0)
put("list", 1) // references tasklist _id = 1
put("duedate", "2025-09-15") // legacy text date format that migration converts
put("todaydate", null as String?)
}
db.insert("tasks", 0, taskValues)
db.close()
// Act
helper.runMigrationsAndValidate(TEST_DB, 7, true, AppDatabase.MIGRATION_6_7)
val migratedRoom = Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB)
.addMigrations(AppDatabase.MIGRATION_6_7)
.build()
// Assert
try {
val listDao = migratedRoom.taskListDao()
val taskDao = migratedRoom.taskDao()
runBlocking {
val migratedList = listDao.getTaskListById(1L)
assertThat(migratedList).isNotNull()
assertThat(migratedList!!.id).isEqualTo(1L)
assertThat(migratedList.name).isEqualTo("Legacy List")
assertThat(migratedList.isDeleted).isEqualTo(false)
}
runBlocking {
val migratedTask = taskDao.getTaskById(1L)
assertThat(migratedTask).isNotNull()
assertThat(migratedTask!!.id).isEqualTo(1L)
assertThat(migratedTask.name).isEqualTo("Legacy Task")
assertThat(migratedTask.description).isEqualTo("Old task description")
assertThat(migratedTask.priority).isEqualTo(Priority.HIGH)
assertThat(migratedTask.isDone).isEqualTo(false)
assertThat(migratedTask.isDeleted).isEqualTo(false)
assertThat(migratedTask.dueDate).isNotNull()
assertThat(migratedTask.dueDate!!).isGreaterThan(0L)
assertThat(migratedTask.taskListId).isEqualTo(1L)
}
} finally {
migratedRoom.close()
}
}
}

View File

@@ -0,0 +1,111 @@
package com.wismna.geoffroy.donext.presentation.viewmodel
import com.wismna.geoffroy.donext.R
import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.GetDueTodayTasksUseCase
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 io.mockk.*
import junit.framework.TestCase.assertEquals
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 DueTodayViewModelTest {
private lateinit var getDueTodayTasksUseCase: GetDueTodayTasksUseCase
private lateinit var toggleTaskDeletedUseCase: ToggleTaskDeletedUseCase
private lateinit var toggleTaskDoneUseCase: ToggleTaskDoneUseCase
private lateinit var uiEventBus: UiEventBus
private lateinit var viewModel: DueTodayViewModel
private val testDispatcher = StandardTestDispatcher()
private val tasksFlow = MutableSharedFlow<List<Task>>(replay = 1)
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
getDueTodayTasksUseCase = mockk()
toggleTaskDeletedUseCase = mockk(relaxed = true)
toggleTaskDoneUseCase = mockk(relaxed = true)
uiEventBus = mockk(relaxed = true)
coEvery { getDueTodayTasksUseCase.invoke() } returns tasksFlow
viewModel = DueTodayViewModel(
getDueTodayTasksUseCase,
toggleTaskDeletedUseCase,
toggleTaskDoneUseCase,
uiEventBus
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `dueTodayTasks updates when flow emits`() = runTest {
val taskList = listOf(Task(taskListId = 0, id = 1, name = "Test Task", description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false))
tasksFlow.emit(taskList)
advanceUntilIdle()
assertEquals(taskList, viewModel.dueTodayTasks)
}
@Test
fun `onTaskClicked sends EditTask event`() = runTest {
val task = Task(taskListId = 0, id = 42, name = "Click me", description = "", priority = Priority.NORMAL, isDone = false, isDeleted = false)
viewModel.onTaskClicked(task)
advanceUntilIdle()
coVerify { uiEventBus.send(UiEvent.EditTask(task)) }
}
@Test
fun `updateTaskDone toggles done and sends snackbar with undo`() = runTest {
val taskId = 5L
viewModel.updateTaskDone(taskId)
advanceUntilIdle()
coVerify { toggleTaskDoneUseCase(taskId, true) }
coVerify {
uiEventBus.send(
match {
it is UiEvent.ShowUndoSnackbar &&
it.message == R.string.snackbar_message_task_done
}
)
}
}
@Test
fun `deleteTask toggles deleted and sends snackbar with undo`() = runTest {
val taskId = 7L
viewModel.deleteTask(taskId)
advanceUntilIdle()
coVerify { toggleTaskDeletedUseCase(taskId, true) }
coVerify {
uiEventBus.send(
match{
it is UiEvent.ShowUndoSnackbar &&
it.message == R.string.snackbar_message_task_recycle
}
)
}
}
}