diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/LapDao.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/LapDao.kt index 3629738..c4f83d9 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/LapDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/LapDao.kt @@ -11,6 +11,9 @@ abstract class LapDao { @Query("SELECT * FROM stopwatch_laps ORDER BY lap_index") abstract fun observeAll(): Flow> + @Query("SELECT COUNT(*) FROM stopwatch_laps") + abstract suspend fun count(): Int + @Query("SELECT * FROM stopwatch_laps ORDER BY lap_index DESC LIMIT 1") abstract suspend fun latest(): LapEntity? 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 index 2d85bc9..1e05ded 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepository.kt @@ -9,6 +9,9 @@ interface StopwatchRepository { fun laps(): Flow> suspend fun currentRun(): StopwatchRun + /** How many laps are recorded right now. Never materialises the rows. */ + suspend fun lapCount(): Int + /** From IDLE: a fresh run, laps cleared. From PAUSED: resume, laps kept. No-op when RUNNING. */ suspend fun start() 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 index dd0aa45..663fd64 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryImpl.kt @@ -3,7 +3,7 @@ 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.stopwatch.StopwatchReadings import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -29,13 +29,28 @@ class StopwatchRepositoryImpl @Inject constructor( override suspend fun currentRun(): StopwatchRun = stateStore.current() + override suspend fun lapCount(): Int = lapDao.count() + 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)) + stateStore.set( + run.copy( + state = StopwatchState.RUNNING, + // Resuming banks the floor a readout already applies: a lap + // recorded inside a segment the reboot repair discarded is a + // real measurement, so the run is at least that far along + // (D11). Without this the record ticks from below its own + // last lap, and the shade's free-running chronometer — posted + // from the floored reading — would count while the tab sat + // frozen. + accumulated = maxOf(run.accumulated, run.lastLapCumulative), + startedAtElapsedRealtime = now, + ), + ) StopwatchState.IDLE -> { // A fresh run, not a resume: last run's laps would be nonsense here. lapDao.deleteAll() @@ -55,7 +70,9 @@ class StopwatchRepositoryImpl @Inject constructor( val run = stateStore.current() if (run.state != StopwatchState.RUNNING) return - val elapsed = run.snapshotAt(elapsedRealtimeClock.elapsedRealtime()).elapsed + // The value every surface is showing, not the raw snapshot: banking less + // than the user was just reading would make the readout jump backwards. + val elapsed = StopwatchReadings.of(run, elapsedRealtimeClock.elapsedRealtime()).elapsed stateStore.set( run.copy( state = StopwatchState.PAUSED, @@ -74,7 +91,11 @@ class StopwatchRepositoryImpl @Inject constructor( val run = stateStore.current() if (run.state != StopwatchState.RUNNING) return null - val cumulative = run.snapshotAt(elapsedRealtimeClock.elapsedRealtime()).elapsed + // The same floored reading the hero figure, the lap table and the shade + // are all showing: a lap that recorded a cumulative *below* the previous + // lap's would drag `lastLapCumulative` — and with it every readout — + // backwards (D11). + val cumulative = StopwatchReadings.of(run, 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/stopwatch/StopwatchEngine.kt b/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchEngine.kt new file mode 100644 index 0000000..0415688 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchEngine.kt @@ -0,0 +1,136 @@ +package de.jeanlucmakiola.clockula.stopwatch + +import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository +import de.jeanlucmakiola.clockula.domain.Lap +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchLaps +import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchNotificationPolicy +import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchNotificationState +import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchReadings +import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock +import de.jeanlucmakiola.clockula.domain.time.WallClock +import de.jeanlucmakiola.clockula.system.RebootRepair +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The stopwatch's analogue of `AlarmEngine` and `TimerEngine`: plain Kotlin, no + * `android.*` import ever, reaching the platform through one seam it does not + * implement (D1, D2). + * + * It is deliberately much smaller than [de.jeanlucmakiola.clockula.timer.TimerEngine], + * because the stopwatch has less to own: nothing has a deadline, so there is no + * `AlarmManager` registration and no wake lock; nothing expires, so there is no + * ring and no arbitration; and there is one record rather than N rows. What it + * does have is a foreground service and four verbs reachable from the shade with + * no ViewModel in sight — which is why the verbs live here and not on a screen. + * + * Like the other two it is not driven by a Flow: [resync] is called explicitly, + * from the app class, the receivers, the service and the ViewModels — threads + * that know nothing about each other, which is what the one `Mutex` is for. + */ +@Singleton +class StopwatchEngine @Inject constructor( + private val stopwatch: StopwatchRepository, + private val service: StopwatchServiceHandle, + private val rebootRepair: RebootRepair, + private val elapsed: ElapsedRealtimeClock, + private val wall: WallClock, +) { + /** + * A `Mutex` is not reentrant, so exactly one layer takes it: the public + * entry points below. Every private `…Locked` body assumes it is held. + */ + private val lock = Mutex() + + /** From IDLE a fresh run with its laps cleared, from PAUSED a resume. No-op when RUNNING. */ + suspend fun start(): Unit = lock.withLock { + val run = stopwatch.currentRun() + // A no-op is a no-op all the way down: a button pressed against a stale + // notification must not re-post one, let alone restart the service. + if (modeOf(run) == StopwatchState.RUNNING) return@withLock + if (run.state == StopwatchState.RUNNING) { + // Stored RUNNING but reading PAUSED: an anchor that did not survive a + // reboot, which every surface already draws as paused with a Resume + // button (D6). Resume must therefore resume, not hit the repository's + // "already running" no-op — so bank the reading first, exactly as the + // repair would have. + stopwatch.pause() + } + stopwatch.start() + syncLocked() + } + + /** No-op unless the stopwatch *reads* RUNNING. */ + suspend fun pause(): Unit = lock.withLock { + if (modeOf(stopwatch.currentRun()) != StopwatchState.RUNNING) return@withLock + stopwatch.pause() + // Still up, not down: dropping the notification on pause would leave a + // user who paused from the shade with no way to resume (D15). + syncLocked() + } + + /** Null when not RUNNING, or when [StopwatchLaps.MAX] laps already exist (D14). */ + suspend fun lap(): Lap? = lock.withLock { + if (modeOf(stopwatch.currentRun()) != StopwatchState.RUNNING) return@withLock null + // Counted rather than listed: an unbounded table behind a button that can + // be held down is a storage leak, and appending a lap must not have to + // materialise 999 rows to find that out. + if (stopwatch.lapCount() >= StopwatchLaps.MAX) return@withLock null + + val lap = stopwatch.lap() + syncLocked() + lap + } + + /** Back to IDLE at zero; deletes every lap; takes the service down. */ + suspend fun reset(): Unit = lock.withLock { + stopwatch.reset() + syncLocked() + } + + /** Match the service to "the stopwatch is active". Writes nothing. */ + suspend fun resync(): Unit = lock.withLock { syncLocked() } + + /** The reboot repair (idempotent within a boot), then a resync (D17). */ + suspend fun onBootCompleted(): Unit = lock.withLock { + // The boot-id gate makes the second and third call of a boot a no-op, so + // no engine depends on another's ordering. + rebootRepair.repairIfRebooted() + syncLocked() + } + + /** + * A clock change. **Writes nothing** — the reading is monotonic, so it cannot + * move. The resync exists only so the service re-posts with a freshly derived + * chronometer base (D4, D17). + */ + suspend fun onSystemTimeChanged(): Unit = lock.withLock { syncLocked() } + + /** Everything the service must show. Null ⇒ nothing active ⇒ stop (D4). */ + suspend fun notificationState(): StopwatchNotificationState? = lock.withLock { + val run = stopwatch.currentRun() + StopwatchNotificationPolicy.stateFor( + run = run, + // Asked only when there is something to show it on. + lapCount = if (StopwatchReadings.isActive(run)) stopwatch.lapCount() else 0, + elapsedRealtime = elapsed.elapsedRealtime(), + now = wall.now(), + ) + } + + /** + * The mode every surface is showing — the snapshot's, never the stored row's + * (D6). A verb that guarded on the row would offer the user a Resume button + * whose handler did nothing while a stale record existed. + */ + private fun modeOf(run: StopwatchRun): StopwatchState = + StopwatchReadings.of(run, elapsed.elapsedRealtime()).mode + + private suspend fun syncLocked() { + service.sync(StopwatchReadings.isActive(stopwatch.currentRun())) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchIntents.kt new file mode 100644 index 0000000..50427fa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchIntents.kt @@ -0,0 +1,26 @@ +package de.jeanlucmakiola.clockula.stopwatch + +/** + * The stopwatch path's actions and `PendingIntent` request codes. No extras and + * no per-id stride: there is exactly one stopwatch, so a button has nothing to + * identify (D16). + */ +object StopwatchIntents { + private const val PREFIX = "de.jeanlucmakiola.clockula." + + const val ACTION_LAP: String = PREFIX + "action.STOPWATCH_LAP" + const val ACTION_PAUSE: String = PREFIX + "action.STOPWATCH_PAUSE" + const val ACTION_RESUME: String = PREFIX + "action.STOPWATCH_RESUME" + const val ACTION_RESET: String = PREFIX + "action.STOPWATCH_RESET" + const val ACTION_SHOW_STOPWATCH: String = PREFIX + "action.SHOW_STOPWATCH" + const val ACTION_SERVICE_STOP: String = PREFIX + "action.STOPWATCH_SERVICE_STOP" + + const val REQUEST_SHOW: Int = 21 + const val REQUEST_LAP: Int = 22 + const val REQUEST_PAUSE: Int = 23 + const val REQUEST_RESUME: Int = 24 + const val REQUEST_RESET: Int = 25 + + val ALL_REQUEST_CODES: List = + listOf(REQUEST_SHOW, REQUEST_LAP, REQUEST_PAUSE, REQUEST_RESUME, REQUEST_RESET) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchServiceHandle.kt b/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchServiceHandle.kt new file mode 100644 index 0000000..0041cb5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchServiceHandle.kt @@ -0,0 +1,6 @@ +package de.jeanlucmakiola.clockula.stopwatch + +/** Brings the foreground service up while the stopwatch is active and down when it is not (D15). */ +interface StopwatchServiceHandle { + fun sync(active: Boolean) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/android/ServiceStopwatchHandle.kt b/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/android/ServiceStopwatchHandle.kt new file mode 100644 index 0000000..1c0be31 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/android/ServiceStopwatchHandle.kt @@ -0,0 +1,27 @@ +package de.jeanlucmakiola.clockula.stopwatch.android + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.clockula.stopwatch.StopwatchServiceHandle +import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchService +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Starts and stops the one stopwatch foreground service. The service is a + * readout and a control surface, never the timekeeper — the persisted monotonic + * anchor is (D3). + */ +@Singleton +class ServiceStopwatchHandle @Inject constructor( + @param:ApplicationContext private val context: Context, +) : StopwatchServiceHandle { + + override fun sync(active: Boolean) { + // Both ways in `runCatching`: a refused start costs the user a shade + // entry, never a measurement — the persisted anchor is the timekeeper. + runCatching { + if (active) StopwatchService.start(context) else StopwatchService.stop(context) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/di/StopwatchModule.kt b/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/di/StopwatchModule.kt new file mode 100644 index 0000000..8d8f2c1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/stopwatch/di/StopwatchModule.kt @@ -0,0 +1,18 @@ +package de.jeanlucmakiola.clockula.stopwatch.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.clockula.stopwatch.StopwatchServiceHandle +import de.jeanlucmakiola.clockula.stopwatch.android.ServiceStopwatchHandle +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class StopwatchModule { + + @Binds + @Singleton + abstract fun bindStopwatchServiceHandle(impl: ServiceStopwatchHandle): StopwatchServiceHandle +} 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 index 6f88aaa..26a4517 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/data/stopwatch/StopwatchRepositoryTest.kt @@ -287,4 +287,104 @@ class StopwatchRepositoryTest { "stopwatch_last_lap_cumulative_millis", ) } + + // --- M7 §5.6: the last lap's cumulative, which M7 is the first caller of --- + + /** §5.6 #1 */ + @Test + fun `a lap records its own cumulative as the last lap's`(@TempDir tempDir: Path) = runTest { + val repository = repository(tempDir) + repository.start() + clock.value = 1_020.seconds + + val lap = repository.lap() + + assertThat(repository.run().first().lastLapCumulative).isEqualTo(lap?.cumulative) + } + + /** §5.6 #2 */ + @Test + fun `resetting clears the last lap's cumulative`(@TempDir tempDir: Path) = runTest { + val repository = repository(tempDir) + repository.start() + clock.value = 1_020.seconds + repository.lap() + + repository.reset() + + assertThat(repository.run().first().lastLapCumulative).isEqualTo(Duration.ZERO) + } + + /** §5.6 #3 */ + @Test + fun `a fresh run starts with no last lap behind it`(@TempDir tempDir: Path) = runTest { + val repository = repository(tempDir) + repository.start() + clock.value = 1_020.seconds + repository.lap() + repository.reset() + + repository.start() + + assertThat(repository.run().first().lastLapCumulative).isEqualTo(Duration.ZERO) + } + + // --- M7 review: the floor the readout applies is banked, not only displayed --- + + /** + * Review finding 2. A lap survives the segment the reboot repair discards, so + * the reading is floored at it (D11) — and resuming has to bank that floor, or + * the record ticks from below its own last lap while every surface shows the + * floored value. + */ + @Test + fun `resuming banks the last lap's total when the reboot discarded the segment`( + @TempDir tempDir: Path, + ) = runTest { + val repository = repository(tempDir) + repository.start() + clock.value = 1_060.seconds + repository.lap() + clock.reboot(5.seconds) + repository.pauseAfterReboot() + + repository.start() + + val run = repository.run().first() + assertThat(run.accumulated).isEqualTo(60.seconds) + assertThat(run.startedAtElapsedRealtime).isEqualTo(5.seconds) + assertThat(run.snapshotAt(15.seconds).elapsed).isEqualTo(70.seconds) + } + + /** Review finding 1: a lap's cumulative can never fall below the previous lap's. */ + @Test + fun `a lap after a discarded segment records the floored reading`(@TempDir tempDir: Path) = runTest { + val repository = repository(tempDir) + repository.start() + clock.value = 1_060.seconds + repository.lap() + clock.reboot(5.seconds) + repository.pauseAfterReboot() + repository.start() + + clock.value = 15.seconds + val lap = repository.lap() + + assertThat(lap).isEqualTo(Lap(index = 2, split = 10.seconds, cumulative = 70.seconds)) + assertThat(repository.run().first().lastLapCumulative).isEqualTo(70.seconds) + } + + /** The same floor when the user pauses: banking less than was on screen is a jump backwards. */ + @Test + fun `pausing never banks less than the last lap's total`(@TempDir tempDir: Path) = runTest { + val repository = repository(tempDir) + repository.start() + clock.value = 1_060.seconds + repository.lap() + clock.reboot(5.seconds) + + repository.pause() + + assertThat(repository.run().first().accumulated).isEqualTo(60.seconds) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchEngineTest.kt new file mode 100644 index 0000000..0f3f675 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchEngineTest.kt @@ -0,0 +1,540 @@ +package de.jeanlucmakiola.clockula.stopwatch + +import androidx.datastore.preferences.core.Preferences +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity +import de.jeanlucmakiola.clockula.domain.Lap +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchLaps +import de.jeanlucmakiola.clockula.domain.time.BootId +import de.jeanlucmakiola.clockula.testing.FakeLapDao +import de.jeanlucmakiola.clockula.testing.StopwatchEngineHarness +import de.jeanlucmakiola.clockula.testing.T0 +import de.jeanlucmakiola.clockula.testing.stopwatchEngineHarness +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +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.nio.file.Path +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +/** + * §5.5 — 24 cases over the **real** [StopwatchEngine], the real + * `StopwatchRepositoryImpl` across the real `appendLap` transaction, a real + * DataStore under a `@TempDir` and `FakeStopwatchServiceHandle`. The engine + * owns the four verbs and the one seam (D1, D2), so this is where the + * milestone's guarantees live: the lap cap (D14), the boot gate (D17) and a + * clock change that writes nothing (D4). + */ +class StopwatchEngineTest { + + private val uptime: Duration = 1_000.seconds + + private suspend fun StopwatchEngineHarness.prefs(): Preferences = store.data.first() + + private suspend fun StopwatchEngineHarness.givenLaps(count: Int) { + (1..count).forEach { index -> + lapDao.insert( + LapEntity( + lapIndex = index, + splitMillis = 1_000L, + cumulativeMillis = index * 1_000L, + ), + ) + } + } + + // --- start, pause, reset: the record and the service --- + + /** §5.5 #1 */ + @Test + fun `starting an idle stopwatch anchors it and brings the service up`( + @TempDir tempDir: Path, + ) = runTest { + val harness = stopwatchEngineHarness(tempDir) + + harness.engine.start() + + assertThat(harness.storedRun()).isEqualTo( + StopwatchRun( + state = StopwatchState.RUNNING, + accumulated = Duration.ZERO, + startedAtElapsedRealtime = uptime, + lastLapCumulative = Duration.ZERO, + ), + ) + assertThat(harness.service.transitions).containsExactly(true) + } + + /** §5.5 #2 */ + @Test + fun `starting a running stopwatch changes nothing`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + val before = harness.storedRun() + harness.elapsed.advance(10.seconds) + + harness.engine.start() + + assertThat(harness.storedRun()).isEqualTo(before) + assertThat(harness.service.transitions).containsExactly(true) + } + + /** §5.5 #3 */ + @Test + fun `pausing banks the elapsed value and leaves the service up`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(30.seconds) + + harness.engine.pause() + + assertThat(harness.storedRun()).isEqualTo( + StopwatchRun( + state = StopwatchState.PAUSED, + accumulated = 30.seconds, + startedAtElapsedRealtime = null, + lastLapCumulative = Duration.ZERO, + ), + ) + assertThat(harness.service.active).isTrue() + } + + /** §5.5 #4 */ + @Test + fun `resuming keeps what it banked and every lap it recorded`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(10.seconds) + harness.engine.lap() + harness.elapsed.advance(10.seconds) + harness.engine.lap() + harness.engine.pause() + + harness.engine.start() + + val run = harness.storedRun() + assertThat(run.state to run.accumulated).isEqualTo(StopwatchState.RUNNING to 20.seconds) + assertThat(harness.storedLaps().map { it.index }).containsExactly(1, 2).inOrder() + } + + /** §5.5 #5 */ + @Test + fun `a fresh run clears the previous run's laps`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.givenLaps(3) + + harness.engine.start() + + assertThat(harness.storedLaps()).isEmpty() + } + + /** §5.5 #6 */ + @Test + fun `resetting empties the record and the lap table and takes the service down`( + @TempDir tempDir: Path, + ) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(10.seconds) + harness.engine.lap() + + harness.engine.reset() + + assertThat(harness.storedRun()).isEqualTo(StopwatchRun()) + assertThat(harness.storedLaps()).isEmpty() + assertThat(harness.service.transitions.last()).isFalse() + } + + /** §5.5 #7 */ + @Test + fun `resetting an idle stopwatch is harmless and syncs the service down`( + @TempDir tempDir: Path, + ) = runTest { + val harness = stopwatchEngineHarness(tempDir) + + harness.engine.reset() + + assertThat(harness.storedRun()).isEqualTo(StopwatchRun()) + assertThat(harness.service.active).isFalse() + } + + // --- laps --- + + /** §5.5 #8 */ + @Test + fun `the first lap is the whole elapsed reading`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(20.seconds) + + val lap = harness.engine.lap() + + assertThat(lap).isEqualTo(Lap(index = 1, split = 20.seconds, cumulative = 20.seconds)) + assertThat(harness.storedRun().lastLapCumulative).isEqualTo(20.seconds) + } + + /** §5.5 #9 */ + @Test + fun `the second lap splits from the first`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(20.seconds) + harness.engine.lap() + harness.elapsed.advance(15.seconds) + + val lap = harness.engine.lap() + + assertThat(lap).isEqualTo(Lap(index = 2, split = 15.seconds, cumulative = 35.seconds)) + } + + /** §5.5 #10 */ + @Test + fun `a paused stopwatch has nothing to lap`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(20.seconds) + harness.engine.lap() + harness.engine.pause() + + val lap = harness.engine.lap() + + assertThat(lap).isNull() + assertThat(harness.storedLaps().map { it.index }).containsExactly(1) + } + + /** §5.5 #11 */ + @Test + fun `an idle stopwatch has nothing to lap`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + + assertThat(harness.engine.lap()).isNull() + } + + /** §5.5 #12 */ + @Test + fun `the lap cap refuses the thousandth lap`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.givenLaps(StopwatchLaps.MAX) + harness.elapsed.advance(20.seconds) + + val lap = harness.engine.lap() + + assertThat(lap).isNull() + assertThat(harness.storedLaps()).hasSize(StopwatchLaps.MAX) + } + + /** §5.5 #13 */ + @Test + fun `the lap just below the cap is still recorded`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.givenLaps(StopwatchLaps.MAX - 1) + harness.elapsed.advance(2_000.seconds) + + val lap = harness.engine.lap() + + assertThat(lap?.index).isEqualTo(StopwatchLaps.MAX) + assertThat(harness.storedLaps()).hasSize(StopwatchLaps.MAX) + } + + // --- the clock, the boot and the shade --- + + /** §5.5 #14 */ + @Test + fun `three hours of wall clock each way rewrites nothing and moves no reading`( + @TempDir tempDir: Path, + ) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(40.seconds) + val before = harness.prefs() + val reading = harness.engine.notificationState()?.elapsed + + harness.wallClock.advance(3.hours) + harness.engine.onSystemTimeChanged() + harness.wallClock.advance(-3.hours) + harness.engine.onSystemTimeChanged() + + assertThat(harness.prefs().asMap()).isEqualTo(before.asMap()) + assertThat(harness.engine.notificationState()?.elapsed).isEqualTo(reading) + } + + /** §5.5 #15 */ + @Test + fun `a new boot pauses the running stopwatch at what it banked`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(40.seconds) + harness.engine.pause() + harness.engine.start() + harness.elapsed.reboot(uptime = 5.seconds) + harness.bootIds.bootId = BootId(bootCount = 2, approximateBootInstant = T0 + 1.hours) + + harness.engine.onBootCompleted() + + val run = harness.storedRun() + assertThat(run.state to run.accumulated).isEqualTo(StopwatchState.PAUSED to 40.seconds) + assertThat(run.startedAtElapsedRealtime).isNull() + // No key of the stopwatch record may hold a wall-clock value: an epoch + // millisecond is ~1.7e12, and nothing the stopwatch stores is that big. + val stopwatchValues = harness.prefs().asMap() + .filterKeys { it.name.startsWith("stopwatch_") } + .values + .filterIsInstance() + assertThat(stopwatchValues.filter { it > 1_000_000_000L }).isEmpty() + } + + /** §5.5 #16 */ + @Test + fun `the persisted boot gate makes the second pass of a boot a no-op`( + @TempDir tempDir: Path, + ) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.engine.onBootCompleted() + harness.engine.start() + + harness.engine.onBootCompleted() + + assertThat(harness.storedRun().state).isEqualTo(StopwatchState.RUNNING) + } + + /** §5.5 #17 */ + @Test + fun `a new boot leaves an already paused record alone`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(40.seconds) + harness.engine.pause() + val before = harness.storedRun() + + harness.engine.onBootCompleted() + + assertThat(harness.storedRun()).isEqualTo(before) + } + + /** §5.5 #18 */ + @Test + fun `a boot pass over an idle stopwatch takes the service down`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + + harness.engine.onBootCompleted() + + assertThat(harness.service.transitions.last()).isFalse() + } + + /** §5.5 #19 */ + @Test + fun `a clock change is a re-post and not a write`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(20.seconds) + harness.engine.lap() + val writes = harness.dataStore.writes + val laps = harness.storedLaps() + + harness.engine.onSystemTimeChanged() + + assertThat(harness.dataStore.writes).isEqualTo(writes) + assertThat(harness.storedLaps()).isEqualTo(laps) + assertThat(harness.service.active).isTrue() + } + + /** §5.5 #20 */ + @Test + fun `an idle stopwatch gives the service nothing to show`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + + assertThat(harness.engine.notificationState()).isNull() + } + + /** §5.5 #21 */ + @Test + fun `the shade's state counts the laps and derives its chronometer base`( + @TempDir tempDir: Path, + ) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(20.seconds) + harness.engine.lap() + harness.elapsed.advance(20.seconds) + harness.engine.lap() + + val state = harness.engine.notificationState() + + assertThat(state?.running).isTrue() + assertThat(state?.lapCount).isEqualTo(2) + assertThat(state?.chronometerBase).isEqualTo(harness.wallClock.now() - 40.seconds) + } + + /** §5.5 #22 */ + @Test + fun `two laps racing each other still get one index each`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(20.seconds) + + coroutineScope { + launch { harness.engine.lap() } + launch { harness.engine.lap() } + } + + assertThat(harness.storedLaps().map { it.index }).containsExactly(1, 2).inOrder() + } + + /** §5.5 #23 */ + @Test + fun `pausing a paused stopwatch writes nothing`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.engine.pause() + val writes = harness.dataStore.writes + + harness.engine.pause() + + assertThat(harness.dataStore.writes).isEqualTo(writes) + } + + /** §5.5 #24 */ + @Test + fun `the run and its laps survive process death`(@TempDir tempDir: Path) = runTest { + val lapDao = FakeLapDao() + val firstProcess = Job() + val before = StopwatchEngineHarness( + tempDir = tempDir, + storeScope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + firstProcess), + lapDao = lapDao, + ) + before.engine.start() + before.elapsed.advance(20.seconds) + before.engine.lap() + before.elapsed.advance(15.seconds) + before.engine.lap() + val storedRun = before.storedRun() + firstProcess.cancel() + + val after = StopwatchEngineHarness( + tempDir = tempDir, + storeScope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), + lapDao = lapDao, + ) + + assertThat(after.storedRun()).isEqualTo(storedRun) + assertThat(after.storedLaps().map { it.index }).containsExactly(1, 2).inOrder() + } + + // --- M7 review: a lap recorded inside a segment the reboot repair discarded --- + + /** + * The review's finding 1 and 2, end to end. A lap taken before a reboot keeps + * its own cumulative, so the reading is floored at it (D11) — and the record + * has to be floored with it, or the next lap records a cumulative *below* the + * previous one and every readout jumps backwards. + */ + @Test + fun `a lap after a reboot never records a cumulative below the last lap's`( + @TempDir tempDir: Path, + ) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(60.seconds) + harness.engine.lap() + harness.elapsed.reboot(uptime = 5.seconds) + harness.bootIds.bootId = BootId(bootCount = 2, approximateBootInstant = T0 + 1.hours) + harness.engine.onBootCompleted() + + harness.engine.start() + + // Resuming banks the floor the readout is already showing. + assertThat(harness.storedRun().accumulated).isEqualTo(60.seconds) + harness.elapsed.advance(10.seconds) + assertThat(harness.engine.lap()).isEqualTo( + Lap(index = 2, split = 10.seconds, cumulative = 70.seconds), + ) + assertThat(harness.storedRun().lastLapCumulative).isEqualTo(70.seconds) + } + + /** + * The same setup from the shade's side: the chronometer base is derived from + * the reading once and then free-runs, so a resumed run whose record sat below + * its own last lap would have the shade counting while the tab stood still. + */ + @Test + fun `the shade and the tab agree after a reboot and a resume`(@TempDir tempDir: Path) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(60.seconds) + harness.engine.lap() + harness.elapsed.reboot(uptime = 5.seconds) + harness.bootIds.bootId = BootId(bootCount = 2, approximateBootInstant = T0 + 1.hours) + harness.engine.onBootCompleted() + + val paused = harness.engine.notificationState() + assertThat(paused?.running).isFalse() + assertThat(paused?.elapsed).isEqualTo(60.seconds) + + harness.engine.start() + + val resumed = harness.engine.notificationState() + assertThat(resumed?.running).isTrue() + assertThat(resumed?.elapsed).isEqualTo(60.seconds) + assertThat(resumed?.chronometerBase).isEqualTo(harness.wallClock.now() - 60.seconds) + // And it moves at once rather than standing still for a minute. + harness.elapsed.advance(10.seconds) + assertThat(harness.engine.notificationState()?.elapsed).isEqualTo(70.seconds) + } + + // --- M7 review: the verbs guard on the reading, not on the stored row --- + + /** + * The review's finding 3. Between a reboot and the repair the record still + * says RUNNING while every surface draws it PAUSED with a Resume button, so + * Resume has to actually resume instead of hitting the repository's "already + * running" no-op. + */ + @Test + fun `resume works against a stale record before the repair has run`( + @TempDir tempDir: Path, + ) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(40.seconds) + harness.engine.pause() + harness.engine.start() + harness.elapsed.reboot(uptime = 5.seconds) + + harness.engine.start() + + val run = harness.storedRun() + assertThat(run.state).isEqualTo(StopwatchState.RUNNING) + assertThat(run.accumulated).isEqualTo(40.seconds) + assertThat(run.startedAtElapsedRealtime).isEqualTo(5.seconds) + harness.elapsed.advance(10.seconds) + assertThat(harness.engine.notificationState()?.elapsed).isEqualTo(50.seconds) + } + + /** Finding 3, the other way: a stale record reads PAUSED, so Lap is refused. */ + @Test + fun `a lap against a stale record is refused rather than recorded below the last one`( + @TempDir tempDir: Path, + ) = runTest { + val harness = stopwatchEngineHarness(tempDir) + harness.engine.start() + harness.elapsed.advance(60.seconds) + harness.engine.lap() + harness.elapsed.reboot(uptime = 5.seconds) + + assertThat(harness.engine.lap()).isNull() + assertThat(harness.storedLaps().map { it.index }).containsExactly(1) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchIntentsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchIntentsTest.kt new file mode 100644 index 0000000..8c5bb71 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/stopwatch/StopwatchIntentsTest.kt @@ -0,0 +1,73 @@ +package de.jeanlucmakiola.clockula.stopwatch + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.alarm.AlarmIntents +import de.jeanlucmakiola.clockula.timer.TimerIntents +import org.junit.jupiter.api.Test + +/** + * §5.12 — 4 cases. A third family of `PendingIntent`s, and two of them sharing + * a request code silently overwrite each other — so the assertion that matters + * is over the **union** of all three objects' codes (D16). + */ +class StopwatchIntentsTest { + + private val stopwatchActions: List get() = listOf( + StopwatchIntents.ACTION_LAP, + StopwatchIntents.ACTION_PAUSE, + StopwatchIntents.ACTION_RESUME, + StopwatchIntents.ACTION_RESET, + StopwatchIntents.ACTION_SHOW_STOPWATCH, + StopwatchIntents.ACTION_SERVICE_STOP, + ) + + private val timerActions: List get() = listOf( + TimerIntents.ACTION_EXPIRY, + TimerIntents.ACTION_PAUSE, + TimerIntents.ACTION_RESUME, + TimerIntents.ACTION_RESET, + TimerIntents.ACTION_ADD_TIME, + TimerIntents.ACTION_SHOW_TIMERS, + TimerIntents.ACTION_SERVICE_STOP, + ) + + private val alarmActions: List get() = listOf( + AlarmIntents.ACTION_FIRE, + AlarmIntents.ACTION_AUTO_SILENCE, + AlarmIntents.ACTION_SNOOZE, + AlarmIntents.ACTION_DISMISS, + ) + + /** §5.12 #1 */ + @Test + fun `no two stopwatch request codes collide`() { + assertThat(StopwatchIntents.ALL_REQUEST_CODES).containsNoDuplicates() + } + + /** §5.12 #2 */ + @Test + fun `no stopwatch request code collides with an alarm's or a timer's`() { + val union = AlarmIntents.ALL_REQUEST_CODES + + TimerIntents.ALL_REQUEST_CODES + + StopwatchIntents.ALL_REQUEST_CODES + + assertThat(union).containsNoDuplicates() + } + + /** §5.12 #3 */ + @Test + fun `a per-timer code can never be a stopwatch code`() { + val derived = TimerIntents.ALL_REQUEST_CODES.flatMap { base -> + listOf(0L, 1L, 7L, 99_999L, 100_000L).map { TimerIntents.requestCodeFor(base, it) } + } + + assertThat(derived.filter { it in StopwatchIntents.ALL_REQUEST_CODES }).isEmpty() + } + + /** §5.12 #4 */ + @Test + fun `every stopwatch action is namespaced and distinct from the other two families`() { + assertThat(stopwatchActions.filterNot { it.startsWith("de.jeanlucmakiola.clockula.") }).isEmpty() + assertThat(stopwatchActions + timerActions + alarmActions).containsNoDuplicates() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt index 88a1c69..fdcc1db 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeAlarmSeams.kt @@ -190,6 +190,7 @@ class RecordingStopwatchRepository : StopwatchRepository { override fun run(): Flow = TODO("not used by the reboot gate") override fun laps(): Flow> = TODO("not used by the reboot gate") override suspend fun currentRun(): StopwatchRun = TODO("not used by the reboot gate") + override suspend fun lapCount(): Int = TODO("not used by the reboot gate") override suspend fun start(): Unit = TODO("not used by the reboot gate") override suspend fun pause(): Unit = TODO("not used by the reboot gate") override suspend fun reset(): Unit = TODO("not used by the reboot gate") diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt index e99055c..cafbca5 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeDaos.kt @@ -161,6 +161,8 @@ class FakeLapDao(initial: List = emptyList()) : LapDao() { override fun observeAll(): Flow> = rows.map { rows -> rows.sortedBy { it.lapIndex } } + override suspend fun count(): Int = rows.value.size + override suspend fun latest(): LapEntity? = rows.value.maxByOrNull { it.lapIndex } /** The unique `lap_index` index, enforced the way SQLite would. */ diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt index b93c99c..bc59532 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeRepositories.kt @@ -195,6 +195,8 @@ class FakeStopwatchRepository(initial: StopwatchRun = StopwatchRun()) : Stopwatc override suspend fun currentRun(): StopwatchRun = state.value + override suspend fun lapCount(): Int = lapRows.value.size + override suspend fun start() { log += "start()" state.update { it.copy(state = StopwatchState.RUNNING) } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeStopwatchSeams.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeStopwatchSeams.kt new file mode 100644 index 0000000..faff9b4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/FakeStopwatchSeams.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.clockula.testing + +import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository +import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine +import de.jeanlucmakiola.clockula.stopwatch.StopwatchServiceHandle +import kotlin.time.Duration +import kotlin.time.Instant + +/** + * The stopwatch service's up/down transitions, in order — the order is the + * contract (M7 D22). Its timer sibling is [FakeTimerServiceHandle]; the two are + * separate seams, so a test can prove one path never reaches the other. + */ +class FakeStopwatchServiceHandle : StopwatchServiceHandle { + + var active: Boolean = false + private set + + private val log = mutableListOf() + + val transitions: List get() = log.toList() + + override fun sync(active: Boolean) { + this.active = active + log += active + } +} + +/** + * A real [StopwatchEngine] over a caller-supplied repository and the two fake + * clocks — the pill's arrangement (M7 D8), where the repository handed in is + * the one whose stored record the assertions read. + */ +class StopwatchEngineSeams( + val stopwatch: StopwatchRepository, + now: Instant = T0, + uptime: Duration = Duration.ZERO, +) { + val service: FakeStopwatchServiceHandle = FakeStopwatchServiceHandle() + val rebootRepair: RecordingRebootRepair = RecordingRebootRepair() + val wallClock: FakeWallClock = FakeWallClock(now) + val elapsed: FakeElapsedRealtimeClock = FakeElapsedRealtimeClock(uptime) + + val engine: StopwatchEngine = StopwatchEngine( + stopwatch = stopwatch, + service = service, + rebootRepair = rebootRepair, + elapsed = elapsed, + wall = wallClock, + ) +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/StopwatchEngineHarness.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/StopwatchEngineHarness.kt new file mode 100644 index 0000000..daca206 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/StopwatchEngineHarness.kt @@ -0,0 +1,90 @@ +package de.jeanlucmakiola.clockula.testing + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import de.jeanlucmakiola.clockula.data.prefs.BootStateStore +import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository +import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepositoryImpl +import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchStateStore +import de.jeanlucmakiola.clockula.domain.Lap +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine +import de.jeanlucmakiola.clockula.system.RebootRepair +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 java.nio.file.Path +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** + * The **real** [StopwatchEngine] over the real [StopwatchRepositoryImpl], the + * real [FakeLapDao] (which *extends* the abstract DAO, so `appendLap`'s real + * `@Transaction` body is under test), a real DataStore under a `@TempDir` and + * the real [RebootRepair] over a fake boot id — `TimerEngineHarness`'s + * arrangement for the third engine (M7 D22). + */ +class StopwatchEngineHarness( + tempDir: Path, + storeScope: CoroutineScope, + now: Instant = T0, + uptime: Duration = 1_000.seconds, + val lapDao: FakeLapDao = FakeLapDao(), +) { + val wallClock: FakeWallClock = FakeWallClock(now) + val elapsed: FakeElapsedRealtimeClock = FakeElapsedRealtimeClock(uptime) + val service: FakeStopwatchServiceHandle = FakeStopwatchServiceHandle() + val bootIds: FakeBootIdProvider = FakeBootIdProvider() + val ticker: FakeTicker = FakeTicker() + + /** Counted, so "a readout writes nothing" is assertable without reaching into the store. */ + val dataStore: CountingPreferencesDataStore = CountingPreferencesDataStore( + PreferenceDataStoreFactory.create( + scope = storeScope, + produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() }, + ), + ) + + val store: PrefStore = PrefStore(dataStore) + + val stopwatch: StopwatchRepository = + StopwatchRepositoryImpl(lapDao, StopwatchStateStore(store), elapsed) + + val rebootRepair: RebootRepair = RebootRepair( + bootIds = bootIds, + bootState = BootStateStore(store), + timers = RecordingTimerRepository(), + stopwatch = stopwatch, + ) + + val engine: StopwatchEngine = StopwatchEngine( + stopwatch = stopwatch, + service = service, + rebootRepair = rebootRepair, + elapsed = elapsed, + wall = wallClock, + ) + + /** The record as storage holds it right now. */ + suspend fun storedRun(): StopwatchRun = stopwatch.run().first() + + /** The lap table as storage holds it right now, ascending. */ + suspend fun storedLaps(): List = stopwatch.laps().first() +} + +/** The harness on `runTest`'s own scheduler, so nothing waits on a real clock. */ +fun TestScope.stopwatchEngineHarness( + tempDir: Path, + now: Instant = T0, + uptime: Duration = 1_000.seconds, + lapDao: FakeLapDao = FakeLapDao(), +): StopwatchEngineHarness = StopwatchEngineHarness( + tempDir = tempDir, + storeScope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), + now = now, + uptime = uptime, + lapDao = lapDao, +)