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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user