fix(alarms): spend a skip at its occurrence again

Keeping a skip armed through its fire grace let the editor's switch or another dismissal inside those two minutes ring the skipped alarm late, or be absorbed. The resolver goes back to spending the skip at its occurrence and recording it as handled, and the upcoming check wakes at an armed skip's occurrence so the list stops saying "skipping next" on time.

Fixes #16
This commit is contained in:
makiolaj committed 2026-10-22 08:07:52 +02:00
1 parent a88e696d52
commit fdb725e64c
6 files changed
+25 -72

No files matched your search

@@ -113,10 +113,7 @@ object AlarmResolver {
}
val watermark = working.skippedOccurrence ?: candidates(alarm, working, now, zone, fireGrace).first()
// Spent only once the skipped occurrence is out of the fire grace too:
// a fire for it can still be accepted until then, and the engine refuses
// that fire by this very watermark (#16).
val consumed = watermark + fireGrace <= now
val consumed = watermark <= now
return AlarmResolution(
alarmId = alarm.id,
// Always the candidate *after* the watermark, so a skipped occurrence
@@ -51,12 +51,13 @@ object UpcomingNotices {
* - a window opening, so its notice goes up;
* - a noticed occurrence's fire grace running out, so a notice whose fire
* came late (Doze, no exact alarms) does not outlive its alarm;
* - an armed skip's ([pendingSkips]) fire grace running out, so a skip
* dismissed in advance is spent on time and the list stops saying
* "skipping next" about an occurrence that has gone by.
* - an armed skip's ([pendingSkips]) occurrence, so a skip dismissed in
* advance is spent on time and the list stops saying "skipping next"
* about an occurrence that has gone by. Nothing is registered to fire
* at a skipped occurrence, so there is no fire to race.
*
* The last two wait out the grace rather than land on the alarm's own
* instant, so they never race its fire.
* The closing waits out the grace rather than land on the alarm's own
* instant, so it never races that alarm's fire.
*/
fun plan(
upcoming: List<UpcomingAlarm>,
@@ -73,7 +74,7 @@ object UpcomingNotices {
.filter { it.source == FireSource.OCCURRENCE }
.mapNotNull { entry -> entry.nextFire?.let { it - lead } }
val closings = notices.map { it.fireAt + fireGrace }
val skipsSpent = pendingSkips.map { it + fireGrace }
val skipsSpent = pendingSkips
val nextCheckAt = (openings + closings + skipsSpent).filter { it > now }.minOrNull()
return UpcomingNoticePlan(notices, nextCheckAt)
}
@@ -15,7 +15,6 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* The upcoming-alarm notification (#16). The harness starts at 05:00 Berlin, so
@@ -72,9 +71,9 @@ class AlarmEngineUpcomingTest {
assertThat(alarm.skipNextOccurrence).isTrue()
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sixThirtyTomorrow)
assertThat(harness.notifier.upcoming).isEmpty()
// Back once the skipped occurrence has gone by, to spend the skip.
assertThat(harness.scheduler.upcomingCheck).isEqualTo(sixThirty + AlarmRing.FIRE_GRACE)
harness.wallClock.instant = sixThirty + AlarmRing.FIRE_GRACE
// Back at the skipped occurrence, to spend the skip.
assertThat(harness.scheduler.upcomingCheck).isEqualTo(sixThirty)
harness.wallClock.instant = sixThirty
harness.engine.onUpcomingWindow()
assertThat(harness.alarms.find(1L)!!.skipNextOccurrence).isFalse()
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sixThirtyTomorrow)
@@ -190,10 +189,7 @@ class AlarmEngineUpcomingTest {
assertThat(outcome).isEqualTo(FireOutcome.Ignored)
assertThat(harness.ring.events).isEmpty()
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sixThirtyTomorrow)
// The skip is spent on the one occurrence once its grace is over,
// not on tomorrow's.
harness.wallClock.instant = sixThirty + AlarmRing.FIRE_GRACE
harness.engine.onUpcomingWindow()
// The skip is spent on the one occurrence, not on tomorrow's.
assertThat(harness.alarms.find(1L)!!.skipNextOccurrence).isFalse()
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sixThirtyTomorrow)
}
@@ -221,24 +217,6 @@ class AlarmEngineUpcomingTest {
assertThat(harness.engine.dismissUpcomingOccurrence(1L, sixThirty)).isEqualTo(DismissOutcome.NotFound)
}
@Test
fun `a fire for the dismissed occurrence is refused even after another reschedule`(@TempDir tempDir: Path) =
runTest {
val harness = alarmEngineHarness(tempDir)
harness.givenAlarm(id = 1L, hour = 6, minute = 30, repeatDays = RepeatDays.EVERY_DAY)
harness.engine.reschedule()
harness.engine.dismissUpcomingOccurrence(1L, sixThirty)
// Another entry point reschedules inside the fire grace, before the
// fire already on its way is processed: the skip is not spent yet.
harness.wallClock.instant = sixThirty + 30.seconds
harness.engine.reschedule()
val outcome = harness.engine.onFire(1L, sixThirty)
assertThat(outcome).isEqualTo(FireOutcome.Ignored)
assertThat(harness.ring.events).isEmpty()
}
@Test
fun `a handled occurrence a clock moved backwards re-armed still rings`(@TempDir tempDir: Path) = runTest {
val harness = alarmEngineHarness(tempDir)
@@ -250,11 +250,7 @@ class AlarmResolverTest {
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 + AlarmRing.FIRE_GRACE + 30.seconds,
)
val resolution = resolve(alarm, ringState(skippedOccurrence = watermark), now = watermark + 30.seconds)
assertThat(resolution.clearSkipFlag).isTrue()
assertThat(resolution.state.skippedOccurrence).isNull()
@@ -265,36 +261,19 @@ class AlarmResolverTest {
fun `a consumed skip cannot ring on the next pass through the fire grace`() {
val alarm = alarmAt(hour = 7, repeatDays = RepeatDays.EVERY_DAY, skipNextOccurrence = true)
val watermark = offsetInstant("2026-06-10T07:00+02:00")
val first = resolve(
alarm,
ringState(skippedOccurrence = watermark),
now = watermark + AlarmRing.FIRE_GRACE + 30.seconds,
)
val first = resolve(alarm, ringState(skippedOccurrence = watermark), now = watermark + 30.seconds)
// The engine persists the first pass: the flag cleared, the state saved.
val second = resolve(
alarm.copy(skipNextOccurrence = false),
first.state,
now = watermark + AlarmRing.FIRE_GRACE + 40.seconds,
now = watermark + 40.seconds,
)
assertThat(first.state.handledOccurrence).isEqualTo(watermark)
assertThat(second.nextFire).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
}
@Test
fun `a skip stays armed through its occurrence's fire grace`() {
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)
// A fire for it can still arrive; the engine refuses it by this watermark.
assertThat(resolution.clearSkipFlag).isFalse()
assertThat(resolution.state.skippedOccurrence).isEqualTo(watermark)
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)
@@ -103,7 +103,7 @@ class UpcomingNoticesTest {
)
// So the skip is spent then, not at tomorrow's window.
assertThat(plan.nextCheckAt).isEqualTo(skipped + AlarmRing.FIRE_GRACE)
assertThat(plan.nextCheckAt).isEqualTo(skipped)
}
@Test