feat(stopwatch): the tab — the readout, the laps, and the fastest and slowest

A fixed readout over a lap list, newest first, the lap in progress counting
as its own row once there is a lap to compare it against. The fastest and
the slowest are marked in `primary` and `tertiary` — never `error`, and never
by colour alone: each carries a spoken marker so the emphasis survives being
read aloud.

The state rebuilds only what actually moves: the recorded rows and their
emphasis come off the lap table once, and the tick recomputes the hero
figure and the row in progress. Laps stop at 999 and the readout ticks only
while the stopwatch is running.
This commit is contained in:
makiolaj committed 2026-09-22 10:23:54 +02:00
1 parent 2aaae5dde8
commit 7b727e0d40
11 files changed
+1272 -26

No files matched your search

@@ -0,0 +1,271 @@
package de.jeanlucmakiola.clockula.ui.stopwatch
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Lap
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.stopwatch.LapEmphasis
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchLaps
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* §5.7 — 16 cases. The tab as pure data: the hero reading, the lap list newest
* first, and the in-progress row that only exists once a lap has been recorded
* (D10, D11).
*/
class StopwatchRowsTest {
private val now: Duration = 1_000.seconds
private fun running(
accumulated: Duration = Duration.ZERO,
anchor: Duration? = now,
lastLapCumulative: Duration = Duration.ZERO,
) = StopwatchRun(
state = StopwatchState.RUNNING,
accumulated = accumulated,
startedAtElapsedRealtime = anchor,
lastLapCumulative = lastLapCumulative,
)
private fun paused(
accumulated: Duration = 30.seconds,
lastLapCumulative: Duration = Duration.ZERO,
) = StopwatchRun(
state = StopwatchState.PAUSED,
accumulated = accumulated,
startedAtElapsedRealtime = null,
lastLapCumulative = lastLapCumulative,
)
private fun lapsOf(vararg splits: Long): List<Lap> {
var running = Duration.ZERO
return splits.map { it.seconds }.mapIndexed { position, split ->
running += split
Lap(index = position + 1, split = split, cumulative = running)
}
}
private fun rows(
run: StopwatchRun,
laps: List<Lap> = emptyList(),
elapsedRealtime: Duration = now,
) = StopwatchRows.from(run, laps, elapsedRealtime)
/** §5.7 #1 */
@Test
fun `an idle stopwatch is a zero readout with no list and no lap button`() {
val state = rows(StopwatchRun())
assertThat(state).isEqualTo(
StopwatchUiState.Loaded(
mode = StopwatchState.IDLE,
elapsed = Duration.ZERO,
laps = emptyList(),
canLap = false,
),
)
}
/** §5.7 #2 */
@Test
fun `a running stopwatch with no recorded lap shows no lap list at all`() {
val state = rows(running(accumulated = 30.seconds))
assertThat(state.laps).isEmpty()
}
/** §5.7 #3 */
@Test
fun `the first row is the lap in progress`() {
val state = rows(
running(accumulated = 30.seconds, lastLapCumulative = 12.seconds),
lapsOf(12),
)
assertThat(state.laps.first().index to state.laps.first().kind)
.isEqualTo(2 to LapKind.IN_PROGRESS)
}
/** §5.7 #4 */
@Test
fun `the lap list runs newest first`() {
val state = rows(
running(accumulated = 40.seconds, lastLapCumulative = 30.seconds),
lapsOf(10, 10, 10),
)
assertThat(state.laps.map { it.index }).containsExactly(4, 3, 2, 1).inOrder()
}
/** §5.7 #5 */
@Test
fun `a paused stopwatch keeps its in-progress row, frozen`() {
val state = rows(
paused(accumulated = 30.seconds, lastLapCumulative = 20.seconds),
lapsOf(10, 10),
elapsedRealtime = now + 5.seconds,
)
assertThat(state.laps.first()).isEqualTo(
LapRowState(
index = 3,
kind = LapKind.IN_PROGRESS,
split = 10.seconds,
cumulative = 30.seconds,
emphasis = LapEmphasis.NONE,
),
)
}
/** §5.7 #6 */
@Test
fun `the in-progress row splits from the last lap's total`() {
val state = rows(
running(accumulated = 30.seconds, lastLapCumulative = 12.seconds),
lapsOf(12),
)
assertThat(state.laps.first().cumulative to state.laps.first().split)
.isEqualTo(30.seconds to 18.seconds)
}
/** §5.7 #7 */
@Test
fun `a lap total above the reading floors the in-progress split at zero`() {
val state = rows(
running(accumulated = 10.seconds, lastLapCumulative = 40.seconds),
lapsOf(40),
)
assertThat(state.laps.first().split).isEqualTo(Duration.ZERO)
}
/** §5.7 #8 */
@Test
fun `three recorded laps mark one best and one worst, and never the one in progress`() {
val state = rows(
running(accumulated = 30.seconds, lastLapCumulative = 21.seconds),
lapsOf(10, 4, 7),
)
val byKind = state.laps.groupBy { it.kind }
assertThat(byKind.getValue(LapKind.RECORDED).map { it.emphasis })
.containsExactly(LapEmphasis.NONE, LapEmphasis.BEST, LapEmphasis.WORST)
assertThat(byKind.getValue(LapKind.IN_PROGRESS).map { it.emphasis })
.containsExactly(LapEmphasis.NONE)
}
/** §5.7 #9 */
@Test
fun `two recorded laps emphasise nothing`() {
val state = rows(
running(accumulated = 30.seconds, lastLapCumulative = 14.seconds),
lapsOf(10, 4),
)
assertThat(state.laps.map { it.emphasis }.toSet()).containsExactly(LapEmphasis.NONE)
}
/** §5.7 #10 */
@Test
fun `a running stopwatch below the cap can still lap`() {
val state = rows(running(accumulated = 30.seconds, lastLapCumulative = 10.seconds), lapsOf(10))
assertThat(state.canLap).isTrue()
}
/** §5.7 #11 */
@Test
fun `a running stopwatch at the cap cannot lap again`() {
val laps = (1..StopwatchLaps.MAX).map {
Lap(index = it, split = 1.seconds, cumulative = it.seconds)
}
val state = rows(
running(accumulated = 2_000.seconds, lastLapCumulative = StopwatchLaps.MAX.seconds),
laps,
)
assertThat(state.canLap).isFalse()
}
/** §5.7 #12 */
@Test
fun `a paused stopwatch has nothing to lap`() {
val state = rows(paused(lastLapCumulative = 10.seconds), lapsOf(10))
assertThat(state.canLap).isFalse()
}
/** §5.7 #13 */
@Test
fun `a run anchored in a previous boot is presented as paused at what it banked`() {
val stale = running(accumulated = 25.seconds, anchor = now + 500.seconds, lastLapCumulative = 10.seconds)
val state = rows(stale, lapsOf(10))
assertThat(Triple(state.mode, state.elapsed, state.canLap))
.isEqualTo(Triple(StopwatchState.PAUSED, 25.seconds, false))
}
/** §5.7 #14 */
@Test
fun `laps handed over in any order come back in descending index order`() {
val laps = lapsOf(10, 4, 7).shuffled()
val state = rows(running(accumulated = 30.seconds, lastLapCumulative = 21.seconds), laps)
val indices = state.laps.map { it.index }
assertThat(indices).isEqualTo(indices.sortedDescending())
assertThat(indices).containsExactly(4, 3, 2, 1)
}
/** §5.7 #15 */
@Test
fun `an idle stopwatch shows no lap list even when the table still holds one`() {
val state = rows(StopwatchRun(), lapsOf(10, 4, 7))
assertThat(state.laps).isEmpty()
}
/** §5.7 #16 */
@Test
fun `a second of monotonic clock moves only the reading and the lap in progress`() {
val run = running(accumulated = 30.seconds, lastLapCumulative = 21.seconds)
val laps = lapsOf(10, 4, 7)
val before = rows(run, laps)
val after = rows(run, laps, elapsedRealtime = now + 1.seconds)
assertThat(after.elapsed).isEqualTo(before.elapsed + 1.seconds)
assertThat(after.laps.filter { it.kind == LapKind.RECORDED })
.isEqualTo(before.laps.filter { it.kind == LapKind.RECORDED })
}
// --- M7 review finding 4: the per-tick path must equal the whole-model one ---
/**
* `StopwatchSource` derives the recorded rows once per lap change and re-uses
* them on every 60 Hz tick, so [StopwatchRows.fromRecorded] has to produce
* exactly what [StopwatchRows.from] does — including the idle case, where the
* rows are dropped whatever the table holds.
*/
@Test
fun `the per-tick path produces the same state as the whole model`() {
val laps = lapsOf(10, 4, 7)
val runs = listOf(
StopwatchRun(),
running(accumulated = 30.seconds, lastLapCumulative = 21.seconds),
paused(accumulated = 30.seconds, lastLapCumulative = 21.seconds),
running(accumulated = 30.seconds, anchor = now + 500.seconds, lastLapCumulative = 21.seconds),
)
runs.forEach { run ->
assertThat(StopwatchRows.fromRecorded(run, StopwatchRows.recorded(laps), now))
.isEqualTo(StopwatchRows.from(run, laps, now))
}
}
}
@@ -0,0 +1,232 @@
package de.jeanlucmakiola.clockula.ui.stopwatch
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.data.stopwatch.LapEntity
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.stopwatch.StopwatchLaps
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import de.jeanlucmakiola.clockula.testing.StopwatchEngineHarness
import de.jeanlucmakiola.clockula.testing.stopwatchEngineHarness
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* §5.8 — 10 cases over the **real** [de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine],
* the real repository, a real DataStore under a `@TempDir` and the fake ticker.
* The four verbs go through the engine, not the repository, which is what keeps
* the foreground service moving with the state (D2) — the service handle's
* transitions are how a test proves the call took that route.
*/
class StopwatchViewModelTest {
@JvmField
@RegisterExtension
val main = MainDispatcherExtension()
private fun TestScope.viewModel(harness: StopwatchEngineHarness) = StopwatchViewModel(
engine = harness.engine,
source = StopwatchSource(harness.stopwatch, harness.elapsed, harness.ticker),
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
private fun StopwatchUiState.loaded(): StopwatchUiState.Loaded = this as StopwatchUiState.Loaded
/** §5.8 #1 */
@Test
fun `the tab starts out loading, not idle`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
assertThat(viewModel.state.value).isEqualTo(StopwatchUiState.Loading)
advanceUntilIdle()
assertThat(viewModel.state.value).isInstanceOf(StopwatchUiState.Loaded::class.java)
}
/** §5.8 #2 */
@Test
fun `Start runs the stopwatch and brings the service up`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
assertThat(harness.storedRun().state).isEqualTo(StopwatchState.RUNNING)
assertThat(harness.service.active).isTrue()
}
/** §5.8 #3 */
@Test
fun `Pause banks the run and leaves the service up`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
viewModel.onPause()
advanceUntilIdle()
assertThat(harness.storedRun().state).isEqualTo(StopwatchState.PAUSED)
assertThat(harness.service.active).isTrue()
}
/** §5.8 #4 */
@Test
fun `Lap records a lap and the list grows by one`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
harness.elapsed.advance(10.seconds)
viewModel.onLap()
advanceUntilIdle()
val before = viewModel.state.value.loaded().laps.size
harness.elapsed.advance(10.seconds)
viewModel.onLap()
advanceUntilIdle()
assertThat(harness.storedLaps().map { it.index }).containsExactly(1, 2).inOrder()
assertThat(viewModel.state.value.loaded().laps).hasSize(before + 1)
}
/** §5.8 #5 */
@Test
fun `Reset empties the run, the laps and the shade`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
harness.elapsed.advance(10.seconds)
viewModel.onLap()
advanceUntilIdle()
viewModel.onReset()
advanceUntilIdle()
assertThat(harness.storedRun().state).isEqualTo(StopwatchState.IDLE)
assertThat(harness.storedLaps()).isEmpty()
assertThat(harness.service.active).isFalse()
}
/** §5.8 #6 */
@Test
fun `Lap at the cap records nothing and the button stays disabled`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
(1..StopwatchLaps.MAX).forEach { index ->
harness.lapDao.insert(
LapEntity(lapIndex = index, splitMillis = 1_000L, cumulativeMillis = index * 1_000L),
)
}
harness.elapsed.advance(2_000.seconds)
advanceUntilIdle()
viewModel.onLap()
advanceUntilIdle()
assertThat(harness.storedLaps()).hasSize(StopwatchLaps.MAX)
assertThat(viewModel.state.value.loaded().canLap).isFalse()
}
/** §5.8 #7 */
@Test
fun `Lap on a paused stopwatch records nothing`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
viewModel.onPause()
advanceUntilIdle()
viewModel.onLap()
advanceUntilIdle()
assertThat(harness.storedLaps()).isEmpty()
}
/** §5.8 #8 — the checklist's "how the paused result is presented" (D26). */
@Test
fun `a run whose anchor a reboot invalidated is presented paused at the banked value`(
@TempDir tempDir: Path,
) = runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
harness.elapsed.advance(20.seconds)
viewModel.onLap()
advanceUntilIdle()
harness.elapsed.advance(20.seconds)
viewModel.onLap()
advanceUntilIdle()
harness.elapsed.reboot(uptime = 5.seconds)
harness.ticker.tick()
advanceUntilIdle()
val state = viewModel.state.value.loaded()
assertThat(state.mode to state.elapsed).isEqualTo(StopwatchState.PAUSED to 40.seconds)
assertThat(state.canLap).isFalse()
assertThat(state.laps.map { it.index to it.kind }).containsExactly(
3 to LapKind.IN_PROGRESS,
2 to LapKind.RECORDED,
1 to LapKind.RECORDED,
).inOrder()
assertThat(state.laps.first().split).isEqualTo(Duration.ZERO)
}
/** §5.8 #9 */
@Test
fun `a readout writes nothing`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
advanceUntilIdle()
val writes = harness.dataStore.writes
val before = viewModel.state.value.loaded().elapsed
repeat(3) {
harness.elapsed.advance(1.seconds)
harness.ticker.tick()
advanceUntilIdle()
}
assertThat(viewModel.state.value.loaded().elapsed).isEqualTo(before + 3.seconds)
assertThat(harness.dataStore.writes).isEqualTo(writes)
}
/** §5.8 #10 */
@Test
fun `two Starts in one frame start one run`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = stopwatchEngineHarness(tempDir)
val viewModel = viewModel(harness)
viewModel.onStart()
viewModel.onStart()
advanceUntilIdle()
assertThat(harness.storedRun().startedAtElapsedRealtime).isEqualTo(harness.elapsed.value)
assertThat(harness.service.transitions).containsExactly(true)
}
}