feat(domain): next-fire resolution, with skip and snooze watermarks

The resolver is pure: it reads an alarm and its ring state and returns what
should happen, and the engine does every write. Two rules keep re-resolution
honest, and both are load-bearing enough to be worth naming.

The grace window lets a fire that arrives a couple of minutes late still count,
so a device powered on at 07:01 rings its 07:00 alarm. The handled-occurrence
watermark suppresses a candidate only when it is both at-or-before the
watermark and at-or-before now — the second conjunct is what stops a user who
set the clock forward, let an alarm fire, then set it back from having every
future occurrence silenced forever.

Skip needs a watermark of its own, because a bare flag eats a second alarm once
the skipped occurrence has passed. On a one-shot alarm, skipping the only
occurrence is dismissing it, so it disables the alarm instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 16:04:40 +02:00
co-authored by Claude Opus 5
parent 6e40b72c50
commit e471c9b750
2 changed files with 505 additions and 0 deletions
@@ -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<Instant> {
val handled = state.handledOccurrence
return AlarmOccurrences.sequence(alarm.time, alarm.repeatDays, now - fireGrace, zone)
.filterNot { handled != null && it <= handled && it <= now }
}
}
@@ -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)
}
}