feat(domain): the live pill's subject, clock formatting and the challenge gate

Which of a running timer, a running stopwatch and a snoozed alarm the pill
speaks for is a pure selection over the repositories' flows, ordered so the
thing closest to needing attention wins. Countdown text truncates where a
stopwatch rounds up, because a timer reading 0:01 must still have a second
left in it.

The dismiss challenge is monotone in both escape routes — neither a wrong
answer nor a re-roll can make the way out shorter — so a half-awake user is
never stranded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 13:07:04 +02:00
parent a1bf6170d2
commit 6dee033aba
8 changed files with 1071 additions and 0 deletions
@@ -0,0 +1,93 @@
package de.jeanlucmakiola.clockula.domain.alarm
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/** A two-term addition the user must answer to dismiss. */
data class MathProblem(val left: Int, val right: Int) {
val answer: Int get() = left + right
val prompt: String get() = "$left + $right"
/** Lenient about whitespace and leading zeros, strict about everything else. */
fun accepts(input: String): Boolean = input.trim().toIntOrNull() == answer
}
object MathProblems {
const val MIN_TERM: Int = 10
const val MAX_TERM: Int = 99
/** Deterministic for a given [seed]. Both terms in [MIN_TERM]..[MAX_TERM]. */
fun generate(seed: Long): MathProblem {
val random = Random(seed)
return MathProblem(
left = random.nextInt(MIN_TERM, MAX_TERM + 1),
right = random.nextInt(MIN_TERM, MAX_TERM + 1),
)
}
}
/**
* The barrier between a ringing alarm and dismissing it — and the promise that
* it always opens. Immutable; every transition returns a new gate.
*
* Two independent escape routes, because one that can fail is not an escape:
* [MAX_WRONG_ATTEMPTS] wrong answers, or [ESCAPE_AFTER] of ringing, whichever
* comes first. Neither is free, and neither can be taken away once earned.
*/
data class ChallengeGate(
val kind: DismissChallenge,
val problem: MathProblem?,
val wrongAttempts: Int,
val holdProgress: Float,
val escapeAvailable: Boolean,
val open: Boolean,
) {
companion object {
const val MAX_WRONG_ATTEMPTS: Int = 3
val ESCAPE_AFTER: Duration = 60.seconds
val HOLD_DURATION: Duration = 2.seconds
/** NONE starts open. MATH starts with a problem from [seed]. HOLD starts at zero. */
fun start(kind: DismissChallenge, seed: Long): ChallengeGate = ChallengeGate(
kind = kind,
problem = if (kind == DismissChallenge.MATH) MathProblems.generate(seed) else null,
wrongAttempts = 0,
holdProgress = 0f,
escapeAvailable = false,
open = kind == DismissChallenge.NONE,
)
}
/** MATH only; other kinds return `this`. Correct opens; wrong re-rolls from [nextSeed]. */
fun answer(input: String, nextSeed: Long): ChallengeGate {
if (kind != DismissChallenge.MATH) return this
val asked = problem ?: return this
if (asked.accepts(input)) return copy(open = true)
// No lockout and no penalty timer: a wrong answer replaces the problem,
// and that is all it does.
val attempts = wrongAttempts + 1
return copy(
problem = MathProblems.generate(nextSeed),
wrongAttempts = attempts,
escapeAvailable = escapeAvailable || attempts >= MAX_WRONG_ATTEMPTS,
)
}
/** HOLD only; other kinds return `this`. Clamped to 0f..1f; 1f opens. */
fun hold(progress: Float): ChallengeGate {
if (kind != DismissChallenge.HOLD) return this
val clamped = progress.coerceIn(0f, 1f)
return copy(holdProgress = clamped, open = open || clamped >= 1f)
}
/** How long the alarm has been ringing. Makes the escape available at [ESCAPE_AFTER]; never revokes it. */
fun ringElapsed(sinceRingStart: Duration): ChallengeGate =
copy(escapeAvailable = escapeAvailable || sinceRingStart >= ESCAPE_AFTER)
/** Opens the gate iff [escapeAvailable]; otherwise returns `this` unchanged. */
fun escape(): ChallengeGate = if (escapeAvailable) copy(open = true) else this
}
@@ -0,0 +1,41 @@
package de.jeanlucmakiola.clockula.domain.format
import java.util.Locale
import kotlin.math.ceil
import kotlin.time.Duration
/**
* The one place a duration becomes a readout: `M:SS` under an hour, `H:MM:SS`
* at or above it, ASCII digits through `Locale.ROOT` so the glyph columns do
* not jitter as the numbers change.
*/
object ClockFormat {
/** Counts *down*: rounds up to the next whole second. Negative reads as zero. */
fun countdown(remaining: Duration): String {
if (remaining <= Duration.ZERO) return format(0L)
// Rounded up, so a timer with a millisecond left reads 0:01 and only
// reads 0:00 when it is genuinely done.
return format(ceil(remaining.inWholeMilliseconds / 1000.0).toLong())
}
/** Counts *up*: truncates to whole seconds. Negative reads as zero. */
fun elapsed(elapsed: Duration): String {
if (elapsed <= Duration.ZERO) return format(0L)
// Truncated, so a stopwatch readout never runs ahead of reality.
return format(elapsed.inWholeSeconds)
}
private fun format(totalSeconds: Long): String {
val hours = totalSeconds / 3_600
val minutes = (totalSeconds % 3_600) / 60
val seconds = totalSeconds % 60
// Locale.ROOT, not the default: the readout is a column of glyphs, and
// a locale with its own numerals would change their width mid-count.
return if (hours > 0) {
String.format(Locale.ROOT, "%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format(Locale.ROOT, "%d:%02d", minutes, seconds)
}
}
}
@@ -0,0 +1,102 @@
package de.jeanlucmakiola.clockula.domain.live
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.snapshotAt
import kotlin.time.Duration
import kotlin.time.Instant
/** What the live pill is currently about. */
sealed interface LivePillSubject {
data class TimerSubject(val timerId: Long, val label: String) : LivePillSubject
data object StopwatchSubject : LivePillSubject
}
enum class LivePillMode { RUNNING, PAUSED, EXPIRED }
data class LivePillState(
val subject: LivePillSubject,
val mode: LivePillMode,
/** Remaining for a timer, elapsed for the stopwatch. Never negative. */
val value: Duration,
/** Other *active* timers — active timers minus this one, floored at zero. */
val otherActiveTimers: Int,
) {
/** False for [LivePillMode.EXPIRED]: there is nothing left to pause. */
val canPause: Boolean get() = mode != LivePillMode.EXPIRED
}
/** The pill's whole decision: which subject the user needs to see, and how. */
object LivePillSelector {
/** Null when nothing is active. Pure: never writes, never throws. */
fun select(
timers: List<Timer>,
stopwatch: StopwatchRun,
elapsedRealtime: Duration,
wallClock: Instant,
): LivePillState? {
// Active, not running: a timer the user paused from the pill must keep
// the pill, or the control vanishes under the thumb that used it.
val active = timers.filter { it.state != TimerState.IDLE }
val winner = active
.map { it.read(elapsedRealtime, wallClock) }
// A deadline missed costs something; a stopwatch tick missed costs
// nothing — so expiry outranks running, which outranks paused, and
// every timer outranks the stopwatch. Ties break the way the alarm
// engine's do: the lower sort order, then the lower id.
.minWithOrNull(
compareBy<Reading> { it.mode.rank }
.thenBy { if (it.mode == LivePillMode.RUNNING) it.value else Duration.ZERO }
.thenBy { it.timer.sortOrder }
.thenBy { it.timer.id },
)
if (winner != null) {
return LivePillState(
subject = LivePillSubject.TimerSubject(winner.timer.id, winner.timer.label),
mode = winner.mode,
value = winner.value,
otherActiveTimers = active.size - 1,
)
}
if (stopwatch.state == StopwatchState.IDLE) return null
// The snapshot, not the stored row: a run whose anchor did not survive a
// reboot reads as paused at what it banked rather than as running.
val snapshot = stopwatch.snapshotAt(elapsedRealtime)
return LivePillState(
subject = LivePillSubject.StopwatchSubject,
mode = if (snapshot.isRunning) LivePillMode.RUNNING else LivePillMode.PAUSED,
value = snapshot.elapsed,
otherActiveTimers = 0,
)
}
/** One timer as the pill would show it, read through the snapshot. */
private data class Reading(val timer: Timer, val mode: LivePillMode, val value: Duration)
private val LivePillMode.rank: Int
get() = when (this) {
LivePillMode.EXPIRED -> 0
LivePillMode.RUNNING -> 1
LivePillMode.PAUSED -> 2
}
private fun Timer.read(elapsedRealtime: Duration, wallClock: Instant): Reading {
if (state == TimerState.PAUSED) return Reading(this, LivePillMode.PAUSED, remaining)
val snapshot = snapshotAt(elapsedRealtime, wallClock)
return when {
// Reached zero: the pill flips before M6's expiry write lands, so it
// never shows a live timer counting into negative time.
snapshot.hasExpired -> Reading(this, LivePillMode.EXPIRED, Duration.ZERO)
snapshot.isRunning -> Reading(this, LivePillMode.RUNNING, snapshot.remaining)
// Neither: a corrupt row. Show what it last banked, and let the user
// start it again rather than throwing inside a list.
else -> Reading(this, LivePillMode.PAUSED, snapshot.remaining)
}
}
}
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.clockula.domain.time
import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration
import kotlin.time.Instant
@@ -28,3 +29,12 @@ interface ElapsedRealtimeClock {
interface ZoneProvider {
fun current(): java.time.ZoneId
}
/**
* Emits on a fixed cadence so a readout can advance without a repository write.
* Injected, never a raw `delay` at a call site, so a test drives the tick
* instead of waiting for it. Emits once immediately, then every [period].
*/
interface Ticker {
fun ticks(period: Duration): Flow<Unit>
}
@@ -0,0 +1,227 @@
package de.jeanlucmakiola.clockula.domain.alarm
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* The barrier between a ringing alarm and dismissing it, and the promise that
* it always opens: three wrong answers, or sixty seconds of ringing, whichever
* comes first. Two independent escape routes, because one that can fail is not
* an escape.
*/
class ChallengeGateTest {
private fun math(seed: Long = 1L) = ChallengeGate.start(DismissChallenge.MATH, seed)
private fun hold(seed: Long = 1L) = ChallengeGate.start(DismissChallenge.HOLD, seed)
private fun none(seed: Long = 1L) = ChallengeGate.start(DismissChallenge.NONE, seed)
private fun ChallengeGate.answerWrong(times: Int): ChallengeGate =
(1..times).fold(this) { gate, n -> gate.answer("-1", nextSeed = n.toLong()) }
@Test
fun `no challenge starts open`() {
val gate = none()
assertThat(listOf(gate.open, gate.problem == null, gate.escapeAvailable))
.containsExactly(true, true, false)
.inOrder()
}
@Test
fun `a maths challenge starts closed, with the problem its seed gives`() {
val gate = math(seed = 1L)
assertThat(gate).isEqualTo(
ChallengeGate(
kind = DismissChallenge.MATH,
problem = MathProblems.generate(1L),
wrongAttempts = 0,
holdProgress = 0f,
escapeAvailable = false,
open = false,
),
)
}
@Test
fun `a hold challenge starts closed at zero progress`() {
val gate = hold()
assertThat(gate).isEqualTo(
ChallengeGate(
kind = DismissChallenge.HOLD,
problem = null,
wrongAttempts = 0,
holdProgress = 0f,
escapeAvailable = false,
open = false,
),
)
}
@Test
fun `the right answer opens the gate`() {
val gate = math(seed = 1L)
val answered = gate.answer(gate.problem!!.answer.toString(), nextSeed = 7L)
assertThat(answered.open).isTrue()
}
@Test
fun `a wrong answer replaces the problem and counts the attempt`() {
val answered = math(seed = 1L).answer("0", nextSeed = 7L)
assertThat(Triple(answered.open, answered.wrongAttempts, answered.problem))
.isEqualTo(Triple(false, 1, MathProblems.generate(7L)))
}
@Test
fun `three wrong answers offer the escape without opening the gate`() {
val answered = math().answerWrong(3)
assertThat(answered.escapeAvailable to answered.open).isEqualTo(true to false)
}
@Test
fun `one or two wrong answers do not yet offer the escape`() {
val offered = listOf(1, 2).map { math().answerWrong(it).escapeAvailable }
assertThat(offered).containsExactly(false, false)
}
@Test
fun `getting it right after two mistakes still opens the gate`() {
val gate = math().answerWrong(2)
val answered = gate.answer(gate.problem!!.answer.toString(), nextSeed = 9L)
assertThat(answered.open).isTrue()
}
@Test
fun `answering a gate that asks no question changes nothing`() {
val gates = listOf(hold(), none())
val answered = gates.map { it.answer("85", nextSeed = 7L) }
assertThat(answered).isEqualTo(gates)
}
@Test
fun `a half-finished hold does not open the gate`() {
val held = hold().hold(0.5f)
assertThat(held.holdProgress to held.open).isEqualTo(0.5f to false)
}
@Test
fun `a completed hold opens the gate`() {
assertThat(hold().hold(1f).open).isTrue()
}
@Test
fun `hold progress is clamped at the top`() {
val held = hold().hold(1.4f)
assertThat(held.holdProgress to held.open).isEqualTo(1f to true)
}
@Test
fun `hold progress is clamped at the bottom`() {
val held = hold().hold(-0.2f)
assertThat(held.holdProgress to held.open).isEqualTo(0f to false)
}
@Test
fun `lifting the finger returns the hold to zero`() {
val held = hold().hold(0.7f).hold(0f)
assertThat(held.holdProgress to held.open).isEqualTo(0f to false)
}
@Test
fun `holding a gate that asks a question changes nothing`() {
val gates = listOf(math(), none())
val held = gates.map { it.hold(1f) }
assertThat(held).isEqualTo(gates)
}
@Test
fun `the escape is not offered a millisecond early`() {
val waited = math().ringElapsed(ChallengeGate.ESCAPE_AFTER - 1.milliseconds)
assertThat(waited.escapeAvailable).isFalse()
}
@Test
fun `a minute of ringing offers the escape to every closed gate without opening it`() {
val waited = listOf(math(), hold()).map { it.ringElapsed(ChallengeGate.ESCAPE_AFTER) }
assertThat(waited.map { it.escapeAvailable to it.open })
.containsExactly(true to false, true to false)
}
@Test
fun `an offered escape is never revoked`() {
val waited = math().answerWrong(3).ringElapsed(1.seconds)
assertThat(waited.escapeAvailable).isTrue()
}
@Test
fun `escaping an offered escape opens the gate`() {
assertThat(math().ringElapsed(ChallengeGate.ESCAPE_AFTER).escape().open).isTrue()
}
@Test
fun `escaping before the escape is offered changes nothing`() {
val gate = math()
assertThat(gate.escape()).isEqualTo(gate)
}
@Test
fun `every challenge kind opens after a minute of ringing`() {
val opened = DismissChallenge.entries.associateWith { kind ->
ChallengeGate.start(kind, seed = 1L)
.ringElapsed(ChallengeGate.ESCAPE_AFTER)
.escape()
.open
}
assertThat(opened.filterValues { !it }).isEmpty()
}
@Test
fun `three wrong answers open the gate with no time passing at all`() {
assertThat(math().answerWrong(3).escape().open).isTrue()
}
@Test
fun `the constants the copy promises are pinned`() {
val constants = listOf(
ChallengeGate.MAX_WRONG_ATTEMPTS,
ChallengeGate.ESCAPE_AFTER,
ChallengeGate.HOLD_DURATION,
)
assertThat(constants).containsExactly(3, 60.seconds, 2.seconds).inOrder()
}
@Test
fun `a gate that has opened never closes again`() {
val opened = none()
.answer("0", nextSeed = 7L)
.hold(0f)
.ringElapsed(1.seconds)
assertThat(opened.open).isTrue()
}
}
@@ -0,0 +1,96 @@
package de.jeanlucmakiola.clockula.domain.alarm
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The maths challenge: two two-digit numbers added, generated from a seed so
* the problem a half-awake person is given is reproducible in a test, and an
* answer parser that refuses nonsense without throwing at 3 a.m.
*/
class MathProblemsTest {
private val problem = MathProblem(47, 38)
@Test
fun `the same seed gives the same problem`() {
assertThat(MathProblems.generate(42L)).isEqualTo(MathProblems.generate(42L))
}
@Test
fun `both terms are always two digits`() {
val outOfRange = (0L..9_999L).map(MathProblems::generate).filterNot {
it.left in MathProblems.MIN_TERM..MathProblems.MAX_TERM &&
it.right in MathProblems.MIN_TERM..MathProblems.MAX_TERM
}
assertThat(outOfRange).isEmpty()
}
@Test
fun `the answer is the sum of the terms`() {
val wrong = (0L..9_999L).map(MathProblems::generate).filterNot {
it.answer == it.left + it.right
}
assertThat(wrong).isEmpty()
}
@Test
fun `the prompt is the sum spelled out`() {
assertThat(problem.prompt).isEqualTo("47 + 38")
}
@Test
fun `a thousand seeds do not collapse onto a handful of problems`() {
val distinct = (0L..999L).map(MathProblems::generate).toSet()
assertThat(distinct.size).isAtLeast(500)
}
@Test
fun `the exact answer is accepted`() {
assertThat(problem.accepts("85")).isTrue()
}
@Test
fun `surrounding whitespace is accepted`() {
assertThat(problem.accepts(" 85 ")).isTrue()
}
@Test
fun `a leading zero is accepted`() {
assertThat(problem.accepts("085")).isTrue()
}
@Test
fun `an empty answer is rejected`() {
val answers = listOf(problem.accepts(""), problem.accepts(" "))
assertThat(answers).containsExactly(false, false)
}
@Test
fun `an unparseable answer is rejected`() {
val answers = listOf(problem.accepts("eighty-five"), problem.accepts("8+5"))
assertThat(answers).containsExactly(false, false)
}
@Test
fun `a negated answer is rejected`() {
assertThat(problem.accepts("-85")).isFalse()
}
@Test
fun `an answer beyond Int is rejected rather than thrown`() {
assertThat(problem.accepts("99999999999999999")).isFalse()
}
@Test
fun `an answer one either side is rejected`() {
val answers = listOf(problem.accepts("84"), problem.accepts("86"))
assertThat(answers).containsExactly(false, false)
}
}
@@ -0,0 +1,113 @@
package de.jeanlucmakiola.clockula.domain.format
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.util.Locale
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
/**
* The readout's grammar: `M:SS` under an hour, `H:MM:SS` at or above it, hours
* never wrapped, countdowns rounded *up* so a live timer never reads `0:00`,
* elapsed truncated so a stopwatch never runs ahead of reality.
*/
class ClockFormatTest {
@Test
fun `a countdown at zero reads zero`() {
assertThat(ClockFormat.countdown(Duration.ZERO)).isEqualTo("0:00")
}
@Test
fun `a countdown of one second has a leading zero on the seconds only`() {
assertThat(ClockFormat.countdown(1.seconds)).isEqualTo("0:01")
}
@Test
fun `a countdown just under a minute stays in the minute form`() {
assertThat(ClockFormat.countdown(59.seconds)).isEqualTo("0:59")
}
@Test
fun `a countdown of a whole minute rolls the minutes`() {
assertThat(ClockFormat.countdown(60.seconds)).isEqualTo("1:00")
}
@Test
fun `a countdown just under an hour keeps two columns`() {
assertThat(ClockFormat.countdown(59.minutes + 59.seconds)).isEqualTo("59:59")
}
@Test
fun `a countdown of an hour grows the hour column`() {
assertThat(ClockFormat.countdown(1.hours)).isEqualTo("1:00:00")
}
@Test
fun `a countdown over an hour zero-pads the minutes`() {
assertThat(ClockFormat.countdown(1.hours + 2.minutes + 3.seconds)).isEqualTo("1:02:03")
}
@Test
fun `a countdown past a day does not wrap the hours`() {
assertThat(ClockFormat.countdown(25.hours)).isEqualTo("25:00:00")
}
@Test
fun `the hour column is unbounded`() {
assertThat(ClockFormat.countdown(999.hours)).isEqualTo("999:00:00")
}
@Test
fun `a countdown rounds up to the next whole second`() {
val readouts = listOf(1.milliseconds, 999.milliseconds, 1500.milliseconds)
.map(ClockFormat::countdown)
assertThat(readouts).containsExactly("0:01", "0:01", "0:02").inOrder()
}
@Test
fun `a negative countdown reads zero rather than throwing`() {
assertThat(ClockFormat.countdown((-5).seconds)).isEqualTo("0:00")
}
@Test
fun `an elapsed reading at zero reads zero`() {
assertThat(ClockFormat.elapsed(Duration.ZERO)).isEqualTo("0:00")
}
@Test
fun `an elapsed reading truncates to whole seconds`() {
val readouts = listOf(999.milliseconds, 1999.milliseconds).map(ClockFormat::elapsed)
assertThat(readouts).containsExactly("0:00", "0:01").inOrder()
}
@Test
fun `an elapsed reading over an hour truncates rather than rounding up`() {
assertThat(ClockFormat.elapsed(1.hours + 999.milliseconds)).isEqualTo("1:00:00")
}
@Test
fun `a negative elapsed reading reads zero rather than throwing`() {
assertThat(ClockFormat.elapsed((-5).seconds)).isEqualTo("0:00")
}
@Test
fun `both readouts stay in ASCII digits under a locale with its own numerals`() {
val original = Locale.getDefault()
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
try {
val value = 1.hours + 2.minutes + 3.seconds
val readouts = listOf(ClockFormat.countdown(value), ClockFormat.elapsed(value))
assertThat(readouts).containsExactly("1:02:03", "1:02:03")
} finally {
Locale.setDefault(original)
}
}
}
@@ -0,0 +1,389 @@
package de.jeanlucmakiola.clockula.domain.live
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.StopwatchState
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.testing.BERLIN
import de.jeanlucmakiola.clockula.testing.instantAt
import de.jeanlucmakiola.clockula.testing.runningTimer
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* The pill's whole decision: which of a drawer full of timers and a stopwatch
* the user actually needs to see, in which mode, and at what value. It reads
* through the snapshots, so a running timer that has reached zero flips to
* EXPIRED before the expiry write lands.
*/
class LivePillSelectorTest {
private val now: Duration = 1.hours
private val wall: Instant = instantAt(BERLIN, "2026-06-10T12:00")
private val idleStopwatch = StopwatchRun()
private fun running(
id: Long = 7L,
label: String = "Pasta",
left: Duration,
sortOrder: Int = 0,
elapsedNow: Duration = now,
): Timer = runningTimer().copy(
id = id,
label = label,
state = TimerState.RUNNING,
remaining = left,
startedAtElapsedRealtime = Duration.ZERO,
endsAtElapsedRealtime = elapsedNow + left,
endsAtWallClock = wall + left,
sortOrder = sortOrder,
)
private fun paused(id: Long = 7L, remaining: Duration, sortOrder: Int = 0): Timer =
runningTimer().copy(
id = id,
state = TimerState.PAUSED,
remaining = remaining,
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
sortOrder = sortOrder,
)
private fun expired(id: Long = 7L, sortOrder: Int = 0): Timer = runningTimer().copy(
id = id,
state = TimerState.EXPIRED,
remaining = Duration.ZERO,
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
sortOrder = sortOrder,
)
private fun idle(id: Long = 7L): Timer = runningTimer().copy(
id = id,
state = TimerState.IDLE,
remaining = 5.minutes,
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
)
private fun stopwatch(
state: StopwatchState,
accumulated: Duration = Duration.ZERO,
startedAt: Duration? = null,
) = StopwatchRun(state = state, accumulated = accumulated, startedAtElapsedRealtime = startedAt)
private fun select(
timers: List<Timer>,
run: StopwatchRun = idleStopwatch,
elapsedRealtime: Duration = now,
wallClock: Instant = wall,
) = LivePillSelector.select(timers, run, elapsedRealtime, wallClock)
@Test
fun `nothing active shows no pill`() {
assertThat(select(emptyList())).isNull()
}
@Test
fun `idle timers and an idle stopwatch show no pill`() {
val timers = listOf(idle(1L), idle(2L), idle(3L))
assertThat(select(timers)).isNull()
}
@Test
fun `a single running timer is the subject, at its remaining time`() {
val timers = listOf(running(id = 7L, label = "Pasta", left = 4.minutes))
val state = select(timers)
assertThat(state).isEqualTo(
LivePillState(
subject = LivePillSubject.TimerSubject(7L, "Pasta"),
mode = LivePillMode.RUNNING,
value = 4.minutes,
otherActiveTimers = 0,
),
)
}
@Test
fun `the value follows the monotonic clock without a repository write`() {
val timers = listOf(running(left = 4.minutes))
val state = select(timers, elapsedRealtime = now + 60.seconds)
assertThat(state?.value).isEqualTo(3.minutes)
}
@Test
fun `changing the system time cannot warp a running timer`() {
val timers = listOf(running(left = 4.minutes))
val values = listOf(wall, wall + 3.hours, wall - 3.hours)
.map { select(timers, wallClock = it)?.value }
assertThat(values).containsExactly(4.minutes, 4.minutes, 4.minutes)
}
@Test
fun `the running timer with the least left wins, and the other is counted`() {
val timers = listOf(
running(id = 1L, label = "Long", left = 9.minutes),
running(id = 2L, label = "Short", left = 2.minutes),
)
val state = select(timers)
assertThat(state).isEqualTo(
LivePillState(
subject = LivePillSubject.TimerSubject(2L, "Short"),
mode = LivePillMode.RUNNING,
value = 2.minutes,
otherActiveTimers = 1,
),
)
}
@Test
fun `a tie on remaining breaks on sort order`() {
val timers = listOf(
running(id = 1L, label = "Second", left = 2.minutes, sortOrder = 1),
running(id = 2L, label = "First", left = 2.minutes, sortOrder = 0),
)
assertThat(select(timers)?.subject).isEqualTo(LivePillSubject.TimerSubject(2L, "First"))
}
@Test
fun `a tie on sort order breaks on the lower id`() {
val timers = listOf(
running(id = 9L, label = "Nine", left = 2.minutes, sortOrder = 0),
running(id = 4L, label = "Four", left = 2.minutes, sortOrder = 0),
)
assertThat(select(timers)?.subject).isEqualTo(LivePillSubject.TimerSubject(4L, "Four"))
}
@Test
fun `an expired timer outranks a running one`() {
val timers = listOf(
running(id = 1L, label = "Nearly", left = 1.seconds),
expired(id = 2L),
)
val state = select(timers)
assertThat(state?.let { it.subject to it.mode to it.value }).isEqualTo(
(LivePillSubject.TimerSubject(2L, "Pasta") to LivePillMode.EXPIRED) to Duration.ZERO,
)
}
@Test
fun `a running timer that has reached zero reads as expired before the write lands`() {
val timers = listOf(running(id = 7L, left = Duration.ZERO))
val state = select(timers)
assertThat(state?.mode to state?.value).isEqualTo(LivePillMode.EXPIRED to Duration.ZERO)
}
@Test
fun `expired timers break their own tie on sort order`() {
val timers = listOf(
expired(id = 1L, sortOrder = 3),
expired(id = 2L, sortOrder = 1),
running(id = 3L, left = 5.minutes),
)
val state = select(timers)
assertThat(state?.subject to state?.otherActiveTimers)
.isEqualTo(LivePillSubject.TimerSubject(2L, "Pasta") to 2)
}
@Test
fun `with only paused timers the lowest sort order is the subject`() {
val timers = listOf(
paused(id = 1L, remaining = 30.seconds, sortOrder = 2),
paused(id = 2L, remaining = 90.seconds, sortOrder = 0),
)
val state = select(timers)
assertThat(state?.let { Triple(it.subject, it.mode, it.value) }).isEqualTo(
Triple(LivePillSubject.TimerSubject(2L, "Pasta"), LivePillMode.PAUSED, 90.seconds),
)
}
@Test
fun `a running timer outranks a paused one`() {
val timers = listOf(paused(id = 1L, remaining = 30.seconds), running(id = 2L, left = 9.minutes))
assertThat(select(timers)?.mode).isEqualTo(LivePillMode.RUNNING)
}
@Test
fun `an expired timer outranks a paused one`() {
val timers = listOf(paused(id = 1L, remaining = 30.seconds), expired(id = 2L))
assertThat(select(timers)?.mode).isEqualTo(LivePillMode.EXPIRED)
}
@Test
fun `any active timer outranks the stopwatch`() {
val timers = listOf(paused(id = 1L, remaining = 30.seconds))
val run = stopwatch(StopwatchState.RUNNING, startedAt = now - 90.seconds)
assertThat(select(timers, run)?.subject)
.isEqualTo(LivePillSubject.TimerSubject(1L, "Pasta"))
}
@Test
fun `a running stopwatch is the subject when no timer is active`() {
val run = stopwatch(StopwatchState.RUNNING, startedAt = now - 90.seconds)
val state = select(emptyList(), run)
assertThat(state).isEqualTo(
LivePillState(
subject = LivePillSubject.StopwatchSubject,
mode = LivePillMode.RUNNING,
value = 90.seconds,
otherActiveTimers = 0,
),
)
}
@Test
fun `a paused stopwatch shows what it banked`() {
val run = stopwatch(StopwatchState.PAUSED, accumulated = 42.seconds)
val state = select(emptyList(), run)
assertThat(state?.let { Triple(it.subject, it.mode, it.value) }).isEqualTo(
Triple(LivePillSubject.StopwatchSubject, LivePillMode.PAUSED, 42.seconds),
)
}
@Test
fun `idle timers do not keep the stopwatch off the pill`() {
val run = stopwatch(StopwatchState.RUNNING, startedAt = now - 5.seconds)
val state = select(listOf(idle(1L), idle(2L)), run)
assertThat(state?.subject to state?.otherActiveTimers)
.isEqualTo(LivePillSubject.StopwatchSubject to 0)
}
@Test
fun `a stopwatch whose anchor survived a reboot reads as paused at what it banked`() {
val run = stopwatch(
StopwatchState.RUNNING,
accumulated = 42.seconds,
startedAt = now + 1.minutes,
)
val state = select(emptyList(), run)
assertThat(state?.mode to state?.value).isEqualTo(LivePillMode.PAUSED to 42.seconds)
}
@Test
fun `a stale anchor falls back to the wall-clock deadline`() {
val timers = listOf(
running(left = 4.minutes).copy(
startedAtElapsedRealtime = now + 1.minutes,
endsAtElapsedRealtime = now + 5.minutes,
endsAtWallClock = wall + 10.minutes,
),
)
val state = select(timers)
assertThat(state?.mode to state?.value).isEqualTo(LivePillMode.RUNNING to 10.minutes)
}
@Test
fun `a stale anchor with no wall-clock fallback reads as expired`() {
val timers = listOf(
running(left = 4.minutes).copy(
startedAtElapsedRealtime = now + 1.minutes,
endsAtElapsedRealtime = now + 5.minutes,
endsAtWallClock = null,
),
)
val state = select(timers)
assertThat(state?.mode to state?.value).isEqualTo(LivePillMode.EXPIRED to Duration.ZERO)
}
@Test
fun `a corrupt running row reads as paused at what it banked, without throwing`() {
val timers = listOf(
running(left = 4.minutes).copy(
remaining = 5.minutes,
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
),
)
val state = select(timers)
assertThat(state?.mode to state?.value).isEqualTo(LivePillMode.PAUSED to 5.minutes)
}
@Test
fun `the other-timer count is never negative`() {
val timers = listOf(running(left = 4.minutes))
assertThat(select(timers)?.otherActiveTimers).isEqualTo(0)
}
@Test
fun `idle timers are not counted as other active timers`() {
val timers = listOf(running(id = 1L, left = 4.minutes)) + (2L..5L).map { idle(it) }
assertThat(select(timers)?.otherActiveTimers).isEqualTo(0)
}
@Test
fun `the stopwatch never reports other active timers`() {
val run = stopwatch(StopwatchState.RUNNING, startedAt = now - 5.seconds)
assertThat(select((1L..4L).map { idle(it) }, run)?.otherActiveTimers).isEqualTo(0)
}
@Test
fun `only an expired pill refuses to be paused`() {
val modes = LivePillMode.entries.associateWith { mode ->
LivePillState(LivePillSubject.StopwatchSubject, mode, Duration.ZERO, 0).canPause
}
assertThat(modes).containsExactly(
LivePillMode.RUNNING, true,
LivePillMode.PAUSED, true,
LivePillMode.EXPIRED, false,
)
}
@Test
fun `selecting twice over the same inputs gives the same answer`() {
val timers = listOf(running(left = 4.minutes), paused(id = 8L, remaining = 30.seconds))
val first = select(timers)
val second = select(timers)
assertThat(first).isEqualTo(second)
}
}