diff --git a/app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/2.json b/app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/2.json new file mode 100644 index 0000000..6193f10 --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/2.json @@ -0,0 +1,370 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "5e5fd3a9d31d77a7aa57306836ab19ad", + "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)", + "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 + } + ], + "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)", + "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 + } + ], + "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, '5e5fd3a9d31d77a7aa57306836ab19ad')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabaseTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabaseTest.kt index 0377479..6a1ee68 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabaseTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/ClockulaDatabaseTest.kt @@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity +import de.jeanlucmakiola.clockula.data.alarms.AlarmStateEntity import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -110,7 +111,45 @@ class ClockulaDatabaseTest { } @Test - fun theDatabaseOpensAtVersionOne() { - assertThat(db.openHelper.readableDatabase.version).isEqualTo(1) + fun upsertingTheSameAlarmStateTwiceLeavesOneRow() = runBlocking { + val id = db.alarmDao().insert(alarm(hour = 7, enabled = true)) + db.alarmStateDao().upsert(ringState(alarmId = id, snoozeCount = 1)) + + db.alarmStateDao().upsert(ringState(alarmId = id, snoozeCount = 2)) + + assertThat(db.alarmStateDao().observeAll().first()).hasSize(1) + assertThat(db.alarmStateDao().findByAlarmId(id)!!.snoozeCount).isEqualTo(2) } + + @Test + fun ringingReturnsTheRowThatStartedRingingLast() = runBlocking { + val first = db.alarmDao().insert(alarm(hour = 7, enabled = true)) + val second = db.alarmDao().insert(alarm(hour = 8, enabled = true)) + db.alarmStateDao().upsert(ringState(alarmId = first, ringingSince = 1_700_000_000_000L)) + db.alarmStateDao().upsert(ringState(alarmId = second, ringingSince = 1_700_000_060_000L)) + + val ringing = db.alarmStateDao().ringing() + + assertThat(ringing!!.alarmId).isEqualTo(second) + } + + @Test + fun theDatabaseOpensAtVersionTwoAndTheNewDaoEmits() = runBlocking { + assertThat(db.openHelper.readableDatabase.version).isEqualTo(2) + + assertThat(db.alarmStateDao().observeAll().first()).isEmpty() + } + + private fun ringState( + alarmId: Long, + snoozeCount: Int = 0, + ringingSince: Long? = null, + ) = AlarmStateEntity( + alarmId = alarmId, + snoozedUntil = null, + snoozeCount = snoozeCount, + ringingSince = ringingSince, + handledOccurrence = null, + skippedOccurrence = null, + ) } 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 new file mode 100644 index 0000000..4fc9849 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/data/db/MigrationTest.kt @@ -0,0 +1,117 @@ +package de.jeanlucmakiola.clockula.data.db + +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.data.alarms.AlarmStateEntity +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The only proof the v1 → v2 migration works. A migration that fails eats the + * user's alarms, so it is validated against the exported schema rather than + * eyeballed. Device only; the build gate compiles it. + */ +@RunWith(AndroidJUnit4::class) +class MigrationTest { + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + ClockulaDatabase::class.java, + ) + + private val insertAlarm = """ + INSERT INTO alarms ( + id, hour, minute, label, enabled, repeat_days, skip_next_occurrence, ringtone_uri, + vibrate, snooze_minutes, snooze_limit, volume_ramp_seconds, dismiss_challenge, + created_at, updated_at + ) VALUES (1, 7, 5, 'Gym', 1, 31, 0, 'content://media/42', 0, 7, 2, 30, 'MATH', + 1700000000000, 1700000060000) + """.trimIndent() + + private fun migratedDatabase(): ClockulaDatabase { + val db = Room.databaseBuilder( + ApplicationProvider.getApplicationContext(), + ClockulaDatabase::class.java, + DB_NAME, + ).addMigrations(*Migrations.ALL).build() + helper.closeWhenFinished(db) + return db + } + + @Test + fun theMigrationValidatesAgainstTheExportedVersionTwoSchema() { + helper.createDatabase(DB_NAME, 1).close() + + val migrated = helper.runMigrationsAndValidate(DB_NAME, 2, true, Migrations.MIGRATION_1_2) + + assertThat(migrated.version).isEqualTo(2) + migrated.close() + } + + @Test + fun alarmsWrittenAtVersionOneSurviveTheMigrationUnchanged() { + helper.createDatabase(DB_NAME, 1).apply { + execSQL(insertAlarm) + close() + } + + helper.runMigrationsAndValidate(DB_NAME, 2, true, Migrations.MIGRATION_1_2).close() + + val alarm = runBlocking { migratedDatabase().alarmDao().findById(1L) } + assertThat(alarm).isNotNull() + assertThat(alarm!!.label).isEqualTo("Gym") + assertThat(alarm.hour).isEqualTo(7) + assertThat(alarm.repeatDays).isEqualTo(31) + } + + @Test + fun ringStateIsAcceptedForAnExistingAlarmAndRefusedForOneThatIsNotThere() { + helper.createDatabase(DB_NAME, 1).apply { + execSQL(insertAlarm) + close() + } + helper.runMigrationsAndValidate(DB_NAME, 2, true, Migrations.MIGRATION_1_2).close() + val db = migratedDatabase() + + runBlocking { db.alarmStateDao().upsert(ringStateFor(alarmId = 1L)) } + + assertThat(runBlocking { db.alarmStateDao().findByAlarmId(1L) }).isNotNull() + runCatching { runBlocking { db.alarmStateDao().upsert(ringStateFor(alarmId = 404L)) } } + .also { assertThat(it.isFailure).isTrue() } + } + + @Test + fun deletingAnAlarmCascadesItsRingStateAway() { + helper.createDatabase(DB_NAME, 1).apply { + execSQL(insertAlarm) + close() + } + helper.runMigrationsAndValidate(DB_NAME, 2, true, Migrations.MIGRATION_1_2).close() + val db = migratedDatabase() + runBlocking { db.alarmStateDao().upsert(ringStateFor(alarmId = 1L)) } + + runBlocking { db.alarmDao().deleteById(1L) } + + assertThat(runBlocking { db.alarmStateDao().findByAlarmId(1L) }).isNull() + } + + private fun ringStateFor(alarmId: Long) = AlarmStateEntity( + alarmId = alarmId, + snoozedUntil = 1_700_000_100_000L, + snoozeCount = 1, + ringingSince = 1_700_000_200_000L, + handledOccurrence = 1_700_000_300_000L, + skippedOccurrence = null, + ) + + private companion object { + const val DB_NAME = "clockula-migration-test.db" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateDao.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateDao.kt new file mode 100644 index 0000000..13daa8b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateDao.kt @@ -0,0 +1,27 @@ +package de.jeanlucmakiola.clockula.data.alarms + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface AlarmStateDao { + @Query("SELECT * FROM alarm_states") + fun observeAll(): Flow> + + @Query("SELECT * FROM alarm_states WHERE alarm_id = :alarmId LIMIT 1") + suspend fun findByAlarmId(alarmId: Long): AlarmStateEntity? + + @Query("SELECT * FROM alarm_states WHERE ringing_since IS NOT NULL ORDER BY ringing_since DESC LIMIT 1") + suspend fun ringing(): AlarmStateEntity? + + @Upsert + suspend fun upsert(state: AlarmStateEntity) + + @Query("DELETE FROM alarm_states WHERE alarm_id = :alarmId") + suspend fun deleteByAlarmId(alarmId: Long) + + @Query("DELETE FROM alarm_states") + suspend fun deleteAll() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateEntity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateEntity.kt new file mode 100644 index 0000000..dc11361 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateEntity.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.clockula.data.alarms + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +/** + * The `alarm_states` row (schema v2): one row per alarm, volatile ring state + * only. Cascades away with its alarm, so deleting an alarm takes its ring state + * with it and M10's backup never sees it. + */ +@Entity( + tableName = "alarm_states", + foreignKeys = [ + ForeignKey( + entity = AlarmEntity::class, + parentColumns = ["id"], + childColumns = ["alarm_id"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class AlarmStateEntity( + @PrimaryKey @ColumnInfo(name = "alarm_id") val alarmId: Long, + @ColumnInfo(name = "snoozed_until") val snoozedUntil: Long?, + @ColumnInfo(name = "snooze_count") val snoozeCount: Int, + @ColumnInfo(name = "ringing_since") val ringingSince: Long?, + @ColumnInfo(name = "handled_occurrence") val handledOccurrence: Long?, + @ColumnInfo(name = "skipped_occurrence") val skippedOccurrence: Long?, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateMapper.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateMapper.kt new file mode 100644 index 0000000..303c33e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateMapper.kt @@ -0,0 +1,33 @@ +package de.jeanlucmakiola.clockula.data.alarms + +import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState +import kotlin.time.Instant + +/** + * Entity ↔ domain. Reads are **forgiving**: a corrupt row degrades, never + * throws. An absent column and a stored epoch-zero are different facts and stay + * different — null means "never happened". + */ +object AlarmStateMapper { + fun toDomain(entity: AlarmStateEntity): AlarmRingState = AlarmRingState( + alarmId = entity.alarmId, + snoozedUntil = entity.snoozedUntil?.toInstant(), + snoozeCount = entity.snoozeCount.coerceAtLeast(0), + ringingSince = entity.ringingSince?.toInstant(), + handledOccurrence = entity.handledOccurrence?.toInstant(), + skippedOccurrence = entity.skippedOccurrence?.toInstant(), + ) + + fun toDomain(entities: List): List = entities.map(::toDomain) + + fun toEntity(state: AlarmRingState): AlarmStateEntity = AlarmStateEntity( + alarmId = state.alarmId, + snoozedUntil = state.snoozedUntil?.toEpochMilliseconds(), + snoozeCount = state.snoozeCount, + ringingSince = state.ringingSince?.toEpochMilliseconds(), + handledOccurrence = state.handledOccurrence?.toEpochMilliseconds(), + skippedOccurrence = state.skippedOccurrence?.toEpochMilliseconds(), + ) + + private fun Long.toInstant(): Instant = Instant.fromEpochMilliseconds(this) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateRepository.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateRepository.kt new file mode 100644 index 0000000..66af9d7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateRepository.kt @@ -0,0 +1,20 @@ +package de.jeanlucmakiola.clockula.data.alarms + +import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState +import kotlinx.coroutines.flow.Flow + +interface AlarmStateRepository { + fun states(): Flow> + + /** Never null: an alarm with no row reads back as [AlarmRingState.initial]. Writes nothing. */ + suspend fun state(alarmId: Long): AlarmRingState + + suspend fun save(state: AlarmRingState) + + /** The single ringing alarm, if any. Most-recent `ringingSince` wins if two rows disagree. */ + suspend fun ringing(): AlarmRingState? + + suspend fun clear(alarmId: Long) + + suspend fun clearAll() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateRepositoryImpl.kt new file mode 100644 index 0000000..13c5c65 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateRepositoryImpl.kt @@ -0,0 +1,31 @@ +package de.jeanlucmakiola.clockula.data.alarms + +import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The ring-state table as the engine sees it. A missing row is a fact rather + * than an error — an alarm that has never rung reads back as the initial record + * and nothing is written until there is something to write. + */ +@Singleton +class AlarmStateRepositoryImpl @Inject constructor( + private val dao: AlarmStateDao, +) : AlarmStateRepository { + + override fun states(): Flow> = dao.observeAll().map(AlarmStateMapper::toDomain) + + override suspend fun state(alarmId: Long): AlarmRingState = + dao.findByAlarmId(alarmId)?.let(AlarmStateMapper::toDomain) ?: AlarmRingState.initial(alarmId) + + override suspend fun save(state: AlarmRingState) = dao.upsert(AlarmStateMapper.toEntity(state)) + + override suspend fun ringing(): AlarmRingState? = dao.ringing()?.let(AlarmStateMapper::toDomain) + + override suspend fun clear(alarmId: Long) = dao.deleteByAlarmId(alarmId) + + override suspend fun clearAll() = dao.deleteAll() +} 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 ffffa65..1c9909a 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 @@ -4,6 +4,8 @@ import androidx.room.Database import androidx.room.RoomDatabase import de.jeanlucmakiola.clockula.data.alarms.AlarmDao import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity +import de.jeanlucmakiola.clockula.data.alarms.AlarmStateDao +import de.jeanlucmakiola.clockula.data.alarms.AlarmStateEntity import de.jeanlucmakiola.clockula.data.stopwatch.LapDao import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity import de.jeanlucmakiola.clockula.data.timers.TimerDao @@ -12,7 +14,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity /** - * Clockula's own storage. Version 1; the schema is exported to `app/schemas/` + * Clockula's own storage. Version 2; 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 @@ -22,15 +24,17 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity @Database( entities = [ AlarmEntity::class, + AlarmStateEntity::class, TimerEntity::class, WorldClockEntity::class, LapEntity::class, ], - version = 1, + version = 2, exportSchema = true, ) abstract class ClockulaDatabase : RoomDatabase() { abstract fun alarmDao(): AlarmDao + abstract fun alarmStateDao(): AlarmStateDao abstract fun timerDao(): TimerDao abstract fun worldClockDao(): WorldClockDao abstract fun lapDao(): LapDao 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 new file mode 100644 index 0000000..02d7a71 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/db/Migrations.kt @@ -0,0 +1,33 @@ +package de.jeanlucmakiola.clockula.data.db + +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +/** + * Every migration this database has ever needed, in one place, so the list a + * builder is handed is the list the tests assert on. The statements are copied + * from the exported schema's `createSql` verbatim: `runMigrationsAndValidate` + * compares the migrated database against that JSON column for column. + */ +object Migrations { + /** v1 → v2: `CREATE TABLE alarm_states` — the snooze and ring state (M3). */ + val MIGRATION_1_2: Migration = object : Migration(1, 2) { + override fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `alarm_states` (" + + "`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 )", + ) + } + } + + val ALL: Array = arrayOf(MIGRATION_1_2) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DatabaseModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DatabaseModule.kt index c2f1ed7..a0eb267 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DatabaseModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/DatabaseModule.kt @@ -8,14 +8,16 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.clockula.data.alarms.AlarmDao +import de.jeanlucmakiola.clockula.data.alarms.AlarmStateDao import de.jeanlucmakiola.clockula.data.db.ClockulaDatabase +import de.jeanlucmakiola.clockula.data.db.Migrations import de.jeanlucmakiola.clockula.data.stopwatch.LapDao import de.jeanlucmakiola.clockula.data.timers.TimerDao import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao import javax.inject.Singleton /** - * The database and its four DAOs. No destructive-migration fallback: docs/PLAN.md + * The database and its five DAOs. No destructive-migration fallback: docs/PLAN.md * §12 wants every schema change to arrive with a tested migration, and a fallback * would quietly drop a user's alarms instead of failing loudly in review. */ @@ -26,11 +28,16 @@ object DatabaseModule { @Provides @Singleton fun provideDatabase(@ApplicationContext context: Context): ClockulaDatabase = - Room.databaseBuilder(context, ClockulaDatabase::class.java, ClockulaDatabase.NAME).build() + Room.databaseBuilder(context, ClockulaDatabase::class.java, ClockulaDatabase.NAME) + .addMigrations(*Migrations.ALL) + .build() @Provides fun provideAlarmDao(database: ClockulaDatabase): AlarmDao = database.alarmDao() + @Provides + fun provideAlarmStateDao(database: ClockulaDatabase): AlarmStateDao = database.alarmStateDao() + @Provides fun provideTimerDao(database: ClockulaDatabase): TimerDao = database.timerDao() diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/RepositoryModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/RepositoryModule.kt index aa12c42..e3b85c8 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/di/RepositoryModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/di/RepositoryModule.kt @@ -6,6 +6,8 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.clockula.data.alarms.AlarmRepository import de.jeanlucmakiola.clockula.data.alarms.AlarmRepositoryImpl +import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository +import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepositoryImpl import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepositoryImpl import de.jeanlucmakiola.clockula.data.timers.TimerRepository @@ -14,7 +16,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepositoryImpl import javax.inject.Singleton -/** The four interfaces the rest of the app sees — and the only ones it may see. */ +/** The five interfaces the rest of the app sees — and the only ones it may see. */ @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { @@ -23,6 +25,10 @@ abstract class RepositoryModule { @Singleton abstract fun bindAlarmRepository(impl: AlarmRepositoryImpl): AlarmRepository + @Binds + @Singleton + abstract fun bindAlarmStateRepository(impl: AlarmStateRepositoryImpl): AlarmStateRepository + @Binds @Singleton abstract fun bindTimerRepository(impl: TimerRepositoryImpl): TimerRepository diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateMapperTest.kt new file mode 100644 index 0000000..e8596ba --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateMapperTest.kt @@ -0,0 +1,110 @@ +package de.jeanlucmakiola.clockula.data.alarms + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +/** Entity ↔ domain for the v2 ring-state table. A read must degrade, never throw. */ +class AlarmStateMapperTest { + + private fun fullEntity() = AlarmStateEntity( + alarmId = 42L, + snoozedUntil = 1_700_000_100_000L, + snoozeCount = 2, + ringingSince = 1_700_000_200_000L, + handledOccurrence = 1_700_000_300_000L, + skippedOccurrence = 1_700_000_400_000L, + ) + + @Test + fun `every column lands on the field that belongs to it`() { + val state = AlarmStateMapper.toDomain(fullEntity()) + + assertThat(state).isEqualTo( + AlarmRingState( + alarmId = 42L, + snoozedUntil = Instant.fromEpochMilliseconds(1_700_000_100_000L), + snoozeCount = 2, + ringingSince = Instant.fromEpochMilliseconds(1_700_000_200_000L), + handledOccurrence = Instant.fromEpochMilliseconds(1_700_000_300_000L), + skippedOccurrence = Instant.fromEpochMilliseconds(1_700_000_400_000L), + ), + ) + } + + @Test + fun `domain to entity and back is the same value`() { + val state = AlarmStateMapper.toDomain(fullEntity()) + + assertThat(AlarmStateMapper.toDomain(AlarmStateMapper.toEntity(state))).isEqualTo(state) + } + + @Test + fun `a negative stored snooze count reads back as none`() { + val degraded = AlarmStateMapper.toDomain(fullEntity().copy(snoozeCount = -3)) + + assertThat(degraded.snoozeCount).isEqualTo(0) + // An implausibly high count is not a user-facing setting, so it is left alone. + assertThat(AlarmStateMapper.toDomain(fullEntity().copy(snoozeCount = 900)).snoozeCount) + .isEqualTo(900) + } + + @Test + fun `an absent column and an epoch-zero one are different facts`() { + val absent = AlarmStateMapper.toDomain(fullEntity().copy(snoozedUntil = null)) + val epochZero = AlarmStateMapper.toDomain(fullEntity().copy(snoozedUntil = 0L)) + + assertThat(absent.snoozedUntil).isNull() + assertThat(epochZero.snoozedUntil).isEqualTo(Instant.fromEpochMilliseconds(0L)) + } + + @Test + fun `the initial record is all absences and no snoozes`() { + val initial = AlarmRingState.initial(42L) + + assertThat(initial).isEqualTo(AlarmRingState(alarmId = 42L)) + assertThat( + listOf( + initial.snoozedUntil, + initial.ringingSince, + initial.handledOccurrence, + initial.skippedOccurrence, + ), + ).containsExactly(null, null, null, null) + assertThat(initial.snoozeCount).isEqualTo(0) + } + + @Test + fun `the initial record maps to a row of nulls`() { + val entity = AlarmStateMapper.toEntity(AlarmRingState.initial(42L)) + + assertThat(entity).isEqualTo( + AlarmStateEntity( + alarmId = 42L, + snoozedUntil = null, + snoozeCount = 0, + ringingSince = null, + handledOccurrence = null, + skippedOccurrence = null, + ), + ) + } + + @Test + fun `no combination of null columns makes the mapper throw`() { + val combinations = (0 until 32).map { bits -> + fullEntity().copy( + snoozedUntil = fullEntity().snoozedUntil.takeIf { bits and 1 == 0 }, + ringingSince = fullEntity().ringingSince.takeIf { bits and 2 == 0 }, + handledOccurrence = fullEntity().handledOccurrence.takeIf { bits and 4 == 0 }, + skippedOccurrence = fullEntity().skippedOccurrence.takeIf { bits and 8 == 0 }, + snoozeCount = if (bits and 16 == 0) 2 else -1, + ) + } + + val mapped = AlarmStateMapper.toDomain(combinations) + + assertThat(mapped).hasSize(32) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateRepositoryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateRepositoryTest.kt new file mode 100644 index 0000000..abcad45 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmStateRepositoryTest.kt @@ -0,0 +1,96 @@ +package de.jeanlucmakiola.clockula.data.alarms + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState +import de.jeanlucmakiola.clockula.testing.FakeAlarmDao +import de.jeanlucmakiola.clockula.testing.FakeAlarmStateDao +import de.jeanlucmakiola.clockula.testing.T0 +import de.jeanlucmakiola.clockula.testing.fullAlarmEntity +import de.jeanlucmakiola.clockula.testing.ringState +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.time.Duration.Companion.minutes + +/** The ring-state table as the engine sees it: a missing row is a fact, not an error. */ +class AlarmStateRepositoryTest { + + private val alarmDao = FakeAlarmDao() + private val dao = FakeAlarmStateDao(alarms = alarmDao) + private val repository = AlarmStateRepositoryImpl(dao) + + private suspend fun givenAlarm(id: Long): Long = alarmDao.insert(fullAlarmEntity().copy(id = id)) + + @Test + fun `an alarm with no row reads back as the initial record, writing nothing`() = runTest { + givenAlarm(1L) + + val state = repository.state(1L) + + assertThat(state).isEqualTo(AlarmRingState.initial(1L)) + assertThat(dao.writes).isEqualTo(0) + } + + @Test + fun `a saved state reads back`() = runTest { + givenAlarm(1L) + val state = ringState(alarmId = 1L, snoozedUntil = T0 + 10.minutes, snoozeCount = 1) + + repository.save(state) + + assertThat(repository.state(1L)).isEqualTo(state) + } + + @Test + fun `saving twice replaces the row rather than adding one`() = runTest { + givenAlarm(1L) + repository.save(ringState(alarmId = 1L, snoozeCount = 1)) + + repository.save(ringState(alarmId = 1L, snoozeCount = 2)) + + assertThat(dao.stored).hasSize(1) + assertThat(repository.state(1L).snoozeCount).isEqualTo(2) + } + + @Test + fun `states re-emits after a save`() = runTest { + givenAlarm(1L) + + repository.states().test { + assertThat(awaitItem()).isEmpty() + + repository.save(ringState(alarmId = 1L, snoozeCount = 1)) + + assertThat(awaitItem()).containsExactly(ringState(alarmId = 1L, snoozeCount = 1)) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `ringing finds the ringing row and is null when there is none`() = runTest { + givenAlarm(1L) + assertThat(repository.ringing()).isNull() + + repository.save(ringState(alarmId = 1L, ringingSince = T0)) + + assertThat(repository.ringing()?.alarmId).isEqualTo(1L) + } + + @Test + fun `two ringing rows resolve to the one that started later`() = runTest { + givenAlarm(1L) + givenAlarm(2L) + repository.save(ringState(alarmId = 1L, ringingSince = T0)) + repository.save(ringState(alarmId = 2L, ringingSince = T0 + 1.minutes)) + + val ringing = repository.ringing() + + assertThat(ringing?.alarmId).isEqualTo(2L) + } + + @Test + fun `ring state for an alarm that no longer exists is refused by the foreign key`() = runTest { + assertThrows { repository.save(ringState(alarmId = 404L)) } + } +} 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 8e659fd..b59a382 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 @@ -27,4 +27,34 @@ class SchemaExportTest { assertThat(text).contains("\"tableName\": \"world_clocks\"") assertThat(text).contains("\"tableName\": \"stopwatch_laps\"") } + + @Test + fun `the version 2 schema is exported and committed`() { + val schema = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase/2.json") + + assertThat(schema.isFile).isTrue() + val text = schema.readText() + assertThat(text).isNotEmpty() + assertThat(text).contains("\"version\": 2") + assertThat(text).contains("\"tableName\": \"alarm_states\"") + } + + @Test + 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") + } + + @Test + fun `exactly one migration ships, and it reaches the exported schema version`() { + val exported = File(schemasDir, "de.jeanlucmakiola.clockula.data.db.ClockulaDatabase") + .list()!!.map { it.removeSuffix(".json").toInt() } + + 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) + } } 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 ac66961..24d0a10 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt @@ -2,6 +2,8 @@ package de.jeanlucmakiola.clockula.testing import de.jeanlucmakiola.clockula.data.alarms.AlarmDao import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity +import de.jeanlucmakiola.clockula.data.alarms.AlarmStateDao +import de.jeanlucmakiola.clockula.data.alarms.AlarmStateEntity import de.jeanlucmakiola.clockula.data.stopwatch.LapDao import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity import de.jeanlucmakiola.clockula.data.timers.TimerDao @@ -40,7 +42,10 @@ class FakeAlarmDao( override suspend fun enabled(): List = sorted(rows.value).filter { it.enabled } override suspend fun insert(alarm: AlarmEntity): Long { - val id = if (alarm.id == 0L) nextRowId++ else alarm.id + val id = if (alarm.id == 0L) nextRowId else alarm.id + // SQLite hands out `max(rowid) + 1`, so a row inserted with an explicit + // id moves the allocator past it — the next generated id never collides. + nextRowId = maxOf(nextRowId, id + 1L) rows.update { it + alarm.copy(id = id) } return id } @@ -83,6 +88,8 @@ open class FakeTimerDao(initial: List = emptyList()) : TimerDao() { override suspend fun findById(id: Long): TimerEntity? = rows.value.firstOrNull { it.id == id } + override suspend fun running(): List = sorted(rows.value).filter { it.state == "RUNNING" } + override suspend fun maxSortOrder(): Int? = rows.value.maxOfOrNull { it.sortOrder } override suspend fun insert(timer: TimerEntity): Long { @@ -168,3 +175,52 @@ class FakeLapDao(initial: List = emptyList()) : LapDao() { rows.value = emptyList() } } + +/** + * The `alarm_states` table in memory. When it is given the alarm table it + * enforces the same foreign key SQLite would, so a test cannot write ring state + * for an alarm that is no longer there and call it passing. + */ +class FakeAlarmStateDao( + initial: List = emptyList(), + private val alarms: FakeAlarmDao? = null, +) : AlarmStateDao { + + private val rows = MutableStateFlow(initial) + + /** Every write this DAO has seen, so "a read wrote nothing" is assertable. */ + var writes: Int = 0 + private set + + val stored: List get() = sorted(rows.value) + + override fun observeAll(): Flow> = rows.map(::sorted) + + override suspend fun findByAlarmId(alarmId: Long): AlarmStateEntity? = + rows.value.firstOrNull { it.alarmId == alarmId } + + override suspend fun ringing(): AlarmStateEntity? = + rows.value.filter { it.ringingSince != null }.maxByOrNull { it.ringingSince!! } + + override suspend fun upsert(state: AlarmStateEntity) { + alarms?.let { table -> + check(table.stored.any { it.id == state.alarmId }) { + "FOREIGN KEY constraint failed: no alarm ${state.alarmId}" + } + } + writes++ + rows.update { rows -> rows.filterNot { it.alarmId == state.alarmId } + state } + } + + override suspend fun deleteByAlarmId(alarmId: Long) { + writes++ + rows.update { rows -> rows.filterNot { it.alarmId == alarmId } } + } + + override suspend fun deleteAll() { + writes++ + rows.value = emptyList() + } + + private fun sorted(rows: List): List = rows.sortedBy { it.alarmId } +} 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 bf751cc..4073080 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/Fixtures.kt @@ -10,6 +10,11 @@ import de.jeanlucmakiola.clockula.domain.RepeatDays import de.jeanlucmakiola.clockula.domain.TimeOfDay import de.jeanlucmakiola.clockula.domain.Timer import de.jeanlucmakiola.clockula.domain.TimerState +import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.ZonedDateTime import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -99,3 +104,85 @@ fun lapEntity(): LapEntity = LapEntity( splitMillis = 1_234L, cumulativeMillis = 5_678L, ) + +// --- M3: zones, instants and alarm fixtures --- + +/** Berlin 2026: spring forward 2026-03-29 02:00→03:00, fall back 2026-10-25 03:00→02:00. */ +val BERLIN: ZoneId = ZoneId.of("Europe/Berlin") + +/** New York 2026: spring forward 2026-03-08, fall back 2026-11-01. */ +val NEW_YORK: ZoneId = ZoneId.of("America/New_York") + +/** Lord Howe 2026: a **30-minute** gap on 2026-10-04 at 02:00. */ +val LORD_HOWE: ZoneId = ZoneId.of("Australia/Lord_Howe") + +/** Apia 2011: the calendar day 2011-12-30 never happened. */ +val APIA: ZoneId = ZoneId.of("Pacific/Apia") + +val UTC_ZONE: ZoneId = ZoneId.of("UTC") + +/** + * A local wall-clock reading resolved in [zone] the way `java.time` resolves + * it. Use it for *inputs*; spell expectations with [offsetInstant] so an + * assertion never restates the behaviour under test. + */ +fun instantAt(zone: ZoneId, iso: String): Instant = + ZonedDateTime.of(LocalDateTime.parse(iso), zone).toInstant().toKotlinInstant() + +/** An absolute instant, spelled with the offset it is expected to carry: `"2026-03-29T03:30+02:00"`. */ +fun offsetInstant(iso: String): Instant = OffsetDateTime.parse(iso).toInstant().toKotlinInstant() + +/** What [this] reads as on a wall clock in [zone]. */ +fun Instant.localAt(zone: ZoneId): LocalDateTime = + java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDateTime() + +/** The offset [zone] was on at [this]. */ +fun Instant.offsetAt(zone: ZoneId): java.time.ZoneOffset = + java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).offset + +private fun java.time.Instant.toKotlinInstant(): Instant = Instant.fromEpochMilliseconds(toEpochMilli()) + +/** An alarm with only the fields M3 reasons about spelled out. */ +fun alarmAt( + id: Long = 1L, + hour: Int, + minute: Int = 0, + repeatDays: RepeatDays = RepeatDays.NONE, + enabled: Boolean = true, + skipNextOccurrence: Boolean = false, + snoozeMinutes: Int? = null, + snoozeLimit: Int? = null, + label: String = "Wake up", +): Alarm = Alarm( + id = id, + time = TimeOfDay(hour, minute), + label = label, + enabled = enabled, + repeatDays = repeatDays, + skipNextOccurrence = skipNextOccurrence, + ringtoneUri = null, + vibrate = null, + snoozeMinutes = snoozeMinutes, + snoozeLimit = snoozeLimit, + volumeRampSeconds = null, + dismissChallenge = null, + createdAt = T0, + updatedAt = T0, +) + +/** Ring state, defaulted to the record an alarm with no stored row reads back as. */ +fun ringState( + alarmId: Long = 1L, + snoozedUntil: Instant? = null, + snoozeCount: Int = 0, + ringingSince: Instant? = null, + handledOccurrence: Instant? = null, + skippedOccurrence: Instant? = null, +): AlarmRingState = AlarmRingState( + alarmId = alarmId, + snoozedUntil = snoozedUntil, + snoozeCount = snoozeCount, + ringingSince = ringingSince, + handledOccurrence = handledOccurrence, + skippedOccurrence = skippedOccurrence, +)