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:
2026-09-11 16:04:26 +02:00
co-authored by Claude Opus 5
parent 5ea3c798b4
commit f4b7c1a16c
17 changed files with 1105 additions and 8 deletions
@@ -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"
}
}