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:
+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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user