feat(domain): the timer readings, expiry, ring and notification policies

One ordering for all of it. The pill, the notification and the ring used to be
three places that each decided which timer mattered most; TimerReadings is now
the single answer, and the pill's own test passing unmodified is the proof the
extraction changed none of M4's behaviour.

Expiry is a pure function of state, which is what makes the arbitration with a
ringing alarm symmetric: the alarm wins the audio, and the timer's ring is
deferred rather than lost. Durations stay anchored to elapsed realtime, so
moving the system clock cannot move an expiry.
This commit is contained in:
2026-09-12 16:48:21 +02:00
parent 5430e0c778
commit a23010c41b
16 changed files with 1596 additions and 39 deletions
@@ -3,8 +3,9 @@ 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 de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.clockula.domain.timer.TimerReadings
import kotlin.time.Duration
import kotlin.time.Instant
@@ -40,25 +41,19 @@ object LivePillSelector {
): 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 },
)
//
// The precedence itself is `TimerReadings`', not the pill's own: the
// pill, the notification and the ring all have to answer "which of
// several running things does the user need to see", and three copies
// of one comparator is how they start disagreeing (M6 D7).
val active = TimerReadings.active(timers, elapsedRealtime, wallClock)
val winner = TimerReadings.subject(active)
if (winner != null) {
return LivePillState(
subject = LivePillSubject.TimerSubject(winner.timer.id, winner.timer.label),
mode = winner.mode,
value = winner.value,
mode = winner.mode.toPillMode(),
value = winner.remaining,
otherActiveTimers = active.size - 1,
)
}
@@ -75,28 +70,15 @@ object LivePillSelector {
)
}
/** 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)
}
/**
* The pill keeps its own three-valued vocabulary — it has a stopwatch to
* describe too — mapped from the read mode in one total `when`. IDLE is
* unreachable here because `active` has already dropped it; showing what
* the row banked is the harmless answer if it ever arrives.
*/
private fun TimerMode.toPillMode(): LivePillMode = when (this) {
TimerMode.EXPIRED -> LivePillMode.EXPIRED
TimerMode.RUNNING -> LivePillMode.RUNNING
TimerMode.PAUSED, TimerMode.IDLE -> LivePillMode.PAUSED
}
}
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.clockula.domain.timer
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/** Digits as typed, right-aligned, at most six (HHMMSS). The keypad's whole state (D23). */
data class TimerDurationEntry(val digits: String = "") {
val isEmpty: Boolean get() = digits.isEmpty()
/**
* "H:MM:SS" over the typed digits. Built by concatenating the typed
* characters rather than formatted, so it is ASCII under a locale with its
* own numerals without asking for `Locale.ROOT` at every call site.
*/
val display: String
get() {
val padded = digits.padStart(DIGITS, '0')
val hours = padded.substring(0, 2).trimStart('0').ifEmpty { "0" }
return "$hours:${padded.substring(2, 4)}:${padded.substring(4, 6)}"
}
/**
* h*3600 + m*60 + s — **minutes and seconds are not clamped to 59**, so
* `0:00:99` is 99 seconds and `0:99:99` is 1:40:39. Google Clock's
* forgiving behaviour (docs/PLAN.md §11 names it the interaction
* reference). Zero when empty.
*/
val duration: Duration
get() {
val padded = digits.padStart(DIGITS, '0')
val hours = padded.substring(0, 2).toLong()
val minutes = padded.substring(2, 4).toLong()
val seconds = padded.substring(4, 6).toLong()
return (hours * 3_600L + minutes * 60L + seconds).seconds
}
/** [duration] inside `TimerPresets.MIN..MAX`. */
val canStart: Boolean get() = duration in TimerPresets.MIN..TimerPresets.MAX
/** A non-digit, a seventh digit, and a leading zero into an empty entry are all ignored. */
fun plusDigit(digit: Char): TimerDurationEntry = when {
!digit.isDigit() -> this
digits.length >= DIGITS -> this
// A leading zero would consume one of the six places without changing
// the readout, so the sixth digit typed would fall off the end.
digits.isEmpty() && digit == '0' -> this
else -> TimerDurationEntry(digits + digit)
}
fun backspace(): TimerDurationEntry =
if (digits.isEmpty()) this else TimerDurationEntry(digits.dropLast(1))
fun cleared(): TimerDurationEntry = TimerDurationEntry()
private companion object {
/** HHMMSS. */
const val DIGITS = 6
}
}
@@ -0,0 +1,37 @@
package de.jeanlucmakiola.clockula.domain.timer
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
/**
* The sweep and the single AlarmManager slot's value (D5).
*
* Both answers come from [snapshotAt] and never from the raw
* `endsAtElapsedRealtime`, so a row whose anchor did not survive a reboot is
* read from its wall-clock fallback instead of from a dead anchor — one rule,
* no branch.
*/
object TimerExpiry {
/** Timers stored RUNNING whose snapshot has reached zero, in subject order. */
fun dueIds(timers: List<Timer>, elapsedRealtime: Duration, wallClock: Instant): List<Long> =
TimerReadings.active(timers, elapsedRealtime, wallClock)
.filter { it.timer.state == TimerState.RUNNING && it.mode == TimerMode.EXPIRED }
.map { it.timer.id }
/** The earliest *elapsed-realtime* deadline still ahead, or null. Derived from the snapshot. */
fun nextDeadline(
timers: List<Timer>,
elapsedRealtime: Duration,
wallClock: Instant,
): Duration? = timers
.filter { it.state == TimerState.RUNNING }
.mapNotNull { timer ->
val snapshot = timer.snapshotAt(elapsedRealtime, wallClock)
if (snapshot.isRunning) elapsedRealtime + snapshot.remaining else null
}
.minOrNull()
}
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.clockula.domain.timer
import kotlin.time.Duration
import kotlin.time.Instant
enum class TimerNotificationAction { PAUSE, RESUME, RESET, ADD_MINUTE }
data class TimerNotificationState(
val subjectId: Long,
/** Verbatim; blank stays blank — the notification supplies the fallback. */
val label: String,
val mode: TimerMode,
val remaining: Duration,
/** The chronometer's wall-clock base. Non-null only for a RUNNING subject with a wall-clock end. */
val chronometerBase: Instant?,
val otherActiveTimers: Int,
val actions: List<TimerNotificationAction>,
/** False exactly once per ring session, so expiry heads-up once and a countdown never does. */
val alertOnce: Boolean,
)
/** One notification for however many timers: the subject, and a count (D17). */
object TimerNotificationPolicy {
/** Null when nothing is active. [active] is [TimerReadings.active]'s output. */
fun stateFor(
active: List<TimerReading>,
alreadyAlerted: Boolean,
): TimerNotificationState? {
val subject = TimerReadings.subject(active) ?: return null
return TimerNotificationState(
subjectId = subject.timer.id,
label = subject.timer.label,
mode = subject.mode,
remaining = subject.remaining,
// The platform renders the countdown, so the service posts once per
// state change rather than once per second (D19). A running row with
// no wall-clock end is corrupt: fall back to a static readout rather
// than count to a wrong instant.
chronometerBase = subject.timer.endsAtWallClock
?.takeIf { subject.mode == TimerMode.RUNNING },
// `active` has already dropped the idle rows, so the others are the
// rest of this list — never a negative.
otherActiveTimers = (active.size - 1).coerceAtLeast(0),
actions = actionsFor(subject.mode),
// An expiry heads-ups exactly once; a countdown update never does,
// and there is no second notification and no channel gymnastics.
alertOnce = subject.mode != TimerMode.EXPIRED || alreadyAlerted,
)
}
/** The same pair the row offers for that mode — one vocabulary, two surfaces (D17). */
private fun actionsFor(mode: TimerMode): List<TimerNotificationAction> = when (mode) {
TimerMode.RUNNING -> listOf(TimerNotificationAction.PAUSE, TimerNotificationAction.ADD_MINUTE)
TimerMode.PAUSED -> listOf(TimerNotificationAction.RESUME, TimerNotificationAction.RESET)
TimerMode.EXPIRED -> listOf(TimerNotificationAction.RESET, TimerNotificationAction.ADD_MINUTE)
// Unreachable: an idle timer is not active. Offering nothing is the
// honest answer rather than a control that would do nothing.
TimerMode.IDLE -> emptyList()
}
}
@@ -0,0 +1,47 @@
package de.jeanlucmakiola.clockula.domain.timer
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 app-wide quick-start durations: sanitised both ways, stored as one string (D13). */
object TimerPresets {
/** 1, 2, 3, 5, 10, 15, 30 min, 1 h. */
val BUILT_IN: List<Duration> =
listOf(1.minutes, 2.minutes, 3.minutes, 5.minutes, 10.minutes, 15.minutes, 30.minutes, 1.hours)
/** `ClockPrefs`' own clamp, so nothing the user can choose is clamped away on the next read. */
val MIN: Duration = 1.seconds
val MAX: Duration = 24.hours
const val LIMIT: Int = 12
/**
* Out-of-range entries **dropped** (never clamped), de-duplicated,
* ascending, capped. A clamp would silently turn one preset into another,
* possibly a duplicate.
*/
fun sanitise(values: List<Duration>): List<Duration> = values
.filter { it in MIN..MAX }
.distinct()
.sorted()
.take(LIMIT)
fun plus(current: List<Duration>, value: Duration): List<Duration> = sanitise(current + value)
fun minus(current: List<Duration>, value: Duration): List<Duration> = sanitise(current - value)
/** Millis joined by ','. */
fun encode(values: List<Duration>): String =
values.joinToString(SEPARATOR) { it.inWholeMilliseconds.toString() }
/** Never throws; a malformed or empty string is an empty list. */
fun decode(raw: String): List<Duration> = raw
.split(SEPARATOR)
.mapNotNull { it.trim().toLongOrNull()?.milliseconds }
private const val SEPARATOR = ","
}
@@ -0,0 +1,83 @@
package de.jeanlucmakiola.clockula.domain.timer
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
/** How a timer *reads* right now, from its snapshot — not how it is stored (D7). */
enum class TimerMode { IDLE, RUNNING, PAUSED, EXPIRED }
data class TimerReading(
val timer: Timer,
val mode: TimerMode,
/** Remaining. Never negative; zero for EXPIRED. */
val remaining: Duration,
)
/** The app's one timer precedence, shared by the pill, the notification and the ring (D7). */
object TimerReadings {
fun of(timer: Timer, elapsedRealtime: Duration, wallClock: Instant): TimerReading {
// A row stored RUNNING that has reached zero *reads* EXPIRED, and one
// whose anchors are gone reads PAUSED at what it banked: conflating the
// stored state with the read mode is how a readout starts lying.
if (timer.state == TimerState.PAUSED) {
return TimerReading(timer, TimerMode.PAUSED, timer.remaining)
}
if (timer.state == TimerState.IDLE) {
return TimerReading(timer, TimerMode.IDLE, timer.remaining)
}
val snapshot = timer.snapshotAt(elapsedRealtime, wallClock)
return when {
snapshot.hasExpired -> TimerReading(timer, TimerMode.EXPIRED, Duration.ZERO)
snapshot.isRunning -> TimerReading(timer, TimerMode.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 -> TimerReading(timer, TimerMode.PAUSED, snapshot.remaining)
}
}
/**
* Non-IDLE readings, in subject order: expired, then running (least
* remaining), then paused; ties `sortOrder` then `id`.
*
* A deadline missed costs something and a paused countdown costs nothing,
* so expiry outranks running, which outranks paused. This is M4's pill
* ordering, extracted verbatim — `LivePillSelectorTest` passing unmodified
* is the proof (D7).
*/
fun active(
timers: List<Timer>,
elapsedRealtime: Duration,
wallClock: Instant,
): List<TimerReading> = timers
.map { of(it, elapsedRealtime, wallClock) }
.filterNot { it.mode == TimerMode.IDLE }
.sortedWith(
compareBy<TimerReading> { it.mode.rank }
// Only a running timer is ranked by what is left: within the
// paused group the tie-break is the storage order, not a
// remainder nobody is counting down.
.thenBy { if (it.mode == TimerMode.RUNNING) it.remaining else Duration.ZERO }
.thenBy { it.timer.sortOrder }
.thenBy { it.timer.id },
)
/** The first of [active], or null. */
fun subject(readings: List<TimerReading>): TimerReading? = readings.firstOrNull()
/** [active] filtered to EXPIRED, order preserved. */
fun expired(readings: List<TimerReading>): List<TimerReading> =
readings.filter { it.mode == TimerMode.EXPIRED }
private val TimerMode.rank: Int
get() = when (this) {
TimerMode.EXPIRED -> 0
TimerMode.RUNNING -> 1
TimerMode.PAUSED -> 2
TimerMode.IDLE -> 3
}
}
@@ -0,0 +1,27 @@
package de.jeanlucmakiola.clockula.domain.timer
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
/**
* The timer ring's constants. Values rather than settings: v1 does not offer
* them to the user (docs/PLAN.md §5, slice plan D11).
*/
object TimerRing {
/**
* How long an unacknowledged timer sounds before the noise stops (D11).
* The same value as the alarm's and its own constant, free to diverge:
* coupling the two would make one number answer two questions.
*/
val AUTO_SILENCE_AFTER: Duration = 10.minutes
/** What "+1 min" grants (D12). */
val ADD_TIME: Duration = 1.minutes
/** A timer's ring does not ramp: it is set by someone awake (D11). */
const val RAMP_SECONDS: Int = 0
/** The floor under every `delay` the service takes, so a rounding error cannot spin it. */
val WAKE_FLOOR: Duration = 100.milliseconds
}
@@ -0,0 +1,81 @@
package de.jeanlucmakiola.clockula.domain.timer
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import kotlin.time.Instant
sealed interface TimerRingDecision {
/** Nothing is expired. */
data object Silent : TimerRingDecision
/** Expired, but an alarm owns the audio (D9). */
data class Suppressed(val subjectId: Long) : TimerRingDecision
/** Expired, but the session's window has run out (D11). */
data class AutoSilenced(val subjectId: Long) : TimerRingDecision
data class Sound(
val subjectId: Long,
val settings: TimerSettings,
/** When the window closes: the session anchor + [TimerRing.AUTO_SILENCE_AFTER]. */
val silenceAt: Instant,
) : TimerRingDecision
}
data class TimerRingOutcome(
val decision: TimerRingDecision,
/** What `timer_ring_sounding_since` should now hold. The engine writes it only when it differs. */
val soundingSince: Instant?,
)
/** The whole audio arbitration, as a pure function of current state (D9–D11). */
object TimerRingPolicy {
/**
* [expired] is `TimerReadings.expired`'s output, so its first element is
* already the subject (D10). Nothing here is an event: a timer suppressed
* by a ringing alarm sounds the moment the same function is asked again
* with the flag down, which is what makes the ring *deferred, not lost*.
*/
fun decide(
expired: List<TimerReading>,
defaults: ClockDefaults,
alarmIsRinging: Boolean,
soundingSince: Instant?,
now: Instant,
): TimerRingOutcome {
// Nothing expired closes the session, whatever was stored: the anchor
// has no meaning without a timer to acknowledge.
val subject = expired.firstOrNull()
?: return TimerRingOutcome(TimerRingDecision.Silent, soundingSince = null)
// Auto-silence before suppression: a window that has run out stays run
// out, and an alarm ringing over it must not hand it a fresh one.
if (soundingSince != null && now - soundingSince >= TimerRing.AUTO_SILENCE_AFTER) {
return TimerRingOutcome(
TimerRingDecision.AutoSilenced(subject.timer.id),
soundingSince = soundingSince,
)
}
// One USAGE_ALARM stream, one winner. The row stays EXPIRED and the
// notification still says so; only the audio waits its turn (D9).
if (alarmIsRinging) {
return TimerRingOutcome(
TimerRingDecision.Suppressed(subject.timer.id),
soundingSince = soundingSince,
)
}
// The anchor is the instant the audio *opened*, so a second timer
// expiring mid-session inherits the window rather than buying a fresh
// ten minutes (D10).
val anchor = soundingSince ?: now
return TimerRingOutcome(
TimerRingDecision.Sound(
subjectId = subject.timer.id,
settings = subject.timer.resolveSettings(defaults),
silenceAt = anchor + TimerRing.AUTO_SILENCE_AFTER,
),
soundingSince = anchor,
)
}
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.clockula.domain.timer
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.Timer
/** A timer's one override collapsed against the app-wide defaults (D27). */
data class TimerSettings(
val ringtoneUri: String?,
val vibrate: Boolean,
val rampSeconds: Int,
)
/**
* The ringtone is the timer's own when it has one, the app default otherwise;
* vibration is app-wide, and the ramp is always zero — a timer's ring does not
* ramp, so the ramp is not a setting to inherit (D11, D27).
*/
fun Timer.resolveSettings(defaults: ClockDefaults): TimerSettings = TimerSettings(
ringtoneUri = ringtoneUri ?: defaults.timerRingtoneUri,
vibrate = defaults.vibrate,
rampSeconds = TimerRing.RAMP_SECONDS,
)
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.clockula.domain.live
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.StopwatchRun
import de.jeanlucmakiola.clockula.domain.timer.TimerReadings
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.pausedTimer
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* §5.2 — 1 case. M6 extracts the pill's precedence into [TimerReadings] so the
* pill, the notification and the ring share one ordering rather than three
* copies (D7). This is the cross-check that the two callers agree;
* `LivePillSelectorTest` passing **unmodified** is the other half of the proof,
* which is why this case lives in its own file rather than inside it.
*/
class LivePillSelectorPrecedenceTest {
private val now: Duration = 1_000.seconds
private val wall: Instant = T0
/** §5.2 #1 */
@Test
fun `the pill's subject is the one the shared precedence names`() {
val timers = listOf(
runningTimerWith(id = 1L, left = 90.seconds, elapsedNow = now, wallNow = wall, sortOrder = 0),
expiredTimer(id = 2L, sortOrder = 1),
runningTimerWith(id = 3L, left = 30.seconds, elapsedNow = now, wallNow = wall, sortOrder = 2),
pausedTimer(id = 4L, remaining = 1.minutes, sortOrder = 3),
)
val pill = LivePillSelector.select(timers, StopwatchRun(), now, wall)
val shared = TimerReadings.subject(TimerReadings.active(timers, now, wall))
assertThat((pill?.subject as LivePillSubject.TimerSubject).timerId)
.isEqualTo(shared?.timer?.id)
}
}
@@ -0,0 +1,140 @@
package de.jeanlucmakiola.clockula.domain.timer
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.minutes
import kotlin.time.Duration.Companion.seconds
/**
* §5.7 — 17 cases. The keypad as a pure state machine over a digit string,
* shifting in from the right. `toDuration` reads the digits as
* `h*3600 + m*60 + s` **without clamping minutes or seconds to 59**, which is
* Google Clock's forgiving behaviour (D23).
*/
class TimerDurationEntryTest {
private fun typed(digits: String): TimerDurationEntry =
digits.fold(TimerDurationEntry()) { entry, digit -> entry.plusDigit(digit) }
/** §5.7 #1 */
@Test
fun `a fresh entry is empty and reads zero`() {
val entry = TimerDurationEntry()
assertThat(listOf(entry.isEmpty, entry.display, entry.duration))
.containsExactly(true, "0:00:00", Duration.ZERO)
.inOrder()
}
/** §5.7 #2 */
@Test
fun `one digit lands in the seconds place`() {
assertThat(typed("5").display).isEqualTo("0:00:05")
}
/** §5.7 #3 */
@Test
fun `the next digit shifts the first one left`() {
assertThat(typed("53").display).isEqualTo("0:00:53")
}
/** §5.7 #4 */
@Test
fun `six digits fill the readout and a seventh is ignored`() {
assertThat(typed("1234567").display).isEqualTo("12:34:56")
}
/** §5.7 #5 */
@Test
fun `a leading zero into an empty entry is ignored`() {
val entry = TimerDurationEntry().plusDigit('0')
assertThat(entry.isEmpty to entry.display).isEqualTo(true to "0:00:00")
}
/** §5.7 #6 */
@Test
fun `backspace shifts the digits right by one`() {
assertThat(typed("130").backspace().display).isEqualTo("0:00:13")
}
/** §5.7 #7 */
@Test
fun `backspace on an empty entry is a no-op`() {
assertThat(TimerDurationEntry().backspace()).isEqualTo(TimerDurationEntry())
}
/** §5.7 #8 */
@Test
fun `clearing empties the entry`() {
assertThat(typed("1234").cleared().isEmpty).isTrue()
}
/** §5.7 #9 */
@Test
fun `a non-digit is ignored`() {
assertThat(typed("1").plusDigit('a')).isEqualTo(typed("1"))
}
/** §5.7 #10 */
@Test
fun `ninety-nine seconds is ninety-nine seconds, not one thirty-nine`() {
assertThat(typed("99").duration).isEqualTo(99.seconds)
}
/** §5.7 #11 */
@Test
fun `minutes are not clamped to fifty-nine either`() {
assertThat(typed("9999").duration).isEqualTo(99.minutes + 99.seconds)
}
/** §5.7 #12 */
@Test
fun `an hour and a half reads as ninety minutes`() {
assertThat(typed("13000").duration).isEqualTo(90.minutes)
}
/** §5.7 #13 */
@Test
fun `an empty entry cannot start`() {
assertThat(TimerDurationEntry().canStart).isFalse()
}
/** §5.7 #14 */
@Test
fun `six typed zeroes cannot start`() {
assertThat(typed("000000").canStart).isFalse()
}
/** §5.7 #15 */
@Test
fun `a duration past twenty-four hours cannot start`() {
assertThat(typed("999999").canStart).isFalse()
}
/** §5.7 #16 */
@Test
fun `one second and twenty-four hours are both startable`() {
val edges = listOf(typed("1"), typed("240000"))
assertThat(edges.map { it.duration to it.canStart })
.containsExactly(1.seconds to true, 24.hours to true)
.inOrder()
}
/** §5.7 #17 */
@Test
fun `the readout uses ASCII digits under a locale with its own numerals`() {
val previous = Locale.getDefault()
try {
Locale.setDefault(Locale.forLanguageTag("ar-EG-u-nu-arab"))
assertThat(typed("123456").display).isEqualTo("12:34:56")
} finally {
Locale.setDefault(previous)
}
}
}
@@ -0,0 +1,195 @@
package de.jeanlucmakiola.clockula.domain.timer
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.pausedTimer
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import de.jeanlucmakiola.clockula.testing.timerAt
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
/**
* §5.3 — 14 cases. One expiry slot for every timer, and the fire is an
* idempotent sweep carrying no id (D5): a late fire expires everything that
* came due, an early one writes nothing, and the anchors *are* the watermark.
* Both answers come from the snapshot, never from the raw anchor.
*/
class TimerExpiryTest {
private val now: Duration = 1_000.seconds
private val wall: Instant = T0
private fun due(
timers: List<Timer>,
elapsedRealtime: Duration = now,
wallClock: Instant = wall,
): List<Long> = TimerExpiry.dueIds(timers, elapsedRealtime, wallClock)
private fun deadline(
timers: List<Timer>,
elapsedRealtime: Duration = now,
wallClock: Instant = wall,
): Duration? = TimerExpiry.nextDeadline(timers, elapsedRealtime, wallClock)
/** §5.3 #1 */
@Test
fun `no timers is nothing due and no deadline`() {
assertThat(due(emptyList()) to deadline(emptyList())).isEqualTo(emptyList<Long>() to null)
}
/** §5.3 #2 */
@Test
fun `a running timer with a minute left is not due and holds the slot`() {
val timers = listOf(runningTimerWith(left = 60.seconds, elapsedNow = now))
assertThat(due(timers) to deadline(timers)).isEqualTo(emptyList<Long>() to now + 60.seconds)
}
/** §5.3 #3 */
@Test
fun `a running timer exactly at zero is due`() {
val timers = listOf(runningTimerWith(id = 7L, left = 60.seconds, elapsedNow = now - 60.seconds))
assertThat(due(timers)).containsExactly(7L)
}
/** §5.3 #4 */
@Test
fun `a fire five minutes late still finds the timer due`() {
val timers = listOf(runningTimerWith(id = 7L, left = 60.seconds, elapsedNow = now - 6.minutes))
assertThat(due(timers)).containsExactly(7L)
}
/** §5.3 #5 */
@Test
fun `a paused timer with nothing left is not due because it is not counting`() {
val timers = listOf(pausedTimer(id = 7L, remaining = Duration.ZERO))
assertThat(due(timers)).isEmpty()
}
/** §5.3 #6 */
@Test
fun `an already-expired timer is neither due again nor a deadline`() {
val timers = listOf(expiredTimer(id = 7L))
assertThat(due(timers) to deadline(timers)).isEqualTo(emptyList<Long>() to null)
}
/** §5.3 #7 */
@Test
fun `the slot holds the earliest of two running deadlines`() {
val timers = listOf(
runningTimerWith(id = 1L, left = 200.seconds, elapsedNow = now),
runningTimerWith(id = 2L, left = 60.seconds, elapsedNow = now),
)
assertThat(deadline(timers)).isEqualTo(now + 60.seconds)
}
/** §5.3 #8 */
@Test
fun `a timer anchored in a previous boot is registered from its wall-clock fallback`() {
val timers = listOf(
timerAt(
state = TimerState.RUNNING,
remaining = 5.minutes,
startedAtElapsedRealtime = now + 1.hours,
endsAtElapsedRealtime = now + 1.hours + 5.minutes,
endsAtWallClock = wall + 30.seconds,
),
)
assertThat(deadline(timers)).isEqualTo(now + 30.seconds)
}
/** §5.3 #9 */
@Test
fun `a dead anchor whose wall-clock fallback has passed is due`() {
val timers = listOf(
timerAt(
id = 7L,
state = TimerState.RUNNING,
remaining = 5.minutes,
startedAtElapsedRealtime = now + 1.hours,
endsAtElapsedRealtime = now + 1.hours + 5.minutes,
endsAtWallClock = wall - 30.seconds,
),
)
assertThat(due(timers)).containsExactly(7L)
}
/** §5.3 #10 */
@Test
fun `a running row with no anchors and time banked is not due`() {
val timers = listOf(
timerAt(
id = 7L,
state = TimerState.RUNNING,
remaining = 45.seconds,
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
),
)
assertThat(due(timers) to deadline(timers)).isEqualTo(emptyList<Long>() to null)
}
/** §5.3 #11 */
@Test
fun `a running row with no anchors and nothing banked is due`() {
val timers = listOf(
timerAt(
id = 7L,
state = TimerState.RUNNING,
remaining = Duration.ZERO,
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
),
)
assertThat(due(timers)).containsExactly(7L)
}
/** §5.3 #12 */
@Test
fun `three due timers come back in subject order`() {
val timers = listOf(
runningTimerWith(id = 1L, left = 1.seconds, elapsedNow = now - 1.minutes, sortOrder = 2),
runningTimerWith(id = 2L, left = 1.seconds, elapsedNow = now - 1.minutes, sortOrder = 0),
runningTimerWith(id = 3L, left = 1.seconds, elapsedNow = now - 1.minutes, sortOrder = 1),
)
assertThat(due(timers)).containsExactly(2L, 3L, 1L).inOrder()
}
/** §5.3 #13 */
@Test
fun `three hours of clock jump forward leaves the deadline where it was`() {
val timers = listOf(runningTimerWith(id = 7L, left = 60.seconds, elapsedNow = now))
val jumped = wall + 3.hours
assertThat(due(timers, wallClock = jumped) to deadline(timers, wallClock = jumped))
.isEqualTo(emptyList<Long>() to now + 60.seconds)
}
/** §5.3 #14 */
@Test
fun `three hours of clock jump backward leaves the deadline where it was`() {
val timers = listOf(runningTimerWith(id = 7L, left = 60.seconds, elapsedNow = now))
assertThat(deadline(timers, wallClock = wall - 3.hours)).isEqualTo(now + 60.seconds)
}
}
@@ -0,0 +1,174 @@
package de.jeanlucmakiola.clockula.domain.timer
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.pausedTimer
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import de.jeanlucmakiola.clockula.testing.timerAt
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* §5.5 — 12 cases. One notification for however many timers: the subject, and
* a count (D17). Its countdown is the platform chronometer, so the base is a
* wall-clock instant and the service posts once per state change rather than
* once per second (D19). `alertOnce` is false exactly once per ring session.
*/
class TimerNotificationPolicyTest {
private val now: Duration = 1_000.seconds
private val wall: Instant = T0
private fun reading(timer: Timer, mode: TimerMode, remaining: Duration) =
TimerReading(timer, mode, remaining)
private fun running(id: Long = 7L, left: Duration = 90.seconds, label: String = "Pasta") =
reading(
runningTimerWith(id = id, left = left, elapsedNow = now, wallNow = wall, label = label),
TimerMode.RUNNING,
left,
)
private fun paused(id: Long = 7L, remaining: Duration = 4.minutes + 32.seconds) =
reading(pausedTimer(id = id, remaining = remaining), TimerMode.PAUSED, remaining)
private fun expired(id: Long = 7L) =
reading(expiredTimer(id = id), TimerMode.EXPIRED, Duration.ZERO)
/** §5.5 #1 */
@Test
fun `nothing active is no notification`() {
assertThat(TimerNotificationPolicy.stateFor(emptyList(), alreadyAlerted = false)).isNull()
}
/** §5.5 #2 */
@Test
fun `a running subject offers pause and plus one minute, and a chronometer base`() {
val subject = running()
val state = TimerNotificationPolicy.stateFor(listOf(subject), alreadyAlerted = false)!!
assertThat(state.actions to state.chronometerBase).isEqualTo(
listOf(TimerNotificationAction.PAUSE, TimerNotificationAction.ADD_MINUTE) to
subject.timer.endsAtWallClock,
)
}
/** §5.5 #3 */
@Test
fun `a paused subject offers resume and reset, and reads its banked remainder`() {
val state = TimerNotificationPolicy.stateFor(listOf(paused()), alreadyAlerted = false)!!
assertThat(listOf(state.actions, state.chronometerBase, state.remaining)).containsExactly(
listOf(TimerNotificationAction.RESUME, TimerNotificationAction.RESET),
null,
4.minutes + 32.seconds,
).inOrder()
}
/** §5.5 #4 */
@Test
fun `an expired subject offers stop and plus one minute at zero`() {
val state = TimerNotificationPolicy.stateFor(listOf(expired()), alreadyAlerted = false)!!
assertThat(listOf(state.actions, state.chronometerBase, state.remaining)).containsExactly(
listOf(TimerNotificationAction.RESET, TimerNotificationAction.ADD_MINUTE),
null,
Duration.ZERO,
).inOrder()
}
/** §5.5 #5 */
@Test
fun `the expired timer is the subject over the running one`() {
val state = TimerNotificationPolicy.stateFor(
listOf(expired(id = 3L), running(id = 7L)),
alreadyAlerted = false,
)!!
assertThat(state.subjectId to state.mode).isEqualTo(3L to TimerMode.EXPIRED)
}
/** §5.5 #6 */
@Test
fun `the others are counted, not listed`() {
val state = TimerNotificationPolicy.stateFor(
listOf(expired(id = 1L), running(id = 2L), running(id = 3L), paused(id = 4L)),
alreadyAlerted = false,
)!!
assertThat(state.otherActiveTimers).isEqualTo(3)
}
/** §5.5 #7 */
@Test
fun `one active timer counts no others and never a negative`() {
val state = TimerNotificationPolicy.stateFor(listOf(running()), alreadyAlerted = false)!!
assertThat(state.otherActiveTimers).isEqualTo(0)
}
/** §5.5 #8 */
@Test
fun `an idle timer is not one of the others`() {
// `active` has already dropped the idle rows (D7), so the count is over
// what is left — two active timers are one subject and one other.
val state = TimerNotificationPolicy.stateFor(
listOf(running(id = 1L), paused(id = 2L)),
alreadyAlerted = false,
)!!
assertThat(state.otherActiveTimers).isEqualTo(1)
}
/** §5.5 #9 */
@Test
fun `an expiry heads-up once per session`() {
val state = TimerNotificationPolicy.stateFor(listOf(expired()), alreadyAlerted = false)!!
assertThat(state.alertOnce).isFalse()
}
/** §5.5 #10 */
@Test
fun `the second post of the same session does not heads-up again`() {
val state = TimerNotificationPolicy.stateFor(listOf(expired()), alreadyAlerted = true)!!
assertThat(state.alertOnce).isTrue()
}
/** §5.5 #11 */
@Test
fun `a countdown never alerts`() {
val states = listOf(running(), paused())
.map { TimerNotificationPolicy.stateFor(listOf(it), alreadyAlerted = false)!! }
assertThat(states.map { it.alertOnce }).containsExactly(true, true)
}
/** §5.5 #12 */
@Test
fun `a running row with no wall-clock end falls back to a static readout`() {
val corrupt = timerAt(
id = 7L,
state = TimerState.RUNNING,
remaining = 90.seconds,
startedAtElapsedRealtime = now,
endsAtElapsedRealtime = now + 90.seconds,
endsAtWallClock = null,
)
val state = TimerNotificationPolicy.stateFor(
listOf(reading(corrupt, TimerMode.RUNNING, 90.seconds)),
alreadyAlerted = false,
)!!
assertThat(state.chronometerBase to state.remaining).isEqualTo(null to 90.seconds)
}
}
@@ -0,0 +1,130 @@
package de.jeanlucmakiola.clockula.domain.timer
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* §5.6 — 15 cases. The presets are a user-editable set of quick-start
* durations, sanitised on read as well as on write (D13). Out-of-range entries
* are **dropped, never clamped**: a clamp would silently turn one preset into
* another, possibly a duplicate.
*/
class TimerPresetsTest {
/** §5.6 #1 */
@Test
fun `the built-in set is ascending and in range`() {
val builtIn = TimerPresets.BUILT_IN
assertThat(builtIn).isInOrder()
assertThat(builtIn.filterNot { it in TimerPresets.MIN..TimerPresets.MAX }).isEmpty()
}
/** §5.6 #2 */
@Test
fun `a new preset lands in ascending position`() {
val result = TimerPresets.plus(listOf(1.minutes, 10.minutes), 5.minutes)
assertThat(result).containsExactly(1.minutes, 5.minutes, 10.minutes).inOrder()
}
/** §5.6 #3 */
@Test
fun `adding a preset that is already there changes nothing`() {
val current = listOf(1.minutes, 5.minutes)
assertThat(TimerPresets.plus(current, 5.minutes)).isEqualTo(current)
}
/** §5.6 #4 */
@Test
fun `a full list evicts nothing silently`() {
val full = (1..TimerPresets.LIMIT).map { it.minutes }
assertThat(TimerPresets.plus(full, 90.minutes)).isEqualTo(full)
}
/** §5.6 #5 */
@Test
fun `removing a preset removes exactly that one`() {
val result = TimerPresets.minus(listOf(1.minutes, 5.minutes, 10.minutes), 5.minutes)
assertThat(result).containsExactly(1.minutes, 10.minutes).inOrder()
}
/** §5.6 #6 */
@Test
fun `removing a preset that is not there changes nothing`() {
val current = listOf(1.minutes, 5.minutes)
assertThat(TimerPresets.minus(current, 7.minutes)).isEqualTo(current)
}
/** §5.6 #7 */
@Test
fun `a duration below the minimum is dropped`() {
assertThat(TimerPresets.sanitise(listOf(100.milliseconds, 1.minutes)))
.containsExactly(1.minutes)
}
/** §5.6 #8 */
@Test
fun `a duration above the maximum is dropped rather than clamped`() {
assertThat(TimerPresets.sanitise(listOf(48.hours, 1.minutes))).containsExactly(1.minutes)
}
/** §5.6 #9 */
@Test
fun `a duplicate appears once`() {
assertThat(TimerPresets.sanitise(listOf(5.minutes, 5.minutes))).containsExactly(5.minutes)
}
/** §5.6 #10 */
@Test
fun `the result is sorted ascending`() {
assertThat(TimerPresets.sanitise(listOf(10.minutes, 1.minutes, 5.minutes)))
.containsExactly(1.minutes, 5.minutes, 10.minutes)
.inOrder()
}
/** §5.6 #11 */
@Test
fun `the result is capped at the limit`() {
val tooMany = (1..(TimerPresets.LIMIT + 5)).map { it.minutes }
assertThat(TimerPresets.sanitise(tooMany)).hasSize(TimerPresets.LIMIT)
}
/** §5.6 #12 */
@Test
fun `a sanitised list round-trips through the stored string`() {
val sanitised = TimerPresets.sanitise(listOf(1.minutes, 5.minutes, 1.hours))
assertThat(TimerPresets.decode(TimerPresets.encode(sanitised))).isEqualTo(sanitised)
}
/** §5.6 #13 */
@Test
fun `nonsense decodes to nothing and throws nothing`() {
assertThat(TimerPresets.decode("nonsense")).isEmpty()
}
/** §5.6 #14 */
@Test
fun `an empty string decodes to an empty list`() {
assertThat(TimerPresets.decode("")).isEmpty()
}
/** §5.6 #15 */
@Test
fun `the valid entries of a partly-corrupt string survive`() {
val decoded: List<Duration> = TimerPresets.decode("60000,oops,120000")
assertThat(decoded).containsExactly(1.minutes, 2.minutes).inOrder()
}
}
@@ -0,0 +1,230 @@
package de.jeanlucmakiola.clockula.domain.timer
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.Timer
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.idleTimer
import de.jeanlucmakiola.clockula.testing.pausedTimer
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import de.jeanlucmakiola.clockula.testing.timerAt
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
/**
* §5.1 — 16 cases. The app's **one** timer precedence, extracted from M4's pill
* so the pill, the notification and the ring cannot disagree (D7). A row stored
* RUNNING that has reached zero *reads* EXPIRED: conflating the stored state
* with the read mode is how a readout starts lying.
*/
class TimerReadingsTest {
private val now: Duration = 1_000.seconds
private val wall: Instant = T0
private fun read(
timer: Timer,
elapsedRealtime: Duration = now,
wallClock: Instant = wall,
): TimerReading = TimerReadings.of(timer, elapsedRealtime, wallClock)
private fun active(
timers: List<Timer>,
elapsedRealtime: Duration = now,
wallClock: Instant = wall,
): List<TimerReading> = TimerReadings.active(timers, elapsedRealtime, wallClock)
/** §5.1 #1 */
@Test
fun `an idle timer reads idle at its configured remaining`() {
val timer = idleTimer(duration = 5.minutes)
val reading = read(timer)
assertThat(reading.mode to reading.remaining).isEqualTo(TimerMode.IDLE to 5.minutes)
}
/** §5.1 #2 */
@Test
fun `a running timer reads running at what its end anchor leaves`() {
val timer = runningTimerWith(left = 100.seconds, elapsedNow = now, wallNow = wall)
val reading = read(timer)
assertThat(reading.mode to reading.remaining).isEqualTo(TimerMode.RUNNING to 100.seconds)
}
/** §5.1 #3 */
@Test
fun `a running timer exactly at its end anchor reads expired`() {
val timer = runningTimerWith(left = 100.seconds, elapsedNow = now - 100.seconds, wallNow = wall)
val reading = read(timer)
assertThat(reading.mode to reading.remaining).isEqualTo(TimerMode.EXPIRED to Duration.ZERO)
}
/** §5.1 #4 */
@Test
fun `a running timer past its end anchor reads zero and never a negative`() {
val timer = runningTimerWith(left = 100.seconds, elapsedNow = now - 200.seconds, wallNow = wall)
val reading = read(timer)
assertThat(reading.mode to reading.remaining).isEqualTo(TimerMode.EXPIRED to Duration.ZERO)
}
/** §5.1 #5 */
@Test
fun `a stored expired timer reads expired at zero`() {
val reading = read(expiredTimer())
assertThat(reading.mode to reading.remaining).isEqualTo(TimerMode.EXPIRED to Duration.ZERO)
}
/** §5.1 #6 */
@Test
fun `a paused timer keeps its banked remainder whatever either clock does`() {
val timer = pausedTimer(remaining = 90.seconds)
val reading = read(timer, elapsedRealtime = 9_999.seconds, wallClock = wall + 3.hours)
assertThat(reading.mode to reading.remaining).isEqualTo(TimerMode.PAUSED to 90.seconds)
}
/** §5.1 #7 */
@Test
fun `a running timer anchored in a previous boot reads from its wall-clock fallback`() {
val timer = timerAt(
state = TimerState.RUNNING,
remaining = 5.minutes,
startedAtElapsedRealtime = now + 1.hours,
endsAtElapsedRealtime = now + 1.hours + 5.minutes,
endsAtWallClock = wall + 42.seconds,
)
val reading = read(timer)
assertThat(reading.mode to reading.remaining).isEqualTo(TimerMode.RUNNING to 42.seconds)
}
/** §5.1 #8 */
@Test
fun `a running row with no end anchor reads paused at what it banked`() {
val timer = timerAt(
state = TimerState.RUNNING,
remaining = 45.seconds,
startedAtElapsedRealtime = now,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
)
val reading = read(timer)
assertThat(reading.mode to reading.remaining).isEqualTo(TimerMode.PAUSED to 45.seconds)
}
/** §5.1 #9 */
@Test
fun `an idle timer is not active`() {
val readings = active(
listOf(
idleTimer(id = 1L),
runningTimerWith(id = 2L, left = 1.minutes, elapsedNow = now),
pausedTimer(id = 3L),
),
)
assertThat(readings.map { it.timer.id }).containsExactly(2L, 3L).inOrder()
}
/** §5.1 #10 */
@Test
fun `expiry outranks running, which outranks paused`() {
val readings = active(
listOf(
pausedTimer(id = 1L),
runningTimerWith(id = 2L, left = 1.minutes, elapsedNow = now),
expiredTimer(id = 3L),
),
)
assertThat(readings.map { it.mode })
.containsExactly(TimerMode.EXPIRED, TimerMode.RUNNING, TimerMode.PAUSED)
.inOrder()
}
/** §5.1 #11 */
@Test
fun `the running timer with the least left comes first`() {
val readings = active(
listOf(
runningTimerWith(id = 1L, left = 90.seconds, elapsedNow = now),
runningTimerWith(id = 2L, left = 30.seconds, elapsedNow = now),
),
)
assertThat(readings.map { it.timer.id }).containsExactly(2L, 1L).inOrder()
}
/** §5.1 #12 */
@Test
fun `two expired timers tie on the lower sort order`() {
val readings = active(
listOf(expiredTimer(id = 1L, sortOrder = 5), expiredTimer(id = 2L, sortOrder = 2)),
)
assertThat(readings.map { it.timer.id }).containsExactly(2L, 1L).inOrder()
}
/** §5.1 #13 */
@Test
fun `an equal sort order ties on the lower id`() {
val readings = active(
listOf(expiredTimer(id = 9L, sortOrder = 3), expiredTimer(id = 4L, sortOrder = 3)),
)
assertThat(readings.map { it.timer.id }).containsExactly(4L, 9L).inOrder()
}
/** §5.1 #14 */
@Test
fun `nothing active has no subject`() {
assertThat(TimerReadings.subject(active(emptyList()))).isNull()
}
/** §5.1 #15 */
@Test
fun `the subject is the first active reading`() {
val readings = active(
listOf(
pausedTimer(id = 1L),
runningTimerWith(id = 2L, left = 1.minutes, elapsedNow = now),
expiredTimer(id = 3L),
idleTimer(id = 4L),
),
)
assertThat(TimerReadings.subject(readings)).isEqualTo(readings.first())
}
/** §5.1 #16 */
@Test
fun `moving the system clock three hours either way moves no reading`() {
val timers = listOf(
runningTimerWith(id = 1L, left = 90.seconds, elapsedNow = now, wallNow = wall),
pausedTimer(id = 2L, remaining = 30.seconds),
)
val before = active(timers)
val forward = active(timers, wallClock = wall + 3.hours)
val backward = active(timers, wallClock = wall - 3.hours)
assertThat(listOf(forward, backward)).containsExactly(before, before)
}
}
@@ -0,0 +1,245 @@
package de.jeanlucmakiola.clockula.domain.timer
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.Ringtones
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.expiredTimer
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* §5.4 — 20 cases. The whole audio arbitration as a pure function of current
* state (D9): an alarm ringing wins, and the timer's ring is **deferred, not
* lost**, because nothing has to re-arm it. One session is one window (D10),
* and auto-silence stops the noise while leaving the row EXPIRED (D11).
*/
class TimerRingPolicyTest {
private val now: Instant = T0
private val defaults = ClockDefaults()
private fun expiredReading(id: Long = 7L, ringtoneUri: String? = null, sortOrder: Int = 0) =
TimerReading(
timer = expiredTimer(id = id, ringtoneUri = ringtoneUri, sortOrder = sortOrder),
mode = TimerMode.EXPIRED,
remaining = Duration.ZERO,
)
private fun decide(
expired: List<TimerReading> = listOf(expiredReading()),
defaults: ClockDefaults = this.defaults,
alarmIsRinging: Boolean = false,
soundingSince: Instant? = null,
at: Instant = now,
): TimerRingOutcome = TimerRingPolicy.decide(expired, defaults, alarmIsRinging, soundingSince, at)
private fun TimerRingOutcome.sound(): TimerRingDecision.Sound = decision as TimerRingDecision.Sound
/** §5.4 #1 */
@Test
fun `nothing expired closes the session`() {
val outcome = decide(expired = emptyList(), soundingSince = now - 30.seconds)
assertThat(outcome).isEqualTo(TimerRingOutcome(TimerRingDecision.Silent, soundingSince = null))
}
/** §5.4 #2 */
@Test
fun `an expired timer with the audio free opens a session now`() {
val outcome = decide()
assertThat(outcome.sound().subjectId to outcome.soundingSince).isEqualTo(7L to now)
}
/** §5.4 #3 */
@Test
fun `the timer's own ringtone beats the app default`() {
val outcome = decide(
expired = listOf(expiredReading(ringtoneUri = "content://mine")),
defaults = defaults.copy(timerRingtoneUri = "content://theirs"),
)
assertThat(outcome.sound().settings.ringtoneUri).isEqualTo("content://mine")
}
/** §5.4 #4 */
@Test
fun `a timer with no ringtone inherits the app default`() {
val outcome = decide(defaults = defaults.copy(timerRingtoneUri = "content://theirs"))
assertThat(outcome.sound().settings.ringtoneUri).isEqualTo("content://theirs")
}
/** §5.4 #5 */
@Test
fun `the silent sentinel is carried through unchanged`() {
val outcome = decide(expired = listOf(expiredReading(ringtoneUri = Ringtones.SILENT_URI)))
assertThat(outcome.sound().settings.ringtoneUri).isEqualTo(Ringtones.SILENT_URI)
}
/** §5.4 #6 */
@Test
fun `vibration comes from the app default`() {
val outcome = decide(defaults = defaults.copy(vibrate = false))
assertThat(outcome.sound().settings.vibrate).isFalse()
}
/** §5.4 #7 */
@Test
fun `a timer's ring does not ramp`() {
val outcome = decide(defaults = defaults.copy(volumeRampSeconds = 30))
assertThat(outcome.sound().settings.rampSeconds).isEqualTo(TimerRing.RAMP_SECONDS)
}
/** §5.4 #8 */
@Test
fun `a session already sounding is not restarted`() {
val opened = now - 30.seconds
val outcome = decide(soundingSince = opened)
assertThat((outcome.decision is TimerRingDecision.Sound) to outcome.soundingSince)
.isEqualTo(true to opened)
}
/** §5.4 #9 */
@Test
fun `a second timer expiring mid-session inherits the window`() {
val opened = now - 30.seconds
val outcome = decide(
expired = listOf(expiredReading(id = 7L), expiredReading(id = 8L, sortOrder = 1)),
soundingSince = opened,
)
assertThat(outcome.soundingSince).isEqualTo(opened)
}
/** §5.4 #10 */
@Test
fun `an alarm ringing suppresses the audio and opens no session`() {
val outcome = decide(alarmIsRinging = true)
assertThat(outcome)
.isEqualTo(TimerRingOutcome(TimerRingDecision.Suppressed(7L), soundingSince = null))
}
/** §5.4 #11 */
@Test
fun `an alarm ringing over a sounding timer leaves its anchor alone`() {
val opened = now - 30.seconds
val outcome = decide(alarmIsRinging = true, soundingSince = opened)
assertThat(outcome)
.isEqualTo(TimerRingOutcome(TimerRingDecision.Suppressed(7L), soundingSince = opened))
}
/** §5.4 #12 */
@Test
fun `a suppressed timer sounds as soon as the alarm's cycle closes`() {
val suppressed = decide(alarmIsRinging = true)
val released = decide(alarmIsRinging = false, soundingSince = suppressed.soundingSince)
assertThat(released).isEqualTo(
TimerRingOutcome(
TimerRingDecision.Sound(
subjectId = 7L,
settings = released.sound().settings,
silenceAt = now + TimerRing.AUTO_SILENCE_AFTER,
),
soundingSince = now,
),
)
}
/** §5.4 #13 */
@Test
fun `an alarm ringing over no expired timer opens no session at all`() {
val ringing = decide(expired = emptyList(), alarmIsRinging = true)
val stopped = decide(expired = emptyList(), soundingSince = ringing.soundingSince)
assertThat(listOf(ringing.soundingSince, stopped.soundingSince)).containsExactly(null, null)
}
/** §5.4 #14 */
@Test
fun `a session that has run its window auto-silences`() {
val opened = now - TimerRing.AUTO_SILENCE_AFTER
val outcome = decide(soundingSince = opened)
assertThat(outcome)
.isEqualTo(TimerRingOutcome(TimerRingDecision.AutoSilenced(7L), soundingSince = opened))
}
/** §5.4 #15 */
@Test
fun `auto-silence is decided before suppression`() {
val opened = now - TimerRing.AUTO_SILENCE_AFTER
val outcome = decide(alarmIsRinging = true, soundingSince = opened)
assertThat(outcome.decision).isEqualTo(TimerRingDecision.AutoSilenced(7L))
}
/** §5.4 #16 */
@Test
fun `an auto-silenced session does not restart a minute later`() {
val opened = now - TimerRing.AUTO_SILENCE_AFTER
val outcome = decide(soundingSince = opened, at = now + 1.minutes)
assertThat(outcome)
.isEqualTo(TimerRingOutcome(TimerRingDecision.AutoSilenced(7L), soundingSince = opened))
}
/** §5.4 #17 */
@Test
fun `resetting the last expired timer closes an auto-silenced session`() {
val outcome =
decide(expired = emptyList(), soundingSince = now - TimerRing.AUTO_SILENCE_AFTER)
assertThat(outcome).isEqualTo(TimerRingOutcome(TimerRingDecision.Silent, soundingSince = null))
}
/** §5.4 #18 */
@Test
fun `a negative session age is inside the window, not past it`() {
val opened = now + 10.minutes
val outcome = decide(soundingSince = opened)
assertThat(outcome.decision).isInstanceOf(TimerRingDecision.Sound::class.java)
}
/** §5.4 #19 */
@Test
fun `the window closes ten minutes after the audio opened`() {
val outcome = decide()
assertThat(outcome.sound().silenceAt).isEqualTo(now + TimerRing.AUTO_SILENCE_AFTER)
}
/** §5.4 #20 */
@Test
fun `the subject is the expired timer with the lower sort order`() {
val outcome = decide(
expired = listOf(
expiredReading(id = 9L, sortOrder = 2),
expiredReading(id = 4L, sortOrder = 5),
),
)
assertThat(outcome.sound().subjectId).isEqualTo(9L)
}
}