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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user