From 6dee033aba3472cc703a99508f46bb77ca213e06 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 12 Sep 2026 13:07:04 +0200 Subject: [PATCH] feat(domain): the live pill's subject, clock formatting and the challenge gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV --- .../clockula/domain/alarm/ChallengeGate.kt | 93 +++++ .../clockula/domain/format/ClockFormat.kt | 41 ++ .../clockula/domain/live/LivePill.kt | 102 +++++ .../clockula/domain/time/Clocks.kt | 10 + .../domain/alarm/ChallengeGateTest.kt | 227 ++++++++++ .../clockula/domain/alarm/MathProblemsTest.kt | 96 +++++ .../clockula/domain/format/ClockFormatTest.kt | 113 +++++ .../domain/live/LivePillSelectorTest.kt | 389 ++++++++++++++++++ 8 files changed, 1071 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/ChallengeGate.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/format/ClockFormat.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/live/LivePill.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/ChallengeGateTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/MathProblemsTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/format/ClockFormatTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/live/LivePillSelectorTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/ChallengeGate.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/ChallengeGate.kt new file mode 100644 index 0000000..e5153a7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/ChallengeGate.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/format/ClockFormat.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/format/ClockFormat.kt new file mode 100644 index 0000000..b9f93b5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/format/ClockFormat.kt @@ -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) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/live/LivePill.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/live/LivePill.kt new file mode 100644 index 0000000..7de55ec --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/live/LivePill.kt @@ -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, + 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 { 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) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt index bb9532e..1ac664c 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt @@ -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 +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/ChallengeGateTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/ChallengeGateTest.kt new file mode 100644 index 0000000..2aff798 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/ChallengeGateTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/MathProblemsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/MathProblemsTest.kt new file mode 100644 index 0000000..1b518ae --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/MathProblemsTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/format/ClockFormatTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/format/ClockFormatTest.kt new file mode 100644 index 0000000..0087bc0 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/format/ClockFormatTest.kt @@ -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) + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/live/LivePillSelectorTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/live/LivePillSelectorTest.kt new file mode 100644 index 0000000..ab787bf --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/live/LivePillSelectorTest.kt @@ -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, + 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) + } +}