feat(stopwatch): the engine, its one seam and the verbs the shade can press

A third engine beside the alarms' and the timers', and a small one: four
verbs behind a single mutex, no scheduler and nothing that rings. Start,
pause, lap and reset are here rather than on the repository because three of
them arrive as notification buttons and must mean the same thing whichever
surface pressed them.

The verbs guard on the *reading's* mode, not the stored row, so a run whose
anchor a reboot invalidated resumes when the tab says Resume instead of
silently doing nothing; resuming banks the last lap's total first, so the
readout never stands still and never walks backwards. `StopwatchIntents`
namespaces the actions and hands out request codes that cannot collide with
the timers'. The service is reached through one seam, which keeps the engine
free of Android and lets the tests watch the transitions in order.
This commit is contained in:
makiolaj committed 2026-09-22 10:23:36 +02:00
1 parent 68728e5e7f
commit 7f8b2e79fc
16 files changed
+1103 -4

No files matched your search

@@ -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)
}
}
@@ -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<Long>()
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)
}
}
@@ -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<String> 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<String> 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<String> 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()
}
}
@@ -190,6 +190,7 @@ class RecordingStopwatchRepository : StopwatchRepository {
override fun run(): Flow<StopwatchRun> = TODO("not used by the reboot gate")
override fun laps(): Flow<List<Lap>> = 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")
@@ -161,6 +161,8 @@ class FakeLapDao(initial: List<LapEntity> = emptyList()) : LapDao() {
override fun observeAll(): Flow<List<LapEntity>> = 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. */
@@ -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) }
@@ -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<Boolean>()
val transitions: List<Boolean> 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,
)
}
@@ -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<Lap> = 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,
)