Repository: kevincianfarini/cardiologist (kotlin)

Issue:
Ensure that pulses do not attempt to emit multiple times on identical instants in time. 
This makes testing difficult but would otherwise never happen with a system clock.

Pull request:
Prior to this commit `LocalDateTime#nextMatch` could produce a value 
identical to itself as the return value. This was incorrect and would lead to 
infinite loops in `Clock#schedulePulse` in certain circumstances, namely 
when using a test clock.

This commit ensures that `LocalDateTime#nextMatch` will actually produce the
_next_ match, and never a value identical to itself.

Closes #22

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 4134db9..ea4505c 100644
--- a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTime.kt
+++ b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTime.kt
@@ -3,6 +3,7 @@ package io.github.kevincianfarini.cardiologist.impl
 import kotlinx.datetime.LocalDateTime
 import kotlinx.datetime.Month
 import kotlinx.datetime.number
+import kotlin.math.min
 
 internal fun LocalDateTime.nextMatch(
     atSeconds: IntRange = 0..59,
@@ -10,11 +11,27 @@ internal fun LocalDateTime.nextMatch(
     atHours: IntRange = 0..23,
     onDaysOfMonth: IntRange = 1..31,
     inMonths: ClosedRange<Month> = Month.JANUARY..Month.DECEMBER,
-): LocalDateTime = nextMonth(inMonths)
-    .nextDay(onDaysOfMonth, inMonths)
-    .nextHour(atHours, onDaysOfMonth, inMonths)
-    .nextMinute(atMinutes, atHours, onDaysOfMonth, inMonths)
-    .nextSecond(atSeconds, atMinutes, atHours, onDaysOfMonth, inMonths)
+): 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)
+}
+
+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.nextMonth(
     inMonths: ClosedRange<Month>,
@@ -45,9 +62,8 @@ private fun LocalDateTime.nextMonth(
     }
 }
 
-private val MONTHS = Month.values()
 private operator fun Month.inc(): Month {
-    return MONTHS[(ordinal + 1) % 12]
+    return Month.entries[(ordinal + 1) % 12]
 }
 
 private fun LocalDateTime.nextDay(


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 4d42170..efb4502 100644
--- a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTimeTest.kt
+++ b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTimeTest.kt
@@ -16,6 +16,11 @@ class LocalDateTimeTest {
         nanosecond = 0,
     )
 
+    @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(
         expected = stubDatetime.copy(second = 1),
         actual = stubDatetime.copy(nanosecond = 500).nextMatch(),
@@ -252,8 +257,5 @@ private fun LocalDateTime.nextMatchSequence(
     onDaysOfMonth: IntRange = 1..31,
     inMonths: ClosedRange<Month> = Month.JANUARY..Month.DECEMBER,
 ): Sequence<LocalDateTime> = generateSequence(seed = this) { seed ->
-    when (val next = seed.nextMatch(atSeconds, atMinutes, atHours, onDaysOfMonth, inMonths)) {
-        seed -> seed.copy(nanosecond = 1).nextMatch(atSeconds, atMinutes, atHours, onDaysOfMonth, inMonths)
-        else -> next
-    }
+    seed.nextMatch(atSeconds, atMinutes, atHours, onDaysOfMonth, inMonths)
 }
\ No newline at end of file