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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user