Repository: kevincianfarini/cardiologist (kotlin)

Issue:
Refactor `LocalDateTime.nextMatch` to accept a set of values instead of ranges of values
This will allow us to specify non-contiguous ranges of values and support cron expressions.

Pull request description:
Closes #92.

Patch:
diff --git a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTime.kt b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTime.kt
index 2a23bb5..fe66816 100644
--- a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTime.kt
+++ b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTime.kt
@@ -1,72 +1,75 @@
 package io.github.kevincianfarini.cardiologist.impl
 
+import io.github.kevincianfarini.cardiologist.PulseSchedule
+import io.github.kevincianfarini.cardiologist.PulseScheduleBuilder
+import io.github.kevincianfarini.cardiologist.buildPulseSchedule
 import kotlinx.datetime.LocalDateTime
 import kotlinx.datetime.Month
 import kotlinx.datetime.number
 
-internal fun LocalDateTime.nextMatch(
-    atSeconds: IntRange = 0..59,
-    atMinutes: IntRange = 0..59,
-    atHours: IntRange = 0..23,
-    onDaysOfMonth: IntRange = 1..31,
-    inMonths: ClosedRange<Month> = Month.JANUARY..Month.DECEMBER,
-): LocalDateTime {
+internal fun LocalDateTime.nextMatch(scheduleBuilder: PulseScheduleBuilder.() -> Unit = {}): LocalDateTime {
+    return nextMatch(buildPulseSchedule(scheduleBuilder))
+}
+
+internal fun LocalDateTime.nextMatch(schedule: PulseSchedule): LocalDateTime {
     // Ensure that the nextMatch of this LocalDateTime doesn't produce itself. If it does then increment the
     // nanosecond component by one to ensure that we produce a match that's distinct from this value.
-    val time = if (matches(atSeconds, atMinutes, atHours, onDaysOfMonth, inMonths)) copy(nanosecond = 1) else this
-    return time.nextMonth(inMonths)
-        .nextDay(onDaysOfMonth, inMonths)
-        .nextHour(atHours, onDaysOfMonth, inMonths)
-        .nextMinute(atMinutes, atHours, onDaysOfMonth, inMonths)
-        .nextSecond(atSeconds, atMinutes, atHours, onDaysOfMonth, inMonths)
+    val time = if (matches(schedule)) copy(nanosecond = 1) else this
+    return with(schedule) {
+        time.nextMonth(inMonths)
+            .nextDay(onDaysOfMonth, inMonths)
+            .nextHour(atHours, onDaysOfMonth, inMonths)
+            .nextMinute(atMinutes, atHours, onDaysOfMonth, inMonths)
+            .nextSecond(atSeconds, atMinutes, atHours, onDaysOfMonth, inMonths)
+    }
 }
 
-private fun LocalDateTime.matches(
-    atSeconds: IntRange = 0..59,
-    atMinutes: IntRange = 0..59,
-    atHours: IntRange = 0..23,
-    onDaysOfMonth: IntRange = 1..31,
-    inMonths: ClosedRange<Month> = Month.JANUARY..Month.DECEMBER,
-): Boolean {
-    return nanosecond == 0 && second in atSeconds && minute in atMinutes && hour in atHours
-            && dayOfMonth in onDaysOfMonth && month in inMonths
-}
+private fun LocalDateTime.matches(schedule: PulseSchedule): Boolean = nanosecond == 0 &&
+        second in schedule.atSeconds &&
+        minute in schedule.atMinutes &&
+        hour in schedule.atHours &&
+        dayOfMonth in schedule.onDaysOfMonth &&
+        month in schedule.inMonths
 
 private fun LocalDateTime.nextMonth(
-    inMonths: ClosedRange<Month>,
+    inMonths: Set<Month>,
     increment: Boolean = false,
 ): LocalDateTime {
     val incrementedMonth = if (increment) month.inc() else month
+    val minMonth = inMonths.minOrNull()!!
+    val maxMonth = inMonths.maxOrNull()!!
     return when {
         incrementedMonth < month -> copy(year = year + 1, monthNumber = incrementedMonth.number)
         incrementedMonth in inMonths -> copy(monthNumber = incrementedMonth.number)
-        incrementedMonth < inMonths.start -> copy(
-            monthNumber = inMonths.start.number,
+        incrementedMonth < minMonth -> copy(
+            monthNumber = minMonth.number,
             dayOfMonth = 1,
             hour = 0,
             minute = 0,
             second = 0,
             nanosecond = 0
         )
-        incrementedMonth > inMonths.endInclusive -> copy(
+        incrementedMonth > maxMonth -> copy(
             year = year + 1,
-            monthNumber = inMonths.start.number,
+            monthNumber = minMonth.number,
             dayOfMonth = 1,
             hour = 0,
             minute = 0,
             second = 0,
             nanosecond = 0
         )
-        else -> error("This should be impossible")
+        else -> copy(monthNumber = inMonths.sorted().first { it > incrementedMonth }.number)
     }
 }
 
 private fun LocalDateTime.nextDay(
-    onDaysOfMonth: IntRange,
-    inMonths: ClosedRange<Month>,
+    onDaysOfMonth: Set<Int>,
+    inMonths: Set<Month>,
     increment: Boolean = false,
 ): LocalDateTime {
-    require(onDaysOfMonth.first >= 1 && onDaysOfMonth.last <= 31) { "onDaysOfMonth $onDaysOfMonth not in range 1..31." }
+    val minDayOfMonth = onDaysOfMonth.minOrNull()!!
+    val maxDayOfMonth = onDaysOfMonth.maxOrNull()!!
+    require(minDayOfMonth >= 1 && maxDayOfMonth <= 31) { "onDaysOfMonth $onDaysOfMonth not in range 1..31." }
     val incrementedDay = when {
         increment && dayOfMonth + 1 <= month.numberOfDays(year) -> dayOfMonth + 1
         increment -> 1
@@ -75,89 +78,95 @@ private fun LocalDateTime.nextDay(
     return when {
         incrementedDay < dayOfMonth -> copy(dayOfMonth = incrementedDay).nextMonth(inMonths, increment = true)
         incrementedDay in onDaysOfMonth -> copy(dayOfMonth = incrementedDay)
-        incrementedDay < onDaysOfMonth.first -> {
-            if (onDaysOfMonth.first <= month.numberOfDays(year)) {
-                copy(dayOfMonth = onDaysOfMonth.first)
+        incrementedDay < minDayOfMonth -> {
+            if (minDayOfMonth <= month.numberOfDays(year)) {
+                copy(dayOfMonth = minDayOfMonth)
             } else {
                 nextMonth(inMonths, increment = true).copy(dayOfMonth = 1)
             }
         }
-        incrementedDay > onDaysOfMonth.last -> {
+        incrementedDay > maxDayOfMonth -> {
             nextMonth(inMonths, increment = true).copy(dayOfMonth = 1)
         }
-        else -> error("This should be impossible.")
+        else -> copy(dayOfMonth = onDaysOfMonth.sorted().first { it > incrementedDay })
     }
 }
 
 private fun LocalDateTime.nextHour(
-    atHours: IntRange,
-    onDaysOfMonth: IntRange,
-    inMonths: ClosedRange<Month>,
+    atHours: Set<Int>,
+    onDaysOfMonth: Set<Int>,
+    inMonths: Set<Month>,
     increment: Boolean = false,
 ): LocalDateTime {
-    require(atHours.first >= 0 && atHours.last <= 23) { "atHours $atHours not in range 0..23." }
+    val minHour = atHours.minOrNull()!!
+    val maxHour = atHours.maxOrNull()!!
+    require(minHour >= 0 && maxHour <= 23) { "atHours $atHours not in range 0..23." }
     val incrementedHour = if (increment) (hour + 1) % 24 else hour
     return when {
         incrementedHour < hour -> nextDay(onDaysOfMonth, inMonths, increment = true).copy(hour = incrementedHour)
         incrementedHour in atHours -> copy(hour = incrementedHour)
-        incrementedHour < atHours.first -> copy(hour = atHours.first, minute = 0, second = 0, nanosecond = 0)
-        incrementedHour > atHours.last -> nextDay(onDaysOfMonth, inMonths, increment = true).copy(
-            hour = atHours.first,
+        incrementedHour < minHour -> copy(hour = minHour, minute = 0, second = 0, nanosecond = 0)
+        incrementedHour > maxHour -> nextDay(onDaysOfMonth, inMonths, increment = true).copy(
+            hour = minHour,
             minute = 0,
             second = 0,
             nanosecond = 0,
         )
-        else -> error("This should be impossible.")
+        else -> copy(hour = atHours.sorted().first { it > incrementedHour })
     }
 }
 
 private fun LocalDateTime.nextMinute(
-    atMinutes: IntRange,
-    atHours: IntRange,
-    onDaysOfMonth: IntRange,
-    inMonths: ClosedRange<Month>,
+    atMinutes: Set<Int>,
+    atHours: Set<Int>,
+    onDaysOfMonth: Set<Int>,
+    inMonths: Set<Month>,
     increment: Boolean = false,
 ): LocalDateTime {
-    require(atMinutes.first >= 0 && atMinutes.last <= 59) { "atMinutes $atMinutes not in range 0..59." }
+    val minMinute = atMinutes.minOrNull()!!
+    val maxMinute = atMinutes.maxOrNull()!!
+    require(minMinute >= 0 && maxMinute <= 59) { "atMinutes $atMinutes not in range 0..59." }
     val incrementedMinute = if (increment) (minute + 1) % 60 else minute
     return when {
         incrementedMinute < minute -> nextHour(atHours, onDaysOfMonth, inMonths, increment = true).copy(
             minute = incrementedMinute
         )
         incrementedMinute in atMinutes -> copy(minute = incrementedMinute)
-        incrementedMinute < atMinutes.first -> copy(minute = atMinutes.first, second = 0, nanosecond = 0)
-        incrementedMinute > atMinutes.last -> nextHour(atHours, onDaysOfMonth, inMonths, increment = true).copy(
-            minute = atMinutes.first,
+        incrementedMinute < minMinute -> copy(minute = minMinute, second = 0, nanosecond = 0)
+        incrementedMinute > maxMinute -> nextHour(atHours, onDaysOfMonth, inMonths, increment = true).copy(
+            minute = minMinute,
             second = 0,
             nanosecond = 0,
         )
-        else -> error("This should be impossible.")
+        else -> copy(minute = atMinutes.sorted().first { it > incrementedMinute })
     }
 }
 
 private fun LocalDateTime.nextSecond(
-    atSeconds: IntRange,
-    atMinutes: IntRange,
-    atHours: IntRange,
-    onDaysOfMonth: IntRange,
-    inMonths: ClosedRange<Month>,
+    atSeconds: Set<Int>,
+    atMinutes: Set<Int>,
+    atHours: Set<Int>,
+    onDaysOfMonth: Set<Int>,
+    inMonths: Set<Month>,
 ): LocalDateTime {
-    require(atSeconds.first >= 0 && atSeconds.last <= 59) { " atSeconds $atSeconds not in range 0..59." }
+    val minSecond = atSeconds.minOrNull()!!
+    val maxSeconds = atSeconds.maxOrNull()!!
+    require(minSecond >= 0 && maxSeconds <= 59) { " atSeconds $atSeconds not in range 0..59." }
     val incrementedSecond = if (nanosecond > 0) (second + 1) % 60 else second
     return when {
         incrementedSecond < second -> nextMinute(atMinutes, atHours, onDaysOfMonth, inMonths, increment = true).copy(
             second = incrementedSecond
         )
         incrementedSecond in atSeconds -> copy(second = incrementedSecond)
-        incrementedSecond < atSeconds.first -> copy(second = atSeconds.first)
-        incrementedSecond > atSeconds.last -> nextMinute(
+        incrementedSecond < minSecond -> copy(second = minSecond)
+        incrementedSecond > maxSeconds -> nextMinute(
             atMinutes,
             atHours,
             onDaysOfMonth,
             inMonths,
             increment = true,
-        ).copy(second = atSeconds.first)
-        else -> error("This should be impossible.")
+        ).copy(second = minSecond)
+        else -> copy(second = atSeconds.sorted().first { it > incrementedSecond })
     }.copy(nanosecond = 0)
 }
 
diff --git a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/schedule.kt b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/schedule.kt
index b399124..5cdf648 100644
--- a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/schedule.kt
+++ b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/schedule.kt
@@ -90,13 +90,13 @@ public fun Clock.schedulePulse(
     val flow = flow {
         var lastPulse: LocalDateTime = now().toLocalDateTime(timeZone)
         while (true) {
-            val nextPulse = lastPulse.nextMatch(
-                atSeconds = atSecond?.let { it..it } ?: 0..59,
-                atMinutes = atMinute?.let { it..it } ?: 0..59,
-                atHours = atHour?.let { it..it } ?: 0..23,
-                onDaysOfMonth = onDayOfMonth?.let { it..it } ?: 1..31,
-                inMonths = inMonth?.let { it..it } ?: Month.JANUARY..Month.DECEMBER,
-            )
+            val nextPulse = lastPulse.nextMatch {
+                atSecond?.run(this::atSeconds)
+                atMinute?.run(this::atMinutes)
+                atHour?.run(this::atHours)
+                onDayOfMonth?.run(this::onDaysOfMonth)
+                inMonth?.run(this::inMonths)
+            }
             delayUntil(nextPulse, timeZone)
             emit(nextPulse.toInstant(timeZone))
             lastPulse = nextPulse


Test patch:
diff --git a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTimeTest.kt b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTimeTest.kt
index 65105f1..c26ccb7 100644
--- a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTimeTest.kt
+++ b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTimeTest.kt
@@ -1,5 +1,7 @@
 package io.github.kevincianfarini.cardiologist.impl
 
+import io.github.kevincianfarini.cardiologist.PulseSchedule
+import io.github.kevincianfarini.cardiologist.buildPulseSchedule
 import kotlinx.datetime.*
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -16,72 +18,86 @@ class LocalDateTimeTest {
         nanosecond = 0,
     )
 
-    @Test fun does_not_match_to_equivalent_value() = assertEquals(
+    @Test
+    fun does_not_match_to_equivalent_value() = assertEquals(
         expected = stubDatetime.copy(second = 1),
         actual = stubDatetime.nextMatch()
     )
 
-    @Test fun half_second_is_adjusted_up_to_second() = assertEquals(
+    @Test
+    fun half_second_is_adjusted_up_to_second() = assertEquals(
         expected = stubDatetime.copy(second = 1),
         actual = stubDatetime.copy(nanosecond = 500).nextMatch(),
     )
 
-    @Test fun half_second_is_adjusted_up_to_2_seconds() = assertEquals(
+    @Test
+    fun half_second_is_adjusted_up_to_2_seconds() = assertEquals(
         expected = stubDatetime.copy(second = 2),
-        actual = stubDatetime.copy(nanosecond = 500).nextMatch(atSeconds = 2..2),
+        actual = stubDatetime.copy(nanosecond = 500).nextMatch { atSeconds(2) },
     )
 
-    @Test fun beginning_of_minute_adjusted_up_to_30_seconds() = assertEquals(
+    @Test
+    fun beginning_of_minute_adjusted_up_to_30_seconds() = assertEquals(
         expected = stubDatetime.copy(second = 30),
-        actual = stubDatetime.copy(nanosecond = 500).nextMatch(atSeconds = 30..30),
+        actual = stubDatetime.copy(nanosecond = 500).nextMatch { atSeconds(30) },
     )
 
-    @Test fun middle_of_minute_adjusted_up_to_minute() = assertEquals(
+    @Test
+    fun middle_of_minute_adjusted_up_to_minute() = assertEquals(
         expected = stubDatetime.copy(minute = 1),
-        actual = stubDatetime.copy(second = 30).nextMatch(atSeconds = 0..0),
+        actual = stubDatetime.copy(second = 30).nextMatch { atSeconds(0) },
     )
 
-    @Test fun beginning_of_minute_adjusted_up_to_minimum_of_range() = assertEquals(
+    @Test
+    fun beginning_of_minute_adjusted_up_to_minimum_of_range() = assertEquals(
         expected = stubDatetime.copy(second = 30),
-        actual = stubDatetime.copy(nanosecond = 500).nextMatch(atSeconds = 30..59),
+        actual = stubDatetime.copy(nanosecond = 500).nextMatch { atSeconds(30..59) },
     )
 
-    @Test fun beginning_of_hour_adjusted_up_to_30_minutes() = assertEquals(
+    @Test
+    fun beginning_of_hour_adjusted_up_to_30_minutes() = assertEquals(
         expected = stubDatetime.copy(minute = 30),
-        actual = stubDatetime.nextMatch(atMinutes = 30..30),
+        actual = stubDatetime.nextMatch { atMinutes(30) },
     )
 
-    @Test fun beginning_of_hour_adjusted_up_to_30_minutes_of_range() = assertEquals(
+    @Test
+    fun beginning_of_hour_adjusted_up_to_30_minutes_of_range() = assertEquals(
         expected = stubDatetime.copy(minute = 30),
-        actual = stubDatetime.nextMatch(atMinutes = 30..59),
+        actual = stubDatetime.nextMatch { atMinutes(30..59) },
     )
 
-    @Test fun noon_adjusted_to_next_day() = assertEquals(
+    @Test
+    fun noon_adjusted_to_next_day() = assertEquals(
         expected = stubDatetime.copy(dayOfMonth = 5),
-        actual = stubDatetime.copy(hour = 12).nextMatch(atHours = 0..0),
+        actual = stubDatetime.copy(hour = 12).nextMatch { atHours(0) },
     )
 
-    @Test fun noon_october_31_adjusted_to_midnight_november_1() = assertEquals(
+    @Test
+    fun noon_october_31_adjusted_to_midnight_november_1() = assertEquals(
         expected = stubDatetime.copy(monthNumber = 11, dayOfMonth = 1),
-        actual = stubDatetime.copy(dayOfMonth = 31, hour = 12).nextMatch(atHours = 0..0),
+        actual = stubDatetime.copy(dayOfMonth = 31, hour = 12).nextMatch { atHours(0) },
     )
 
-    @Test fun noon_february_28_adjusted_to_midnight_march_1() = assertEquals(
+    @Test
+    fun noon_february_28_adjusted_to_midnight_march_1() = assertEquals(
         expected = stubDatetime.copy(monthNumber = 3, dayOfMonth = 1),
-        actual = stubDatetime.copy(monthNumber = 2, dayOfMonth = 28, hour = 12).nextMatch(atHours = 0..0),
+        actual = stubDatetime.copy(monthNumber = 2, dayOfMonth = 28, hour = 12).nextMatch { atHours(0) },
     )
 
-    @Test fun noon_february_28_2024_adjusted_to_midnight_feb_29() = assertEquals(
+    @Test
+    fun noon_february_28_2024_adjusted_to_midnight_feb_29() = assertEquals(
         expected = stubDatetime.copy(year = 2024, monthNumber = 2, dayOfMonth = 29),
-        actual = stubDatetime.copy(year = 2024, monthNumber = 2, dayOfMonth = 28, hour = 12).nextMatch(atHours = 0..0),
+        actual = stubDatetime.copy(year = 2024, monthNumber = 2, dayOfMonth = 28, hour = 12).nextMatch { atHours(0) },
     )
 
-    @Test fun noon_december_31_2023_adjusted_to_midnight_jan_1_2024() = assertEquals(
+    @Test
+    fun noon_december_31_2023_adjusted_to_midnight_jan_1_2024() = assertEquals(
         expected = stubDatetime.copy(year = 2024, monthNumber = 1, dayOfMonth = 1),
-        actual = stubDatetime.copy(year = 2023, monthNumber = 12, dayOfMonth = 31, hour = 12).nextMatch(atHours = 0..0),
+        actual = stubDatetime.copy(year = 2023, monthNumber = 12, dayOfMonth = 31, hour = 12).nextMatch { atHours(0) },
     )
 
-    @Test fun nanosecond_before_2024_cascades_all_fields() = assertEquals(
+    @Test
+    fun nanosecond_before_2024_cascades_all_fields() = assertEquals(
         expected = stubDatetime.copy(year = 2024, monthNumber = 1, dayOfMonth = 1),
         actual = LocalDateTime(
             year = 2023,
@@ -91,15 +107,20 @@ class LocalDateTimeTest {
             minute = 59,
             second = 59,
             nanosecond = 999_999_999,
-        ).nextMatch(atSeconds = 0..0),
+        ).nextMatch { atSeconds(0) }
     )
 
-    @Test fun matches_next_five_minute_interval_of_noon() = assertEquals(
+    @Test
+    fun matches_next_five_minute_interval_of_noon() = assertEquals(
         expected = stubDatetime.copy(hour = 12, minute = 5),
-        actual = stubDatetime.nextMatch(atMinutes = 5..5, atHours = 12..23),
+        actual = stubDatetime.nextMatch {
+            atMinutes(5)
+            atHours(12..23)
+        },
     )
 
-    @Test fun matches_up_to_next_minute_complex() = assertEquals(
+    @Test
+    fun matches_up_to_next_minute_complex() = assertEquals(
         expected = LocalDateTime(
             year = 2023,
             monthNumber = 10,
@@ -116,134 +137,265 @@ class LocalDateTimeTest {
             minute = 56,
             second = 17,
             nanosecond = 707401025,
-        ).nextMatch(atMinutes = 58..59),
+        ).nextMatch { atMinutes(58, 59) },
     )
 
-    @Test fun matches_next_month() = assertEquals(
+    @Test
+    fun matches_next_month() = assertEquals(
         expected = stubDatetime.copy(monthNumber = 11, dayOfMonth = 1),
-        actual = stubDatetime.nextMatch(inMonths = Month.NOVEMBER..Month.NOVEMBER),
+        actual = stubDatetime.nextMatch { inMonths(Month.NOVEMBER) },
     )
 
-    @Test fun matches_10_seconds() = assertEquals(
+    @Test
+    fun matches_10_seconds() = assertEquals(
         expected = stubDatetime.copy(hour = 20, minute = 30, second = 10),
-        actual = stubDatetime.copy(hour = 20, minute = 29, second = 36).nextMatch(atSeconds = 10..10)
+        actual = stubDatetime.copy(hour = 20, minute = 29, second = 36).nextMatch { atSeconds(10) },
     )
 
-    @Test fun matches_10_seconds_through_45_seconds() = assertEquals(
+    @Test
+    fun matches_10_seconds_through_45_seconds() = assertEquals(
         expected = stubDatetime.copy(hour = 20, minute = 36, second = 10, nanosecond = 0),
         actual = stubDatetime.copy(
             hour = 20,
             minute = 35,
             second = 45,
             nanosecond = 1,
-        ).nextMatch(atSeconds = 10..45)
+        ).nextMatch { atSeconds(10..45) },
     )
 
-    @Test fun matches_10_minutes_through_57_minutes() = assertEquals(
+    @Test
+    fun matches_10_minutes_through_57_minutes() = assertEquals(
         expected = stubDatetime.copy(dayOfMonth = 5, hour = 0, minute = 10, second = 0),
         actual = stubDatetime.copy(
             hour = 23,
             minute = 57,
             second = 1,
-        ).nextMatch(atMinutes = 10..57, atSeconds = 0..0)
+        ).nextMatch {
+            atSeconds(0)
+            atMinutes(10..57)
+        },
     )
 
-    @Test fun matches_september_2024_from_october_2023() = assertEquals(
+    @Test
+    fun matches_september_2024_from_october_2023() = assertEquals(
         expected = stubDatetime.copy(year = 2024, monthNumber = 9, dayOfMonth = 1),
-        actual = stubDatetime.nextMatch(
-            inMonths = Month.SEPTEMBER..Month.SEPTEMBER,
-            onDaysOfMonth = 1..1,
-            atHours = 0..0,
-            atMinutes = 0..0,
-            atSeconds = 0..0,
-        )
+        actual = stubDatetime.nextMatch {
+            atSeconds(0)
+            atMinutes(0)
+            atHours(0)
+            onDaysOfMonth(1)
+            inMonths(Month.SEPTEMBER)
+        },
     )
 
-    @Test fun matches_september_2024_from_october_2023_any_hour() = assertEquals(
+    @Test
+    fun matches_september_2024_from_october_2023_any_hour() = assertEquals(
         expected = stubDatetime.copy(year = 2024, monthNumber = 9, dayOfMonth = 1),
-        actual = stubDatetime.copy(dayOfMonth = 6, hour = 0, minute = 41, second = 7).nextMatch(
-            inMonths = Month.SEPTEMBER..Month.SEPTEMBER,
-            onDaysOfMonth = 1..1,
-            atMinutes = 0..0,
-            atSeconds = 0..0,
-        )
+        actual = stubDatetime.copy(dayOfMonth = 6, hour = 0, minute = 41, second = 7).nextMatch {
+            atSeconds(0)
+            atMinutes(0)
+            onDaysOfMonth(1)
+            inMonths(Month.SEPTEMBER)
+        },
     )
 
-    @Test fun matches_jan_18_2024_to_jan_19_2024() = assertEquals(
+    @Test
+    fun matches_jan_18_2024_to_jan_19_2024() = assertEquals(
         expected = LocalDateTime(year = 2024, monthNumber = 1, dayOfMonth = 19, hour = 0, minute = 0),
         actual = LocalDateTime(year = 2024, monthNumber = 1, dayOfMonth = 18, hour = 23, minute = 59, second = 48)
-            .nextMatch(atSeconds = 0..0, atMinutes = 0..0, atHours = 0..0)
+            .nextMatch {
+                atSeconds(0)
+                atMinutes(0)
+                atHours(0)
+            },
     )
 
-    @Test fun one_month_gap_for_two_years() {
+    @Test
+    fun one_month_gap_for_two_years() {
         LocalDateTime(year = 2023, monthNumber = 1, dayOfMonth = 1, hour = 0, minute = 0).assertGap(
             assertPeriod = DateTimePeriod(months = 1),
-            atSeconds = 0..0,
-            atMinutes = 0..0,
-            atHours = 0..0,
-            onDaysOfMonth = 1..1,
+            schedule = buildPulseSchedule {
+                atSeconds(0)
+                atMinutes(0)
+                atHours(0)
+                onDaysOfMonth(1)
+            },
         ) { it.year < 2025 }
     }
 
-    @Test fun one_month_gap_for_a_year() {
+    @Test
+    fun one_month_gap_for_a_year() {
         LocalDateTime(year = 2023, monthNumber = 1, dayOfMonth = 1, hour = 0, minute = 0).assertGap(
             assertPeriod = DateTimePeriod(months = 1),
-            atSeconds = 0..0,
-            atMinutes = 0..0,
-            atHours = 0..0,
-            onDaysOfMonth = 1..1,
+            schedule = buildPulseSchedule {
+                atSeconds(0)
+                atMinutes(0)
+                atHours(0)
+                onDaysOfMonth(1)
+            },
         ) { it.year < 2024 }
     }
 
-    @Test fun one_day_gap_for_a_year() {
+    @Test
+    fun one_day_gap_for_a_year() {
         LocalDateTime(year = 2023, monthNumber = 1, dayOfMonth = 1, hour = 0, minute = 0).assertGap(
             assertPeriod = DateTimePeriod(days = 1),
-            atSeconds = 0..0,
-            atMinutes = 0..0,
-            atHours = 0..0,
+            schedule = buildPulseSchedule {
+                atSeconds(0)
+                atMinutes(0)
+                atHours(0)
+            },
         ) { it.year < 2024 }
     }
 
-    @Test fun one_day_gap_for_two_years() {
+    @Test
+    fun one_day_gap_for_two_years() {
         LocalDateTime(year = 2023, monthNumber = 1, dayOfMonth = 1, hour = 0, minute = 0).assertGap(
             assertPeriod = DateTimePeriod(days = 1),
-            atSeconds = 0..0,
-            atMinutes = 0..0,
-            atHours = 0..0,
+            schedule = buildPulseSchedule {
+                atSeconds(0)
+                atMinutes(0)
+                atHours(0)
+            },
         ) { it.year < 2025 }
     }
 
-    @Test fun one_hour_gap_for_a_year() {
+    @Test
+    fun one_hour_gap_for_a_year() {
         LocalDateTime(year = 2023, monthNumber = 1, dayOfMonth = 1, hour = 0, minute = 0).assertGap(
             assertPeriod = DateTimePeriod(hours = 1),
-            atSeconds = 0..0,
-            atMinutes = 0..0,
+            schedule = buildPulseSchedule {
+                atSeconds(0)
+                atMinutes(0)
+            },
         ) { it.year < 2024 }
     }
 
-    @Test fun one_minute_gap_for_a_year() {
+    @Test
+    fun one_minute_gap_for_a_year() {
         LocalDateTime(year = 2023, monthNumber = 1, dayOfMonth = 1, hour = 0, minute = 0).assertGap(
             assertPeriod = DateTimePeriod(minutes = 1),
-            atSeconds = 0..0,
+            schedule = buildPulseSchedule { atSeconds(0) },
         ) { it.year < 2024 }
     }
 
-    @Test fun one_second_gap_for_a_year() {
+    @Test
+    fun one_second_gap_for_a_year() {
         LocalDateTime(year = 2023, monthNumber = 1, dayOfMonth = 1, hour = 0, minute = 0).assertGap(
             assertPeriod = DateTimePeriod(seconds = 1),
         ) { it.year < 2024 }
     }
+
+    @Test
+    fun non_continuous_seconds_matches() = assertEquals(
+        expected = stubDatetime.copy(second = 5),
+        actual = stubDatetime.copy(second = 2).nextMatch {
+            atSeconds(0, 5, 10)
+        },
+    )
+
+    @Test
+    fun non_continuous_unsorted_matches() = assertEquals(
+        expected = stubDatetime.copy(second = 5),
+        actual = stubDatetime.copy(second = 2).nextMatch {
+            atSeconds(10, 0, 5)
+        },
+    )
+
+    @Test
+    fun non_continuous_minutes_matches() = assertEquals(
+        expected = stubDatetime.copy(minute = 5),
+        actual = stubDatetime.copy(minute = 2).nextMatch {
+            atMinutes(0, 5, 10)
+        },
+    )
+
+    @Test
+    fun non_continuous_minutes_unsorted_matches() = assertEquals(
+        expected = stubDatetime.copy(minute = 5),
+        actual = stubDatetime.copy(minute = 2).nextMatch {
+            atMinutes(10, 0, 5)
+        },
+    )
+
+    @Test
+    fun non_continuous_hours_matches() = assertEquals(
+        expected = stubDatetime.copy(hour = 5),
+        actual = stubDatetime.copy(hour = 2).nextMatch {
+            atHours(0, 5, 10)
+        },
+    )
+
+    @Test
+    fun non_continuous_hours_unsorted_matches() = assertEquals(
+        expected = stubDatetime.copy(hour = 5),
+        actual = stubDatetime.copy(hour = 2).nextMatch {
+            atHours(10, 5, 0)
+        },
+    )
+
+    @Test
+    fun non_continuous_days_of_month_matches() = assertEquals(
+        expected = stubDatetime.copy(dayOfMonth = 5),
+        actual = stubDatetime.copy(dayOfMonth = 2).nextMatch {
+            onDaysOfMonth(1, 5, 10)
+        },
+    )
+
+    @Test
+    fun non_continuous_days_of_month_unsorted_matches() = assertEquals(
+        expected = stubDatetime.copy(dayOfMonth = 5),
+        actual = stubDatetime.copy(dayOfMonth = 2).nextMatch {
+            onDaysOfMonth(10, 5, 1)
+        },
+    )
+
+    @Test
+    fun non_continuous_month_matches() = assertEquals(
+        expected = stubDatetime.copy(monthNumber = 5),
+        actual = stubDatetime.copy(monthNumber = 2).nextMatch {
+            inMonths(Month(1), Month(5), Month(10))
+        },
+    )
+
+    @Test
+    fun non_continuous_month_unsorted_matches() = assertEquals(
+        expected = stubDatetime.copy(monthNumber = 5),
+        actual = stubDatetime.copy(monthNumber = 2).nextMatch {
+            inMonths(Month(10), Month(5), Month(1))
+        },
+    )
+
+    @Test
+    fun non_continuous_match_complicated() = assertEquals(
+        expected = stubDatetime.copy(
+            monthNumber = 5,
+            dayOfMonth = 5,
+            hour = 5,
+            minute = 5,
+            second = 5,
+        ),
+        actual = stubDatetime.copy(
+            monthNumber = 2,
+            dayOfMonth = 2,
+            hour = 2,
+            minute = 2,
+            second = 2,
+        ).nextMatch {
+            inMonths(Month(10), Month(5), Month(1))
+            onDaysOfMonth(10, 5, 1)
+            atHours(10, 5, 0)
+            atMinutes(10, 0, 5)
+            atSeconds(10, 0, 5)
+        },
+    )
 }
 
 private fun LocalDateTime.assertGap(
     assertPeriod: DateTimePeriod,
-    atSeconds: IntRange = 0..59,
-    atMinutes: IntRange = 0..59,
-    atHours: IntRange = 0..23,
-    onDaysOfMonth: IntRange = 1..31,
-    inMonths: ClosedRange<Month> = Month.JANUARY..Month.DECEMBER,
+    schedule: PulseSchedule = buildPulseSchedule {  },
     takeWhile: (LocalDateTime) -> Boolean,
-) = nextMatchSequence(atSeconds, atMinutes, atHours, onDaysOfMonth, inMonths)
+) = nextMatchSequence(schedule)
     .takeWhile(takeWhile)
     .windowed(2) { (first, second) ->
         assertEquals(
@@ -256,12 +408,6 @@ private fun LocalDateTime.assertGap(
         )
     }.last()
 
-private fun LocalDateTime.nextMatchSequence(
-    atSeconds: IntRange = 0..59,
-    atMinutes: IntRange = 0..59,
-    atHours: IntRange = 0..23,
-    onDaysOfMonth: IntRange = 1..31,
-    inMonths: ClosedRange<Month> = Month.JANUARY..Month.DECEMBER,
-): Sequence<LocalDateTime> = generateSequence(seed = this) { seed ->
-    seed.nextMatch(atSeconds, atMinutes, atHours, onDaysOfMonth, inMonths)
-}
\ No newline at end of file
+private fun LocalDateTime.nextMatchSequence(schedule: PulseSchedule): Sequence<LocalDateTime> {
+    return generateSequence(seed = this) { seed -> seed.nextMatch(schedule) }
+}