diff --git a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/1.json b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/1.json new file mode 100644 index 0000000..485362a --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/1.json @@ -0,0 +1,522 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "c94852274d874fe255ee76e1e46a3003", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principalUrl", + "columnName": "principal_url", + "affinity": "TEXT" + }, + { + "fieldPath": "homeSetUrl", + "columnName": "home_set_url", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncAt", + "columnName": "last_sync_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncError", + "columnName": "last_sync_error", + "affinity": "TEXT" + } + ], + "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, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "isVisible", + "columnName": "is_visible", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "isSynced", + "columnName": "is_synced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "ctag", + "columnName": "ctag", + "affinity": "TEXT" + }, + { + "fieldPath": "syncToken", + "columnName": "sync_token", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_lists_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "account_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "list_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "percentComplete", + "columnName": "percent_complete", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAt", + "columnName": "completed_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "classification", + "columnName": "classification", + "affinity": "INTEGER" + }, + { + "fieldPath": "dtstart", + "columnName": "dtstart", + "affinity": "INTEGER" + }, + { + "fieldPath": "due", + "columnName": "due", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT" + }, + { + "fieldPath": "isAllDay", + "columnName": "is_all_day", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT" + }, + { + "fieldPath": "rrule", + "columnName": "rrule", + "affinity": "TEXT" + }, + { + "fieldPath": "rdate", + "columnName": "rdate", + "affinity": "TEXT" + }, + { + "fieldPath": "exdate", + "columnName": "exdate", + "affinity": "TEXT" + }, + { + "fieldPath": "recurrenceId", + "columnName": "recurrence_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "masterId", + "columnName": "master_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "unknownProperties", + "columnName": "unknown_properties", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_list_id_is_deleted", + "unique": false, + "columnNames": [ + "list_id", + "is_deleted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)" + }, + { + "name": "index_tasks_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_tasks_master_id_recurrence_id", + "unique": false, + "columnNames": [ + "master_id", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)" + }, + { + "name": "index_tasks_is_dirty", + "unique": false, + "columnNames": [ + "is_dirty" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)" + }, + { + "name": "index_tasks_list_id_uid_recurrence_id", + "unique": true, + "columnNames": [ + "list_id", + "uid", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)" + } + ], + "foreignKeys": [ + { + "table": "task_lists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "list_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "master_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "parent_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "task_alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesBefore", + "columnName": "minutes_before", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reference", + "columnName": "reference", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DUE'" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_alarms_task_id", + "unique": false, + "columnNames": [ + "task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)" + } + ], + "foreignKeys": [ + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "task_id" + ], + "referencedColumns": [ + "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, 'c94852274d874fe255ee76e1e46a3003')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt new file mode 100644 index 0000000..17053f9 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseTest.kt @@ -0,0 +1,274 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +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.domain.TaskStatus +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.time.Instant + +/** + * The schema, exercised through the DAOs. Instrumented rather than JVM because + * the app's unit tests are plain JUnit 5 with no Robolectric, and Room needs a + * real SQLite. + */ +@RunWith(AndroidJUnit4::class) +class TasksDatabaseTest { + + private lateinit var db: TasksDatabase + private lateinit var lists: TaskListDao + private lateinit var tasks: TaskDao + private lateinit var alarms: TaskAlarmDao + private lateinit var accounts: AccountDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + TasksDatabase::class.java, + ).allowMainThreadQueries().build() + lists = db.taskLists() + tasks = db.tasks() + alarms = db.alarms() + accounts = db.accounts() + } + + @After + fun tearDown() = db.close() + + private fun newList(name: String = "Groceries", accountId: Long? = null): Long = + lists.insert(TaskListEntity(name = name, color = 0xFF00FF00.toInt(), accountId = accountId)) + + private fun newTask( + listId: Long, + uid: String = "uid-${counter++}", + title: String? = "Buy milk", + status: TaskStatus = TaskStatus.NEEDS_ACTION, + parentId: Long? = null, + masterId: Long? = null, + recurrenceId: Instant? = null, + ): Long = tasks.insert( + TaskEntity( + listId = listId, + uid = uid, + title = title, + status = status, + parentId = parentId, + masterId = masterId, + recurrenceId = recurrenceId, + ), + ) + + @Test + fun writesAndReadsAListWithItsTasks() { + val accountId = accounts.insert(AccountEntity(displayName = "Fastmail")) + val listId = newList(accountId = accountId) + val due = Instant.fromEpochMilliseconds(1_700_000_000_000) + val taskId = tasks.insert( + TaskEntity( + listId = listId, + uid = "uid-1", + title = "Buy milk", + description = "2%", + due = due, + priority = 3, + status = TaskStatus.IN_PROCESS, + percentComplete = 40, + ), + ) + + val list = lists.lists().single() + assertThat(list.list.id).isEqualTo(listId) + assertThat(list.list.name).isEqualTo("Groceries") + assertThat(list.accountDisplayName).isEqualTo("Fastmail") + + val row = tasks.task(taskId)!! + assertThat(row.task.title).isEqualTo("Buy milk") + assertThat(row.task.due).isEqualTo(due) + // Stored raw: an off-bucket PRIORITY must come back as it went in. + assertThat(row.task.priority).isEqualTo(3) + assertThat(row.task.status).isEqualTo(TaskStatus.IN_PROCESS) + assertThat(row.task.percentComplete).isEqualTo(40) + assertThat(row.listName).isEqualTo("Groceries") + assertThat(row.accountDisplayName).isEqualTo("Fastmail") + } + + @Test + fun readsTasksOfOneListAndHidesClosedOnesUnlessAsked() { + val a = newList("A") + val b = newList("B") + newTask(a, title = "open") + newTask(a, title = "done", status = TaskStatus.COMPLETED) + newTask(a, title = "cancelled", status = TaskStatus.CANCELLED) + newTask(b, title = "elsewhere") + + assertThat(tasks.tasks(a, includeCompleted = false).map { it.task.title }) + .containsExactly("open") + assertThat(tasks.tasks(a, includeCompleted = true)).hasSize(3) + assertThat(tasks.tasks(null, includeCompleted = true)).hasSize(4) + } + + @Test + fun readsSubtasksByParent() { + val listId = newList() + val parent = newTask(listId, title = "parent") + newTask(listId, title = "child", parentId = parent) + + assertThat(tasks.subtasks(parent).map { it.task.title }).containsExactly("child") + } + + @Test + fun hidesTombstonesFromReadsAndExports() { + val listId = newList() + val taskId = newTask(listId) + tasks.markDeleted(taskId, Instant.fromEpochMilliseconds(1)) + + assertThat(tasks.tasks(listId, includeCompleted = true)).isEmpty() + assertThat(tasks.task(taskId)).isNull() + assertThat(tasks.exportTasks(listId)).isEmpty() + assertThat(tasks.entity(taskId)).isNotNull() + } + + @Test + fun keepsOverridesOutOfTheMasterReads() { + val listId = newList() + val master = newTask(listId, uid = "series") + val override = newTask( + listId, + uid = "series", + masterId = master, + recurrenceId = Instant.fromEpochMilliseconds(5_000), + ) + + assertThat(tasks.tasks(listId, includeCompleted = true).map { it.task.id }) + .containsExactly(master) + assertThat(tasks.overrides(master).map { it.id }).containsExactly(override) + assertThat(tasks.overridesForList(listId).map { it.id }).containsExactly(override) + assertThat(tasks.override(master, Instant.fromEpochMilliseconds(5_000))?.id) + .isEqualTo(override) + assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master) + } + + // --- cascades ------------------------------------------------------------- + + @Test + fun deletingAListDeletesItsTasks() { + val listId = newList() + val taskId = newTask(listId) + + lists.delete(listId) + + assertThat(tasks.entity(taskId)).isNull() + } + + @Test + fun deletingASeriesDeletesItsOverrides() { + val listId = newList() + val master = newTask(listId, uid = "series") + val override = newTask( + listId, + uid = "series", + masterId = master, + recurrenceId = Instant.fromEpochMilliseconds(5_000), + ) + + tasks.delete(master) + + assertThat(tasks.entity(override)).isNull() + } + + @Test + fun deletingAParentPromotesItsSubtasks() { + val listId = newList() + val parent = newTask(listId, title = "parent") + val child = newTask(listId, title = "child", parentId = parent) + + tasks.delete(parent) + + val promoted = tasks.entity(child) + assertThat(promoted).isNotNull() + assertThat(promoted!!.parentId).isNull() + } + + @Test + fun deletingATaskDeletesItsAlarms() { + val listId = newList() + val taskId = newTask(listId) + alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15)) + assertThat(alarms.all()).hasSize(1) + + tasks.delete(taskId) + + assertThat(alarms.all()).isEmpty() + } + + @Test + fun deletingAnAccountDetachesItsListsInsteadOfDeletingThem() { + val accountId = accounts.insert(AccountEntity(displayName = "Fastmail")) + val listId = newList(accountId = accountId) + + accounts.delete(accountId) + + assertThat(lists.entity(listId)!!.accountId).isNull() + } + + @Test + fun replacingAnAlarmLeavesOnlyTheNewOne() { + val listId = newList() + val taskId = newTask(listId) + alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15)) + alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 30)) + + assertThat(alarms.forTask(taskId).map { it.minutesBefore }).containsExactly(30) + assertThat(alarms.forTask(taskId).single().reference).isEqualTo(AlarmReference.DUE) + + alarms.replaceForTask(taskId, null) + assertThat(alarms.forTask(taskId)).isEmpty() + } + + // --- the unique index ----------------------------------------------------- + + @Test + fun anOverrideMayShareItsMastersUid() { + val listId = newList() + val master = newTask(listId, uid = "series") + newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(1)) + newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(2)) + + assertThat(tasks.overrides(master)).hasSize(2) + } + + @Test + fun rejectsTwoOverridesOfTheSameOccurrence() { + val listId = newList() + val master = newTask(listId, uid = "series") + val at = Instant.fromEpochMilliseconds(1) + newTask(listId, uid = "series", masterId = master, recurrenceId = at) + + val failure = runCatching { + newTask(listId, uid = "series", masterId = master, recurrenceId = at) + }.exceptionOrNull() + + assertThat(failure).isNotNull() + assertThat(failure!!.message).contains("UNIQUE") + } + + @Test + fun theSameUidMayExistInAnotherList() { + val a = newList("A") + val b = newList("B") + newTask(a, uid = "shared") + newTask(b, uid = "shared") + + assertThat(tasks.byUid(a, "shared")).isNotNull() + assertThat(tasks.byUid(b, "shared")).isNotNull() + } + + private companion object { + var counter = 0 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt new file mode 100644 index 0000000..e21f14a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import kotlin.time.Instant + +/** Reads and writes over `accounts`. Unused until sync lands. */ +@Dao +interface AccountDao { + + @Query("SELECT * FROM accounts ORDER BY display_name") + fun all(): List + + @Query("SELECT * FROM accounts WHERE id = :accountId") + fun account(accountId: Long): AccountEntity? + + @Insert + fun insert(account: AccountEntity): Long + + @Update + fun update(account: AccountEntity) + + @Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId") + fun recordSync(accountId: Long, at: Instant?, error: String?) + + @Query("DELETE FROM accounts WHERE id = :accountId") + fun delete(accountId: Long): Int +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt new file mode 100644 index 0000000..f4eae17 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Converters.kt @@ -0,0 +1,38 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.TypeConverter +import de.jeanlucmakiola.agendula.domain.TaskStatus +import de.jeanlucmakiola.agendula.domain.statusFromInt +import de.jeanlucmakiola.agendula.domain.toInt +import kotlin.time.Instant + +/** + * Storage encodings for the entity types SQLite has no column type for. Time is + * epoch millis; [TaskStatus] goes through the `domain` mappers so that numbering + * keeps its single home. + * + * `PRIORITY` deliberately has no converter — it is stored as the raw iCalendar + * integer, because [de.jeanlucmakiola.agendula.domain.Priority] is a lossy + * bucketing and a converter would apply it before the value reaches disk. + */ +object Converters { + + @TypeConverter + fun instantToMillis(value: Instant?): Long? = value?.toEpochMilliseconds() + + @TypeConverter + fun instantFromMillis(value: Long?): Instant? = value?.let(Instant::fromEpochMilliseconds) + + @TypeConverter + fun statusToInt(value: TaskStatus): Int = value.toInt() + + @TypeConverter + fun statusFrom(value: Int): TaskStatus = statusFromInt(value) + + @TypeConverter + fun alarmReferenceToString(value: AlarmReference): String = value.name + + @TypeConverter + fun alarmReferenceFrom(value: String): AlarmReference = + runCatching { AlarmReference.valueOf(value) }.getOrDefault(AlarmReference.DUE) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt new file mode 100644 index 0000000..ba58e5b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt @@ -0,0 +1,213 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE +import de.jeanlucmakiola.agendula.domain.TaskStatus +import kotlin.time.Instant + +/** + * A CalDAV account. Empty until sync lands (`docs/SYNC.md` phase 2), but the FK + * from [TaskListEntity] exists from v1 so turning sync on never needs a + * migration. The app password is never stored here — Keystore only. + */ +@Entity(tableName = "accounts") +data class AccountEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "display_name") val displayName: String, + @ColumnInfo(name = "principal_url") val principalUrl: String? = null, + @ColumnInfo(name = "home_set_url") val homeSetUrl: String? = null, + @ColumnInfo(name = "username") val username: String? = null, + @ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null, + @ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null, +) + +/** + * A task list. [accountId] is nullable: `NULL` is a device-only list, and + * attaching one to an account later is a plain `UPDATE` rather than a data + * migration. + * + * Deleting an account detaches its lists (`SET NULL`) instead of deleting them, + * for the same reason [TaskEntity.parentId] does — removing an account is not + * an instruction to destroy the tasks it held. + */ +@Entity( + tableName = "task_lists", + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = ["id"], + childColumns = ["account_id"], + onDelete = ForeignKey.SET_NULL, + ), + ], + indices = [Index(value = ["account_id"])], +) +data class TaskListEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "name") val name: String, + /** ARGB. */ + @ColumnInfo(name = "color") val color: Int, + @ColumnInfo(name = "account_id") val accountId: Long? = null, + @ColumnInfo(name = "is_visible", defaultValue = "1") val isVisible: Boolean = true, + @ColumnInfo(name = "is_synced", defaultValue = "1") val isSynced: Boolean = true, + /** CalDAV owner display name. */ + @ColumnInfo(name = "owner") val owner: String? = null, + @ColumnInfo(name = "is_read_only", defaultValue = "0") val isReadOnly: Boolean = false, + /** User ordering. */ + @ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0, + /** Collection URL, relative to the account root. */ + @ColumnInfo(name = "href") val href: String? = null, + @ColumnInfo(name = "ctag") val ctag: String? = null, + /** RFC 6578 sync token, per collection. */ + @ColumnInfo(name = "sync_token") val syncToken: String? = null, + @ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false, +) + +/** + * A task. Series masters *and* `RECURRENCE-ID` overrides live in this table; an + * override is a row with [recurrenceId] set and [masterId] pointing at its + * master, sharing the master's [uid]. + * + * [masterId] and [parentId] are different things: [parentId] is task hierarchy + * (`RELATED-TO;RELTYPE=PARENT`), [masterId] is recurrence. A row can carry both. + */ +@Entity( + tableName = "tasks", + foreignKeys = [ + ForeignKey( + entity = TaskListEntity::class, + parentColumns = ["id"], + childColumns = ["list_id"], + onDelete = ForeignKey.CASCADE, + ), + // Deleting a series takes its overrides with it — they would otherwise be + // unreachable rows that still sync. + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["master_id"], + onDelete = ForeignKey.CASCADE, + ), + // Deleting a parent promotes its subtasks to top level rather than + // destroying work the user did not ask to lose. + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["parent_id"], + onDelete = ForeignKey.SET_NULL, + ), + ], + indices = [ + Index(value = ["list_id", "is_deleted"]), + Index(value = ["parent_id"]), + Index(value = ["master_id", "recurrence_id"]), + Index(value = ["is_dirty"]), + // An override shares its master's UID, so (list_id, uid) alone would + // reject the very rows recurrence depends on. With recurrence_id NULL on + // the master and set on each override this reads as: one master and at + // most one override per occurrence, per UID, per list. Note SQLite treats + // NULLs as distinct in a unique index, so the master half is a statement + // of intent, not an enforced constraint. + Index(value = ["list_id", "uid", "recurrence_id"], unique = true), + ], +) +data class TaskEntity( + // identity + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "list_id") val listId: Long, + /** RFC 4122 UUID, minted at creation in every mode, synced or not. */ + @ColumnInfo(name = "uid") val uid: String, + @ColumnInfo(name = "href") val href: String? = null, + @ColumnInfo(name = "etag") val etag: String? = null, + + // content + @ColumnInfo(name = "title") val title: String? = null, + @ColumnInfo(name = "description") val description: String? = null, + @ColumnInfo(name = "location") val location: String? = null, + @ColumnInfo(name = "url") val url: String? = null, + /** ARGB override for the list colour. */ + @ColumnInfo(name = "color") val color: Int? = null, + + // state + @ColumnInfo(name = "status", defaultValue = "0") val status: TaskStatus = TaskStatus.NEEDS_ACTION, + @ColumnInfo(name = "percent_complete") val percentComplete: Int? = null, + @ColumnInfo(name = "completed_at") val completedAt: Instant? = null, + /** + * Raw iCalendar `PRIORITY`: 0 none, 1 highest, 9 lowest. Stored unbucketed — + * [de.jeanlucmakiola.agendula.domain.Priority] folds 1–4 into HIGH, so + * converting on the way *in* would rewrite a server's `PRIORITY:3` as `1` and + * lose it on the next round-trip. The bucketing belongs to the mapper, which + * is where the UI needs it. + */ + @ColumnInfo(name = "priority", defaultValue = "0") val priority: Int = PRIORITY_NONE, + /** RFC 5545 `CLASS`: 0 public, 1 private, 2 confidential. */ + @ColumnInfo(name = "classification") val classification: Int? = null, + + // time + @ColumnInfo(name = "dtstart") val dtstart: Instant? = null, + @ColumnInfo(name = "due") val due: Instant? = null, + /** RFC 5545 `DURATION`, verbatim. Mutually exclusive with [due]. */ + @ColumnInfo(name = "duration") val duration: String? = null, + @ColumnInfo(name = "is_all_day", defaultValue = "0") val isAllDay: Boolean = false, + @ColumnInfo(name = "timezone") val timezone: String? = null, + + // recurrence + @ColumnInfo(name = "rrule") val rrule: String? = null, + @ColumnInfo(name = "rdate") val rdate: String? = null, + @ColumnInfo(name = "exdate") val exdate: String? = null, + /** This row's `RECURRENCE-ID` anchor; `NULL` on a master. */ + @ColumnInfo(name = "recurrence_id") val recurrenceId: Instant? = null, + /** The series this row overrides; `NULL` on a master. */ + @ColumnInfo(name = "master_id") val masterId: Long? = null, + + // hierarchy + @ColumnInfo(name = "parent_id") val parentId: Long? = null, + @ColumnInfo(name = "sort_order", defaultValue = "0") val sortOrder: Int = 0, + + // audit + @ColumnInfo(name = "created_at") val createdAt: Instant? = null, + @ColumnInfo(name = "last_modified") val lastModified: Instant? = null, + @ColumnInfo(name = "sequence", defaultValue = "0") val sequence: Int = 0, + + // sync + @ColumnInfo(name = "is_dirty", defaultValue = "0") val isDirty: Boolean = false, + /** Tombstone: deleted locally, still owed to a server. */ + @ColumnInfo(name = "is_deleted", defaultValue = "0") val isDeleted: Boolean = false, + /** + * Raw unfolded iCalendar lines of every property we do not model, re-emitted + * verbatim on write so a round-trip cannot silently lose a field. + */ + @ColumnInfo(name = "unknown_properties") val unknownProperties: String? = null, +) + +/** What [TaskAlarmEntity.minutesBefore] counts back from. */ +enum class AlarmReference { DUE, START } + +/** A reminder lead on a task. Positive [minutesBefore] is *before* [reference]. */ +@Entity( + tableName = "task_alarms", + foreignKeys = [ + ForeignKey( + entity = TaskEntity::class, + parentColumns = ["id"], + childColumns = ["task_id"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["task_id"])], +) +data class TaskAlarmEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0, + @ColumnInfo(name = "task_id") val taskId: Long, + @ColumnInfo(name = "minutes_before") val minutesBefore: Int, + @ColumnInfo(name = "reference", defaultValue = "DUE") val reference: AlarmReference = AlarmReference.DUE, + @ColumnInfo(name = "message") val message: String? = null, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt new file mode 100644 index 0000000..619b0fa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt @@ -0,0 +1,26 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.ColumnInfo +import androidx.room.Embedded + +/** + * A list plus its account's display name, which the domain + * [de.jeanlucmakiola.agendula.domain.TaskList] carries and groups by. + * `null` means a device-only list. + */ +data class TaskListRow( + @Embedded val list: TaskListEntity, + @ColumnInfo(name = "account_display_name") val accountDisplayName: String?, +) + +/** + * A task plus the three columns of its list the domain + * [de.jeanlucmakiola.agendula.domain.Task] carries, so reading a screenful is + * one query rather than one per list. + */ +data class TaskRow( + @Embedded val task: TaskEntity, + @ColumnInfo(name = "list_name") val listName: String, + @ColumnInfo(name = "list_color") val listColor: Int, + @ColumnInfo(name = "account_display_name") val accountDisplayName: String?, +) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt new file mode 100644 index 0000000..d77d15f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskAlarmDao.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction + +/** Reads and writes over `task_alarms`. */ +@Dao +interface TaskAlarmDao { + + /** Every reminder in the store, for one scheduler pass. */ + @Query("SELECT * FROM task_alarms") + fun all(): List + + @Query("SELECT * FROM task_alarms WHERE task_id = :taskId") + fun forTask(taskId: Long): List + + @Insert + fun insert(alarm: TaskAlarmEntity): Long + + @Query("DELETE FROM task_alarms WHERE task_id = :taskId") + fun deleteForTask(taskId: Long): Int + + /** Set the task's only reminder, or clear it with `null`. */ + @Transaction + fun replaceForTask(taskId: Long, alarm: TaskAlarmEntity?) { + deleteForTask(taskId) + alarm?.let { insert(it.copy(taskId = taskId)) } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt new file mode 100644 index 0000000..82bce92 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt @@ -0,0 +1,124 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import de.jeanlucmakiola.agendula.domain.TaskStatus +import kotlin.time.Instant + +/** + * Reads and writes over `tasks`. + * + * Reads split masters from overrides on purpose: [tasks] returns the rows a + * recurrence expander expands (a non-recurring task is its own single + * occurrence), and [overridesForList] / [overrides] return the + * `RECURRENCE-ID` rows that replace individual occurrences. Nothing here + * expands anything — that is phase 2's job, in Kotlin. + */ +@Dao +interface TaskDao { + + // --- reads ---------------------------------------------------------------- + + /** + * Master (and non-recurring) rows, optionally narrowed to one list. Closed + * tasks — `COMPLETED` and `CANCELLED` — are excluded unless + * [includeCompleted]; tombstones always are. + */ + @Query( + """ + SELECT t.*, l.name AS list_name, l.color AS list_color, + a.display_name AS account_display_name + FROM tasks t + JOIN task_lists l ON l.id = t.list_id + LEFT JOIN accounts a ON a.id = l.account_id + WHERE t.is_deleted = 0 + AND t.master_id IS NULL + AND (:listId IS NULL OR t.list_id = :listId) + AND (:includeCompleted = 1 OR t.status NOT IN (2, 3)) + """ + ) + fun tasks(listId: Long?, includeCompleted: Boolean): List + + @Query( + """ + SELECT t.*, l.name AS list_name, l.color AS list_color, + a.display_name AS account_display_name + FROM tasks t + JOIN task_lists l ON l.id = t.list_id + LEFT JOIN accounts a ON a.id = l.account_id + WHERE t.id = :taskId AND t.is_deleted = 0 + """ + ) + fun task(taskId: Long): TaskRow? + + @Query( + """ + SELECT t.*, l.name AS list_name, l.color AS list_color, + a.display_name AS account_display_name + FROM tasks t + JOIN task_lists l ON l.id = t.list_id + LEFT JOIN accounts a ON a.id = l.account_id + WHERE t.parent_id = :parentTaskId AND t.is_deleted = 0 AND t.master_id IS NULL + """ + ) + fun subtasks(parentTaskId: Long): List + + @Query("SELECT * FROM tasks WHERE id = :taskId") + fun entity(taskId: Long): TaskEntity? + + /** Every override in [listId], for expansion alongside [tasks]. */ + @Query("SELECT * FROM tasks WHERE list_id = :listId AND master_id IS NOT NULL AND is_deleted = 0") + fun overridesForList(listId: Long): List + + @Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0") + fun overrides(masterId: Long): List + + @Query( + "SELECT * FROM tasks WHERE master_id = :masterId AND recurrence_id IS :recurrenceId AND is_deleted = 0" + ) + fun override(masterId: Long, recurrenceId: Instant?): TaskEntity? + + @Query("SELECT * FROM tasks WHERE list_id = :listId AND uid = :uid AND recurrence_id IS :recurrenceId") + fun byUid(listId: Long, uid: String, recurrenceId: Instant? = null): TaskEntity? + + /** Masters only, tombstones excluded — what an `.ics` export writes. */ + @Query("SELECT * FROM tasks WHERE list_id = :listId AND is_deleted = 0 AND master_id IS NULL") + fun exportTasks(listId: Long): List + + @Query("SELECT * FROM tasks WHERE is_dirty = 1") + fun dirty(): List + + // --- writes --------------------------------------------------------------- + + @Insert + fun insert(task: TaskEntity): Long + + @Update + fun update(task: TaskEntity): Int + + @Query( + """ + UPDATE tasks SET status = :status, percent_complete = :percentComplete, + completed_at = :completedAt, last_modified = :lastModified, is_dirty = :dirty + WHERE id = :taskId + """ + ) + fun setCompletion( + taskId: Long, + status: TaskStatus, + percentComplete: Int?, + completedAt: Instant?, + lastModified: Instant?, + dirty: Boolean, + ): Int + + /** Hard delete. Used when the row was never on a server. */ + @Query("DELETE FROM tasks WHERE id = :taskId") + fun delete(taskId: Long): Int + + /** Tombstone, for a row a server still knows about. */ + @Query("UPDATE tasks SET is_deleted = 1, is_dirty = 1, last_modified = :at WHERE id = :taskId") + fun markDeleted(taskId: Long, at: Instant?): Int +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt new file mode 100644 index 0000000..f6017a6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update + +/** Reads and writes over `task_lists`. Synchronous, like the seam above it. */ +@Dao +interface TaskListDao { + + @Query( + """ + SELECT l.*, a.display_name AS account_display_name + FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id + ORDER BY a.display_name, l.sort_order, l.name + """ + ) + fun lists(): List + + @Query( + """ + SELECT l.*, a.display_name AS account_display_name + FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id + WHERE l.id = :listId + """ + ) + fun list(listId: Long): TaskListRow? + + @Query("SELECT * FROM task_lists WHERE id = :listId") + fun entity(listId: Long): TaskListEntity? + + @Query("SELECT COUNT(*) FROM task_lists WHERE id = :listId") + fun exists(listId: Long): Int + + @Insert + fun insert(list: TaskListEntity): Long + + @Update + fun update(list: TaskListEntity) + + @Query("UPDATE task_lists SET is_visible = :visible WHERE id = :listId") + fun setVisible(listId: Long, visible: Boolean) + + /** Attach a list to an account, or detach it with `null`. */ + @Query("UPDATE task_lists SET account_id = :accountId WHERE id = :listId") + fun setAccount(listId: Long, accountId: Long?) + + @Query("DELETE FROM task_lists WHERE id = :listId") + fun delete(listId: Long) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt new file mode 100644 index 0000000..9b8aabf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt @@ -0,0 +1,34 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters + +/** + * Agendula's own task store (`docs/OWN-STORE.md`). Four tables, designed from + * what the app actually reads and writes plus RFC 5545's `VTODO`. + * + * Schemas are exported to `app/schemas/` and committed, so a future version can + * be migration-tested against this one. + */ +@Database( + entities = [ + AccountEntity::class, + TaskListEntity::class, + TaskEntity::class, + TaskAlarmEntity::class, + ], + version = 1, + exportSchema = true, +) +@TypeConverters(Converters::class) +abstract class TasksDatabase : RoomDatabase() { + abstract fun taskLists(): TaskListDao + abstract fun tasks(): TaskDao + abstract fun alarms(): TaskAlarmDao + abstract fun accounts(): AccountDao + + companion object { + const val NAME = "agendula-tasks.db" + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt new file mode 100644 index 0000000..2ef8067 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/ConvertersTest.kt @@ -0,0 +1,53 @@ +package de.jeanlucmakiola.agendula.data.tasks.room + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.agendula.domain.Priority +import de.jeanlucmakiola.agendula.domain.priorityFromICal +import de.jeanlucmakiola.agendula.domain.toICal +import de.jeanlucmakiola.agendula.domain.TaskStatus +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class ConvertersTest { + + @Test + fun `round-trips an instant through epoch millis`() { + val value = Instant.fromEpochMilliseconds(1_700_000_000_123) + + val stored = Converters.instantToMillis(value) + + assertThat(stored).isEqualTo(1_700_000_000_123) + assertThat(Converters.instantFromMillis(stored)).isEqualTo(value) + } + + @Test + fun `maps null time both ways`() { + assertThat(Converters.instantToMillis(null)).isNull() + assertThat(Converters.instantFromMillis(null)).isNull() + } + + @Test + fun `round-trips every status through the domain encoding`() { + TaskStatus.entries.forEach { status -> + assertThat(Converters.statusFrom(Converters.statusToInt(status))).isEqualTo(status) + } + } + + @Test + fun `priority is stored raw, so an off-bucket value survives`() { + // There is no priority converter on purpose: PRIORITY:3 is a legitimate + // value a server can send, and Priority buckets 1..4 into HIGH. Bucketing + // on the way in would rewrite it as 1 and lose it on the next round-trip. + assertThat(priorityFromICal(3)).isEqualTo(Priority.HIGH) + assertThat(Priority.HIGH.toICal()).isEqualTo(1) + } + + @Test + fun `round-trips an alarm reference and falls back on an unknown one`() { + AlarmReference.entries.forEach { reference -> + assertThat(Converters.alarmReferenceFrom(Converters.alarmReferenceToString(reference))) + .isEqualTo(reference) + } + assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE) + } +}