diff --git a/app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/3.json b/app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/3.json new file mode 100644 index 0000000..b355d27 --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/3.json @@ -0,0 +1,384 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "b41398764e44a3f293815797eec1658d", + "entities": [ + { + "tableName": "alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `label` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `repeat_days` INTEGER NOT NULL, `skip_next_occurrence` INTEGER NOT NULL, `ringtone_uri` TEXT, `vibrate` INTEGER, `snooze_minutes` INTEGER, `snooze_limit` INTEGER, `volume_ramp_seconds` INTEGER, `dismiss_challenge` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `delete_after_use` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatDays", + "columnName": "repeat_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "skipNextOccurrence", + "columnName": "skip_next_occurrence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ringtoneUri", + "columnName": "ringtone_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "vibrate", + "columnName": "vibrate", + "affinity": "INTEGER" + }, + { + "fieldPath": "snoozeMinutes", + "columnName": "snooze_minutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "snoozeLimit", + "columnName": "snooze_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "volumeRampSeconds", + "columnName": "volume_ramp_seconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "dismissChallenge", + "columnName": "dismiss_challenge", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleteAfterUse", + "columnName": "delete_after_use", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_alarms_enabled", + "unique": false, + "columnNames": [ + "enabled" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_alarms_enabled` ON `${TABLE_NAME}` (`enabled`)" + } + ] + }, + { + "tableName": "alarm_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`alarm_id` INTEGER NOT NULL, `snoozed_until` INTEGER, `snooze_count` INTEGER NOT NULL, `ringing_since` INTEGER, `handled_occurrence` INTEGER, `skipped_occurrence` INTEGER, PRIMARY KEY(`alarm_id`), FOREIGN KEY(`alarm_id`) REFERENCES `alarms`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "alarmId", + "columnName": "alarm_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snoozedUntil", + "columnName": "snoozed_until", + "affinity": "INTEGER" + }, + { + "fieldPath": "snoozeCount", + "columnName": "snooze_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ringingSince", + "columnName": "ringing_since", + "affinity": "INTEGER" + }, + { + "fieldPath": "handledOccurrence", + "columnName": "handled_occurrence", + "affinity": "INTEGER" + }, + { + "fieldPath": "skippedOccurrence", + "columnName": "skipped_occurrence", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "alarm_id" + ] + }, + "foreignKeys": [ + { + "table": "alarms", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "alarm_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "timers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `duration_millis` INTEGER NOT NULL, `state` TEXT NOT NULL, `remaining_millis` INTEGER NOT NULL, `started_at_elapsed_realtime_millis` INTEGER, `ends_at_elapsed_realtime_millis` INTEGER, `ends_at_wall_clock_millis` INTEGER, `ringtone_uri` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `delete_after_use` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationMillis", + "columnName": "duration_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remainingMillis", + "columnName": "remaining_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAtElapsedRealtimeMillis", + "columnName": "started_at_elapsed_realtime_millis", + "affinity": "INTEGER" + }, + { + "fieldPath": "endsAtElapsedRealtimeMillis", + "columnName": "ends_at_elapsed_realtime_millis", + "affinity": "INTEGER" + }, + { + "fieldPath": "endsAtWallClockMillis", + "columnName": "ends_at_wall_clock_millis", + "affinity": "INTEGER" + }, + { + "fieldPath": "ringtoneUri", + "columnName": "ringtone_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleteAfterUse", + "columnName": "delete_after_use", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_timers_sort_order", + "unique": false, + "columnNames": [ + "sort_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_timers_sort_order` ON `${TABLE_NAME}` (`sort_order`)" + } + ] + }, + { + "tableName": "world_clocks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `zone_id` TEXT NOT NULL, `label` TEXT, `sort_order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "zoneId", + "columnName": "zone_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_world_clocks_zone_id", + "unique": true, + "columnNames": [ + "zone_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_world_clocks_zone_id` ON `${TABLE_NAME}` (`zone_id`)" + }, + { + "name": "index_world_clocks_sort_order", + "unique": false, + "columnNames": [ + "sort_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_world_clocks_sort_order` ON `${TABLE_NAME}` (`sort_order`)" + } + ] + }, + { + "tableName": "stopwatch_laps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `lap_index` INTEGER NOT NULL, `split_millis` INTEGER NOT NULL, `cumulative_millis` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lapIndex", + "columnName": "lap_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "splitMillis", + "columnName": "split_millis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cumulativeMillis", + "columnName": "cumulative_millis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_stopwatch_laps_lap_index", + "unique": true, + "columnNames": [ + "lap_index" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_stopwatch_laps_lap_index` ON `${TABLE_NAME}` (`lap_index`)" + } + ] + } + ], + "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, 'b41398764e44a3f293815797eec1658d')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/MigrationTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/MigrationTest.kt index 4fc9849..b23bdc5 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/MigrationTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/MigrationTest.kt @@ -35,6 +35,15 @@ class MigrationTest { 1700000000000, 1700000060000) """.trimIndent() + private val insertTimer = """ + INSERT INTO timers ( + id, label, duration_millis, state, remaining_millis, + started_at_elapsed_realtime_millis, ends_at_elapsed_realtime_millis, + ends_at_wall_clock_millis, ringtone_uri, sort_order, created_at, updated_at + ) VALUES (1, 'Pasta', 300000, 'IDLE', 300000, NULL, NULL, NULL, NULL, 0, + 1700000000000, 1700000060000) + """.trimIndent() + private fun migratedDatabase(): ClockulaDatabase { val db = Room.databaseBuilder( ApplicationProvider.getApplicationContext(), @@ -102,6 +111,33 @@ class MigrationTest { assertThat(runBlocking { db.alarmStateDao().findByAlarmId(1L) }).isNull() } + @Test + fun theSecondMigrationValidatesAgainstTheExportedVersionThreeSchema() { + helper.createDatabase(DB_NAME, 2).close() + + val migrated = helper.runMigrationsAndValidate(DB_NAME, 3, true, Migrations.MIGRATION_2_3) + + assertThat(migrated.version).isEqualTo(3) + migrated.close() + } + + @Test + fun rowsWrittenAtVersionTwoSurviveAsPermanentOnes() { + helper.createDatabase(DB_NAME, 2).apply { + execSQL(insertAlarm) + execSQL(insertTimer) + close() + } + + helper.runMigrationsAndValidate(DB_NAME, 3, true, Migrations.MIGRATION_2_3).close() + + val db = migratedDatabase() + val alarm = runBlocking { db.alarmDao().findById(1L) } + val timer = runBlocking { db.timerDao().findById(1L) } + assertThat(alarm!!.deleteAfterUse).isFalse() + assertThat(timer!!.deleteAfterUse).isFalse() + } + private fun ringStateFor(alarmId: Long) = AlarmStateEntity( alarmId = alarmId, snoozedUntil = 1_700_000_100_000L, diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmEntity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmEntity.kt index bea3678..1c2dada 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmEntity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmEntity.kt @@ -27,4 +27,5 @@ data class AlarmEntity( @ColumnInfo(name = "dismiss_challenge") val dismissChallenge: String?, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long, + @ColumnInfo(name = "delete_after_use", defaultValue = "0") val deleteAfterUse: Boolean = false, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapper.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapper.kt index c5f2933..e86e10c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapper.kt @@ -27,6 +27,7 @@ object AlarmMapper { dismissChallenge = entity.dismissChallenge?.toEnum(DismissChallenge.NONE), createdAt = Instant.fromEpochMilliseconds(entity.createdAt), updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt), + deleteAfterUse = entity.deleteAfterUse, ) fun toDomain(entities: List): List = entities.map(::toDomain) @@ -47,6 +48,7 @@ object AlarmMapper { dismissChallenge = alarm.dismissChallenge?.name, createdAt = alarm.createdAt.toEpochMilliseconds(), updatedAt = alarm.updatedAt.toEpochMilliseconds(), + deleteAfterUse = alarm.deleteAfterUse, ) /** id = 0, createdAt = updatedAt = [now]. */ @@ -66,5 +68,6 @@ object AlarmMapper { dismissChallenge = draft.dismissChallenge?.name, createdAt = now.toEpochMilliseconds(), updatedAt = now.toEpochMilliseconds(), + deleteAfterUse = draft.deleteAfterUse, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt index 1c9909a..cda9b3b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabase.kt @@ -14,7 +14,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity /** - * Clockula's own storage. Version 2; the schema is exported to `app/schemas/` + * Clockula's own storage. Version 3; the schema is exported to `app/schemas/` * and committed, so every later migration is reviewable and testable. * * No `TypeConverters` — every column is a primitive and the whole translation @@ -29,7 +29,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity WorldClockEntity::class, LapEntity::class, ], - version = 2, + version = 3, exportSchema = true, ) abstract class ClockulaDatabase : RoomDatabase() { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/db/Migrations.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/db/Migrations.kt index 02d7a71..b0ab3fe 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/db/Migrations.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/db/Migrations.kt @@ -29,5 +29,20 @@ object Migrations { } } - val ALL: Array = arrayOf(MIGRATION_1_2) + /** v2 → v3: `delete_after_use` on `alarms` and on `timers` (M9 D10). */ + val MIGRATION_2_3: Migration = object : Migration(2, 3) { + override fun migrate(connection: SQLiteConnection) { + // `DEFAULT 0` matches the entities' own `defaultValue`, so the + // migrated table validates against the exported schema — and every + // row written before M9 reads as the permanent alarm it was. + connection.execSQL( + "ALTER TABLE `alarms` ADD COLUMN `delete_after_use` INTEGER NOT NULL DEFAULT 0", + ) + connection.execSQL( + "ALTER TABLE `timers` ADD COLUMN `delete_after_use` INTEGER NOT NULL DEFAULT 0", + ) + } + } + + val ALL: Array = arrayOf(MIGRATION_1_2, MIGRATION_2_3) } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerEntity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerEntity.kt index fd9d46a..1f90c95 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerEntity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerEntity.kt @@ -20,4 +20,5 @@ data class TimerEntity( @ColumnInfo(name = "sort_order") val sortOrder: Int, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long, + @ColumnInfo(name = "delete_after_use", defaultValue = "0") val deleteAfterUse: Boolean = false, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerMapper.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerMapper.kt index 2b126a7..1cbd642 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerMapper.kt @@ -22,6 +22,7 @@ object TimerMapper { sortOrder = entity.sortOrder, createdAt = Instant.fromEpochMilliseconds(entity.createdAt), updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt), + deleteAfterUse = entity.deleteAfterUse, ) fun toDomain(entities: List): List = entities.map(::toDomain) @@ -39,6 +40,7 @@ object TimerMapper { sortOrder = timer.sortOrder, createdAt = timer.createdAt.toEpochMilliseconds(), updatedAt = timer.updatedAt.toEpochMilliseconds(), + deleteAfterUse = timer.deleteAfterUse, ) fun toEntity(draft: TimerDraft, sortOrder: Int, now: Instant): TimerEntity { @@ -56,6 +58,7 @@ object TimerMapper { sortOrder = sortOrder, createdAt = now.toEpochMilliseconds(), updatedAt = now.toEpochMilliseconds(), + deleteAfterUse = draft.deleteAfterUse, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/Alarm.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Alarm.kt index a42c864..0129029 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/Alarm.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Alarm.kt @@ -91,6 +91,8 @@ data class Alarm( val dismissChallenge: DismissChallenge?, val createdAt: Instant, val updatedAt: Instant, + /** A transient alarm the `AlarmClock` contract asked for: deleted once its cycle closes (M9 D10). */ + val deleteAfterUse: Boolean, ) /** Everything needed to create an alarm; the repository supplies id and timestamps. */ @@ -105,6 +107,7 @@ data class AlarmDraft( val snoozeLimit: Int? = null, val volumeRampSeconds: Int? = null, val dismissChallenge: DismissChallenge? = null, + val deleteAfterUse: Boolean = false, ) /** An alarm's overrides resolved against the app defaults — no nullables left. */ diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/Timer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Timer.kt index ff719aa..d54562b 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/Timer.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Timer.kt @@ -22,12 +22,15 @@ data class Timer( val sortOrder: Int, val createdAt: Instant, val updatedAt: Instant, + /** A transient timer the `AlarmClock` contract asked for: deleted once it is dismissed (M9 D10). */ + val deleteAfterUse: Boolean, ) data class TimerDraft( val duration: Duration, val label: String = "", val ringtoneUri: String? = null, + val deleteAfterUse: Boolean = false, ) /** What a [Timer] reads as *right now*. [remaining] is never negative. */ diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapperTest.kt index e33cb12..4d6d18e 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmMapperTest.kt @@ -39,6 +39,7 @@ class AlarmMapperTest { dismissChallenge = DismissChallenge.MATH, createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L), updatedAt = Instant.fromEpochMilliseconds(1_700_000_060_000L), + deleteAfterUse = false, ), ) } @@ -133,4 +134,29 @@ class AlarmMapperTest { assertThat(roundTripped.ringtoneUri).isEqualTo(Ringtones.SILENT_URI) } + + @Test + fun `a transient row reads back as a transient alarm`() { + val alarm = AlarmMapper.toDomain(fullAlarmEntity().copy(deleteAfterUse = true)) + + assertThat(alarm.deleteAfterUse).isTrue() + } + + @Test + fun `a transient alarm writes the column back`() { + val entity = AlarmMapper.toEntity(fullAlarm().copy(deleteAfterUse = true)) + + assertThat(entity.deleteAfterUse).isTrue() + } + + @Test + fun `a draft carries the flag, and an ordinary draft does not`() { + val flags = listOf( + AlarmMapper.toEntity(AlarmDraft(TimeOfDay(6, 0), deleteAfterUse = true), T0).deleteAfterUse, + AlarmMapper.toEntity(AlarmDraft(TimeOfDay(6, 0)), T0).deleteAfterUse, + ) + + assertThat(flags).containsExactly(true, false).inOrder() + } + } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryTest.kt index 99e4660..9ffdb71 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryTest.kt @@ -228,4 +228,24 @@ class AlarmRepositoryTest { assertThat(repository.find(id)!!.ringtoneUri).isEqualTo(Ringtones.SILENT_URI) } + + @Test + fun `a transient draft is stored and read back as transient`() = runTest { + val repository = repository(FakeAlarmDao()) + + val id = repository.create(AlarmDraft(TimeOfDay(7, 0), deleteAfterUse = true)) + + assertThat(repository.find(id)!!.deleteAfterUse).isTrue() + } + + @Test + fun `editing an alarm leaves the transient flag alone`() = runTest { + val repository = repository(FakeAlarmDao()) + val id = repository.create(AlarmDraft(TimeOfDay(7, 0), deleteAfterUse = true)) + + repository.edit(id) { it.copy(label = "x") } + + assertThat(repository.find(id)!!.deleteAfterUse).isTrue() + } + } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/db/SchemaExportTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/db/SchemaExportTest.kt index b59a382..361cfec 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/db/SchemaExportTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/db/SchemaExportTest.kt @@ -40,21 +40,35 @@ class SchemaExportTest { } @Test - fun `the previous version is never regenerated away`() { - val schemas = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase") + fun `the version 3 schema is exported and committed`() { + val schema = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/3.json") - assertThat(schemas.list()?.toList()).containsAtLeast("1.json", "2.json") + assertThat(schema.isFile).isTrue() + val text = schema.readText() + assertThat(text).contains("\"version\": 3") + assertThat(text).contains("delete_after_use") + assertThat(text).contains("\"tableName\": \"alarms\"") + assertThat(text).contains("\"tableName\": \"timers\"") } @Test - fun `exactly one migration ships, and it reaches the exported schema version`() { + fun `the previous version is never regenerated away`() { + val schemas = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase") + + assertThat(schemas.list()?.toList()).containsAtLeast("1.json", "2.json", "3.json") + } + + @Test + fun `the migrations form an unbroken chain up to the exported schema version`() { val exported = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase") .list()!!.map { it.removeSuffix(".json").toInt() } + val migrations = Migrations.ALL.toList() - assertThat(exported.max()).isEqualTo(2) - assertThat(Migrations.ALL.toList()).hasSize(1) - assertThat(Migrations.ALL.single().startVersion).isEqualTo(1) - assertThat(Migrations.ALL.single().endVersion).isEqualTo(2) - assertThat(Migrations.ALL.single()).isEqualTo(Migrations.MIGRATION_1_2) + assertThat(exported.max()).isEqualTo(3) + assertThat(migrations).hasSize(2) + assertThat(migrations.map { it.startVersion to it.endVersion }) + .containsExactly(1 to 2, 2 to 3).inOrder() + assertThat(migrations).containsExactly(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3) + .inOrder() } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerMapperTest.kt index 6a23110..87c9268 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerMapperTest.kt @@ -94,4 +94,29 @@ class TimerMapperTest { ), ) } + + @Test + fun `a transient row reads back as a transient timer`() { + val timer = TimerMapper.toDomain(runningTimerEntity().copy(deleteAfterUse = true)) + + assertThat(timer.deleteAfterUse).isTrue() + } + + @Test + fun `a transient timer writes the column back`() { + val entity = TimerMapper.toEntity(runningTimer().copy(deleteAfterUse = true)) + + assertThat(entity.deleteAfterUse).isTrue() + } + + @Test + fun `a draft carries the flag, and an ordinary draft does not`() { + val flags = listOf( + TimerMapper.toEntity(TimerDraft(90.seconds, deleteAfterUse = true), 0, T0).deleteAfterUse, + TimerMapper.toEntity(TimerDraft(90.seconds), 0, T0).deleteAfterUse, + ) + + assertThat(flags).containsExactly(true, false).inOrder() + } + } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt index 43d4cea..4057e50 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt @@ -534,4 +534,24 @@ class TimerRepositoryTest { assertThat(repository.find(id)).isEqualTo(before) } + + @Test + fun `a transient draft is stored and read back as transient`() = runTest { + val id = repository.create(TimerDraft(5.minutes, "Pasta", deleteAfterUse = true)) + + assertThat(repository.find(id)!!.deleteAfterUse).isTrue() + } + + @Test + fun `resetting a transient timer returns it to idle without deleting it`() = runTest { + val id = repository.create(TimerDraft(5.minutes, "Pasta", deleteAfterUse = true)) + repository.start(id) + repository.markExpired(id) + + repository.reset(id) + + val timer = repository.find(id) + assertThat(timer).isNotNull() + assertThat(timer!!.state).isEqualTo(TimerState.IDLE) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/AlarmSettingsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/AlarmSettingsTest.kt index 03c27c8..5b6165c 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/AlarmSettingsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/AlarmSettingsTest.kt @@ -42,6 +42,7 @@ class AlarmSettingsTest { dismissChallenge = dismissChallenge, createdAt = T0, updatedAt = T0, + deleteAfterUse = false, ) @Test diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/TimerSnapshotTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/TimerSnapshotTest.kt index 47cd6b6..6a6b8be 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/TimerSnapshotTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/TimerSnapshotTest.kt @@ -35,6 +35,7 @@ class TimerSnapshotTest { sortOrder = 0, createdAt = T0, updatedAt = T0, + deleteAfterUse = false, ) private fun idleTimer(state: TimerState, remaining: Duration) = timer( diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/AlarmEngineHarness.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/AlarmEngineHarness.kt index 11761bc..fd19f67 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/AlarmEngineHarness.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/AlarmEngineHarness.kt @@ -62,6 +62,12 @@ class AlarmEngineHarness( zones = zones, ) + init { + // `ON DELETE CASCADE`, as the schema declares it (M3) and as M9's + // transient alarms rely on: deleting the alarm deletes its ring state. + alarmDao.onDeleted = { stateDao.deleteByAlarmId(it) } + } + /** Inserts an alarm straight into storage, bypassing the engine. */ suspend fun givenAlarm( id: Long = 1L, @@ -73,6 +79,7 @@ class AlarmEngineHarness( label: String = "Wake up", snoozeMinutes: Int? = null, snoozeLimit: Int? = null, + deleteAfterUse: Boolean = false, ): Alarm { val alarm = alarmAt( id = id, @@ -84,6 +91,7 @@ class AlarmEngineHarness( label = label, snoozeMinutes = snoozeMinutes, snoozeLimit = snoozeLimit, + deleteAfterUse = deleteAfterUse, ) alarmDao.insert(AlarmMapper.toEntity(alarm)) return alarm diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt index cafbca5..51a4056 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt @@ -32,6 +32,15 @@ open class FakeAlarmDao( /** The stored rows, for asserting that a call wrote nothing. */ val stored: List get() = sorted(rows.value) + /** + * `alarm_states.alarm_id` is declared `ON DELETE CASCADE`, so a deleted + * alarm takes its ring state with it. The fake cannot infer that from a + * schema it does not have, so the harness wires it here — otherwise a test + * about a deleted alarm would leave an orphan row the real database cannot + * hold. + */ + var onDeleted: (suspend (Long) -> Unit)? = null + override fun observeAll(): Flow> = rows.map(::sorted) override fun observeById(id: Long): Flow = @@ -56,6 +65,7 @@ open class FakeAlarmDao( override suspend fun deleteById(id: Long) { rows.update { rows -> rows.filterNot { it.id == id } } + onDeleted?.invoke(id) } override suspend fun setEnabled(id: Long, enabled: Boolean, updatedAt: Long) { diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt index 1d474fd..99a0ee5 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt @@ -62,6 +62,7 @@ fun fullAlarm(): Alarm = Alarm( dismissChallenge = DismissChallenge.MATH, createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L), updatedAt = Instant.fromEpochMilliseconds(1_700_000_060_000L), + deleteAfterUse = false, ) fun runningTimerEntity(): TimerEntity = TimerEntity( @@ -92,6 +93,7 @@ fun runningTimer(): Timer = Timer( sortOrder = 3, createdAt = Instant.fromEpochMilliseconds(1_700_000_000_000L), updatedAt = Instant.fromEpochMilliseconds(1_700_000_060_000L), + deleteAfterUse = false, ) fun worldClockEntity(): WorldClockEntity = WorldClockEntity( @@ -156,6 +158,7 @@ fun alarmAt( snoozeMinutes: Int? = null, snoozeLimit: Int? = null, label: String = "Wake up", + deleteAfterUse: Boolean = false, ): Alarm = Alarm( id = id, time = TimeOfDay(hour, minute), @@ -171,6 +174,7 @@ fun alarmAt( dismissChallenge = null, createdAt = T0, updatedAt = T0, + deleteAfterUse = deleteAfterUse, ) /** Ring state, defaulted to the record an alarm with no stored row reads back as. */ @@ -236,6 +240,7 @@ fun timerAt( endsAtWallClock: Instant? = null, ringtoneUri: String? = null, sortOrder: Int = 0, + deleteAfterUse: Boolean = false, ): Timer = Timer( id = id, label = label, @@ -249,6 +254,7 @@ fun timerAt( sortOrder = sortOrder, createdAt = T0, updatedAt = T0, + deleteAfterUse = deleteAfterUse, ) fun idleTimer( @@ -257,6 +263,7 @@ fun idleTimer( label: String = "Pasta", sortOrder: Int = 0, ringtoneUri: String? = null, + deleteAfterUse: Boolean = false, ): Timer = timerAt( id = id, label = label, @@ -265,6 +272,7 @@ fun idleTimer( remaining = duration, sortOrder = sortOrder, ringtoneUri = ringtoneUri, + deleteAfterUse = deleteAfterUse, ) /** RUNNING with all three anchors derived from one reading of both clocks, [left] from now. */ @@ -277,6 +285,7 @@ fun runningTimerWith( label: String = "Pasta", sortOrder: Int = 0, ringtoneUri: String? = null, + deleteAfterUse: Boolean = false, ): Timer = timerAt( id = id, label = label, @@ -288,6 +297,7 @@ fun runningTimerWith( endsAtWallClock = wallNow + left, sortOrder = sortOrder, ringtoneUri = ringtoneUri, + deleteAfterUse = deleteAfterUse, ) fun pausedTimer( @@ -313,6 +323,7 @@ fun expiredTimer( label: String = "Pasta", sortOrder: Int = 0, ringtoneUri: String? = null, + deleteAfterUse: Boolean = false, ): Timer = timerAt( id = id, label = label, @@ -321,4 +332,5 @@ fun expiredTimer( remaining = Duration.ZERO, sortOrder = sortOrder, ringtoneUri = ringtoneUri, + deleteAfterUse = deleteAfterUse, )