diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/live/LivePill.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/live/LivePill.kt index 7de55ec..18d58a3 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/live/LivePill.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/live/LivePill.kt @@ -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 { 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 } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerDurationEntry.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerDurationEntry.kt new file mode 100644 index 0000000..e32cf80 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerDurationEntry.kt @@ -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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerExpiry.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerExpiry.kt new file mode 100644 index 0000000..f98c4ef --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerExpiry.kt @@ -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, elapsedRealtime: Duration, wallClock: Instant): List = + 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, + 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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerNotification.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerNotification.kt new file mode 100644 index 0000000..5dad6e5 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerNotification.kt @@ -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, + /** 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, + 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 = 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() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerPresets.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerPresets.kt new file mode 100644 index 0000000..ed332ef --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerPresets.kt @@ -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 = + 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): List = values + .filter { it in MIN..MAX } + .distinct() + .sorted() + .take(LIMIT) + + fun plus(current: List, value: Duration): List = sanitise(current + value) + + fun minus(current: List, value: Duration): List = sanitise(current - value) + + /** Millis joined by ','. */ + fun encode(values: List): String = + values.joinToString(SEPARATOR) { it.inWholeMilliseconds.toString() } + + /** Never throws; a malformed or empty string is an empty list. */ + fun decode(raw: String): List = raw + .split(SEPARATOR) + .mapNotNull { it.trim().toLongOrNull()?.milliseconds } + + private const val SEPARATOR = "," +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerReading.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerReading.kt new file mode 100644 index 0000000..370e6c8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerReading.kt @@ -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, + elapsedRealtime: Duration, + wallClock: Instant, + ): List = timers + .map { of(it, elapsedRealtime, wallClock) } + .filterNot { it.mode == TimerMode.IDLE } + .sortedWith( + compareBy { 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? = readings.firstOrNull() + + /** [active] filtered to EXPIRED, order preserved. */ + fun expired(readings: List): List = + 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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt new file mode 100644 index 0000000..7d0d82e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRing.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicy.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicy.kt new file mode 100644 index 0000000..663c03e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicy.kt @@ -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, + 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, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt new file mode 100644 index 0000000..6c3ff0c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/timer/TimerSettings.kt @@ -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, +) diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/live/LivePillSelectorPrecedenceTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/live/LivePillSelectorPrecedenceTest.kt new file mode 100644 index 0000000..dc679bb --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/live/LivePillSelectorPrecedenceTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerDurationEntryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerDurationEntryTest.kt new file mode 100644 index 0000000..f8a4d2d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerDurationEntryTest.kt @@ -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) + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerExpiryTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerExpiryTest.kt new file mode 100644 index 0000000..b995a2f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerExpiryTest.kt @@ -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, + elapsedRealtime: Duration = now, + wallClock: Instant = wall, + ): List = TimerExpiry.dueIds(timers, elapsedRealtime, wallClock) + + private fun deadline( + timers: List, + 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() 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() 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() 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() 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() 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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerNotificationPolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerNotificationPolicyTest.kt new file mode 100644 index 0000000..f9609e4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerNotificationPolicyTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerPresetsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerPresetsTest.kt new file mode 100644 index 0000000..248cbc6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerPresetsTest.kt @@ -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 = TimerPresets.decode("60000,oops,120000") + + assertThat(decoded).containsExactly(1.minutes, 2.minutes).inOrder() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerReadingsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerReadingsTest.kt new file mode 100644 index 0000000..cfbba37 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerReadingsTest.kt @@ -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, + elapsedRealtime: Duration = now, + wallClock: Instant = wall, + ): List = 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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt new file mode 100644 index 0000000..d95ecc1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/timer/TimerRingPolicyTest.kt @@ -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 = 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) + } +}