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:
2026-09-22 10:23:36 +02:00
parent 68728e5e7f
commit 7f8b2e79fc
16 changed files with 1103 additions and 4 deletions
@@ -11,6 +11,9 @@ abstract class LapDao {
@Query("SELECT * FROM stopwatch_laps ORDER BY lap_index")
abstract fun observeAll(): Flow<List<LapEntity>>
@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?
@@ -9,6 +9,9 @@ interface StopwatchRepository {
fun laps(): Flow<List<Lap>>
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()
@@ -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)
@@ -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()))
}
}
@@ -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<Int> =
listOf(REQUEST_SHOW, REQUEST_LAP, REQUEST_PAUSE, REQUEST_RESUME, REQUEST_RESET)
}
@@ -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)
}
@@ -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)
}
}
}
@@ -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
}
@@ -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,
)