From a2beae94ec677f6a139f5ce6e38954c169bf92c5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 11 Sep 2026 13:52:02 +0200 Subject: [PATCH] feat(domain): plain-Kotlin models and the two clocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../jeanlucmakiola/clockula/domain/Alarm.kt | 131 ++++++++++++ .../clockula/domain/ClockDefaults.kt | 20 ++ .../clockula/domain/Stopwatch.kt | 46 ++++ .../jeanlucmakiola/clockula/domain/Timer.kt | 73 +++++++ .../clockula/domain/WorldClock.kt | 22 ++ .../clockula/domain/time/Clocks.kt | 22 ++ .../clockula/domain/AlarmSettingsTest.kt | 101 +++++++++ .../clockula/domain/RepeatDaysTest.kt | 110 ++++++++++ .../clockula/domain/StopwatchSnapshotTest.kt | 85 ++++++++ .../clockula/domain/TimeOfDayTest.kt | 46 ++++ .../clockula/domain/TimerSnapshotTest.kt | 198 ++++++++++++++++++ .../clockula/domain/ZonesTest.kt | 53 +++++ .../clockula/testing/TestClocks.kt | 29 +++ 13 files changed, 936 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/Alarm.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/Stopwatch.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/Timer.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/AlarmSettingsTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/RepeatDaysTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/StopwatchSnapshotTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/TimeOfDayTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/TimerSnapshotTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/ZonesTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/testing/TestClocks.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/Alarm.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Alarm.kt new file mode 100644 index 0000000..a42c864 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Alarm.kt @@ -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 + 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): 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, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt new file mode 100644 index 0000000..e7336d3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ClockDefaults.kt @@ -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, +) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/Stopwatch.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Stopwatch.kt new file mode 100644 index 0000000..6e6d6c8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Stopwatch.kt @@ -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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/Timer.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Timer.kt new file mode 100644 index 0000000..ff719aa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/Timer.kt @@ -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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt new file mode 100644 index 0000000..de6f7f4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/WorldClock.kt @@ -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) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt new file mode 100644 index 0000000..dc596aa --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/time/Clocks.kt @@ -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 +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/AlarmSettingsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/AlarmSettingsTest.kt new file mode 100644 index 0000000..03c27c8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/AlarmSettingsTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/RepeatDaysTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/RepeatDaysTest.kt new file mode 100644 index 0000000..5fd38fa --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/RepeatDaysTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/StopwatchSnapshotTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/StopwatchSnapshotTest.kt new file mode 100644 index 0000000..446720b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/StopwatchSnapshotTest.kt @@ -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), + ) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/TimeOfDayTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/TimeOfDayTest.kt new file mode 100644 index 0000000..4fd6714 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/TimeOfDayTest.kt @@ -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 { TimeOfDay(24, 0) } + assertThrows { TimeOfDay(-1, 0) } + } + + @Test + fun `an out-of-range minute is rejected`() { + assertThrows { TimeOfDay(0, 60) } + assertThrows { 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)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/TimerSnapshotTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/TimerSnapshotTest.kt new file mode 100644 index 0000000..47cd6b6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/TimerSnapshotTest.kt @@ -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), + ) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/ZonesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ZonesTest.kt new file mode 100644 index 0000000..2872253 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ZonesTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/testing/TestClocks.kt b/app/src/test/java/de/jeanlucmakiola/clockula/testing/TestClocks.kt new file mode 100644 index 0000000..4afb210 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/testing/TestClocks.kt @@ -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 + } +}