diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b846f2f..732b234 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -129,6 +129,10 @@ android { isReturnDefaultValues = true } } + + // MigrationTestHelper reads the exported schemas out of the test APK's + // assets, so app/schemas/ has to ship with the instrumented tests. + sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas") } kotlin { @@ -203,6 +207,7 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.truth) + androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) } diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt new file mode 100644 index 0000000..cf4b272 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt @@ -0,0 +1,68 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.testing.MigrationTestHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The migration harness, proven against the committed schema in `app/schemas/`. + * + * There is one schema version today, so all there is to assert is that the helper + * can build v1 from the exported JSON, seed it, and validate it back — i.e. the + * export, the assets wiring and the identity hash all line up. That is the point: + * the first real migration only has to add its own case. + * + * **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s + * `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the + * database, and add a test here shaped like this: + * + * ``` + * helper.createDatabase(TEST_DB, 1).use { db -> + * db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)") + * } + * helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db -> + * // read the seeded rows back — validation proves the shape, not the data + * } + * ``` + */ +@RunWith(AndroidJUnit4::class) +class TasksDatabaseMigrationTest { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + TasksDatabase::class.java, + ) + + @Test + fun buildsV1FromTheExportedSchema() { + helper.createDatabase(TEST_DB, 1).use { db -> + db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Groceries', 0)") + db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (1, 1, 'uid-1', 'Buy milk')") + + db.query("SELECT title FROM tasks").use { cursor -> + assertThat(cursor.moveToFirst()).isTrue() + assertThat(cursor.getString(0)).isEqualTo("Buy milk") + } + } + } + + @Test + fun validatesV1AgainstTheExportedSchema() { + helper.createDatabase(TEST_DB, 1).close() + + // No migrations to run: v1 is opened and checked against 1.json, which is + // what proves the harness rather than the schema. + helper.runMigrationsAndValidate(TEST_DB, 1, true).use { db -> + assertThat(db.version).isEqualTo(1) + } + } + + private companion object { + const val TEST_DB = "migration-test.db" + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabasePerformanceTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabasePerformanceTest.kt new file mode 100644 index 0000000..594e500 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabasePerformanceTest.kt @@ -0,0 +1,107 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import android.content.Context +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 de.jeanlucmakiola.agendula.data.tasks.TaskQuery +import de.jeanlucmakiola.agendula.domain.TaskForm +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.measureTime +import kotlin.time.measureTimedValue + +/** + * The plan's shape at scale: 5,000 tasks with 20 recurring series, read the way a + * smart list reads them — one `tasks(TaskQuery(includeCompleted = true))`, which + * includes expanding every series in memory. + * + * The assertion is a deliberately loose ceiling, so it catches a real regression + * rather than CI jitter; the printed numbers are what the check is actually for. + */ +@RunWith(AndroidJUnit4::class) +class TasksDatabasePerformanceTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private lateinit var db: TasksDatabase + private lateinit var source: RoomTasksDataSource + private var listId = 0L + + @Before + fun setUp() { + delete() + db = Room.databaseBuilder(context, TasksDatabase::class.java, DB) + .allowMainThreadQueries() + .build() + source = RoomTasksDataSource(db) + listId = source.createLocalList("Everything", 0xFF112233.toInt()) + } + + @After + fun tearDown() { + db.close() + delete() + } + + @Test + fun readsFiveThousandTasksWithTwentySeriesInsideTheBudget() { + val seeded = measureTime { seed() } + + // Discard the first read: it pays for statement compilation and page cache + // warming, which a running app has already paid. + source.tasks(TaskQuery(includeCompleted = true)) + val (tasks, elapsed) = measureTimedValue { + source.tasks(TaskQuery(includeCompleted = true)) + } + + println( + "[perf] $TASK_COUNT tasks / $SERIES_COUNT series -> ${tasks.size} occurrences " + + "in $elapsed (seed $seeded)", + ) + // Expansion is bounded twice over: the read window is 1 year back and 2 + // forward, and each series stops at ExpansionWindow.maxOccurrences (500), + // so the occurrence count cannot grow with the age of the series. + assertThat(tasks.size).isAtLeast(TASK_COUNT) + assertThat(elapsed.inWholeMilliseconds).isLessThan(CEILING_MILLIS) + } + + private fun seed() { + val anchor = Clock.System.now() - 30.days + val ids = ArrayList(TASK_COUNT) + db.runInTransaction { + repeat(TASK_COUNT) { index -> + ids += source.insertTask( + TaskForm(title = "Task $index", listId = listId, due = anchor + index.days), + ) + } + } + db.runInTransaction { + ids.take(SERIES_COUNT).forEach { id -> + val entity = db.tasks().entity(id)!! + db.tasks().update( + entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = "FREQ=DAILY"), + ) + } + } + } + + private fun delete() { + val base = context.getDatabasePath(DB) + base.delete() + listOf("-wal", "-shm").forEach { File(base.path + it).delete() } + } + + private companion object { + const val DB = "performance-test.db" + const val TASK_COUNT = 5_000 + const val SERIES_COUNT = 20 + const val CEILING_MILLIS = 8_000L + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseRestoreTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseRestoreTest.kt new file mode 100644 index 0000000..962ddde --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseRestoreTest.kt @@ -0,0 +1,155 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import android.content.Context +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 de.jeanlucmakiola.agendula.data.tasks.TaskQuery +import de.jeanlucmakiola.agendula.domain.TaskForm +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * The Auto Backup restore path, on disk. + * + * Auto Backup copies database files without checkpointing, and Room runs in WAL + * mode — so `.db` alone can be a *stale* copy of a database whose recent writes + * are still in the `-wal` sidecar. `res/xml/backup_rules.xml` carries all three + * files and [DatabaseCheckpoint] truncates the log on `ON_STOP`; this asserts + * that both of those actually do what they claim, and that neither alone is an + * assumption. + * + * A file copy of a live database stands in for the backup transport — the + * transport is what Auto Backup does to these files, and it is not what is under + * test here. + */ +@RunWith(AndroidJUnit4::class) +class TasksDatabaseRestoreTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private lateinit var db: TasksDatabase + private lateinit var source: RoomTasksDataSource + private var listId = 0L + private var restored: TasksDatabase? = null + + @Before + fun setUp() { + delete(LIVE) + delete(BACKUP) + db = open(LIVE) + source = RoomTasksDataSource(db) + listId = source.createLocalList("Personal", 0xFF112233.toInt()) + } + + @After + fun tearDown() { + restored?.close() + db.close() + delete(LIVE) + delete(BACKUP) + } + + @Test + fun roomRunsInWalMode() { + // Everything below is only interesting because of this. + assertThat(journalMode()).isEqualTo("wal") + } + + @Test + fun aBackupOfTheDbFileAloneLosesWhateverIsStillInTheWal() { + write("checkpointed") + checkpoint() + write("only in the wal") + + backUp(withSidecars = false) + + assertThat(restore()).containsExactly("checkpointed") + } + + @Test + fun aBackupThatCarriesTheSidecarsKeepsTheLastWrite() { + write("checkpointed") + checkpoint() + write("only in the wal") + + backUp(withSidecars = true) + + assertThat(restore()).containsExactly("checkpointed", "only in the wal") + } + + @Test + fun checkpointingFirstMakesTheDbFileAloneEnough() { + write("checkpointed") + checkpoint() + write("last write") + + // What DatabaseCheckpoint runs on ON_STOP — the fallback for a restore + // that arrives without the sidecars. + checkpoint() + backUp(withSidecars = false) + + assertThat(restore()).containsExactly("checkpointed", "last write") + } + + // --- the moving parts ----------------------------------------------------- + + private fun open(name: String): TasksDatabase = + Room.databaseBuilder(context, TasksDatabase::class.java, name) + .allowMainThreadQueries() + .build() + + private fun write(title: String) { + source.insertTask(TaskForm(title = title, listId = listId)) + } + + private fun journalMode(): String = + db.openHelper.writableDatabase.query("PRAGMA journal_mode").use { cursor -> + cursor.moveToFirst() + cursor.getString(0).lowercase() + } + + /** [DatabaseCheckpoint]'s pragma, asserting it was not blocked by a reader. */ + private fun checkpoint() { + db.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").use { cursor -> + cursor.moveToFirst() + assertThat(cursor.getInt(0)).isEqualTo(0) + } + } + + /** Copies the live database the way Auto Backup would: no checkpoint, files as they lie. */ + private fun backUp(withSidecars: Boolean) { + delete(BACKUP) + val live = context.getDatabasePath(LIVE) + val backup = context.getDatabasePath(BACKUP) + live.copyTo(backup, overwrite = true) + if (!withSidecars) return + SIDECARS.forEach { suffix -> + val from = File(live.path + suffix) + if (from.exists()) from.copyTo(File(backup.path + suffix), overwrite = true) + } + } + + /** Opens the copy as a fresh install would and reports the task titles that survived. */ + private fun restore(): List { + restored?.close() + val database = open(BACKUP).also { restored = it } + return RoomTasksDataSource(database).tasks(TaskQuery(includeCompleted = true)).map { it.title } + } + + private fun delete(name: String) { + val base = context.getDatabasePath(name) + base.delete() + SIDECARS.forEach { File(base.path + it).delete() } + } + + private companion object { + const val LIVE = "restore-live.db" + const val BACKUP = "restore-backup.db" + val SIDECARS = listOf("-wal", "-shm") + } +} diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index dbfacf5..8d670da 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -5,17 +5,18 @@ files without checkpointing, so the `-wal` sidecar can hold writes the `.db` alone does not — all three go in together, and the app checkpoints on ON_STOP so a restore is consistent either way. + + Naming any makes everything else excluded by default, so the + archived dmfs database (`tasks.db.imported`, kept one release as the + import's rollback path) is already left out. An explicit for it + would be redundant *and* rejected — lint's FullBackupContent check errors + on an exclude that sits under no included path. + + Settings live in DataStore, which this exclusion now also covers, so its + sharedpref file is listed back in. --> - - - - - + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 81ba3d0..c7530b1 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,16 +1,21 @@ + - - + - +