feat(data): schema v3 — an alarm or a timer that deletes itself after use

The `AlarmClock` contract says twice that an alarm or a timer created with
`SKIP_UI` should be removed once it has been dismissed. Without somewhere to
record that, a voice user's alarm list becomes a graveyard of one-shots nobody
asked to keep.

One column on each of `alarms` and `timers`, defaulting to 0, and a migration
that adds them. Existing rows keep the behaviour they have always had.
This commit is contained in:
2026-09-23 11:01:31 +02:00
parent cd26fefc69
commit 74a6295543
20 changed files with 598 additions and 12 deletions
@@ -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')"
]
}
}
@@ -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,
@@ -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,
)
@@ -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<AlarmEntity>): List<Alarm> = 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,
)
}
@@ -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() {
@@ -29,5 +29,20 @@ object Migrations {
}
}
val ALL: Array<Migration> = 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<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3)
}
@@ -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,
)
@@ -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<TimerEntity>): List<Timer> = 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,
)
}
}
@@ -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. */
@@ -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. */
@@ -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()
}
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -42,6 +42,7 @@ class AlarmSettingsTest {
dismissChallenge = dismissChallenge,
createdAt = T0,
updatedAt = T0,
deleteAfterUse = false,
)
@Test
@@ -35,6 +35,7 @@ class TimerSnapshotTest {
sortOrder = 0,
createdAt = T0,
updatedAt = T0,
deleteAfterUse = false,
)
private fun idleTimer(state: TimerState, remaining: Duration) = timer(
@@ -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
@@ -32,6 +32,15 @@ open class FakeAlarmDao(
/** The stored rows, for asserting that a call wrote nothing. */
val stored: List<AlarmEntity> 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<List<AlarmEntity>> = rows.map(::sorted)
override fun observeById(id: Long): Flow<AlarmEntity?> =
@@ -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) {
@@ -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,
)