diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt new file mode 100644 index 0000000..616518c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt @@ -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> + fun alarm(id: Long): Flow + suspend fun find(id: Long): Alarm? + suspend fun enabledAlarms(): List + 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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryImpl.kt new file mode 100644 index 0000000..80cc84d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryImpl.kt @@ -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> = dao.observeAll().map(AlarmMapper::toDomain) + + override fun alarm(id: Long): Flow = 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 = 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()) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepository.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepository.kt new file mode 100644 index 0000000..e360364 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepository.kt @@ -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 + fun laps(): Flow> + 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? +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryImpl.kt new file mode 100644 index 0000000..039fd95 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryImpl.kt @@ -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 = stateStore.run + + override fun laps(): Flow> = 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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt new file mode 100644 index 0000000..a394f26 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt @@ -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> + fun timer(id: Long): Flow + 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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryImpl.kt new file mode 100644 index 0000000..af99610 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryImpl.kt @@ -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> = dao.observeAll().map(TimerMapper::toDomain) + + override fun timer(id: Long): Flow = 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) { + 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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockRepository.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockRepository.kt new file mode 100644 index 0000000..5dbd463 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockRepository.kt @@ -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> + + /** 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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockRepositoryImpl.kt new file mode 100644 index 0000000..4560c98 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockRepositoryImpl.kt @@ -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> = 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) { + val known = dao.observeAll().first().mapTo(HashSet()) { it.id } + dao.reorder(idsInOrder.filter { it in known }) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryTest.kt new file mode 100644 index 0000000..5bba639 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryTest.kt @@ -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() + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryTest.kt new file mode 100644 index 0000000..2af705a --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt new file mode 100644 index 0000000..c47348b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockRepositoryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockRepositoryTest.kt new file mode 100644 index 0000000..4288ab7 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockRepositoryTest.kt @@ -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 { repository.add("") } + assertThrows { repository.add(" ") } + + assertThat(repository.worldClocks().first()).isEmpty() + } + + @Test + fun `an unknown zone id is rejected and writes nothing`() = runTest { + assertThrows { repository.add("Mars/Phobos") } + assertThrows { 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 { 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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt new file mode 100644 index 0000000..ac66961 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt @@ -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 = 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 get() = sorted(rows.value) + + override fun observeAll(): Flow> = rows.map(::sorted) + + override fun observeById(id: Long): Flow = + 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 = 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): List = + rows.sortedWith(compareBy({ it.hour }, { it.minute }, { it.id })) +} + +open class FakeTimerDao(initial: List = emptyList()) : TimerDao() { + + private val rows = MutableStateFlow(initial) + private var nextRowId = (initial.maxOfOrNull { it.id } ?: 0L) + 1L + + val stored: List get() = sorted(rows.value) + + override fun observeAll(): Flow> = rows.map(::sorted) + + override fun observeById(id: Long): Flow = + 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): List = + rows.sortedWith(compareBy({ it.sortOrder }, { it.id })) +} + +open class FakeWorldClockDao(initial: List = emptyList()) : WorldClockDao() { + + private val rows = MutableStateFlow(initial) + private var nextRowId = (initial.maxOfOrNull { it.id } ?: 0L) + 1L + + val stored: List get() = sorted(rows.value) + + override fun observeAll(): Flow> = 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): List = + rows.sortedWith(compareBy({ it.sortOrder }, { it.id })) +} + +class FakeLapDao(initial: List = emptyList()) : LapDao() { + + private val rows = MutableStateFlow(initial) + private var nextRowId = (initial.maxOfOrNull { it.id } ?: 0L) + 1L + + val stored: List get() = rows.value.sortedBy { it.lapIndex } + + override fun observeAll(): Flow> = 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() + } +}