diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolver.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolver.kt new file mode 100644 index 0000000..ba6d548 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolver.kt @@ -0,0 +1,145 @@ +package de.jeanlucmakiola.clockula.domain.alarm + +import de.jeanlucmakiola.clockula.domain.Alarm +import java.time.ZoneId +import kotlin.time.Duration +import kotlin.time.Instant + +enum class FireSource { NONE, OCCURRENCE, SNOOZE, RESUMED_RING } + +/** + * What one alarm resolves to, plus the state as it should now be persisted and + * the two instructions that live outside the state table. + */ +data class AlarmResolution( + val alarmId: Long, + val nextFire: Instant?, + val source: FireSource, + val state: AlarmRingState, + val clearSkipFlag: Boolean = false, + val disableAlarm: Boolean = false, +) + +/** + * Pure. Never touches a repository, a clock or `ZoneId.systemDefault()`, so the + * whole of the milestone's behaviour is callable from a plain JUnit test with + * hand-built inputs. + * + * The precedence is fixed and total: disabled, then ringing, then snoozed, then + * the natural occurrence. + */ +object AlarmResolver { + fun resolve( + alarm: Alarm, + state: AlarmRingState, + now: Instant, + zone: ZoneId, + autoSilenceAfter: Duration = AlarmRing.AUTO_SILENCE_AFTER, + fireGrace: Duration = AlarmRing.FIRE_GRACE, + ): AlarmResolution { + // 1. Disabled: every volatile field goes, except the dismissal watermark + // — re-enabling an alarm must not un-suppress an occurrence already rung. + if (!alarm.enabled) { + return AlarmResolution( + alarmId = alarm.id, + nextFire = null, + source = FireSource.NONE, + state = AlarmRingState(alarmId = state.alarmId, handledOccurrence = state.handledOccurrence), + ) + } + + // 2. Ringing. A clock moved backwards under a ringing alarm reads as a + // negative age, which is inside the window: it keeps ringing. + val ringingSince = state.ringingSince + if (ringingSince != null && now - ringingSince < autoSilenceAfter) { + return AlarmResolution( + alarmId = alarm.id, + nextFire = null, + source = FireSource.RESUMED_RING, + state = state, + ) + } + var working = if (ringingSince == null) { + state + } else { + // Nobody dismissed it: the cycle closes as missed and we fall through. + state.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0) + } + + // 3. Snooze. A snooze promised before a reboot is kept, even if it came + // due while the device was off; one older than the ring window is gone. + val snoozedUntil = working.snoozedUntil + val snoozePending = snoozedUntil != null && now - snoozedUntil < autoSilenceAfter + if (snoozedUntil != null && !snoozePending) { + working = working.copy(snoozedUntil = null, snoozeCount = 0) + } + + if (snoozedUntil != null && snoozePending) { + // Arming a skip is deferred until the snooze cycle ends, so nothing + // skip-related is read or written here. + val snoozing = working + val armed = snoozing.skippedOccurrence + val occurrence = candidates(alarm, snoozing, now, zone, fireGrace) + .first { armed == null || it > armed } + return if (occurrence < snoozedUntil) { + // Defined so the function is total; unreachable for one alarm in practice. + AlarmResolution(alarm.id, occurrence, FireSource.OCCURRENCE, working) + } else { + AlarmResolution(alarm.id, snoozedUntil, FireSource.SNOOZE, working) + } + } + + // 4. The natural occurrence, with the skip watermark on top. + if (!alarm.skipNextOccurrence) { + return AlarmResolution( + alarmId = alarm.id, + nextFire = candidates(alarm, working, now, zone, fireGrace).first(), + source = FireSource.OCCURRENCE, + state = working.copy(skippedOccurrence = null), + ) + } + + // A one-shot alarm has exactly one occurrence, so skipping it is + // dismissing it in advance (D5). + if (!alarm.repeatDays.isRepeating) { + return AlarmResolution( + alarmId = alarm.id, + nextFire = null, + source = FireSource.NONE, + state = working.copy(skippedOccurrence = null), + clearSkipFlag = true, + disableAlarm = true, + ) + } + + val watermark = working.skippedOccurrence ?: candidates(alarm, working, now, zone, fireGrace).first() + val consumed = watermark <= now + return AlarmResolution( + alarmId = alarm.id, + // Always the candidate *after* the watermark, so a skipped occurrence + // cannot ring on its way out through the grace window. + nextFire = candidates(alarm, working, now, zone, fireGrace).first { it > watermark }, + source = FireSource.OCCURRENCE, + state = working.copy(skippedOccurrence = watermark.takeUnless { consumed }), + clearSkipFlag = consumed, + ) + } + + /** + * The eligible occurrences, in order. The grace window lets a late fire + * count; the watermark suppresses an occurrence already handled, but only + * while it is still in the past — so a clock moved backwards can never + * silence the future. + */ + private fun candidates( + alarm: Alarm, + state: AlarmRingState, + now: Instant, + zone: ZoneId, + fireGrace: Duration, + ): Sequence { + val handled = state.handledOccurrence + return AlarmOccurrences.sequence(alarm.time, alarm.repeatDays, now - fireGrace, zone) + .filterNot { handled != null && it <= handled && it <= now } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolverTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolverTest.kt new file mode 100644 index 0000000..960cb2a --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/AlarmResolverTest.kt @@ -0,0 +1,360 @@ +package de.jeanlucmakiola.clockula.domain.alarm + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.Alarm +import de.jeanlucmakiola.clockula.domain.RepeatDays +import de.jeanlucmakiola.clockula.testing.BERLIN +import de.jeanlucmakiola.clockula.testing.alarmAt +import de.jeanlucmakiola.clockula.testing.instantAt +import de.jeanlucmakiola.clockula.testing.offsetInstant +import de.jeanlucmakiola.clockula.testing.ringState +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 function the milestone lives or dies on: pure, total, and called here + * with hand-built inputs and no fakes at all. + */ +class AlarmResolverTest { + + private val morning: Instant = instantAt(BERLIN, "2026-06-10T06:00") + + private fun resolve( + alarm: Alarm, + state: AlarmRingState = ringState(alarmId = alarm.id), + now: Instant = morning, + fireGrace: Duration = AlarmRing.FIRE_GRACE, + ): AlarmResolution = + AlarmResolver.resolve(alarm, state, now, BERLIN, AlarmRing.AUTO_SILENCE_AFTER, fireGrace) + + // --- T32–T34: the disabled and plain cases --- + + @Test + fun `a disabled alarm clears its volatile state but keeps the dismissal watermark`() { + val alarm = alarmAt(hour = 7, enabled = false, repeatDays = RepeatDays.EVERY_DAY) + val handled = offsetInstant("2026-06-10T05:00+02:00") + val state = ringState( + snoozedUntil = morning + 5.minutes, + snoozeCount = 2, + ringingSince = morning - 1.minutes, + handledOccurrence = handled, + skippedOccurrence = morning + 1.hours, + ) + + val resolution = resolve(alarm, state) + + assertThat(resolution).isEqualTo( + AlarmResolution( + alarmId = alarm.id, + nextFire = null, + source = FireSource.NONE, + state = ringState(handledOccurrence = handled), + ), + ) + } + + @Test + fun `an enabled one-shot alarm resolves to its next daily occurrence`() { + val alarm = alarmAt(hour = 7) + + val resolution = resolve(alarm) + + assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-10T07:00+02:00")) + assertThat(resolution.source).isEqualTo(FireSource.OCCURRENCE) + } + + @Test + fun `a weekday alarm resolved on a Saturday lands on Monday`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.WEEKDAYS) + + val resolution = resolve(alarm, now = instantAt(BERLIN, "2026-06-13T12:00")) + + assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-15T07:00+02:00")) + } + + // --- T35–T38: snooze --- + + @Test + fun `a pending snooze is what fires next`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val snooze = morning + 5.minutes + + val resolution = resolve(alarm, ringState(snoozedUntil = snooze, snoozeCount = 1)) + + assertThat(resolution.nextFire).isEqualTo(snooze) + assertThat(resolution.source).isEqualTo(FireSource.SNOOZE) + } + + @Test + fun `a snooze that came due while the device was off is still honoured at its own instant`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val snooze = morning - 1.minutes + + val resolution = resolve(alarm, ringState(snoozedUntil = snooze, snoozeCount = 1)) + + assertThat(resolution.nextFire).isEqualTo(snooze) + assertThat(resolution.source).isEqualTo(FireSource.SNOOZE) + } + + @Test + fun `a snooze older than the auto-silence window is abandoned`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + + val resolution = resolve(alarm, ringState(snoozedUntil = morning - 11.minutes, snoozeCount = 2)) + + assertThat(resolution.state.snoozedUntil).isNull() + assertThat(resolution.state.snoozeCount).isEqualTo(0) + assertThat(resolution.source).isEqualTo(FireSource.OCCURRENCE) + } + + @Test + fun `an occurrence earlier than the snooze wins while the snooze stays in state`() { + val alarm = alarmAt(hour = 23, minute = 45, repeatDays = RepeatDays.EVERY_DAY) + val now = instantAt(BERLIN, "2026-06-10T23:40") + val snooze = instantAt(BERLIN, "2026-06-10T23:50") + + val resolution = resolve(alarm, ringState(snoozedUntil = snooze, snoozeCount = 1), now = now) + + assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-10T23:45+02:00")) + assertThat(resolution.source).isEqualTo(FireSource.OCCURRENCE) + assertThat(resolution.state.snoozedUntil).isEqualTo(snooze) + } + + // --- T39–T42: ringing --- + + @Test + fun `an alarm ringing inside the window is resumed rather than scheduled`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val state = ringState(ringingSince = morning - 3.minutes, handledOccurrence = morning - 3.minutes) + + val resolution = resolve(alarm, state) + + assertThat(resolution.source).isEqualTo(FireSource.RESUMED_RING) + assertThat(resolution.nextFire).isNull() + assertThat(resolution.state).isEqualTo(state) + } + + @Test + fun `an alarm ringing past the window is auto-dismissed and re-scheduled`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val state = ringState( + ringingSince = morning - 11.minutes, + snoozeCount = 1, + handledOccurrence = morning - 11.minutes, + ) + + val resolution = resolve(alarm, state) + + assertThat(resolution.state.ringingSince).isNull() + assertThat(resolution.state.snoozeCount).isEqualTo(0) + assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-10T07:00+02:00")) + assertThat(resolution.source).isEqualTo(FireSource.OCCURRENCE) + } + + @Test + fun `a ring exactly as old as the auto-silence window has timed out`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val state = ringState(ringingSince = morning - AlarmRing.AUTO_SILENCE_AFTER) + + val resolution = resolve(alarm, state) + + assertThat(resolution.source).isEqualTo(FireSource.OCCURRENCE) + assertThat(resolution.state.ringingSince).isNull() + } + + @Test + fun `a clock moved backwards under a ringing alarm keeps it ringing`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val state = ringState(ringingSince = morning + 30.minutes) + + val resolution = resolve(alarm, state) + + assertThat(resolution.source).isEqualTo(FireSource.RESUMED_RING) + } + + // --- T43–T44, T58: the handled-occurrence watermark --- + + @Test + fun `an occurrence already handled is suppressed while it is still in the past`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val occurrence = offsetInstant("2026-06-10T07:00+02:00") + val now = occurrence + 30.seconds + + val resolution = resolve(alarm, ringState(handledOccurrence = occurrence), now = now) + + assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-11T07:00+02:00")) + } + + @Test + fun `a watermark in the future suppresses nothing, so a backwards clock cannot silence the future`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val occurrence = offsetInstant("2026-06-10T07:00+02:00") + + val resolution = resolve(alarm, ringState(handledOccurrence = occurrence), now = morning) + + assertThat(resolution.nextFire).isEqualTo(occurrence) + } + + @Test + fun `suppression scans past every handled candidate rather than giving up`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val now = instantAt(BERLIN, "2026-06-10T08:00") + val handled = offsetInstant("2026-06-10T07:00+02:00") + + val resolution = resolve(alarm, ringState(handledOccurrence = handled), now = now, fireGrace = 72.hours) + + assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-11T07:00+02:00")) + } + + // --- T45–T51, T55: skip --- + + @Test + fun `an unarmed skip arms itself on the first candidate and fires the second`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true) + + val resolution = resolve(alarm) + + assertThat(resolution.state.skippedOccurrence).isEqualTo(offsetInstant("2026-06-10T07:00+02:00")) + assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-11T07:00+02:00")) + assertThat(resolution.clearSkipFlag).isFalse() + } + + @Test + fun `arming a skip is idempotent`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true) + val armed = resolve(alarm) + + val again = resolve(alarm, armed.state) + + assertThat(again).isEqualTo(armed) + } + + @Test + fun `a skip watermark in the future holds, and nothing is rewritten`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true) + val watermark = offsetInstant("2026-06-10T07:00+02:00") + + val resolution = resolve(alarm, ringState(skippedOccurrence = watermark)) + + assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-11T07:00+02:00")) + assertThat(resolution.state.skippedOccurrence).isEqualTo(watermark) + assertThat(resolution.clearSkipFlag).isFalse() + } + + @Test + fun `a skip watermark that has passed is consumed without letting its occurrence ring`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true) + val watermark = offsetInstant("2026-06-10T07:00+02:00") + + val resolution = resolve(alarm, ringState(skippedOccurrence = watermark), now = watermark + 30.seconds) + + assertThat(resolution.clearSkipFlag).isTrue() + assertThat(resolution.state.skippedOccurrence).isNull() + assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-11T07:00+02:00")) + } + + @Test + fun `skipping a one-shot alarm disables it`() { + val alarm = alarmAt(hour = 7, skipNextOccurrence = true) + + val resolution = resolve(alarm) + + assertThat(resolution.nextFire).isNull() + assertThat(resolution.disableAlarm).isTrue() + assertThat(resolution.clearSkipFlag).isTrue() + assertThat(resolution.state.skippedOccurrence).isNull() + } + + @Test + fun `a stale watermark with the flag off is cleared`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = false) + + val resolution = resolve(alarm, ringState(skippedOccurrence = offsetInstant("2026-06-09T07:00+02:00"))) + + assertThat(resolution.state.skippedOccurrence).isNull() + assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-10T07:00+02:00")) + } + + @Test + fun `arming a skip is deferred while a snooze is still pending`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true) + val snooze = morning + 5.minutes + + val resolution = resolve(alarm, ringState(snoozedUntil = snooze, snoozeCount = 1)) + + assertThat(resolution.source).isEqualTo(FireSource.SNOOZE) + assertThat(resolution.nextFire).isEqualTo(snooze) + assertThat(resolution.state.skippedOccurrence).isNull() + } + + @Test + fun `a skip over a spring-forward night arms on the shifted occurrence`() { + val alarm = alarmAt(hour = 2, minute = 30, repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true) + + val resolution = resolve(alarm, now = instantAt(BERLIN, "2026-03-28T12:00")) + + assertThat(resolution.state.skippedOccurrence).isEqualTo(offsetInstant("2026-03-29T03:30+02:00")) + assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-03-30T02:30+02:00")) + } + + // --- T52–T54: the fire-grace window --- + + @Test + fun `an occurrence a minute in the past is still inside the grace window`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val occurrence = offsetInstant("2026-06-10T07:00+02:00") + + val resolution = resolve(alarm, now = occurrence + 60.seconds) + + assertThat(resolution.nextFire).isEqualTo(occurrence) + } + + @Test + fun `an occurrence beyond the grace window is missed, not rung late`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val occurrence = offsetInstant("2026-06-10T07:00+02:00") + + val resolution = resolve(alarm, now = occurrence + 3.minutes) + + assertThat(resolution.nextFire).isEqualTo(offsetInstant("2026-06-11T07:00+02:00")) + } + + @Test + fun `an occurrence exactly at now still counts`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val occurrence = offsetInstant("2026-06-10T07:00+02:00") + + val resolution = resolve(alarm, now = occurrence) + + assertThat(resolution.nextFire).isEqualTo(occurrence) + } + + // --- T56–T57: absolute instants, and purity --- + + @Test + fun `a snooze is an absolute instant that a DST transition cannot move`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY) + val snooze = offsetInstant("2026-03-29T03:45+02:00") + + val resolution = resolve(alarm, ringState(snoozedUntil = snooze, snoozeCount = 1), now = snooze - 5.minutes) + + assertThat(resolution.nextFire).isEqualTo(snooze) + assertThat(resolution.source).isEqualTo(FireSource.SNOOZE) + } + + @Test + fun `resolving twice gives the same answer and leaves the input state untouched`() { + val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.WEEKDAYS, skipNextOccurrence = true) + val state = ringState(snoozeCount = 1, handledOccurrence = morning - 1.hours) + val copy = state.copy() + + val first = resolve(alarm, state) + + assertThat(resolve(alarm, state)).isEqualTo(first) + assertThat(state).isEqualTo(copy) + } +}