feat(domain): plain-Kotlin models and the two clocks

The aggregates Clockula stores — alarms, timers, world clocks and the
stopwatch run — as ordinary Kotlin, with no Room or Android type anywhere
near them.

Time is taken as a parameter, never read ambiently: WallClock and
ElapsedRealtimeClock are separate types so a call site has to name which
one it means. A running timer resolves against the monotonic clock and
keeps a wall-clock value only as a post-reboot fallback; the stopwatch
gets no wall-clock value at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 13:52:02 +02:00
co-authored by Claude Opus 5
parent bd8654bf31
commit a2beae94ec
13 changed files with 936 additions and 0 deletions
@@ -0,0 +1,131 @@
package de.jeanlucmakiola.clockula.domain
import java.time.DayOfWeek
import kotlin.time.Instant
/** A local time of day. Always valid; use [clamped] at a data boundary. */
data class TimeOfDay(val hour: Int, val minute: Int) {
init {
require(hour in 0..23) { "hour out of range: $hour" }
require(minute in 0..59) { "minute out of range: $minute" }
}
/** Minutes since local midnight, 0..1439. */
val minutesOfDay: Int get() = hour * 60 + minute
companion object {
/** Never throws — out-of-range input is clamped into range. */
fun clamped(hour: Int, minute: Int): TimeOfDay =
TimeOfDay(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
}
}
/**
* The days an alarm repeats on, as a 7-bit mask: bit `n` is ISO day `n + 1`,
* so Monday is bit 0 and Sunday is bit 6. The constructor is private; every
* entry point sanitises, so a corrupt stored mask can never widen the set.
*/
@JvmInline
value class RepeatDays private constructor(val mask: Int) {
/** Iteration order MONDAY..SUNDAY. */
val days: Set<DayOfWeek>
get() = DayOfWeek.entries.filterTo(LinkedHashSet()) { it in this }
val isRepeating: Boolean get() = mask != 0
operator fun contains(day: DayOfWeek): Boolean = mask and day.bit != 0
operator fun plus(day: DayOfWeek): RepeatDays = RepeatDays(mask or day.bit)
operator fun minus(day: DayOfWeek): RepeatDays = RepeatDays(mask and day.bit.inv())
companion object {
const val ALL_MASK: Int = 0b111_1111
val NONE: RepeatDays = RepeatDays(0)
val EVERY_DAY: RepeatDays = RepeatDays(ALL_MASK)
val WEEKDAYS: RepeatDays = of(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY,
)
val WEEKENDS: RepeatDays = of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY)
/** Keeps only bits 0..6; never throws. The only way to build one from stored data. */
fun fromMask(mask: Int): RepeatDays = RepeatDays(mask and ALL_MASK)
fun of(days: Set<DayOfWeek>): RepeatDays =
RepeatDays(days.fold(0) { mask, day -> mask or day.bit })
fun of(vararg days: DayOfWeek): RepeatDays =
RepeatDays(days.fold(0) { mask, day -> mask or day.bit })
/** ISO day `n` lives in bit `n - 1`, so `DayOfWeek.value` needs no lookup table. */
private val DayOfWeek.bit: Int get() = 1 shl (value - 1)
}
}
/** The optional barrier between a ringing alarm and dismissing it. */
enum class DismissChallenge { NONE, MATH, HOLD }
/**
* One alarm as the app sees it. The nullable fields are **overrides**: null
* means "inherit [ClockDefaults]". Resolve with [resolveSettings].
*/
data class Alarm(
val id: Long,
val time: TimeOfDay,
val label: String,
val enabled: Boolean,
val repeatDays: RepeatDays,
val skipNextOccurrence: Boolean,
val ringtoneUri: String?,
val vibrate: Boolean?,
val snoozeMinutes: Int?,
val snoozeLimit: Int?,
val volumeRampSeconds: Int?,
val dismissChallenge: DismissChallenge?,
val createdAt: Instant,
val updatedAt: Instant,
)
/** Everything needed to create an alarm; the repository supplies id and timestamps. */
data class AlarmDraft(
val time: TimeOfDay,
val label: String = "",
val enabled: Boolean = true,
val repeatDays: RepeatDays = RepeatDays.NONE,
val ringtoneUri: String? = null,
val vibrate: Boolean? = null,
val snoozeMinutes: Int? = null,
val snoozeLimit: Int? = null,
val volumeRampSeconds: Int? = null,
val dismissChallenge: DismissChallenge? = null,
)
/** An alarm's overrides resolved against the app defaults — no nullables left. */
data class AlarmSettings(
val ringtoneUri: String?,
val vibrate: Boolean,
val snoozeMinutes: Int,
val snoozeLimit: Int,
val volumeRampSeconds: Int,
val dismissChallenge: DismissChallenge,
)
/**
* `?:` throughout, never `takeIf`/truthiness: a stored `false` is a choice the
* user made, not an absent value.
*/
fun Alarm.resolveSettings(defaults: ClockDefaults): AlarmSettings = AlarmSettings(
ringtoneUri = ringtoneUri ?: defaults.alarmRingtoneUri,
vibrate = vibrate ?: defaults.vibrate,
snoozeMinutes = snoozeMinutes ?: defaults.snoozeMinutes,
snoozeLimit = snoozeLimit ?: defaults.snoozeLimit,
volumeRampSeconds = volumeRampSeconds ?: defaults.volumeRampSeconds,
dismissChallenge = dismissChallenge ?: defaults.dismissChallenge,
)
@@ -0,0 +1,20 @@
package de.jeanlucmakiola.clockula.domain
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
/**
* The app-wide defaults an alarm or timer inherits when it has no override.
* Backed by DataStore, edited in M10's settings screen.
*/
data class ClockDefaults(
val snoozeMinutes: Int = 10,
val snoozeLimit: Int = 3,
val vibrate: Boolean = true,
val volumeRampSeconds: Int = 15,
val alarmRingtoneUri: String? = null,
val timerRingtoneUri: String? = null,
val dismissChallenge: DismissChallenge = DismissChallenge.NONE,
val timerDuration: Duration = 5.minutes,
val homeZoneId: String? = null,
)
@@ -0,0 +1,46 @@
package de.jeanlucmakiola.clockula.domain
import kotlin.time.Duration
enum class StopwatchState { IDLE, RUNNING, PAUSED }
/**
* The stopwatch's persisted run record. Carries **no wall-clock value at all**:
* a stopwatch is monotonic time and nothing else.
*/
data class StopwatchRun(
val state: StopwatchState = StopwatchState.IDLE,
val accumulated: Duration = Duration.ZERO,
val startedAtElapsedRealtime: Duration? = null,
val lastLapCumulative: Duration = Duration.ZERO,
)
data class StopwatchSnapshot(
val elapsed: Duration,
val isRunning: Boolean,
val anchorIsStale: Boolean,
)
/** One recorded lap. [index] is 1-based and is the lap's identity. */
data class Lap(
val index: Int,
val split: Duration,
val cumulative: Duration,
)
/** Resolve against the monotonic clock. Never throws. */
fun StopwatchRun.snapshotAt(elapsedRealtime: Duration): StopwatchSnapshot = when {
state == StopwatchState.IDLE -> StopwatchSnapshot(Duration.ZERO, false, anchorIsStale = false)
state == StopwatchState.PAUSED -> StopwatchSnapshot(accumulated, false, anchorIsStale = false)
// RUNNING from here: no anchor, or a monotonic clock that has gone backwards,
// both mean the current segment is unknowable. Discard it rather than guess —
// a stopwatch has no wall-clock fallback, by design.
startedAtElapsedRealtime == null -> StopwatchSnapshot(accumulated, false, anchorIsStale = true)
elapsedRealtime < startedAtElapsedRealtime ->
StopwatchSnapshot(accumulated, false, anchorIsStale = true)
else -> StopwatchSnapshot(
elapsed = accumulated + (elapsedRealtime - startedAtElapsedRealtime),
isRunning = true,
anchorIsStale = false,
)
}
@@ -0,0 +1,73 @@
package de.jeanlucmakiola.clockula.domain
import kotlin.time.Duration
import kotlin.time.Instant
enum class TimerState { IDLE, RUNNING, PAUSED, EXPIRED }
/**
* One timer as stored. [endsAtElapsedRealtime] is authoritative while running;
* [endsAtWallClock] is the post-reboot fallback only.
*/
data class Timer(
val id: Long,
val label: String,
val duration: Duration,
val state: TimerState,
val remaining: Duration,
val startedAtElapsedRealtime: Duration?,
val endsAtElapsedRealtime: Duration?,
val endsAtWallClock: Instant?,
val ringtoneUri: String?,
val sortOrder: Int,
val createdAt: Instant,
val updatedAt: Instant,
)
data class TimerDraft(
val duration: Duration,
val label: String = "",
val ringtoneUri: String? = null,
)
/** What a [Timer] reads as *right now*. [remaining] is never negative. */
data class TimerSnapshot(
val remaining: Duration,
val isRunning: Boolean,
val hasExpired: Boolean,
val anchorIsStale: Boolean,
)
/**
* Resolve this timer against both clocks. [wallClock] is consulted **only**
* when the elapsed-realtime anchor is stale — i.e. after a reboot. Never throws.
*/
fun Timer.snapshotAt(elapsedRealtime: Duration, wallClock: Instant): TimerSnapshot {
if (state != TimerState.RUNNING) {
return when (state) {
TimerState.EXPIRED -> TimerSnapshot(Duration.ZERO, false, hasExpired = true, anchorIsStale = false)
else -> TimerSnapshot(remaining, false, hasExpired = false, anchorIsStale = false)
}
}
val startedAt = startedAtElapsedRealtime
val endsAt = endsAtElapsedRealtime
// A row that says RUNNING without both anchors is corrupt, not stale-by-reboot;
// read it as whatever it last banked rather than throwing inside a list.
if (startedAt == null || endsAt == null) {
return TimerSnapshot(remaining, false, hasExpired = remaining == Duration.ZERO, anchorIsStale = true)
}
// Staleness is decided monotonically: elapsedRealtime() never decreases within
// one boot, so only a reboot can put it behind the start anchor. A user moving
// the system clock therefore cannot make a running timer look stale.
if (elapsedRealtime < startedAt) {
val endsAtWall = endsAtWallClock
?: return TimerSnapshot(Duration.ZERO, false, hasExpired = true, anchorIsStale = true)
val left = (endsAtWall - wallClock).coerceAtLeast(Duration.ZERO)
return TimerSnapshot(left, left > Duration.ZERO, left == Duration.ZERO, anchorIsStale = true)
}
val left = (endsAt - elapsedRealtime).coerceAtLeast(Duration.ZERO)
return TimerSnapshot(left, left > Duration.ZERO, left == Duration.ZERO, anchorIsStale = false)
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.clockula.domain
import java.time.ZoneId
data class WorldClock(
val id: Long,
val zoneId: String,
val label: String?,
val sortOrder: Int,
)
/** False for a row whose zone id no longer exists in the device's tzdata. */
val WorldClock.isKnownZone: Boolean get() = Zones.isValid(zoneId)
/** IANA zone-id validation. Shared with M9's intent-extra validation. */
object Zones {
/** True only for region-based IANA ids the device knows; rejects blanks and fixed offsets. */
fun isValid(zoneId: String): Boolean = zoneId in ZoneId.getAvailableZoneIds()
/** Trimmed id if [isValid] after trimming, else null. Never throws. */
fun normalise(zoneId: String?): String? = zoneId?.trim()?.takeIf(::isValid)
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.clockula.domain.time
import kotlin.time.Duration
import kotlin.time.Instant
/**
* Wall-clock time: what the user's calendar says. Moves when the user or the
* network changes the system time, and across DST. Alarms and record
* timestamps use this — never a running timer.
*/
interface WallClock {
fun now(): Instant
}
/**
* Monotonic milliseconds since boot (`SystemClock.elapsedRealtime()`), as a
* Duration. Never decreases within one boot and is immune to system-clock
* changes; resets to zero on reboot. Running timers and the stopwatch use this.
*/
interface ElapsedRealtimeClock {
fun elapsedRealtime(): Duration
}
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.clockula.domain
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.testing.T0
import org.junit.jupiter.api.Test
/**
* Per-alarm settings are nullable *overrides*: null means "inherit the app
* default", so changing a default later reaches every alarm the user never
* customised.
*/
class AlarmSettingsTest {
private val defaults = ClockDefaults(
snoozeMinutes = 7,
snoozeLimit = 2,
vibrate = false,
volumeRampSeconds = 30,
alarmRingtoneUri = "content://default",
dismissChallenge = DismissChallenge.HOLD,
)
private fun alarm(
ringtoneUri: String? = null,
vibrate: Boolean? = null,
snoozeMinutes: Int? = null,
snoozeLimit: Int? = null,
volumeRampSeconds: Int? = null,
dismissChallenge: DismissChallenge? = null,
) = Alarm(
id = 1L,
time = TimeOfDay(7, 0),
label = "",
enabled = true,
repeatDays = RepeatDays.NONE,
skipNextOccurrence = false,
ringtoneUri = ringtoneUri,
vibrate = vibrate,
snoozeMinutes = snoozeMinutes,
snoozeLimit = snoozeLimit,
volumeRampSeconds = volumeRampSeconds,
dismissChallenge = dismissChallenge,
createdAt = T0,
updatedAt = T0,
)
@Test
fun `an alarm with no overrides inherits every default`() {
val settings = alarm().resolveSettings(defaults)
assertThat(settings).isEqualTo(
AlarmSettings(
ringtoneUri = "content://default",
vibrate = false,
snoozeMinutes = 7,
snoozeLimit = 2,
volumeRampSeconds = 30,
dismissChallenge = DismissChallenge.HOLD,
),
)
}
@Test
fun `each override wins over its default`() {
val alarm = alarm(
ringtoneUri = "content://mine",
vibrate = true,
snoozeMinutes = 1,
snoozeLimit = 9,
volumeRampSeconds = 0,
dismissChallenge = DismissChallenge.MATH,
)
val settings = alarm.resolveSettings(defaults)
assertThat(settings).isEqualTo(
AlarmSettings(
ringtoneUri = "content://mine",
vibrate = true,
snoozeMinutes = 1,
snoozeLimit = 9,
volumeRampSeconds = 0,
dismissChallenge = DismissChallenge.MATH,
),
)
}
@Test
fun `a null ringtone over a null default stays null`() {
val settings = alarm(ringtoneUri = null).resolveSettings(ClockDefaults())
assertThat(settings.ringtoneUri).isNull()
}
@Test
fun `a false vibrate override is not treated as absent`() {
val settings = alarm(vibrate = false).resolveSettings(ClockDefaults(vibrate = true))
assertThat(settings.vibrate).isFalse()
}
}
@@ -0,0 +1,110 @@
package de.jeanlucmakiola.clockula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.DayOfWeek
/**
* The repeat mask: bit `n` is ISO day `n + 1`, so Monday is bit 0 and Sunday
* bit 6. M9 translates to the platform's Calendar constants at the intent
* boundary — storage never does.
*/
class RepeatDaysTest {
@Test
fun `NONE repeats on no day`() {
val days = RepeatDays.NONE
assertThat(days.mask).isEqualTo(0)
assertThat(days.days).isEmpty()
assertThat(days.isRepeating).isFalse()
}
@Test
fun `Monday is bit zero`() {
assertThat(RepeatDays.of(DayOfWeek.MONDAY).mask).isEqualTo(1)
}
@Test
fun `Sunday is bit six`() {
assertThat(RepeatDays.of(DayOfWeek.SUNDAY).mask).isEqualTo(64)
}
@Test
fun `EVERY_DAY is all seven days`() {
val days = RepeatDays.EVERY_DAY
assertThat(days.mask).isEqualTo(127)
assertThat(days.days).containsExactlyElementsIn(DayOfWeek.entries)
}
@Test
fun `WEEKDAYS is Monday to Friday`() {
val days = RepeatDays.WEEKDAYS
assertThat(days.mask).isEqualTo(31)
assertThat(days.days)
.containsExactly(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY,
)
.inOrder()
}
@Test
fun `WEEKENDS is Saturday and Sunday`() {
val days = RepeatDays.WEEKENDS
assertThat(days.mask).isEqualTo(96)
assertThat(days.days).containsExactly(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY).inOrder()
}
@Test
fun `every mask round-trips through its day set`() {
val roundTripped = (0..RepeatDays.ALL_MASK).map { RepeatDays.of(RepeatDays.fromMask(it).days).mask }
assertThat(roundTripped).containsExactlyElementsIn(0..RepeatDays.ALL_MASK).inOrder()
}
@Test
fun `days come back in Monday-first order`() {
val days = RepeatDays.of(DayOfWeek.SUNDAY, DayOfWeek.WEDNESDAY, DayOfWeek.MONDAY)
assertThat(days.days.toList())
.containsExactly(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.SUNDAY)
.inOrder()
}
@Test
fun `a stored mask with high bits set is sanitised`() {
val days = RepeatDays.fromMask(0b1_0000_0001)
assertThat(days.mask).isEqualTo(1)
assertThat(days.days).containsExactly(DayOfWeek.MONDAY)
}
@Test
fun `a negative stored mask keeps only the seven day bits`() {
val days = RepeatDays.fromMask(-1)
assertThat(days.mask).isEqualTo(127)
assertThat(days.days).hasSize(7)
}
@Test
fun `adding, removing and testing a day`() {
val monday = RepeatDays.of(DayOfWeek.MONDAY)
assertThat(DayOfWeek.MONDAY in monday).isTrue()
assertThat(DayOfWeek.TUESDAY in monday).isFalse()
assertThat((monday + DayOfWeek.TUESDAY).days)
.containsExactly(DayOfWeek.MONDAY, DayOfWeek.TUESDAY)
.inOrder()
assertThat(monday + DayOfWeek.MONDAY).isEqualTo(monday)
assertThat(monday - DayOfWeek.TUESDAY).isEqualTo(monday)
assertThat(monday - DayOfWeek.MONDAY).isEqualTo(RepeatDays.NONE)
}
}
@@ -0,0 +1,85 @@
package de.jeanlucmakiola.clockula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* The stopwatch is monotonic time and nothing else: it has no wall-clock
* fallback, so a run that spans a reboot discards the segment it cannot know.
*/
class StopwatchSnapshotTest {
private fun running(startedAtElapsedRealtime: Duration? = 1_000.seconds) = StopwatchRun(
state = StopwatchState.RUNNING,
accumulated = 42.seconds,
startedAtElapsedRealtime = startedAtElapsedRealtime,
)
@Test
fun `a fresh stopwatch reads zero`() {
val run = StopwatchRun()
val snapshot = run.snapshotAt(500.seconds)
assertThat(snapshot).isEqualTo(
StopwatchSnapshot(Duration.ZERO, isRunning = false, anchorIsStale = false),
)
}
@Test
fun `a paused stopwatch reads its accumulated time`() {
val run = StopwatchRun(state = StopwatchState.PAUSED, accumulated = 42.seconds)
val snapshot = run.snapshotAt(9_999.seconds)
assertThat(snapshot).isEqualTo(
StopwatchSnapshot(42.seconds, isRunning = false, anchorIsStale = false),
)
}
@Test
fun `a running stopwatch adds the current segment`() {
val run = running()
val snapshot = run.snapshotAt(1_010.seconds)
assertThat(snapshot).isEqualTo(
StopwatchSnapshot(52.seconds, isRunning = true, anchorIsStale = false),
)
}
@Test
fun `exactly at the segment start it runs at the accumulated value`() {
val run = running()
val snapshot = run.snapshotAt(1_000.seconds)
assertThat(snapshot).isEqualTo(
StopwatchSnapshot(42.seconds, isRunning = true, anchorIsStale = false),
)
}
@Test
fun `after a reboot the lost segment is discarded`() {
val run = running()
val snapshot = run.snapshotAt(5.seconds)
assertThat(snapshot).isEqualTo(
StopwatchSnapshot(42.seconds, isRunning = false, anchorIsStale = true),
)
}
@Test
fun `a running record with no start anchor degrades instead of throwing`() {
val run = running(startedAtElapsedRealtime = null)
val snapshot = run.snapshotAt(1_010.seconds)
assertThat(snapshot).isEqualTo(
StopwatchSnapshot(42.seconds, isRunning = false, anchorIsStale = true),
)
}
}
@@ -0,0 +1,46 @@
package de.jeanlucmakiola.clockula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
/**
* A [TimeOfDay] is always valid once built; [TimeOfDay.clamped] is the forgiving
* door that a stored row or an M9 intent extra comes through.
*/
class TimeOfDayTest {
@Test
fun `a valid time holds its values`() {
val time = TimeOfDay(7, 30)
assertThat(time.hour).isEqualTo(7)
assertThat(time.minute).isEqualTo(30)
assertThat(time.minutesOfDay).isEqualTo(450)
}
@Test
fun `midnight and the last minute of the day are valid`() {
assertThat(TimeOfDay(0, 0).minutesOfDay).isEqualTo(0)
assertThat(TimeOfDay(23, 59).minutesOfDay).isEqualTo(1439)
}
@Test
fun `an out-of-range hour is rejected`() {
assertThrows<IllegalArgumentException> { TimeOfDay(24, 0) }
assertThrows<IllegalArgumentException> { TimeOfDay(-1, 0) }
}
@Test
fun `an out-of-range minute is rejected`() {
assertThrows<IllegalArgumentException> { TimeOfDay(0, 60) }
assertThrows<IllegalArgumentException> { TimeOfDay(0, -1) }
}
@Test
fun `clamped never throws`() {
assertThat(TimeOfDay.clamped(99, 99)).isEqualTo(TimeOfDay(23, 59))
assertThat(TimeOfDay.clamped(-5, -5)).isEqualTo(TimeOfDay(0, 0))
assertThat(TimeOfDay.clamped(7, 30)).isEqualTo(TimeOfDay(7, 30))
}
}
@@ -0,0 +1,198 @@
package de.jeanlucmakiola.clockula.domain
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.testing.T0
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* The wall-clock / elapsed-realtime distinction, resolved. A running timer
* counts down on the monotonic clock and consults the wall clock only after a
* reboot — docs/PLAN.md §5 calls this the single easiest thing to get wrong.
*/
class TimerSnapshotTest {
private fun timer(
state: TimerState = TimerState.RUNNING,
remaining: Duration = 5.minutes,
startedAtElapsedRealtime: Duration? = 1_000.seconds,
endsAtElapsedRealtime: Duration? = 1_300.seconds,
endsAtWallClock: Instant? = T0 + 300.seconds,
) = Timer(
id = 1L,
label = "Pasta",
duration = 5.minutes,
state = state,
remaining = remaining,
startedAtElapsedRealtime = startedAtElapsedRealtime,
endsAtElapsedRealtime = endsAtElapsedRealtime,
endsAtWallClock = endsAtWallClock,
ringtoneUri = null,
sortOrder = 0,
createdAt = T0,
updatedAt = T0,
)
private fun idleTimer(state: TimerState, remaining: Duration) = timer(
state = state,
remaining = remaining,
startedAtElapsedRealtime = null,
endsAtElapsedRealtime = null,
endsAtWallClock = null,
)
@Test
fun `an idle timer reads its stored remaining time`() {
val timer = idleTimer(TimerState.IDLE, 5.minutes)
val snapshot = timer.snapshotAt(9_999.seconds, T0)
assertThat(snapshot).isEqualTo(
TimerSnapshot(5.minutes, isRunning = false, hasExpired = false, anchorIsStale = false),
)
}
@Test
fun `a paused timer reads its stored remaining time`() {
val timer = idleTimer(TimerState.PAUSED, 90.seconds)
val snapshot = timer.snapshotAt(9_999.seconds, T0)
assertThat(snapshot).isEqualTo(
TimerSnapshot(90.seconds, isRunning = false, hasExpired = false, anchorIsStale = false),
)
}
@Test
fun `an expired timer reads zero`() {
val timer = idleTimer(TimerState.EXPIRED, 0.seconds)
val snapshot = timer.snapshotAt(1.seconds, T0)
assertThat(snapshot).isEqualTo(
TimerSnapshot(Duration.ZERO, isRunning = false, hasExpired = true, anchorIsStale = false),
)
}
@Test
fun `a running timer counts down on the monotonic clock`() {
val timer = timer()
val snapshot = timer.snapshotAt(1_100.seconds, T0)
assertThat(snapshot).isEqualTo(
TimerSnapshot(200.seconds, isRunning = true, hasExpired = false, anchorIsStale = false),
)
}
@Test
fun `moving the system clock forward does not warp a running timer`() {
val timer = timer()
val snapshot = timer.snapshotAt(1_100.seconds, T0 + 3.hours)
assertThat(snapshot).isEqualTo(timer.snapshotAt(1_100.seconds, T0))
assertThat(snapshot.remaining).isEqualTo(200.seconds)
}
@Test
fun `moving the system clock backward does not warp a running timer`() {
val timer = timer()
val snapshot = timer.snapshotAt(1_100.seconds, T0 - 3.hours)
assertThat(snapshot).isEqualTo(timer.snapshotAt(1_100.seconds, T0))
assertThat(snapshot.remaining).isEqualTo(200.seconds)
}
@Test
fun `exactly at its end instant a running timer has expired`() {
val timer = timer()
val snapshot = timer.snapshotAt(1_300.seconds, T0)
assertThat(snapshot).isEqualTo(
TimerSnapshot(Duration.ZERO, isRunning = false, hasExpired = true, anchorIsStale = false),
)
}
@Test
fun `past its end instant the remaining time clamps at zero`() {
val timer = timer()
val snapshot = timer.snapshotAt(2_000.seconds, T0)
assertThat(snapshot.remaining).isEqualTo(Duration.ZERO)
assertThat(snapshot.remaining.isNegative()).isFalse()
assertThat(snapshot.hasExpired).isTrue()
}
@Test
fun `exactly at its start instant the anchor is not stale`() {
val timer = timer()
val snapshot = timer.snapshotAt(1_000.seconds, T0)
assertThat(snapshot.anchorIsStale).isFalse()
assertThat(snapshot.remaining).isEqualTo(300.seconds)
}
@Test
fun `after a reboot a running timer falls back to the wall clock`() {
val timer = timer()
val snapshot = timer.snapshotAt(30.seconds, T0 + 100.seconds)
assertThat(snapshot).isEqualTo(
TimerSnapshot(200.seconds, isRunning = true, hasExpired = false, anchorIsStale = true),
)
}
@Test
fun `after a reboot past the wall-clock end the timer has expired`() {
val timer = timer()
val snapshot = timer.snapshotAt(30.seconds, T0 + 400.seconds)
assertThat(snapshot).isEqualTo(
TimerSnapshot(Duration.ZERO, isRunning = false, hasExpired = true, anchorIsStale = true),
)
}
@Test
fun `a stale anchor with no wall-clock fallback reads as expired`() {
val timer = timer(endsAtWallClock = null)
val snapshot = timer.snapshotAt(30.seconds, T0)
assertThat(snapshot).isEqualTo(
TimerSnapshot(Duration.ZERO, isRunning = false, hasExpired = true, anchorIsStale = true),
)
}
@Test
fun `a running row with no end anchor degrades instead of throwing`() {
val timer = timer(remaining = 42.seconds, endsAtElapsedRealtime = null)
val snapshot = timer.snapshotAt(1_100.seconds, T0)
assertThat(snapshot).isEqualTo(
TimerSnapshot(42.seconds, isRunning = false, hasExpired = false, anchorIsStale = true),
)
}
@Test
fun `a running row with no start anchor degrades instead of throwing`() {
val timer = timer(remaining = 42.seconds, startedAtElapsedRealtime = null)
val snapshot = timer.snapshotAt(1_100.seconds, T0)
assertThat(snapshot).isEqualTo(
TimerSnapshot(42.seconds, isRunning = false, hasExpired = false, anchorIsStale = true),
)
}
}
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.clockula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* IANA zone ids, not a bespoke city table. The same validation M9 will run over
* a hostile intent extra.
*/
class ZonesTest {
@Test
fun `a real region id is valid`() {
assertThat(Zones.isValid("Europe/Berlin")).isTrue()
assertThat(Zones.isValid("America/New_York")).isTrue()
assertThat(Zones.isValid("Pacific/Auckland")).isTrue()
}
@Test
fun `UTC is valid`() {
assertThat(Zones.isValid("UTC")).isTrue()
}
@Test
fun `a blank id is invalid`() {
assertThat(Zones.isValid("")).isFalse()
assertThat(Zones.isValid(" ")).isFalse()
}
@Test
fun `an unknown zone is invalid`() {
assertThat(Zones.isValid("Mars/Olympus_Mons")).isFalse()
}
@Test
fun `a fixed offset is not an IANA zone`() {
assertThat(Zones.isValid("+02:00")).isFalse()
assertThat(Zones.isValid("GMT+2")).isFalse()
}
@Test
fun `normalise trims surrounding whitespace`() {
assertThat(Zones.normalise(" Europe/Berlin ")).isEqualTo("Europe/Berlin")
}
@Test
fun `normalise returns null for anything invalid`() {
assertThat(Zones.normalise(null)).isNull()
assertThat(Zones.normalise("")).isNull()
assertThat(Zones.normalise("Mars/Phobos")).isNull()
assertThat(Zones.normalise("+02:00")).isNull()
}
}
@@ -0,0 +1,29 @@
package de.jeanlucmakiola.clockula.testing
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlin.time.Duration
import kotlin.time.Instant
/** A wall clock the test moves by hand — including backwards, as a user can. */
class FakeWallClock(var instant: Instant = T0) : WallClock {
override fun now(): Instant = instant
fun advance(by: Duration) {
instant += by
}
}
/** A monotonic clock the test moves by hand. [reboot] is the only way it goes back. */
class FakeElapsedRealtimeClock(var value: Duration = Duration.ZERO) : ElapsedRealtimeClock {
override fun elapsedRealtime(): Duration = value
fun advance(by: Duration) {
value += by
}
/** What a reboot looks like to this clock: back to (near) zero. */
fun reboot(uptime: Duration = Duration.ZERO) {
value = uptime
}
}