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:
makiolaj committed 2026-09-12 13:07:04 +02:00
1 parent a1bf6170d2
commit 6dee033aba
8 files changed
+1071

No files matched your search

@@ -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)
}
}