feat(domain): DST-correct occurrence generation over a repeat mask
Occurrences are generated by walking local dates forward and mapping each through ZonedDateTime.of, never by adding 24 hours to an instant. That is the whole DST story: java.time's default resolver shifts a 02:30 alarm forward to 03:30 on a spring-forward night rather than dropping it, and takes the earlier of the two 02:30s on a fall-back night rather than ringing twice. Asserted for Berlin and New York, and across all 128 repeat masks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Local-date iteration into absolute instants. The one place in the app that
|
||||
* knows a DST gap or overlap exists: a next fire time is re-resolved from the
|
||||
* local time-of-day and the zone, never derived by adding millis to the last one
|
||||
* (docs/PLAN.md §4).
|
||||
*/
|
||||
object AlarmOccurrences {
|
||||
|
||||
/**
|
||||
* `ZonedDateTime.of(date, time, zone).toInstant()`, with java.time's default
|
||||
* resolver and no help from us: a gap shifts forward by the gap's own length
|
||||
* (a 02:30 alarm on a spring-forward night rings at 03:30) and an overlap
|
||||
* takes the earlier offset (a fall-back 02:30 rings once, not twice).
|
||||
*/
|
||||
fun at(date: LocalDate, time: TimeOfDay, zone: ZoneId): Instant =
|
||||
ZonedDateTime.of(date, LocalTime.of(time.hour, time.minute), zone)
|
||||
.toInstant()
|
||||
.let { Instant.fromEpochMilliseconds(it.toEpochMilli()) }
|
||||
|
||||
/**
|
||||
* Strictly increasing instants, each strictly after [after]. Infinite for any
|
||||
* mask, including [RepeatDays.NONE], which matches every day — a one-shot
|
||||
* alarm is just "the next daily candidate", and what makes it one-shot is
|
||||
* what happens after it rings.
|
||||
*
|
||||
* Two local dates can collapse onto one instant (a skipped calendar day, a
|
||||
* gap), so a candidate that did not advance is dropped and the caller may
|
||||
* rely on `drop(1)` meaning "the one after".
|
||||
*/
|
||||
fun sequence(
|
||||
time: TimeOfDay,
|
||||
repeatDays: RepeatDays,
|
||||
after: Instant,
|
||||
zone: ZoneId,
|
||||
): Sequence<Instant> = Sequence {
|
||||
iterator {
|
||||
// One day back, because a gap can push a candidate from the previous
|
||||
// local date past [after].
|
||||
var date = after.localDate(zone).minusDays(1)
|
||||
var previous: Instant? = null
|
||||
while (true) {
|
||||
if (!repeatDays.isRepeating || date.dayOfWeek in repeatDays) {
|
||||
val candidate = at(date, time, zone)
|
||||
val last = previous
|
||||
if (candidate > after && (last == null || candidate > last)) {
|
||||
previous = candidate
|
||||
yield(candidate)
|
||||
}
|
||||
}
|
||||
date = date.plusDays(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun next(
|
||||
time: TimeOfDay,
|
||||
repeatDays: RepeatDays,
|
||||
after: Instant,
|
||||
zone: ZoneId,
|
||||
): Instant? = sequence(time, repeatDays, after, zone).firstOrNull()
|
||||
|
||||
private fun Instant.localDate(zone: ZoneId): LocalDate =
|
||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* The ring cycle's constants. They are values rather than settings: v1 does not
|
||||
* offer them to the user (docs/PLAN.md §4, slice plan D8/D19).
|
||||
*/
|
||||
object AlarmRing {
|
||||
/**
|
||||
* How long an undismissed alarm rings before it is silenced as missed.
|
||||
* AOSP DeskClock's default, and the window a resumed ring is measured against.
|
||||
*/
|
||||
val AUTO_SILENCE_AFTER: Duration = 10.minutes
|
||||
|
||||
/**
|
||||
* How late a fire may arrive and still count. A fire delayed by doze, a slow
|
||||
* boot or a `TIME_SET` nudge still rings; one missed by hours does not.
|
||||
*/
|
||||
val FIRE_GRACE: Duration = 2.minutes
|
||||
|
||||
/** How often the ringing service steps the volume ramp. */
|
||||
val RAMP_TICK: Duration = 200.milliseconds
|
||||
|
||||
/** The ramp's floor — an alarm never starts at true silence. */
|
||||
const val MIN_VOLUME: Float = 0.05f
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Volatile per-alarm ring state. Persisted in `alarm_states` (schema v2). */
|
||||
data class AlarmRingState(
|
||||
val alarmId: Long,
|
||||
val snoozedUntil: Instant? = null,
|
||||
val snoozeCount: Int = 0,
|
||||
val ringingSince: Instant? = null,
|
||||
val handledOccurrence: Instant? = null,
|
||||
val skippedOccurrence: Instant? = null,
|
||||
) {
|
||||
val isRinging: Boolean get() = ringingSince != null
|
||||
|
||||
val isSnoozed: Boolean get() = snoozedUntil != null
|
||||
|
||||
companion object {
|
||||
/** The record an alarm with no stored row reads back as. */
|
||||
fun initial(alarmId: Long): AlarmRingState = AlarmRingState(alarmId = alarmId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmSettings
|
||||
import kotlin.time.Instant
|
||||
|
||||
data class UpcomingAlarm(
|
||||
val alarm: Alarm,
|
||||
val nextFire: Instant?,
|
||||
val source: FireSource,
|
||||
)
|
||||
|
||||
data class ScheduledFire(
|
||||
val alarmId: Long,
|
||||
val fireAt: Instant,
|
||||
val source: FireSource,
|
||||
val mode: ScheduleMode,
|
||||
)
|
||||
|
||||
/** Everything the ringing service needs, rebuilt from persisted state after a kill. */
|
||||
data class RingSession(
|
||||
val alarm: Alarm,
|
||||
val settings: AlarmSettings,
|
||||
val startedAt: Instant,
|
||||
val canSnooze: Boolean,
|
||||
)
|
||||
|
||||
sealed interface FireOutcome {
|
||||
data class Ringing(val alarmId: Long) : FireOutcome
|
||||
data object Ignored : FireOutcome
|
||||
}
|
||||
|
||||
sealed interface SnoozeOutcome {
|
||||
data class Snoozed(val alarmId: Long, val until: Instant, val remainingSnoozes: Int) : SnoozeOutcome
|
||||
|
||||
/** The limit was already reached; the alarm was dismissed instead. */
|
||||
data class LimitReached(val alarmId: Long) : SnoozeOutcome
|
||||
data object NotRinging : SnoozeOutcome
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package de.jeanlucmakiola.clockula.domain.alarm
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.testing.APIA
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.LORD_HOWE
|
||||
import de.jeanlucmakiola.clockula.testing.NEW_YORK
|
||||
import de.jeanlucmakiola.clockula.testing.UTC_ZONE
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import de.jeanlucmakiola.clockula.testing.localAt
|
||||
import de.jeanlucmakiola.clockula.testing.offsetAt
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The DST heart of the app. Every expectation here is written as an absolute
|
||||
* instant with the offset it should carry, so an assertion can never restate
|
||||
* the behaviour it is checking.
|
||||
*/
|
||||
class AlarmOccurrencesTest {
|
||||
|
||||
private val daily = RepeatDays.NONE
|
||||
|
||||
private fun nextFor(
|
||||
hour: Int,
|
||||
minute: Int,
|
||||
repeatDays: RepeatDays = daily,
|
||||
after: Instant,
|
||||
zone: ZoneId = BERLIN,
|
||||
): Instant? = AlarmOccurrences.next(TimeOfDay(hour, minute), repeatDays, after, zone)
|
||||
|
||||
private fun take(
|
||||
count: Int,
|
||||
hour: Int,
|
||||
minute: Int,
|
||||
repeatDays: RepeatDays = daily,
|
||||
after: Instant,
|
||||
zone: ZoneId = BERLIN,
|
||||
): List<Instant> =
|
||||
AlarmOccurrences.sequence(TimeOfDay(hour, minute), repeatDays, after, zone).take(count).toList()
|
||||
|
||||
private fun hasGapAt(zone: ZoneId, local: LocalDateTime): Boolean =
|
||||
zone.rules.getTransition(local)?.isGap == true
|
||||
|
||||
// --- T1–T11: the plain calendar cases ---
|
||||
|
||||
@Test
|
||||
fun `next is today when the time of day is still ahead`() {
|
||||
val after = instantAt(BERLIN, "2026-06-10T06:00")
|
||||
|
||||
val next = nextFor(hour = 7, minute = 0, after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-06-10T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next rolls to tomorrow once today's time of day has gone by`() {
|
||||
val after = instantAt(BERLIN, "2026-06-10T08:00")
|
||||
|
||||
val next = nextFor(hour = 7, minute = 0, after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next is strictly after, so an after equal to the occurrence yields the following day`() {
|
||||
val after = offsetInstant("2026-06-10T07:00+02:00")
|
||||
|
||||
val next = nextFor(hour = 7, minute = 0, after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-06-11T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next is today's occurrence when after is one millisecond before it`() {
|
||||
val after = offsetInstant("2026-06-10T07:00+02:00") - 1.milliseconds
|
||||
|
||||
val next = nextFor(hour = 7, minute = 0, after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-06-10T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a weekday alarm asked on a Saturday lands on Monday`() {
|
||||
val after = instantAt(BERLIN, "2026-06-13T12:00") // a Saturday
|
||||
|
||||
val next = nextFor(hour = 7, minute = 0, repeatDays = RepeatDays.WEEKDAYS, after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-06-15T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a weekday alarm asked on a Friday morning skips the weekend`() {
|
||||
val after = instantAt(BERLIN, "2026-06-12T09:00") // a Friday, after 07:00
|
||||
|
||||
val next = nextFor(hour = 7, minute = 0, repeatDays = RepeatDays.WEEKDAYS, after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-06-15T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Sunday-only alarm asked early on a Sunday fires today`() {
|
||||
val after = instantAt(BERLIN, "2026-06-14T06:00") // a Sunday
|
||||
|
||||
val next = nextFor(hour = 7, minute = 0, repeatDays = RepeatDays.of(DayOfWeek.SUNDAY), after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-06-14T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Sunday-only alarm asked after its time waits a full week`() {
|
||||
val after = instantAt(BERLIN, "2026-06-14T08:00")
|
||||
|
||||
val next = nextFor(hour = 7, minute = 0, repeatDays = RepeatDays.of(DayOfWeek.SUNDAY), after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-06-21T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every day yields seven consecutive local days at the same local time`() {
|
||||
val after = instantAt(BERLIN, "2026-06-10T08:00")
|
||||
|
||||
val week = take(7, hour = 7, minute = 0, repeatDays = RepeatDays.EVERY_DAY, after = after)
|
||||
|
||||
assertThat(week.map { it.localAt(BERLIN) }).containsExactly(
|
||||
LocalDateTime.parse("2026-06-11T07:00"),
|
||||
LocalDateTime.parse("2026-06-12T07:00"),
|
||||
LocalDateTime.parse("2026-06-13T07:00"),
|
||||
LocalDateTime.parse("2026-06-14T07:00"),
|
||||
LocalDateTime.parse("2026-06-15T07:00"),
|
||||
LocalDateTime.parse("2026-06-16T07:00"),
|
||||
LocalDateTime.parse("2026-06-17T07:00"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a weekend alarm asked on a Wednesday lands on Saturday`() {
|
||||
val after = instantAt(BERLIN, "2026-06-10T12:00") // a Wednesday
|
||||
|
||||
val next = nextFor(hour = 7, minute = 0, repeatDays = RepeatDays.WEEKENDS, after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-06-13T07:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the full mask resolves identically to EVERY_DAY`() {
|
||||
val after = instantAt(BERLIN, "2026-06-10T08:00")
|
||||
|
||||
val fromMask = nextFor(7, 0, RepeatDays.fromMask(RepeatDays.ALL_MASK), after)
|
||||
|
||||
assertThat(fromMask).isEqualTo(nextFor(7, 0, RepeatDays.EVERY_DAY, after))
|
||||
}
|
||||
|
||||
// --- T12–T13: every repeat configuration ---
|
||||
|
||||
@Test
|
||||
fun `all 128 repeat masks yield increasing, in-mask occurrences at the alarm's local time`() {
|
||||
val time = TimeOfDay(2, 30) // deliberately inside Berlin's spring-forward gap one night
|
||||
val after = instantAt(BERLIN, "2026-03-01T12:00")
|
||||
|
||||
for (mask in 0..RepeatDays.ALL_MASK) {
|
||||
val repeatDays = RepeatDays.fromMask(mask)
|
||||
val occurrences =
|
||||
AlarmOccurrences.sequence(time, repeatDays, after, BERLIN).take(10).toList()
|
||||
|
||||
val locals = occurrences.map { it.localAt(BERLIN) }
|
||||
if (mask == 0) {
|
||||
assertThat(locals.map { it.toLocalDate() })
|
||||
.isEqualTo(generateSequence(locals.first().toLocalDate()) { it.plusDays(1) }.take(10).toList())
|
||||
} else {
|
||||
assertThat(locals.map { it.dayOfWeek }.filterNot { it in repeatDays }).isEmpty()
|
||||
}
|
||||
assertThat(occurrences).isInStrictOrder()
|
||||
assertThat(occurrences.filter { it <= after }).isEmpty()
|
||||
assertThat(
|
||||
locals.filterNot {
|
||||
it.toLocalTime() == LocalTime.of(2, 30) ||
|
||||
hasGapAt(BERLIN, LocalDateTime.of(it.toLocalDate(), LocalTime.of(2, 30)))
|
||||
},
|
||||
).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a long take is strictly increasing with no duplicates`() {
|
||||
val after = instantAt(BERLIN, "2026-01-01T00:00")
|
||||
|
||||
val occurrences = take(30, hour = 2, minute = 30, repeatDays = RepeatDays.EVERY_DAY, after = after)
|
||||
|
||||
assertThat(occurrences).isInStrictOrder()
|
||||
}
|
||||
|
||||
// --- T14–T25: DST ---
|
||||
|
||||
@Test
|
||||
fun `a Berlin 0230 alarm on a spring-forward night rings at 0330 rather than vanishing`() {
|
||||
val after = instantAt(BERLIN, "2026-03-28T12:00")
|
||||
|
||||
val next = nextFor(hour = 2, minute = 30, after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-03-29T03:30+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Berlin 0130 alarm is untouched by the spring-forward gap`() {
|
||||
val after = instantAt(BERLIN, "2026-03-28T12:00")
|
||||
|
||||
val next = nextFor(hour = 1, minute = 30, after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-03-29T01:30+01:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `0230 and 0330 collapse onto the same moment on a spring-forward night`() {
|
||||
val after = instantAt(BERLIN, "2026-03-28T12:00")
|
||||
|
||||
val halfPastThree = nextFor(hour = 3, minute = 30, after = after)
|
||||
|
||||
assertThat(halfPastThree).isEqualTo(offsetInstant("2026-03-29T03:30+02:00"))
|
||||
assertThat(halfPastThree).isEqualTo(nextFor(hour = 2, minute = 30, after = after))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Berlin 0230 alarm on a fall-back night takes the earlier of the two 0230s`() {
|
||||
val after = instantAt(BERLIN, "2026-10-24T12:00")
|
||||
|
||||
val next = nextFor(hour = 2, minute = 30, after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-10-25T02:30+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the fall-back 0230 is emitted exactly once`() {
|
||||
val after = instantAt(BERLIN, "2026-10-24T12:00")
|
||||
|
||||
val occurrences = take(2, hour = 2, minute = 30, after = after)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
offsetInstant("2026-10-25T02:30+02:00"),
|
||||
offsetInstant("2026-10-26T02:30+01:00"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wall time is preserved across a transition even though the instant interval is not`() {
|
||||
val spring = take(2, hour = 12, minute = 0, after = instantAt(BERLIN, "2026-03-28T11:00"))
|
||||
val fall = take(2, hour = 12, minute = 0, after = instantAt(BERLIN, "2026-10-24T11:00"))
|
||||
|
||||
assertThat(spring[1] - spring[0]).isEqualTo(23.hours)
|
||||
assertThat(fall[1] - fall[0]).isEqualTo(25.hours)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a New York 0230 alarm on a spring-forward night resolves to 0330 minus four`() {
|
||||
val after = instantAt(NEW_YORK, "2026-03-07T12:00")
|
||||
|
||||
val next = nextFor(hour = 2, minute = 30, after = after, zone = NEW_YORK)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-03-08T03:30-04:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a New York 0130 alarm on a fall-back night takes the earlier offset`() {
|
||||
val after = instantAt(NEW_YORK, "2026-10-31T12:00")
|
||||
|
||||
val occurrences = take(2, hour = 1, minute = 30, after = after, zone = NEW_YORK)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
offsetInstant("2026-11-01T01:30-04:00"),
|
||||
offsetInstant("2026-11-02T01:30-05:00"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a thirty-minute gap shifts the alarm by thirty minutes, not by an hour`() {
|
||||
val after = instantAt(LORD_HOWE, "2026-10-03T12:00")
|
||||
|
||||
val next = nextFor(hour = 2, minute = 15, after = after, zone = LORD_HOWE)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-10-04T02:45+11:00"))
|
||||
assertThat(next!!.localAt(LORD_HOWE)).isEqualTo(LocalDateTime.parse("2026-10-04T02:45"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a skipped calendar day collapses onto the next day without emitting it twice`() {
|
||||
val after = offsetInstant("2011-12-29T20:00-10:00")
|
||||
|
||||
val occurrences = take(2, hour = 7, minute = 0, after = after, zone = APIA)
|
||||
|
||||
assertThat(occurrences).containsExactly(
|
||||
offsetInstant("2011-12-31T07:00+14:00"),
|
||||
offsetInstant("2012-01-01T07:00+14:00"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in UTC every interval is exactly twenty-four hours`() {
|
||||
val after = instantAt(UTC_ZONE, "2026-01-01T00:00")
|
||||
|
||||
val occurrences = take(400, hour = 7, minute = 0, after = after, zone = UTC_ZONE)
|
||||
|
||||
assertThat(occurrences.zipWithNext { a, b -> b - a }.distinct()).containsExactly(24.hours)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same alarm re-resolved in another zone is another moment`() {
|
||||
val after = instantAt(UTC_ZONE, "2026-06-10T00:00")
|
||||
|
||||
val berlin = nextFor(hour = 7, minute = 0, after = after, zone = BERLIN)!!
|
||||
val newYork = nextFor(hour = 7, minute = 0, after = after, zone = NEW_YORK)!!
|
||||
|
||||
val offsetDifference =
|
||||
(newYork.offsetAt(NEW_YORK).totalSeconds - berlin.offsetAt(BERLIN).totalSeconds).toLong()
|
||||
assertThat((newYork - berlin).inWholeSeconds).isEqualTo(-offsetDifference)
|
||||
}
|
||||
|
||||
// --- T26–T31: boundaries ---
|
||||
|
||||
@Test
|
||||
fun `the scan terminates within a week for a single-day mask`() {
|
||||
val epoch = Instant.fromEpochMilliseconds(0L)
|
||||
|
||||
val next = nextFor(
|
||||
hour = 7,
|
||||
minute = 0,
|
||||
repeatDays = RepeatDays.of(DayOfWeek.MONDAY),
|
||||
after = epoch,
|
||||
zone = UTC_ZONE,
|
||||
)
|
||||
|
||||
assertThat(next!! - epoch).isLessThan(24.hours * 8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no emitted occurrence is at or before after`() {
|
||||
val after = instantAt(BERLIN, "2026-03-15T13:37")
|
||||
|
||||
val occurrences = take(50, hour = 13, minute = 37, repeatDays = RepeatDays.EVERY_DAY, after = after)
|
||||
|
||||
assertThat(occurrences.filter { it <= after }).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a midnight alarm asked a millisecond before midnight fires at the coming midnight`() {
|
||||
val after = offsetInstant("2026-06-11T00:00+02:00") - 1.milliseconds
|
||||
|
||||
val next = nextFor(hour = 0, minute = 0, after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-06-11T00:00+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 2359 alarm asked exactly at 2359 waits for tomorrow`() {
|
||||
val after = offsetInstant("2026-06-10T23:59+02:00")
|
||||
|
||||
val next = nextFor(hour = 23, minute = 59, after = after)
|
||||
|
||||
assertThat(next).isEqualTo(offsetInstant("2026-06-11T23:59+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the gap primitive maps 0230 and 0330 to the same instant`() {
|
||||
val springForward = LocalDate.parse("2026-03-29")
|
||||
|
||||
val halfPastTwo = AlarmOccurrences.at(springForward, TimeOfDay(2, 30), BERLIN)
|
||||
|
||||
assertThat(halfPastTwo).isEqualTo(AlarmOccurrences.at(springForward, TimeOfDay(3, 30), BERLIN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `iterating one sequence twice yields the same elements both times`() {
|
||||
val after = instantAt(BERLIN, "2026-03-28T12:00")
|
||||
val sequence = AlarmOccurrences.sequence(TimeOfDay(2, 30), RepeatDays.EVERY_DAY, after, BERLIN)
|
||||
|
||||
val first = sequence.take(5).toList()
|
||||
|
||||
assertThat(sequence.take(5).toList()).isEqualTo(first)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user