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:
@@ -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()
|
||||
|
||||
|
||||
+25
-4
@@ -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)
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
+100
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user