feat(data): Flow-based repositories over the DAOs

The four interfaces the rest of the app will talk to. They speak domain
types and Flows only — no entity and no query string crosses this seam.

Every read-modify-write goes through a DAO transaction rather than a
find-then-update, so the ringing service marking a timer expired cannot
silently lose the minute a user just added. Adding a world clock that is
already there returns the existing row instead of the insert sentinel.

Tested on the JVM against fake DAOs over MutableStateFlow with injected
fake clocks, so repository behaviour needs no device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 13:52:22 +02:00
co-authored by Claude Opus 5
parent 11b220aba0
commit 6b7a4d73ed
13 changed files with 1371 additions and 0 deletions
@@ -0,0 +1,17 @@
package de.jeanlucmakiola.clockula.data.alarms
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import kotlinx.coroutines.flow.Flow
interface AlarmRepository {
fun alarms(): Flow<List<Alarm>>
fun alarm(id: Long): Flow<Alarm?>
suspend fun find(id: Long): Alarm?
suspend fun enabledAlarms(): List<Alarm>
suspend fun create(draft: AlarmDraft): Long
suspend fun update(alarm: Alarm)
suspend fun delete(id: Long)
suspend fun setEnabled(id: Long, enabled: Boolean)
suspend fun setSkipNextOccurrence(id: Long, skip: Boolean)
}
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.clockula.data.alarms
import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* An alarm *record* follows the wall clock: its timestamps are calendar facts,
* unlike a running timer's anchors. Room dispatches its own queries, so nothing
* here wraps a call in `withContext`.
*/
@Singleton
class AlarmRepositoryImpl @Inject constructor(
private val dao: AlarmDao,
private val wallClock: WallClock,
) : AlarmRepository {
override fun alarms(): Flow<List<Alarm>> = dao.observeAll().map(AlarmMapper::toDomain)
override fun alarm(id: Long): Flow<Alarm?> = dao.observeById(id).map { it?.let(AlarmMapper::toDomain) }
override suspend fun find(id: Long): Alarm? = dao.findById(id)?.let(AlarmMapper::toDomain)
override suspend fun enabledAlarms(): List<Alarm> = AlarmMapper.toDomain(dao.enabled())
override suspend fun create(draft: AlarmDraft): Long =
dao.insert(AlarmMapper.toEntity(draft, wallClock.now()))
override suspend fun update(alarm: Alarm) {
dao.update(AlarmMapper.toEntity(alarm).copy(updatedAt = wallClock.now().toEpochMilliseconds()))
}
override suspend fun delete(id: Long) = dao.deleteById(id)
override suspend fun setEnabled(id: Long, enabled: Boolean) =
dao.setEnabled(id, enabled, wallClock.now().toEpochMilliseconds())
override suspend fun setSkipNextOccurrence(id: Long, skip: Boolean) =
dao.setSkipNextOccurrence(id, skip, wallClock.now().toEpochMilliseconds())
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.clockula.data.stopwatch
import de.jeanlucmakiola.clockula.domain.Lap
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import kotlinx.coroutines.flow.Flow
interface StopwatchRepository {
fun run(): Flow<StopwatchRun>
fun laps(): Flow<List<Lap>>
suspend fun currentRun(): StopwatchRun
/** From IDLE: a fresh run, laps cleared. From PAUSED: resume, laps kept. No-op when RUNNING. */
suspend fun start()
suspend fun pause()
/** Back to IDLE at zero; deletes every lap. */
suspend fun reset()
/** Marks a lap at the current elapsed value. Returns null unless the stopwatch is RUNNING. */
suspend fun lap(): Lap?
}
@@ -0,0 +1,82 @@
package de.jeanlucmakiola.clockula.data.stopwatch
import de.jeanlucmakiola.clockula.domain.Lap
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.snapshotAt
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlin.time.Duration
import javax.inject.Inject
import javax.inject.Singleton
/**
* The run record lives in DataStore and the laps in Room, so a run survives
* process death without a table of one row. Everything here is monotonic — the
* wall clock is never consulted.
*/
@Singleton
class StopwatchRepositoryImpl @Inject constructor(
private val lapDao: LapDao,
private val stateStore: StopwatchStateStore,
private val elapsedRealtimeClock: ElapsedRealtimeClock,
) : StopwatchRepository {
override fun run(): Flow<StopwatchRun> = stateStore.run
override fun laps(): Flow<List<Lap>> = lapDao.observeAll().map(LapMapper::toDomain)
override suspend fun currentRun(): StopwatchRun = stateStore.current()
override suspend fun start() {
val run = stateStore.current()
val now = elapsedRealtimeClock.elapsedRealtime()
when (run.state) {
StopwatchState.RUNNING -> return
StopwatchState.PAUSED ->
stateStore.set(run.copy(state = StopwatchState.RUNNING, startedAtElapsedRealtime = now))
StopwatchState.IDLE -> {
// A fresh run, not a resume: last run's laps would be nonsense here.
lapDao.deleteAll()
stateStore.set(
StopwatchRun(
state = StopwatchState.RUNNING,
accumulated = Duration.ZERO,
startedAtElapsedRealtime = now,
lastLapCumulative = Duration.ZERO,
),
)
}
}
}
override suspend fun pause() {
val run = stateStore.current()
if (run.state != StopwatchState.RUNNING) return
val elapsed = run.snapshotAt(elapsedRealtimeClock.elapsedRealtime()).elapsed
stateStore.set(
run.copy(
state = StopwatchState.PAUSED,
accumulated = elapsed,
startedAtElapsedRealtime = null,
),
)
}
override suspend fun reset() {
lapDao.deleteAll()
stateStore.set(StopwatchRun())
}
override suspend fun lap(): Lap? {
val run = stateStore.current()
if (run.state != StopwatchState.RUNNING) return null
val cumulative = run.snapshotAt(elapsedRealtimeClock.elapsedRealtime()).elapsed
val entity = lapDao.appendLap(cumulative.inWholeMilliseconds)
stateStore.set(run.copy(lastLapCumulative = cumulative))
return LapMapper.toDomain(entity)
}
}
@@ -0,0 +1,30 @@
package de.jeanlucmakiola.clockula.data.timers
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerDraft
import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration
interface TimerRepository {
fun timers(): Flow<List<Timer>>
fun timer(id: Long): Flow<Timer?>
suspend fun find(id: Long): Timer?
suspend fun create(draft: TimerDraft): Long
suspend fun rename(id: Long, label: String)
suspend fun delete(id: Long)
/** Starts an IDLE/EXPIRED timer from its configured duration, resumes a PAUSED one. No-op when RUNNING. */
suspend fun start(id: Long)
suspend fun pause(id: Long)
/** Back to IDLE with the full configured duration; clears every anchor. */
suspend fun reset(id: Long)
/** "+1 min": extends the remaining time only — [Timer.duration] is untouched. */
suspend fun addTime(id: Long, extra: Duration)
/** The ringing service calls this when a timer reaches zero. */
suspend fun markExpired(id: Long)
suspend fun reorder(idsInOrder: List<Long>)
}
@@ -0,0 +1,129 @@
package de.jeanlucmakiola.clockula.data.timers
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.snapshotAt
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlin.time.Duration
import kotlin.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
/**
* A running timer is anchored on the monotonic clock; the wall clock is written
* alongside it only so a reboot has something to fall back on. Every write here
* reads the row first *inside the same transaction* ([TimerDao.updateWithin]),
* so the ringing service and the user cannot lose each other's edits, and a call
* naming a timer that is no longer there is a silent no-op rather than a crash.
*/
@Singleton
class TimerRepositoryImpl @Inject constructor(
private val dao: TimerDao,
private val elapsedRealtimeClock: ElapsedRealtimeClock,
private val wallClock: WallClock,
) : TimerRepository {
override fun timers(): Flow<List<Timer>> = dao.observeAll().map(TimerMapper::toDomain)
override fun timer(id: Long): Flow<Timer?> = dao.observeById(id).map { it?.let(TimerMapper::toDomain) }
override suspend fun find(id: Long): Timer? = dao.findById(id)?.let(TimerMapper::toDomain)
override suspend fun create(draft: TimerDraft): Long = dao.insert(
TimerMapper.toEntity(draft, sortOrder = (dao.maxSortOrder() ?: -1) + 1, now = wallClock.now()),
)
override suspend fun rename(id: Long, label: String) = edit(id) { it.copy(label = label) }
override suspend fun delete(id: Long) = dao.deleteById(id)
override suspend fun start(id: Long) {
val now = elapsedRealtimeClock.elapsedRealtime()
val wall = wallClock.now()
edit(id, wall) { timer ->
if (timer.state == TimerState.RUNNING) return@edit null
// Resuming continues the banked remainder; starting afresh takes the
// configured duration back.
val left = if (timer.state == TimerState.PAUSED) timer.remaining else timer.duration
timer.anchored(left, now, wall)
}
}
override suspend fun pause(id: Long) {
val now = elapsedRealtimeClock.elapsedRealtime()
val wall = wallClock.now()
edit(id, wall) { timer ->
if (timer.state != TimerState.RUNNING) return@edit null
timer.copy(
state = TimerState.PAUSED,
remaining = timer.snapshotAt(now, wall).remaining,
).cleared()
}
}
override suspend fun reset(id: Long) = edit(id) {
it.copy(state = TimerState.IDLE, remaining = it.duration).cleared()
}
override suspend fun addTime(id: Long, extra: Duration) {
val now = elapsedRealtimeClock.elapsedRealtime()
val wall = wallClock.now()
edit(id, wall) { timer ->
if (timer.state == TimerState.RUNNING) {
// Rebased on what is actually left, not on the old end anchor:
// a timer whose anchor is already past has zero left, and the
// user asked for `extra` more than zero — not `extra` more than
// an anchor that has gone by.
timer.anchored(timer.snapshotAt(now, wall).remaining + extra, now, wall)
} else {
// An expired timer that gains time is paused, not running: it has
// no anchors, and the user still has to press start.
val state = if (timer.state == TimerState.EXPIRED) TimerState.PAUSED else timer.state
timer.copy(state = state, remaining = timer.remaining + extra)
}
}
}
override suspend fun markExpired(id: Long) = edit(id) {
it.copy(state = TimerState.EXPIRED, remaining = Duration.ZERO).cleared()
}
/** Ids the table no longer holds are dropped, so the survivors number 0..n-1. */
override suspend fun reorder(idsInOrder: List<Long>) {
val known = dao.observeAll().first().mapTo(HashSet()) { it.id }
dao.reorder(idsInOrder.filter { it in known })
}
/**
* The row is read, transformed and written back in one DAO transaction, so a
* second writer racing this one cannot have its write swallowed whole. A
* missing row, or a [transform] returning null, writes nothing.
*/
private suspend fun edit(id: Long, now: Instant = wallClock.now(), transform: (Timer) -> Timer?) {
dao.updateWithin(id) { entity ->
transform(TimerMapper.toDomain(entity))?.let { TimerMapper.toEntity(it.copy(updatedAt = now)) }
}
}
/** Running, with all three anchors derived from one reading of both clocks. */
private fun Timer.anchored(left: Duration, now: Duration, wall: Instant): Timer = copy(
state = TimerState.RUNNING,
remaining = left,
startedAtElapsedRealtime = now,
endsAtElapsedRealtime = now + left,
endsAtWallClock = wall + left,
)
/** Every anchor dropped — the row is no longer counting down against anything. */
private fun Timer.cleared(): Timer = copy(
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
)
}
@@ -0,0 +1,18 @@
package de.jeanlucmakiola.clockula.data.worldclocks
import de.jeanlucmakiola.clockula.domain.WorldClock
import kotlinx.coroutines.flow.Flow
interface WorldClockRepository {
fun worldClocks(): Flow<List<WorldClock>>
/** Idempotent. Throws [IllegalArgumentException] for a blank or non-IANA id. */
suspend fun add(zoneId: String, label: String? = null): Long
/** Blank normalises to null. */
suspend fun setLabel(id: Long, label: String?)
suspend fun remove(id: Long)
suspend fun reorder(idsInOrder: List<Long>)
}
@@ -0,0 +1,43 @@
package de.jeanlucmakiola.clockula.data.worldclocks
import de.jeanlucmakiola.clockula.domain.WorldClock
import de.jeanlucmakiola.clockula.domain.Zones
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WorldClockRepositoryImpl @Inject constructor(
private val dao: WorldClockDao,
) : WorldClockRepository {
override fun worldClocks(): Flow<List<WorldClock>> = dao.observeAll().map(WorldClockMapper::toDomain)
/**
* `zone_id` is unique, so adding a zone the user already has returns the row
* they already have rather than failing or duplicating it. The lookup and
* the insert are one transaction in the DAO, so the id that comes back is
* always a row that exists.
*/
override suspend fun add(zoneId: String, label: String?): Long {
val normalised = requireNotNull(Zones.normalise(zoneId)) { "not an IANA zone id: $zoneId" }
return dao.addIfAbsent(normalised, label?.takeIf { it.isNotBlank() })
}
override suspend fun setLabel(id: Long, label: String?) =
dao.setLabel(id, label?.takeIf { it.isNotBlank() })
override suspend fun remove(id: Long) = dao.deleteById(id)
/**
* An id the list names but the table no longer holds is dropped before the
* numbering, so a stale drag-and-drop leaves the survivors at 0..n-1 rather
* than with a hole where the deleted row used to be.
*/
override suspend fun reorder(idsInOrder: List<Long>) {
val known = dao.observeAll().first().mapTo(HashSet()) { it.id }
dao.reorder(idsInOrder.filter { it in known })
}
}
@@ -0,0 +1,163 @@
package de.jeanlucmakiola.clockula.data.alarms
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.AlarmDraft
import de.jeanlucmakiola.clockula.domain.TimeOfDay
import de.jeanlucmakiola.clockula.testing.FakeAlarmDao
import de.jeanlucmakiola.clockula.testing.FakeWallClock
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.fullAlarmEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
/**
* An alarm *record* follows the wall clock — its timestamps are calendar facts,
* unlike a running timer's anchors.
*/
class AlarmRepositoryTest {
private val wallClock = FakeWallClock(T0)
private fun repository(dao: FakeAlarmDao) = AlarmRepositoryImpl(dao, wallClock)
@Test
fun `an empty store emits an empty list rather than nothing`() = runTest {
val repository = repository(FakeAlarmDao())
repository.alarms().test {
assertThat(awaitItem()).isEmpty()
cancel()
}
}
@Test
fun `stored alarms come back as domain models in the dao's order`() = runTest {
val dao = FakeAlarmDao(
listOf(
fullAlarmEntity().copy(id = 1L, hour = 9, minute = 0),
fullAlarmEntity().copy(id = 2L, hour = 6, minute = 30),
),
)
val alarms = repository(dao).alarms().first()
assertThat(alarms.map { it.id }).containsExactly(2L, 1L).inOrder()
}
@Test
fun `creating an alarm re-emits the list`() = runTest {
val repository = repository(FakeAlarmDao())
repository.alarms().test {
assertThat(awaitItem()).isEmpty()
repository.create(AlarmDraft(TimeOfDay(7, 0)))
assertThat(awaitItem().map { it.time }).containsExactly(TimeOfDay(7, 0))
cancel()
}
}
@Test
fun `creating stamps both timestamps from the wall clock and returns the new id`() = runTest {
val dao = FakeAlarmDao()
val repository = repository(dao)
val id = repository.create(AlarmDraft(TimeOfDay(7, 0)))
val stored = repository.find(id)!!
assertThat(dao.stored.map { it.id }).containsExactly(id)
assertThat(stored.createdAt).isEqualTo(T0)
assertThat(stored.updatedAt).isEqualTo(T0)
}
@Test
fun `updating moves updatedAt on the wall clock and leaves createdAt alone`() = runTest {
val repository = repository(FakeAlarmDao())
val id = repository.create(AlarmDraft(TimeOfDay(7, 0)))
val original = repository.find(id)!!
wallClock.advance(1.hours)
repository.update(original.copy(label = "Gym"))
val updated = repository.find(id)!!
assertThat(updated.updatedAt).isEqualTo(T0 + 1.hours)
assertThat(updated.createdAt).isEqualTo(T0)
}
@Test
fun `disabling an alarm touches only that flag and the timestamp`() = runTest {
val repository = repository(FakeAlarmDao())
val id = repository.create(AlarmDraft(TimeOfDay(7, 0)))
val original = repository.find(id)!!
wallClock.advance(5.minutes)
repository.setEnabled(id, false)
assertThat(repository.find(id))
.isEqualTo(original.copy(enabled = false, updatedAt = T0 + 5.minutes))
}
@Test
fun `skipping the next occurrence flips only that flag and the timestamp`() = runTest {
val repository = repository(FakeAlarmDao())
val id = repository.create(AlarmDraft(TimeOfDay(7, 0)))
val original = repository.find(id)!!
wallClock.advance(5.minutes)
repository.setSkipNextOccurrence(id, true)
assertThat(repository.find(id))
.isEqualTo(original.copy(skipNextOccurrence = true, updatedAt = T0 + 5.minutes))
}
@Test
fun `deleting re-emits the list without the alarm`() = runTest {
val repository = repository(FakeAlarmDao())
val first = repository.create(AlarmDraft(TimeOfDay(6, 0)))
val second = repository.create(AlarmDraft(TimeOfDay(7, 0)))
repository.delete(first)
assertThat(repository.alarms().first().map { it.id }).containsExactly(second)
}
@Test
fun `finding a missing id returns null`() = runTest {
val repository = repository(FakeAlarmDao())
assertThat(repository.find(999L)).isNull()
}
@Test
fun `enabledAlarms returns only the enabled ones`() = runTest {
val repository = repository(FakeAlarmDao())
val first = repository.create(AlarmDraft(TimeOfDay(6, 0)))
val second = repository.create(AlarmDraft(TimeOfDay(7, 0)))
val third = repository.create(AlarmDraft(TimeOfDay(8, 0)))
repository.setEnabled(second, false)
val enabled = repository.enabledAlarms()
assertThat(enabled.map { it.id }).containsExactly(first, third).inOrder()
}
@Test
fun `following one alarm emits null until that row exists`() = runTest {
val dao = FakeAlarmDao(nextId = 7L)
val repository = repository(dao)
repository.alarm(7L).test {
assertThat(awaitItem()).isNull()
repository.create(AlarmDraft(TimeOfDay(7, 0)))
assertThat(awaitItem()!!.id).isEqualTo(7L)
cancel()
}
}
}
@@ -0,0 +1,225 @@
package de.jeanlucmakiola.clockula.data.stopwatch
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Lap
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchSnapshot
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.snapshotAt
import de.jeanlucmakiola.clockula.testing.FakeElapsedRealtimeClock
import de.jeanlucmakiola.clockula.testing.FakeLapDao
import de.jeanlucmakiola.floret.prefs.PrefStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.nio.file.Path
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* The stopwatch over a real DataStore file and an in-memory lap table, so
* "survives process death" is a real assertion rather than a fake's memory.
*/
class StopwatchRepositoryTest {
private val dao = FakeLapDao()
private val clock = FakeElapsedRealtimeClock(1_000.seconds)
private fun TestScope.repository(
tempDir: Path,
job: Job = Job(),
dao: FakeLapDao = this@StopwatchRepositoryTest.dao,
clock: FakeElapsedRealtimeClock = this@StopwatchRepositoryTest.clock,
) = repository(tempDir.resolve("stopwatch_test.preferences_pb").toFile(), job, dao, clock)
private fun TestScope.repository(
file: File,
job: Job,
dao: FakeLapDao,
clock: FakeElapsedRealtimeClock,
): StopwatchRepositoryImpl {
val dataStore = PreferenceDataStoreFactory.create(
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + job),
produceFile = { file },
)
return StopwatchRepositoryImpl(dao, StopwatchStateStore(PrefStore(dataStore)), clock)
}
@Test
fun `a fresh install is idle at zero with no laps`(@TempDir tempDir: Path) = runTest {
val repository = repository(tempDir)
assertThat(repository.run().first()).isEqualTo(StopwatchRun())
assertThat(repository.laps().first()).isEmpty()
}
@Test
fun `starting anchors on the monotonic clock`(@TempDir tempDir: Path) = runTest {
val repository = repository(tempDir)
repository.start()
assertThat(repository.run().first()).isEqualTo(
StopwatchRun(
state = StopwatchState.RUNNING,
accumulated = Duration.ZERO,
startedAtElapsedRealtime = 1_000.seconds,
lastLapCumulative = Duration.ZERO,
),
)
}
@Test
fun `pausing banks the segment`(@TempDir tempDir: Path) = runTest {
val repository = repository(tempDir)
repository.start()
clock.value = 1_030.seconds
repository.pause()
assertThat(repository.run().first()).isEqualTo(
StopwatchRun(
state = StopwatchState.PAUSED,
accumulated = 30.seconds,
startedAtElapsedRealtime = null,
lastLapCumulative = Duration.ZERO,
),
)
}
@Test
fun `resuming keeps the accumulated time`(@TempDir tempDir: Path) = runTest {
val repository = repository(tempDir)
repository.start()
clock.value = 1_030.seconds
repository.pause()
clock.value = 5_000.seconds
repository.start()
val run = repository.run().first()
assertThat(run.accumulated).isEqualTo(30.seconds)
assertThat(run.startedAtElapsedRealtime).isEqualTo(5_000.seconds)
assertThat(run.snapshotAt(5_010.seconds).elapsed).isEqualTo(40.seconds)
}
@Test
fun `the first lap's split equals its cumulative`(@TempDir tempDir: Path) = runTest {
val repository = repository(tempDir)
repository.start()
clock.value = 1_012.seconds
val lap = repository.lap()
assertThat(lap).isEqualTo(Lap(index = 1, split = 12.seconds, cumulative = 12.seconds))
assertThat(repository.laps().first()).containsExactly(lap)
}
@Test
fun `the second lap's split is the difference from the first`(@TempDir tempDir: Path) = runTest {
val repository = repository(tempDir)
repository.start()
clock.value = 1_012.seconds
repository.lap()
clock.value = 1_020.seconds
val lap = repository.lap()
assertThat(lap).isEqualTo(Lap(index = 2, split = 8.seconds, cumulative = 20.seconds))
assertThat(repository.laps().first().map { it.index }).containsExactly(1, 2).inOrder()
}
@Test
fun `lapping is only possible while running`(@TempDir tempDir: Path) = runTest {
val repository = repository(tempDir)
assertThat(repository.lap()).isNull()
repository.start()
clock.value = 1_030.seconds
repository.pause()
assertThat(repository.lap()).isNull()
assertThat(repository.laps().first()).isEmpty()
}
@Test
fun `resetting clears the run and the laps`(@TempDir tempDir: Path) = runTest {
val repository = repository(tempDir)
repository.start()
clock.value = 1_012.seconds
repository.lap()
clock.value = 1_020.seconds
repository.lap()
repository.reset()
assertThat(repository.run().first()).isEqualTo(StopwatchRun())
assertThat(repository.laps().first()).isEmpty()
}
@Test
fun `a new run starts clean and a resumed one does not`(@TempDir tempDir: Path) = runTest {
val repository = repository(tempDir)
repository.start()
clock.value = 1_012.seconds
repository.lap()
clock.value = 1_020.seconds
repository.lap()
repository.reset()
repository.start()
assertThat(repository.laps().first()).isEmpty()
clock.value = 1_032.seconds
repository.lap()
clock.value = 1_040.seconds
repository.lap()
repository.pause()
repository.start()
assertThat(repository.laps().first()).hasSize(2)
}
@Test
fun `the run survives process death`(@TempDir tempDir: Path) = runTest {
val file = tempDir.resolve("stopwatch_test.preferences_pb").toFile()
val firstProcess = Job()
val before = repository(file, firstProcess, dao, clock)
before.start()
clock.value = 1_020.seconds
before.lap()
firstProcess.cancel()
clock.value = 1_050.seconds
val after = repository(file, Job(), dao, clock)
val run = after.run().first()
assertThat(run.state).isEqualTo(StopwatchState.RUNNING)
assertThat(run.startedAtElapsedRealtime).isEqualTo(1_000.seconds)
assertThat(run.snapshotAt(1_050.seconds).elapsed).isEqualTo(50.seconds)
assertThat(after.laps().first().map { it.index }).containsExactly(1)
}
@Test
fun `a reboot discards the lost segment rather than lying`(@TempDir tempDir: Path) = runTest {
val repository = repository(tempDir)
repository.start()
clock.value = 1_012.seconds
repository.lap()
clock.reboot(5.seconds)
assertThat(repository.run().first().snapshotAt(5.seconds)).isEqualTo(
StopwatchSnapshot(elapsed = Duration.ZERO, isRunning = false, anchorIsStale = true),
)
assertThat(repository.laps().first().map { it.index }).containsExactly(1)
}
}
@@ -0,0 +1,302 @@
package de.jeanlucmakiola.clockula.data.timers
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.TimerDraft
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.snapshotAt
import de.jeanlucmakiola.clockula.testing.FakeElapsedRealtimeClock
import de.jeanlucmakiola.clockula.testing.FakeTimerDao
import de.jeanlucmakiola.clockula.testing.FakeWallClock
import de.jeanlucmakiola.clockula.testing.T0
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* A running timer is anchored on the monotonic clock. Moving the system clock
* must not move it — that is the milestone's headline guarantee.
*/
class TimerRepositoryTest {
private val dao = FakeTimerDao()
private val elapsed = FakeElapsedRealtimeClock(1_000.seconds)
private val wallClock = FakeWallClock(T0)
private val repository = TimerRepositoryImpl(dao, elapsed, wallClock)
private suspend fun startedTimer(): Long {
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
repository.start(id)
return id
}
@Test
fun `a new timer is idle with no anchors and the next sort order`() = runTest {
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
val timer = repository.find(id)!!
assertThat(timer.state).isEqualTo(TimerState.IDLE)
assertThat(timer.remaining).isEqualTo(5.minutes)
assertThat(
listOf(timer.startedAtElapsedRealtime, timer.endsAtElapsedRealtime, timer.endsAtWallClock),
).containsExactly(null, null, null)
assertThat(timer.sortOrder).isEqualTo(0)
assertThat(listOf(timer.createdAt, timer.updatedAt)).containsExactly(T0, T0)
}
@Test
fun `sort order continues from the highest existing`() = runTest {
repository.create(TimerDraft(1.minutes))
repository.create(TimerDraft(2.minutes))
val id = repository.create(TimerDraft(3.minutes))
assertThat(repository.find(id)!!.sortOrder).isEqualTo(2)
}
@Test
fun `starting an idle timer sets all three anchors`() = runTest {
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
repository.start(id)
val timer = repository.find(id)!!
assertThat(timer.state).isEqualTo(TimerState.RUNNING)
assertThat(timer.startedAtElapsedRealtime).isEqualTo(1_000.seconds)
assertThat(timer.endsAtElapsedRealtime).isEqualTo(1_300.seconds)
assertThat(timer.endsAtWallClock).isEqualTo(T0 + 5.minutes)
}
@Test
fun `pausing banks the monotonic remainder and clears every anchor`() = runTest {
val id = startedTimer()
elapsed.value = 1_100.seconds
repository.pause(id)
val timer = repository.find(id)!!
assertThat(timer.state).isEqualTo(TimerState.PAUSED)
assertThat(timer.remaining).isEqualTo(200.seconds)
assertThat(
listOf(timer.startedAtElapsedRealtime, timer.endsAtElapsedRealtime, timer.endsAtWallClock),
).containsExactly(null, null, null)
}
@Test
fun `resuming re-anchors from the banked remainder`() = runTest {
val id = startedTimer()
elapsed.value = 1_100.seconds
repository.pause(id)
elapsed.value = 5_000.seconds
repository.start(id)
val timer = repository.find(id)!!
assertThat(timer.startedAtElapsedRealtime).isEqualTo(5_000.seconds)
assertThat(timer.endsAtElapsedRealtime).isEqualTo(5_200.seconds)
}
@Test
fun `changing the system clock does not warp a running timer`() = runTest {
startedTimer()
val storedBefore = dao.stored.single()
elapsed.value = 1_100.seconds
wallClock.advance(3.hours)
val forward = repository.timers().first().single()
assertThat(forward.snapshotAt(elapsed.elapsedRealtime(), wallClock.now()).remaining)
.isEqualTo(200.seconds)
assertThat(dao.stored.single()).isEqualTo(storedBefore)
wallClock.advance(-6.hours)
val backward = repository.timers().first().single()
assertThat(backward.snapshotAt(elapsed.elapsedRealtime(), wallClock.now()).remaining)
.isEqualTo(200.seconds)
assertThat(dao.stored.single()).isEqualTo(storedBefore)
}
@Test
fun `starting an already-running timer changes nothing`() = runTest {
val id = startedTimer()
val storedBefore = dao.stored.single()
elapsed.advance(30.seconds)
repository.start(id)
assertThat(dao.stored.single()).isEqualTo(storedBefore)
}
@Test
fun `resetting returns the timer to its configured duration`() = runTest {
val id = startedTimer()
repository.addTime(id, 1.minutes)
repository.reset(id)
val timer = repository.find(id)!!
assertThat(timer.state).isEqualTo(TimerState.IDLE)
assertThat(timer.remaining).isEqualTo(5.minutes)
assertThat(timer.duration).isEqualTo(5.minutes)
assertThat(
listOf(timer.startedAtElapsedRealtime, timer.endsAtElapsedRealtime, timer.endsAtWallClock),
).containsExactly(null, null, null)
}
@Test
fun `adding time to a running timer moves both end anchors and not the duration`() = runTest {
val id = startedTimer()
elapsed.value = 1_100.seconds
repository.addTime(id, 1.minutes)
val timer = repository.find(id)!!
assertThat(timer.endsAtElapsedRealtime).isEqualTo(1_360.seconds)
// Both end anchors are rebased on what is left, each from its own clock
// read at the moment of the edit. The fake wall clock has not moved
// while the fake monotonic clock advanced 100 s — a divergence only a
// test can produce — so the fallback lands 260 s after T0.
assertThat(timer.endsAtWallClock).isEqualTo(T0 + 260.seconds)
assertThat(timer.duration).isEqualTo(5.minutes)
assertThat(timer.snapshotAt(1_100.seconds, T0).remaining).isEqualTo(260.seconds)
}
@Test
fun `adding time to a running timer whose end anchor has passed grants the whole extra`() = runTest {
val id = startedTimer()
elapsed.value = 1_400.seconds // 100 s past the 1_300 s end anchor
repository.addTime(id, 1.minutes)
val timer = repository.find(id)!!
assertThat(timer.remaining).isEqualTo(1.minutes)
assertThat(timer.endsAtElapsedRealtime).isEqualTo(1_460.seconds)
assertThat(timer.endsAtWallClock).isEqualTo(T0 + 1.minutes)
assertThat(timer.snapshotAt(1_400.seconds, T0).remaining).isEqualTo(1.minutes)
}
@Test
fun `every read-modify-write goes through the DAO transaction`() = runTest {
val dao = TransactionSpyTimerDao()
val repository = TimerRepositoryImpl(dao, elapsed, wallClock)
val id = repository.create(TimerDraft(5.minutes, "Pasta"))
repository.start(id)
elapsed.advance(30.seconds)
repository.addTime(id, 1.minutes)
repository.pause(id)
repository.rename(id, "Risotto")
repository.markExpired(id)
repository.reset(id)
assertThat(dao.updatesOutsideATransaction).isEqualTo(0)
}
/** Counts every `update` the repository makes without an open transaction. */
private class TransactionSpyTimerDao : FakeTimerDao() {
private var depth = 0
var updatesOutsideATransaction = 0
private set
override suspend fun updateWithin(id: Long, transform: (TimerEntity) -> TimerEntity?) {
depth++
try {
super.updateWithin(id, transform)
} finally {
depth--
}
}
override suspend fun update(timer: TimerEntity) {
if (depth == 0) updatesOutsideATransaction++
super.update(timer)
}
}
@Test
fun `adding time to a paused timer extends the remainder only`() = runTest {
val id = startedTimer()
elapsed.value = 1_100.seconds
repository.pause(id)
repository.addTime(id, 1.minutes)
val timer = repository.find(id)!!
assertThat(timer.remaining).isEqualTo(260.seconds)
assertThat(timer.duration).isEqualTo(5.minutes)
}
@Test
fun `marking a timer expired zeroes it and clears the anchors`() = runTest {
val id = startedTimer()
repository.markExpired(id)
val timer = repository.find(id)!!
assertThat(timer.state).isEqualTo(TimerState.EXPIRED)
assertThat(timer.remaining).isEqualTo(Duration.ZERO)
assertThat(
listOf(timer.startedAtElapsedRealtime, timer.endsAtElapsedRealtime, timer.endsAtWallClock),
).containsExactly(null, null, null)
}
@Test
fun `starting an expired timer restarts it from the full duration`() = runTest {
val id = startedTimer()
repository.markExpired(id)
elapsed.value = 2_000.seconds
repository.start(id)
val timer = repository.find(id)!!
assertThat(timer.state).isEqualTo(TimerState.RUNNING)
assertThat(timer.endsAtElapsedRealtime).isEqualTo(2_300.seconds)
assertThat(timer.remaining).isEqualTo(5.minutes)
}
@Test
fun `reordering rewrites sort order in the given order`() = runTest {
val a = repository.create(TimerDraft(1.minutes, "a"))
val b = repository.create(TimerDraft(2.minutes, "b"))
val c = repository.create(TimerDraft(3.minutes, "c"))
repository.reorder(listOf(c, a, b))
val timers = repository.timers().first()
assertThat(timers.map { it.id }).containsExactly(c, a, b).inOrder()
assertThat(timers.map { it.sortOrder }).containsExactly(0, 1, 2).inOrder()
}
@Test
fun `reordering tolerates an unknown id`() = runTest {
val a = repository.create(TimerDraft(1.minutes, "a"))
val b = repository.create(TimerDraft(2.minutes, "b"))
val c = repository.create(TimerDraft(3.minutes, "c"))
repository.reorder(listOf(c, 999L, a, b))
assertThat(repository.timers().first().map { it.id }).containsExactly(c, a, b).inOrder()
}
@Test
fun `writes to a missing id are silent no-ops`() = runTest {
startedTimer()
val before = repository.timers().first()
repository.start(999L)
repository.pause(999L)
repository.reset(999L)
repository.addTime(999L, 1.minutes)
repository.markExpired(999L)
repository.rename(999L, "x")
repository.delete(999L)
assertThat(repository.timers().first()).isEqualTo(before)
assertThat(repository.find(999L)).isNull()
}
}
@@ -0,0 +1,126 @@
package de.jeanlucmakiola.clockula.data.worldclocks
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.testing.FakeWorldClockDao
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
/**
* Adding a zone is idempotent and validating: `zone_id` is unique, and only an
* IANA region id the device knows gets through.
*/
class WorldClockRepositoryTest {
private val dao = FakeWorldClockDao()
private val repository = WorldClockRepositoryImpl(dao)
@Test
fun `an empty store emits an empty list`() = runTest {
assertThat(repository.worldClocks().first()).isEmpty()
}
@Test
fun `adding a zone stores it at sort order zero`() = runTest {
val id = repository.add("Europe/Berlin")
val clocks = repository.worldClocks().first()
assertThat(clocks).hasSize(1)
assertThat(clocks.single().id).isEqualTo(id)
assertThat(clocks.single().zoneId).isEqualTo("Europe/Berlin")
assertThat(clocks.single().label).isNull()
assertThat(clocks.single().sortOrder).isEqualTo(0)
}
@Test
fun `adding the same zone twice returns the existing row`() = runTest {
val first = repository.add("Europe/Berlin")
val second = repository.add("Europe/Berlin")
assertThat(second).isEqualTo(first)
assertThat(repository.worldClocks().first()).hasSize(1)
}
@Test
fun `a padded zone id is trimmed before storage`() = runTest {
repository.add(" Europe/Berlin ")
assertThat(repository.worldClocks().first().single().zoneId).isEqualTo("Europe/Berlin")
}
@Test
fun `a blank zone id is rejected and writes nothing`() = runTest {
assertThrows<IllegalArgumentException> { repository.add("") }
assertThrows<IllegalArgumentException> { repository.add(" ") }
assertThat(repository.worldClocks().first()).isEmpty()
}
@Test
fun `an unknown zone id is rejected and writes nothing`() = runTest {
assertThrows<IllegalArgumentException> { repository.add("Mars/Phobos") }
assertThrows<IllegalArgumentException> { repository.add("+02:00") }
assertThat(repository.worldClocks().first()).isEmpty()
}
@Test
fun `sort order continues from the highest existing`() = runTest {
repository.add("Europe/Berlin")
repository.add("America/New_York")
val id = repository.add("Pacific/Auckland")
assertThat(repository.worldClocks().first().single { it.id == id }.sortOrder).isEqualTo(2)
}
@Test
fun `adding never hands back the insert sentinel`() = runTest {
// A DAO whose insert always reports the unique-index conflict and whose
// table stays empty — the row that won the race having been deleted.
val hostile = object : FakeWorldClockDao() {
override suspend fun insert(clock: WorldClockEntity): Long = -1L
}
assertThrows<IllegalStateException> { WorldClockRepositoryImpl(hostile).add("Europe/Berlin") }
}
@Test
fun `a label is set, changed and cleared`() = runTest {
val id = repository.add("Europe/Berlin")
repository.setLabel(id, "Home")
assertThat(repository.worldClocks().first().single().label).isEqualTo("Home")
repository.setLabel(id, null)
assertThat(repository.worldClocks().first().single().label).isNull()
repository.setLabel(id, " ")
assertThat(repository.worldClocks().first().single().label).isNull()
}
@Test
fun `removing re-emits the list without the clock`() = runTest {
val first = repository.add("Europe/Berlin")
val second = repository.add("America/New_York")
repository.remove(first)
assertThat(repository.worldClocks().first().map { it.id }).containsExactly(second)
}
@Test
fun `reordering rewrites sort order and ignores an unknown id`() = runTest {
val a = repository.add("Europe/Berlin")
val b = repository.add("America/New_York")
val c = repository.add("Pacific/Auckland")
repository.reorder(listOf(c, 999L, a, b))
val clocks = repository.worldClocks().first()
assertThat(clocks.map { it.id }).containsExactly(c, a, b).inOrder()
assertThat(clocks.map { it.sortOrder }).containsExactly(0, 1, 2).inOrder()
}
}
@@ -0,0 +1,170 @@
package de.jeanlucmakiola.clockula.testing
import de.jeanlucmakiola.clockula.data.alarms.AlarmDao
import de.jeanlucmakiola.clockula.data.alarms.AlarmEntity
import de.jeanlucmakiola.clockula.data.stopwatch.LapDao
import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity
import de.jeanlucmakiola.clockula.data.timers.TimerDao
import de.jeanlucmakiola.clockula.data.timers.TimerEntity
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockDao
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
/**
* In-memory stand-ins for the four DAOs, each backed by a [MutableStateFlow] so
* a write re-emits on the observing flow exactly as Room's would. The three
* abstract DAOs are *extended*, not reimplemented, so their `@Transaction`
* default bodies are the ones under test.
*/
class FakeAlarmDao(
initial: List<AlarmEntity> = emptyList(),
nextId: Long = 1L,
) : AlarmDao {
private val rows = MutableStateFlow(initial)
private var nextRowId = maxOf(nextId, (initial.maxOfOrNull { it.id } ?: 0L) + 1L)
/** The stored rows, for asserting that a call wrote nothing. */
val stored: List<AlarmEntity> get() = sorted(rows.value)
override fun observeAll(): Flow<List<AlarmEntity>> = rows.map(::sorted)
override fun observeById(id: Long): Flow<AlarmEntity?> =
rows.map { rows -> rows.firstOrNull { it.id == id } }
override suspend fun findById(id: Long): AlarmEntity? = rows.value.firstOrNull { it.id == id }
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
rows.update { it + alarm.copy(id = id) }
return id
}
override suspend fun update(alarm: AlarmEntity) {
rows.update { rows -> rows.map { if (it.id == alarm.id) alarm else it } }
}
override suspend fun deleteById(id: Long) {
rows.update { rows -> rows.filterNot { it.id == id } }
}
override suspend fun setEnabled(id: Long, enabled: Boolean, updatedAt: Long) {
rows.update { rows ->
rows.map { if (it.id == id) it.copy(enabled = enabled, updatedAt = updatedAt) else it }
}
}
override suspend fun setSkipNextOccurrence(id: Long, skip: Boolean, updatedAt: Long) {
rows.update { rows ->
rows.map { if (it.id == id) it.copy(skipNextOccurrence = skip, updatedAt = updatedAt) else it }
}
}
private fun sorted(rows: List<AlarmEntity>): List<AlarmEntity> =
rows.sortedWith(compareBy({ it.hour }, { it.minute }, { it.id }))
}
open class FakeTimerDao(initial: List<TimerEntity> = emptyList()) : TimerDao() {
private val rows = MutableStateFlow(initial)
private var nextRowId = (initial.maxOfOrNull { it.id } ?: 0L) + 1L
val stored: List<TimerEntity> get() = sorted(rows.value)
override fun observeAll(): Flow<List<TimerEntity>> = rows.map(::sorted)
override fun observeById(id: Long): Flow<TimerEntity?> =
rows.map { rows -> rows.firstOrNull { it.id == id } }
override suspend fun findById(id: Long): TimerEntity? = rows.value.firstOrNull { it.id == id }
override suspend fun maxSortOrder(): Int? = rows.value.maxOfOrNull { it.sortOrder }
override suspend fun insert(timer: TimerEntity): Long {
val id = if (timer.id == 0L) nextRowId++ else timer.id
rows.update { it + timer.copy(id = id) }
return id
}
override suspend fun update(timer: TimerEntity) {
rows.update { rows -> rows.map { if (it.id == timer.id) timer else it } }
}
override suspend fun deleteById(id: Long) {
rows.update { rows -> rows.filterNot { it.id == id } }
}
override suspend fun setSortOrder(id: Long, sortOrder: Int) {
rows.update { rows -> rows.map { if (it.id == id) it.copy(sortOrder = sortOrder) else it } }
}
private fun sorted(rows: List<TimerEntity>): List<TimerEntity> =
rows.sortedWith(compareBy({ it.sortOrder }, { it.id }))
}
open class FakeWorldClockDao(initial: List<WorldClockEntity> = emptyList()) : WorldClockDao() {
private val rows = MutableStateFlow(initial)
private var nextRowId = (initial.maxOfOrNull { it.id } ?: 0L) + 1L
val stored: List<WorldClockEntity> get() = sorted(rows.value)
override fun observeAll(): Flow<List<WorldClockEntity>> = rows.map(::sorted)
override suspend fun findByZoneId(zoneId: String): WorldClockEntity? =
rows.value.firstOrNull { it.zoneId == zoneId }
override suspend fun maxSortOrder(): Int? = rows.value.maxOfOrNull { it.sortOrder }
/** Mirrors `OnConflictStrategy.IGNORE` on the unique `zone_id` index. */
override suspend fun insert(clock: WorldClockEntity): Long {
if (rows.value.any { it.zoneId == clock.zoneId }) return -1L
val id = if (clock.id == 0L) nextRowId++ else clock.id
rows.update { it + clock.copy(id = id) }
return id
}
override suspend fun setLabel(id: Long, label: String?) {
rows.update { rows -> rows.map { if (it.id == id) it.copy(label = label) else it } }
}
override suspend fun setSortOrder(id: Long, sortOrder: Int) {
rows.update { rows -> rows.map { if (it.id == id) it.copy(sortOrder = sortOrder) else it } }
}
override suspend fun deleteById(id: Long) {
rows.update { rows -> rows.filterNot { it.id == id } }
}
private fun sorted(rows: List<WorldClockEntity>): List<WorldClockEntity> =
rows.sortedWith(compareBy({ it.sortOrder }, { it.id }))
}
class FakeLapDao(initial: List<LapEntity> = emptyList()) : LapDao() {
private val rows = MutableStateFlow(initial)
private var nextRowId = (initial.maxOfOrNull { it.id } ?: 0L) + 1L
val stored: List<LapEntity> get() = rows.value.sortedBy { it.lapIndex }
override fun observeAll(): Flow<List<LapEntity>> = rows.map { rows -> rows.sortedBy { it.lapIndex } }
override suspend fun latest(): LapEntity? = rows.value.maxByOrNull { it.lapIndex }
/** The unique `lap_index` index, enforced the way SQLite would. */
override suspend fun insert(lap: LapEntity): Long {
check(rows.value.none { it.lapIndex == lap.lapIndex }) { "duplicate lap_index: ${lap.lapIndex}" }
val id = if (lap.id == 0L) nextRowId++ else lap.id
rows.update { it + lap.copy(id = id) }
return id
}
override suspend fun deleteAll() {
rows.value = emptyList()
}
}