From 6b7a4d73edc9bb16941a676b174351c32ec5ce75 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 11 Sep 2026 13:52:22 +0200 Subject: [PATCH] feat(data): Flow-based repositories over the DAOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../clockula/data/alarms/AlarmRepository.kt | 17 + .../data/alarms/AlarmRepositoryImpl.kt | 44 +++ .../data/stopwatch/StopwatchRepository.kt | 22 ++ .../data/stopwatch/StopwatchRepositoryImpl.kt | 82 +++++ .../clockula/data/timers/TimerRepository.kt | 30 ++ .../data/timers/TimerRepositoryImpl.kt | 129 ++++++++ .../data/worldclocks/WorldClockRepository.kt | 18 ++ .../worldclocks/WorldClockRepositoryImpl.kt | 43 +++ .../data/alarms/AlarmRepositoryTest.kt | 163 ++++++++++ .../data/stopwatch/StopwatchRepositoryTest.kt | 225 +++++++++++++ .../data/timers/TimerRepositoryTest.kt | 302 ++++++++++++++++++ .../worldclocks/WorldClockRepositoryTest.kt | 126 ++++++++ .../clockula/testing/FakeDaos.kt | 170 ++++++++++ 13 files changed, 1371 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepository.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryImpl.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepository.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryImpl.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepository.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryImpl.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockRepository.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockRepositoryImpl.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/data/alarms/AlarmRepositoryTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/data/timers/TimerRepositoryTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/data/worldclocks/WorldClockRepositoryTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt 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() + } +}