feat(data): ring state in a new alarm_states table at schema v2
Snooze, the handled-occurrence watermark and the skip watermark need somewhere to live that survives a process death. They go in their own table rather than as columns on alarms: a missing row is the initial record, and the FK cascade means deleting an alarm cannot leave orphaned ring state behind. Schema v2 is exported and committed alongside v1, so MIGRATION_1_2 is reviewable and testable rather than taken on faith. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
+41
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<List<AlarmStateEntity>>
|
||||
|
||||
@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()
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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<AlarmStateEntity>): List<AlarmRingState> = 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)
|
||||
}
|
||||
@@ -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<List<AlarmRingState>>
|
||||
|
||||
/** 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()
|
||||
}
|
||||
@@ -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<List<AlarmRingState>> = 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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<Migration> = arrayOf(MIGRATION_1_2)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<IllegalStateException> { repository.save(ringState(alarmId = 404L)) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AlarmEntity> = 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<TimerEntity> = emptyList()) : TimerDao() {
|
||||
|
||||
override suspend fun findById(id: Long): TimerEntity? = rows.value.firstOrNull { it.id == id }
|
||||
|
||||
override suspend fun running(): List<TimerEntity> = 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<LapEntity> = 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<AlarmStateEntity> = 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<AlarmStateEntity> get() = sorted(rows.value)
|
||||
|
||||
override fun observeAll(): Flow<List<AlarmStateEntity>> = 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<AlarmStateEntity>): List<AlarmStateEntity> = rows.sortedBy { it.alarmId }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user