feat(stopwatch): the readout, the readings and what the shade is told

The pure-Kotlin half of the stopwatch, with no Android on it anywhere.

`StopwatchFormat` splits a duration into a major field it asks `ClockFormat`
for and two truncated hundredths, so the fraction can be drawn smaller than
the seconds beside it. `StopwatchReadings` turns a stored run plus the
monotonic clock into the one reading the tab, the pill and the notification
all draw — a run whose anchor belongs to a previous boot reads paused at what
it banked, never negative and never below the last lap's total. `LapStats`
marks the fastest and the slowest, but only once three laps exist, with ties
going to the earlier lap and an all-equal set marking neither.
`StopwatchNotification` says what the shade shows without knowing what a
`Notification` is.
This commit is contained in:
2026-09-22 10:23:25 +02:00
parent 6ea11f7bbd
commit 68728e5e7f
9 changed files with 805 additions and 0 deletions
@@ -0,0 +1,36 @@
package de.jeanlucmakiola.clockula.domain.format
import java.util.Locale
import kotlin.time.Duration
/** The big readout, split so the hundredths can be drawn smaller than the seconds (D5). */
data class StopwatchReadout(
/** `M:SS` under an hour, `H:MM:SS` at or above it — exactly `ClockFormat.elapsed`. */
val major: String,
/** Exactly two ASCII digits, `00`..`99`. Truncated, never rounded. */
val hundredths: String,
) {
/** `"$major.$hundredths"`. */
val text: String get() = "$major.$hundredths"
}
object StopwatchFormat {
fun readout(elapsed: Duration): StopwatchReadout {
// The whole-second half is ClockFormat's, called and not reimplemented,
// so the tab, the pill and the timer row can never disagree about what
// a duration reads — and its truncate-never-round rule is inherited.
if (elapsed <= Duration.ZERO) return StopwatchReadout(ClockFormat.elapsed(Duration.ZERO), "00")
val hundredths = (elapsed.inWholeMilliseconds % 1_000L) / 10L
return StopwatchReadout(
major = ClockFormat.elapsed(elapsed),
// Locale.ROOT for ClockFormat's own reason: a locale with its own
// numerals would change the glyph width mid-count.
hundredths = String.format(Locale.ROOT, "%02d", hundredths),
)
}
/** [readout] as one string; the lap table's and the paused notification's form. */
fun precise(elapsed: Duration): String = readout(elapsed).text
}
@@ -0,0 +1,38 @@
package de.jeanlucmakiola.clockula.domain.stopwatch
import de.jeanlucmakiola.clockula.domain.Lap
enum class LapEmphasis { NONE, BEST, WORST }
data class LapStats(val bestIndex: Int?, val worstIndex: Int?) {
fun emphasisFor(lapIndex: Int): LapEmphasis = when (lapIndex) {
bestIndex -> LapEmphasis.BEST
worstIndex -> LapEmphasis.WORST
else -> LapEmphasis.NONE
}
companion object {
/** Below three recorded laps there is no best and no worst (D7). */
const val MIN_LAPS: Int = 3
/** Pure; order-independent; ties go to the lowest index; all-equal gives neither. */
fun of(laps: List<Lap>): LapStats {
// With one lap there is nothing to compare; with two, "best" and
// "worst" mark every row and say the same thing twice.
if (laps.size < MIN_LAPS) return LapStats(bestIndex = null, worstIndex = null)
val fastest = laps.minOf { it.split }
val slowest = laps.maxOf { it.split }
// Every lap identical is not a run with a best and a worst in it.
if (fastest == slowest) return LapStats(bestIndex = null, worstIndex = null)
// Ties go to the earliest lap: "first to achieve it" is the
// defensible reading, and it is deterministic.
return LapStats(
bestIndex = laps.filter { it.split == fastest }.minOf { it.index },
worstIndex = laps.filter { it.split == slowest }.minOf { it.index },
)
}
}
}
@@ -0,0 +1,6 @@
package de.jeanlucmakiola.clockula.domain.stopwatch
object StopwatchLaps {
/** The cap (D14). Three digits, so the index column has a fixed width. */
const val MAX: Int = 999
}
@@ -0,0 +1,50 @@
package de.jeanlucmakiola.clockula.domain.stopwatch
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import kotlin.time.Duration
import kotlin.time.Instant
enum class StopwatchNotificationAction { LAP, PAUSE, RESUME, RESET }
data class StopwatchNotificationState(
val running: Boolean,
val elapsed: Duration,
/** `now - elapsed`. Non-null exactly while running. **Derived, never stored** (D4). */
val chronometerBase: Instant?,
/** Never negative. */
val lapCount: Int,
val actions: List<StopwatchNotificationAction>,
)
object StopwatchNotificationPolicy {
/** Null when the run is IDLE — nothing active, so the service stops itself. */
fun stateFor(
run: StopwatchRun,
lapCount: Int,
elapsedRealtime: Duration,
now: Instant,
): StopwatchNotificationState? {
if (!StopwatchReadings.isActive(run)) return null
val reading = StopwatchReadings.of(run, elapsedRealtime)
val running = reading.mode == StopwatchState.RUNNING
return StopwatchNotificationState(
running = running,
elapsed = reading.elapsed,
// The platform renders the ticking, so the service posts once per
// state change rather than sixty times a second. The base is derived
// here and never stored — the record carries no wall-clock field.
chronometerBase = (now - reading.elapsed).takeIf { running },
lapCount = lapCount.coerceAtLeast(0),
// The same pair the tab's controls offer for that mode — one
// vocabulary, two surfaces.
actions = if (running) {
listOf(StopwatchNotificationAction.LAP, StopwatchNotificationAction.PAUSE)
} else {
listOf(StopwatchNotificationAction.RESUME, StopwatchNotificationAction.RESET)
},
)
}
}
@@ -0,0 +1,58 @@
package de.jeanlucmakiola.clockula.domain.stopwatch
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.snapshotAt
import kotlin.time.Duration
/** What the stopwatch reads *now* — from the snapshot, never from the stored row (D6). */
data class StopwatchReading(
/** IDLE, RUNNING or PAUSED. A stale RUNNING record reads PAUSED. */
val mode: StopwatchState,
/** Never negative. Zero when IDLE. */
val elapsed: Duration,
/** The in-progress lap's running split, floored at zero (D11). */
val currentLapSplit: Duration,
val anchorIsStale: Boolean,
)
object StopwatchReadings {
/** Pure; never throws. */
fun of(run: StopwatchRun, elapsedRealtime: Duration): StopwatchReading {
if (run.state == StopwatchState.IDLE) {
// An idle record carrying an accumulated value is corrupt; zero is
// what an idle stopwatch reads, whatever the bytes say.
return StopwatchReading(
mode = StopwatchState.IDLE,
elapsed = Duration.ZERO,
currentLapSplit = Duration.ZERO,
anchorIsStale = false,
)
}
val snapshot = run.snapshotAt(elapsedRealtime)
// Floored at the last lap's total as well as at zero. A lap recorded
// inside a segment the reboot repair later discarded keeps its own
// cumulative, and a readout that sits *below* a lap the same run printed
// is not a readout anyone can believe — the lap is a real measurement,
// so the run is at least that far along. The laps themselves are never
// deleted to make the arithmetic tidy (D11).
val elapsed = snapshot.elapsed
.coerceAtLeast(run.lastLapCumulative)
.coerceAtLeast(Duration.ZERO)
return StopwatchReading(
// The mode is the snapshot's, not the row's: a RUNNING record whose
// anchor did not survive a reboot reads PAUSED at what it banked.
mode = if (snapshot.isRunning) StopwatchState.RUNNING else StopwatchState.PAUSED,
elapsed = elapsed,
// Never negative: the floor above already guarantees it, and the
// clamp says so where a reader will look for it (D11).
currentLapSplit = (elapsed - run.lastLapCumulative).coerceAtLeast(Duration.ZERO),
anchorIsStale = snapshot.anchorIsStale,
)
}
/** The one "active" predicate: anything but IDLE (D15). */
fun isActive(run: StopwatchRun): Boolean = run.state != StopwatchState.IDLE
}
@@ -0,0 +1,141 @@
package de.jeanlucmakiola.clockula.domain.format
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* §5.1 — 14 cases. The big readout, split in two so the hundredths can be drawn
* smaller than the seconds (D5). Its whole-second part *is* [ClockFormat.elapsed],
* called rather than reimplemented, so the tab, the pill and the timer row can
* never disagree about what a duration reads.
*/
class StopwatchFormatTest {
/** #10 and #12's table: the boundaries the two halves have to agree on. */
private val table: List<Duration> = listOf(
Duration.ZERO,
1.milliseconds,
999.milliseconds,
1.seconds,
59_999.milliseconds,
1.hours,
100.hours,
)
/** §5.1 #1 */
@Test
fun `zero reads as zero with two zero hundredths`() {
val readout = StopwatchFormat.readout(Duration.ZERO)
assertThat(readout).isEqualTo(StopwatchReadout(major = "0:00", hundredths = "00"))
}
/** §5.1 #2 */
@Test
fun `one and twenty-three hundredths of a second reads its hundredths`() {
val readout = StopwatchFormat.readout(1_230.milliseconds)
assertThat(readout).isEqualTo(StopwatchReadout(major = "0:01", hundredths = "23"))
}
/** §5.1 #3 */
@Test
fun `the last hundredth before a minute still reads under the minute`() {
val readout = StopwatchFormat.readout(59_990.milliseconds)
assertThat(readout).isEqualTo(StopwatchReadout(major = "0:59", hundredths = "99"))
}
/** §5.1 #4 */
@Test
fun `exactly a minute rolls the major field and resets the hundredths`() {
val readout = StopwatchFormat.readout(60.seconds)
assertThat(readout).isEqualTo(StopwatchReadout(major = "1:00", hundredths = "00"))
}
/** §5.1 #5 */
@Test
fun `exactly an hour grows the major field to hours`() {
val readout = StopwatchFormat.readout(1.hours)
assertThat(readout).isEqualTo(StopwatchReadout(major = "1:00:00", hundredths = "00"))
}
/** §5.1 #6 */
@Test
fun `an hour two minutes and three and a bit seconds reads every field`() {
val readout = StopwatchFormat.readout(1.hours + 2.minutes + 3_450.milliseconds)
assertThat(readout).isEqualTo(StopwatchReadout(major = "1:02:03", hundredths = "45"))
}
/** §5.1 #7 */
@Test
fun `a negative duration reads as zero and never as a minus sign`() {
val readout = StopwatchFormat.readout(-5.seconds)
assertThat(readout).isEqualTo(StopwatchReadout(major = "0:00", hundredths = "00"))
}
/** §5.1 #8 */
@Test
fun `the last millisecond before a second is truncated, never rounded up`() {
val readout = StopwatchFormat.readout(999.milliseconds)
assertThat(readout).isEqualTo(StopwatchReadout(major = "0:00", hundredths = "99"))
}
/** §5.1 #9 */
@Test
fun `the hundredths truncate too`() {
val readout = StopwatchFormat.readout(1_009.milliseconds)
assertThat(readout).isEqualTo(StopwatchReadout(major = "0:01", hundredths = "00"))
}
/** §5.1 #10 */
@Test
fun `the major field is ClockFormat's elapsed, called and not reimplemented`() {
val majors = table.map { StopwatchFormat.readout(it).major }
assertThat(majors).isEqualTo(table.map(ClockFormat::elapsed))
}
/** §5.1 #11 */
@Test
fun `every millisecond remainder yields exactly two ASCII digits`() {
val hundredths = (0..999).map { StopwatchFormat.readout(it.milliseconds).hundredths }
assertThat(hundredths.filterNot { it.length == 2 && it.all { c -> c in '0'..'9' } }).isEmpty()
}
/** §5.1 #12 */
@Test
fun `precise is the two halves joined by a point`() {
val joined = table.map { StopwatchFormat.readout(it) }.map { "${it.major}.${it.hundredths}" }
assertThat(table.map(StopwatchFormat::precise)).isEqualTo(joined)
}
/** §5.1 #13 */
@Test
fun `a hundred hours is neither padded nor wrapped at a day`() {
val readout = StopwatchFormat.readout(100.hours)
assertThat(readout.major).isEqualTo("100:00:00")
}
/** §5.1 #14 */
@Test
fun `a day reads as twenty-four hours`() {
val readout = StopwatchFormat.readout(24.hours)
assertThat(readout).isEqualTo(StopwatchReadout(major = "24:00:00", hundredths = "00"))
}
}
@@ -0,0 +1,149 @@
package de.jeanlucmakiola.clockula.domain.stopwatch
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Lap
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* §5.3 — 14 cases. Best and worst, and the three rules the roadmap left open
* (D7): three laps before anything is emphasised, ties to the earliest lap, and
* all-equal means neither.
*/
class LapStatsTest {
/** Laps at the given splits, indexed from one, with the cumulative that follows. */
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)
}
}
/** §5.3 #1 */
@Test
fun `no laps has no best and no worst`() {
val stats = LapStats.of(emptyList())
assertThat(stats).isEqualTo(LapStats(bestIndex = null, worstIndex = null))
}
/** §5.3 #2 */
@Test
fun `one lap has nothing to compare itself to`() {
val stats = LapStats.of(lapsOf(10))
assertThat(stats).isEqualTo(LapStats(bestIndex = null, worstIndex = null))
}
/** §5.3 #3 */
@Test
fun `two laps would mark every row, so neither is marked`() {
val stats = LapStats.of(lapsOf(10, 4))
assertThat(stats).isEqualTo(LapStats(bestIndex = null, worstIndex = null))
}
/** §5.3 #4 */
@Test
fun `three laps name the fastest and the slowest`() {
val stats = LapStats.of(lapsOf(10, 4, 7))
assertThat(stats).isEqualTo(LapStats(bestIndex = 2, worstIndex = 1))
}
/** §5.3 #5 */
@Test
fun `three identical laps are not a run with a best and a worst in it`() {
val stats = LapStats.of(lapsOf(7, 7, 7))
assertThat(stats).isEqualTo(LapStats(bestIndex = null, worstIndex = null))
}
/** §5.3 #6 */
@Test
fun `a tie for fastest goes to the earlier lap`() {
val stats = LapStats.of(lapsOf(9, 4, 4))
assertThat(stats.bestIndex).isEqualTo(2)
}
/** §5.3 #7 */
@Test
fun `a tie for slowest goes to the earlier lap`() {
val stats = LapStats.of(lapsOf(4, 9, 9))
assertThat(stats.worstIndex).isEqualTo(2)
}
/** §5.3 #8 */
@Test
fun `a lap is never both the best and the worst`() {
val runs = listOf(
lapsOf(10, 4, 7),
lapsOf(1, 1, 2),
lapsOf(5, 5, 5, 9),
)
val collisions = runs.map(LapStats::of)
.filter { it.bestIndex != null && it.bestIndex == it.worstIndex }
assertThat(collisions).isEmpty()
}
/** §5.3 #9 */
@Test
fun `the emphasis of a lap is the one its index earned`() {
val stats = LapStats.of(lapsOf(10, 4, 7))
assertThat(listOf(1, 2, 3).map(stats::emphasisFor))
.containsExactly(LapEmphasis.WORST, LapEmphasis.BEST, LapEmphasis.NONE)
.inOrder()
}
/** §5.3 #10 */
@Test
fun `an index that is not in the run carries no emphasis`() {
val stats = LapStats.of(lapsOf(10, 4, 7))
assertThat(stats.emphasisFor(99)).isEqualTo(LapEmphasis.NONE)
}
/** §5.3 #11 */
@Test
fun `it reads splits, not positions`() {
val ascending = lapsOf(10, 4, 7, 8)
val descending = LapStats.of(ascending.reversed())
assertThat(descending).isEqualTo(LapStats.of(ascending))
}
/** §5.3 #12 */
@Test
fun `a zero-length split is the fastest lap there is`() {
val stats = LapStats.of(lapsOf(10, 0, 7))
assertThat(stats.bestIndex).isEqualTo(2)
}
/** §5.3 #13 */
@Test
fun `exactly one lap is best and exactly one is worst`() {
val laps = lapsOf(10, 4, 7, 8, 6)
val stats = LapStats.of(laps)
val emphases = laps.map { stats.emphasisFor(it.index) }
assertThat(emphases.count { it == LapEmphasis.BEST }).isEqualTo(1)
assertThat(emphases.count { it == LapEmphasis.WORST }).isEqualTo(1)
}
/** §5.3 #14 */
@Test
fun `emphasis needs three laps, and the constant is the rule`() {
assertThat(LapStats.MIN_LAPS).isEqualTo(3)
}
}
@@ -0,0 +1,160 @@
package de.jeanlucmakiola.clockula.domain.stopwatch
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.testing.T0
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* §5.4 — 12 cases. What the shade must show, decided as pure data (D4). The
* chronometer base is `now - elapsed`, **derived at post time and never
* stored**, which is how the notification gets a wall-clock base without
* breaking the record's "no wall-clock field whatsoever" rule.
*/
class StopwatchNotificationPolicyTest {
private val now: Duration = 1_000.seconds
private val wall: Instant = T0
private fun running(accumulated: Duration = Duration.ZERO, anchor: Duration? = now) = StopwatchRun(
state = StopwatchState.RUNNING,
accumulated = accumulated,
startedAtElapsedRealtime = anchor,
)
private fun paused(accumulated: Duration = 90.seconds) = StopwatchRun(
state = StopwatchState.PAUSED,
accumulated = accumulated,
startedAtElapsedRealtime = null,
)
private fun stateFor(
run: StopwatchRun,
lapCount: Int = 0,
elapsedRealtime: Duration = now,
wallClock: Instant = wall,
): StopwatchNotificationState? =
StopwatchNotificationPolicy.stateFor(run, lapCount, elapsedRealtime, wallClock)
/** §5.4 #1 */
@Test
fun `an idle stopwatch gives the service nothing to show`() {
assertThat(stateFor(StopwatchRun())).isNull()
}
/** §5.4 #2 */
@Test
fun `a running stopwatch offers Lap and Pause`() {
val state = stateFor(running(accumulated = 40.seconds))
assertThat(state?.running to state?.actions).isEqualTo(
true to listOf(StopwatchNotificationAction.LAP, StopwatchNotificationAction.PAUSE),
)
}
/** §5.4 #3 */
@Test
fun `the chronometer base is the wall clock minus the elapsed reading`() {
val state = stateFor(running(accumulated = 40.seconds))
assertThat(state?.chronometerBase).isEqualTo(wall - 40.seconds)
}
/** §5.4 #4 */
@Test
fun `a paused stopwatch offers Resume and Reset and has nothing to tick`() {
val state = stateFor(paused())
assertThat(Triple(state?.running, state?.chronometerBase, state?.actions)).isEqualTo(
Triple(
false,
null,
listOf(StopwatchNotificationAction.RESUME, StopwatchNotificationAction.RESET),
),
)
}
/** §5.4 #5 */
@Test
fun `a paused stopwatch shows what it banked`() {
val state = stateFor(paused(accumulated = 90.seconds), elapsedRealtime = now + 3.hours)
assertThat(state?.elapsed).isEqualTo(90.seconds)
}
/** §5.4 #6 */
@Test
fun `a running record from a previous boot is shown paused`() {
val stale = running(accumulated = 25.seconds, anchor = now + 500.seconds)
val state = stateFor(stale)
assertThat(Triple(state?.running, state?.chronometerBase, state?.actions)).isEqualTo(
Triple(
false,
null,
listOf(StopwatchNotificationAction.RESUME, StopwatchNotificationAction.RESET),
),
)
}
/** §5.4 #7 */
@Test
fun `no laps is no lap count`() {
val state = stateFor(running(), lapCount = 0)
assertThat(state?.lapCount).isEqualTo(0)
}
/** §5.4 #8 */
@Test
fun `the lap count is carried through`() {
val state = stateFor(running(), lapCount = 4)
assertThat(state?.lapCount).isEqualTo(4)
}
/** §5.4 #9 */
@Test
fun `a negative lap count is a caller's bug and is clamped`() {
val state = stateFor(running(), lapCount = -3)
assertThat(state?.lapCount).isEqualTo(0)
}
/** §5.4 #10 */
@Test
fun `three hours of wall clock move the base and not the reading`() {
val run = running(accumulated = 40.seconds)
val later = wall + 3.hours
val state = stateFor(run, wallClock = later)
assertThat(state?.elapsed to state?.chronometerBase)
.isEqualTo(40.seconds to later - 40.seconds)
}
/** §5.4 #11 */
@Test
fun `five seconds of monotonic clock move the reading and the base with it`() {
val run = running(accumulated = 40.seconds)
val state = stateFor(run, elapsedRealtime = now + 5.seconds)
assertThat(state?.elapsed to state?.chronometerBase)
.isEqualTo(45.seconds to wall - 45.seconds)
}
/** §5.4 #12 */
@Test
fun `a stopwatch at zero has its base at now`() {
val state = stateFor(running(accumulated = Duration.ZERO))
assertThat(state?.chronometerBase).isEqualTo(wall)
}
}
@@ -0,0 +1,167 @@
package de.jeanlucmakiola.clockula.domain.stopwatch
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* §5.2 — 12 cases. The one reading the pill, the notification and the tab all
* share (D6). Its load-bearing rule: **the mode comes from the snapshot, not
* from the stored row** — a RUNNING record whose anchor did not survive a
* reboot reads PAUSED at what it banked.
*/
class StopwatchReadingsTest {
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 = 42.seconds) = StopwatchRun(
state = StopwatchState.PAUSED,
accumulated = accumulated,
startedAtElapsedRealtime = null,
)
/** §5.2 #1 */
@Test
fun `an idle stopwatch reads idle at zero`() {
val reading = StopwatchReadings.of(StopwatchRun(), now)
assertThat(reading).isEqualTo(
StopwatchReading(
mode = StopwatchState.IDLE,
elapsed = Duration.ZERO,
currentLapSplit = Duration.ZERO,
anchorIsStale = false,
),
)
}
/** §5.2 #2 */
@Test
fun `an idle record carrying an accumulated value still reads zero`() {
val corrupt = StopwatchRun(state = StopwatchState.IDLE, accumulated = 30.seconds)
val reading = StopwatchReadings.of(corrupt, now)
assertThat(reading.mode to reading.elapsed).isEqualTo(StopwatchState.IDLE to Duration.ZERO)
}
/** §5.2 #3 */
@Test
fun `a running stopwatch adds the segment since its anchor`() {
val run = running(accumulated = 10.seconds, anchor = now - 30.seconds)
val reading = StopwatchReadings.of(run, now)
assertThat(reading.mode to reading.elapsed).isEqualTo(StopwatchState.RUNNING to 40.seconds)
}
/** §5.2 #4 */
@Test
fun `a running stopwatch read at its own anchor reads what it banked`() {
val run = running(accumulated = 10.seconds, anchor = now)
val reading = StopwatchReadings.of(run, now)
assertThat(reading.mode to reading.elapsed).isEqualTo(StopwatchState.RUNNING to 10.seconds)
}
/** §5.2 #5 */
@Test
fun `a paused stopwatch does not move with the monotonic clock`() {
val run = paused(accumulated = 42.seconds)
val reading = StopwatchReadings.of(run, now + 9.seconds)
assertThat(reading.mode to reading.elapsed).isEqualTo(StopwatchState.PAUSED to 42.seconds)
}
/** §5.2 #6 */
@Test
fun `a running record anchored in a previous boot reads paused and stale`() {
val run = running(accumulated = 25.seconds, anchor = now + 500.seconds)
val reading = StopwatchReadings.of(run, now)
assertThat(Triple(reading.mode, reading.elapsed, reading.anchorIsStale))
.isEqualTo(Triple(StopwatchState.PAUSED, 25.seconds, true))
}
/** §5.2 #7 */
@Test
fun `a running record with no anchor at all reads paused and stale`() {
val run = running(accumulated = 25.seconds, anchor = null)
val reading = StopwatchReadings.of(run, now)
assertThat(Triple(reading.mode, reading.elapsed, reading.anchorIsStale))
.isEqualTo(Triple(StopwatchState.PAUSED, 25.seconds, true))
}
/** §5.2 #8 */
@Test
fun `the in-progress split is the elapsed reading minus the last lap's total`() {
val run = running(accumulated = 30.seconds, anchor = now, lastLapCumulative = 12.seconds)
val reading = StopwatchReadings.of(run, now)
assertThat(reading.currentLapSplit).isEqualTo(18.seconds)
}
/** §5.2 #9 */
@Test
fun `with no lap recorded the in-progress split is the whole elapsed reading`() {
val run = running(accumulated = 30.seconds, anchor = now, lastLapCumulative = Duration.ZERO)
val reading = StopwatchReadings.of(run, now)
assertThat(reading.currentLapSplit).isEqualTo(30.seconds)
}
/** §5.2 #10 */
@Test
fun `a lap total above the elapsed reading floors the split at zero`() {
val run = running(accumulated = 10.seconds, anchor = now, lastLapCumulative = 40.seconds)
val reading = StopwatchReadings.of(run, now)
assertThat(reading.currentLapSplit).isEqualTo(Duration.ZERO)
}
/** §5.2 #11 */
@Test
fun `active is anything but idle`() {
val answers = listOf(StopwatchRun(), running(), paused()).map(StopwatchReadings::isActive)
assertThat(answers).containsExactly(false, true, true).inOrder()
}
/** §5.2 #12 */
@Test
fun `five seconds of monotonic clock is five seconds of elapsed`() {
val run = running(accumulated = 10.seconds, anchor = now)
val before = StopwatchReadings.of(run, now)
val after = StopwatchReadings.of(run, now + 5.seconds)
assertThat(after).isEqualTo(
before.copy(
elapsed = before.elapsed + 5.seconds,
currentLapSplit = before.currentLapSplit + 5.seconds,
),
)
}
}