feat(data): the Room database, its four tables and their DAOs

alarms, timers, world_clocks and stopwatch_laps, with the schema exported
to app/schemas and committed so migrations can be tested from v1 onwards.

The DAOs return Flows for reads and keep every multi-step write inside a
@Transaction, so a read-modify-write cannot lose a concurrent one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 13:52:11 +02:00
co-authored by Claude Opus 5
parent a2beae94ec
commit 778d08af24
13 changed files with 787 additions and 0 deletions
@@ -0,0 +1,116 @@
package de.jeanlucmakiola.clockula.data.db
import androidx.room.Room
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.worldclocks.WorldClockEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* The real DAOs against an in-memory Room database — the half of the data layer
* a fake cannot prove: generated SQL, unique indices and `@Transaction`.
* These run on a device only; the build gate compiles them.
*/
@RunWith(AndroidJUnit4::class)
class ClockulaDatabaseTest {
private lateinit var db: ClockulaDatabase
private fun alarm(hour: Int, enabled: Boolean) = AlarmEntity(
hour = hour,
minute = 30,
label = "Gym",
enabled = enabled,
repeatDays = 31,
skipNextOccurrence = false,
ringtoneUri = "content://media/42",
vibrate = true,
snoozeMinutes = 7,
snoozeLimit = 2,
volumeRampSeconds = 30,
dismissChallenge = "MATH",
createdAt = 1_700_000_000_000L,
updatedAt = 1_700_000_060_000L,
)
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
ClockulaDatabase::class.java,
).build()
}
@After
fun tearDown() {
db.close()
}
@Test
fun anAlarmRoundTripsThroughTheRealDao() = runBlocking {
val id = db.alarmDao().insert(alarm(hour = 7, enabled = true))
val stored = db.alarmDao().observeAll().first().single()
assertThat(stored).isEqualTo(alarm(hour = 7, enabled = true).copy(id = id))
}
@Test
fun enabledFiltersOnTheIndexedColumn() = runBlocking {
val enabledId = db.alarmDao().insert(alarm(hour = 7, enabled = true))
db.alarmDao().insert(alarm(hour = 8, enabled = false))
val enabled = db.alarmDao().enabled()
assertThat(enabled.map { it.id }).containsExactly(enabledId)
}
@Test
fun theWorldClockZoneIdIsUnique() = runBlocking {
db.worldClockDao().insert(WorldClockEntity(zoneId = "Europe/Berlin", label = null, sortOrder = 0))
val second = db.worldClockDao()
.insert(WorldClockEntity(zoneId = "Europe/Berlin", label = null, sortOrder = 1))
assertThat(second).isEqualTo(-1L)
assertThat(db.worldClockDao().observeAll().first()).hasSize(1)
}
@Test
fun appendLapIncrementsTheIndexAndDerivesTheSplit() = runBlocking {
val first = db.lapDao().appendLap(12_000L)
val second = db.lapDao().appendLap(20_000L)
assertThat(listOf(first.lapIndex, second.lapIndex)).containsExactly(1, 2).inOrder()
assertThat(listOf(first.splitMillis, second.splitMillis)).containsExactly(12_000L, 8_000L).inOrder()
db.lapDao().deleteAll()
assertThat(db.lapDao().observeAll().first()).isEmpty()
}
@Test
fun reorderRewritesEveryRowsSortOrder() = runBlocking {
val ids = listOf("Europe/Berlin", "America/New_York", "Pacific/Auckland").mapIndexed { index, zone ->
db.worldClockDao().insert(WorldClockEntity(zoneId = zone, label = null, sortOrder = index))
}
db.worldClockDao().reorder(ids.reversed())
assertThat(db.worldClockDao().observeAll().first().map { it.id })
.containsExactlyElementsIn(ids.reversed())
.inOrder()
}
@Test
fun theDatabaseOpensAtVersionOne() {
assertThat(db.openHelper.readableDatabase.version).isEqualTo(1)
}
}