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