From 68728e5e7f5f415644d29b54df41846cfba56f9f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 22 Sep 2026 10:23:25 +0200 Subject: [PATCH] feat(stopwatch): the readout, the readings and what the shade is told MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../clockula/domain/format/StopwatchFormat.kt | 36 ++++ .../clockula/domain/stopwatch/LapStats.kt | 38 ++++ .../domain/stopwatch/StopwatchLaps.kt | 6 + .../domain/stopwatch/StopwatchNotification.kt | 50 ++++++ .../domain/stopwatch/StopwatchReadings.kt | 58 ++++++ .../domain/format/StopwatchFormatTest.kt | 141 +++++++++++++++ .../clockula/domain/stopwatch/LapStatsTest.kt | 149 ++++++++++++++++ .../StopwatchNotificationPolicyTest.kt | 160 +++++++++++++++++ .../domain/stopwatch/StopwatchReadingsTest.kt | 167 ++++++++++++++++++ 9 files changed, 805 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/format/StopwatchFormat.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/LapStats.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchLaps.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchNotification.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchReadings.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/format/StopwatchFormatTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/stopwatch/LapStatsTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchNotificationPolicyTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchReadingsTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/format/StopwatchFormat.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/format/StopwatchFormat.kt new file mode 100644 index 0000000..27f9ad7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/format/StopwatchFormat.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/LapStats.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/LapStats.kt new file mode 100644 index 0000000..469f52c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/LapStats.kt @@ -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): 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 }, + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchLaps.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchLaps.kt new file mode 100644 index 0000000..d04cd15 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchLaps.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchNotification.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchNotification.kt new file mode 100644 index 0000000..f648353 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchNotification.kt @@ -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, +) + +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) + }, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchReadings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchReadings.kt new file mode 100644 index 0000000..720fcca --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchReadings.kt @@ -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 +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/format/StopwatchFormatTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/format/StopwatchFormatTest.kt new file mode 100644 index 0000000..48ef9bf --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/format/StopwatchFormatTest.kt @@ -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 = 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")) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/stopwatch/LapStatsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/stopwatch/LapStatsTest.kt new file mode 100644 index 0000000..da085b0 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/stopwatch/LapStatsTest.kt @@ -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 { + 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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchNotificationPolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchNotificationPolicyTest.kt new file mode 100644 index 0000000..0a67d1a --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchNotificationPolicyTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchReadingsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchReadingsTest.kt new file mode 100644 index 0000000..e7e9794 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/stopwatch/StopwatchReadingsTest.kt @@ -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, + ), + ) + } +}