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:
@@ -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,
|
||||
)
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
+174
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user