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:
@@ -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
|
||||
}
|
||||
+50
@@ -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)
|
||||
}
|
||||
}
|
||||
+160
@@ -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)
|
||||
}
|
||||
}
|
||||
+167
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user