Repository: kevincianfarini/cardiologist
Pull request #114 (2025-05-10 18:22:18)

Enable -Werror


Patch:
diff --git a/README.md b/README.md
index 65e5cd0..59e7585 100644
--- a/README.md
+++ b/README.md
@@ -118,19 +118,18 @@ clock.fixedPeriodPulse(5.seconds).beat(strategy = PulseBackpressureStrategy.Skip
 
 ### How do I schedule one-off jobs? 
 
-Cardiologist exposes the function `Clock.delayUntil` which delays until a specific moment in time using either a 
-`kotlinx.datetime.Instant` or a `kotlinx.datetime.LocalDateTime`. This building block allows you build rich APIs
-for executing one-off jobs. For example:
+Cardiologist provides `Clock.executeAt` which executes an action at a specific `Instant` or a specific `LocalDateTime`.
 
 ```kt
-suspend fun Clock.executeAt(instant: Instant, job: suspend () -> Unit) {
-    this.delayUntil(instant)
-    job()
+val instant = Instant.parse("2025-05-10T13:15:00Z")
+clock.executeAt(instant) { occurred: Instant ->
+    println("I executed at $occurred!")
 }
 
-suspend fun Clock.executeAt(dateTime: LocalDateTime, timeZone: TimeZone, job: suspend () -> Unit) {
-    this.delayUntil(dateTime, timeZone)
-    job()
+val localDateTime = LocalDatetime(2025, 5, 10, 13, 15, 0)
+val timeZone = TimeZone.of("America/New_York")
+clock.executeAt(localDateTime, timeZone) { occurred: LocalDateTime ->
+    println("I executed at $occurred!")
 }
 ```
 
@@ -184,7 +183,7 @@ val cron: String = schedule.toCronExpression() // 0,5,10,15,20,25,30 9-17 * * 1-
 
 > [!WARNING]
 > Transforming a `PulseSchedule` into a standard Cron expression will omit any seconds components as 
-that's not included in the standard Cron specification.
+> that's not included in the standard Cron specification.
 
 ### What's the difference between Cardiologist and other tools like cron?
 
diff --git a/build.gradle.kts b/build.gradle.kts
index 6e1af87..d1a8539 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -12,6 +12,7 @@ plugins {
 
 kotlin {
 
+    compilerOptions { allWarningsAsErrors.set(true) }
     explicitApi()
 
     androidNativeArm32()
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 14299c5..2a23bb5 100644
--- a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTime.kt
+++ b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/impl/LocalDateTime.kt
@@ -174,7 +174,6 @@ private fun Month.numberOfDays(year: Int) = when (this) {
     Month.OCTOBER -> 31
     Month.NOVEMBER -> 30
     Month.DECEMBER -> 31
-    else -> error("Impossible.")
 }
 
 private val Int.isLeapYear: Boolean get() {
diff --git a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/suspend.kt b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/suspend.kt
index 70a1401..bdfbf29 100644
--- a/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/suspend.kt
+++ b/src/commonMain/kotlin/io/github/kevincianfarini/cardiologist/suspend.kt
@@ -7,6 +7,43 @@ import kotlinx.datetime.Instant
 import kotlinx.datetime.LocalDateTime
 import kotlinx.datetime.TimeZone
 import kotlinx.datetime.toInstant
+import kotlinx.datetime.toLocalDateTime
+
+/**
+ * Execute the provided [action] at or after [instant].
+ *
+ * The coroutine will be suspended until [instant] without blocking the thread. If [Clock.now] returns an [Instant]
+ * greater than the supplied [instant], this function executes [action] without suspending.
+ *
+ * The supplied [action] is invoked with an [Instant] parameter specifying when the action was actually executed.
+ */
+public suspend fun <T> Clock.executeAt(
+    instant: Instant,
+    action: suspend (occurred: Instant) -> T,
+): T {
+    delayUntil(instant)
+    return action(now())
+}
+
+/**
+ * Execute the provided [action] at or after [dateTime] in [timeZone].
+ *
+ * The coroutine will be suspended until [dateTime] in [timeZone] without blocking the thread. If [Clock.now] returns an
+ * [Instant] greater than the [Instant] associated with [dateTime] in [timeZone], this function executes [action] without
+ * suspending.
+ *
+ * The supplied [action] is invoked with a [LocalDateTime] parameter specifying the instant that [action] was executed
+ * in [timeZone]. Local time conversion is sometimes ambiguous, and therefore it's recommended to schedule pulses in a
+ * fixed UTC offset timezone. See [LocalDateTime.toInstant] for more details.
+ */
+public suspend fun <T> Clock.executeAt(
+    dateTime: LocalDateTime,
+    timeZone: TimeZone,
+    action: suspend (occurred: LocalDateTime) -> T,
+): T {
+    delayUntil(dateTime, timeZone)
+    return action(now().toLocalDateTime(timeZone))
+}
 
 /**
  * Suspend the coroutine until [instant] without blocking the thread. If [Clock.now] is greater than [instant], this
@@ -14,7 +51,7 @@ import kotlinx.datetime.toInstant
  *
  * Coroutines delayed for more than one minute will occasionally be resumed to account for clock drift.
  */
-public suspend fun Clock.delayUntil(instant: Instant) {
+internal suspend fun Clock.delayUntil(instant: Instant) {
     var now = now()
     while (now < instant) {
         val durationUntilInstant = instant - now
@@ -30,6 +67,6 @@ public suspend fun Clock.delayUntil(instant: Instant) {
  *
  * Coroutines delayed for more than one minute will occasionally be resumed to account for clock drift.
  */
-public suspend fun Clock.delayUntil(dateTime: LocalDateTime, timeZone: TimeZone) {
+internal suspend fun Clock.delayUntil(dateTime: LocalDateTime, timeZone: TimeZone) {
     delayUntil(instant = dateTime.toInstant(timeZone))
 }


Test patch:
diff --git a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/FakeClock.kt b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/FakeClock.kt
index 369e13a..a92e03b 100644
--- a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/FakeClock.kt
+++ b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/FakeClock.kt
@@ -3,10 +3,8 @@ package io.github.kevincianfarini.cardiologist
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.test.TestCoroutineScheduler
 import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.advanceTimeBy
-import kotlinx.coroutines.test.currentTime
-import kotlinx.datetime.*
-import kotlin.time.Duration
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
 
 @OptIn(ExperimentalCoroutinesApi::class)
 private class TestClock(private val scheduler: TestCoroutineScheduler) : Clock {
@@ -15,17 +13,6 @@ private class TestClock(private val scheduler: TestCoroutineScheduler) : Clock {
 
 val TestScope.testClock: Clock get() = TestClock(testScheduler)
 
-@OptIn(ExperimentalCoroutinesApi::class)
-fun TestScope.testClockAt(instant: Instant): Clock {
-    val currentTime = Instant.fromEpochMilliseconds(currentTime)
-    advanceTimeBy(instant - currentTime)
-    return TestClock(testScheduler)
-}
-
-fun TestScope.testClockAt(localDateTime: LocalDateTime, timeZone: TimeZone): Clock {
-    return testClockAt(localDateTime.toInstant(timeZone))
-}
-
 private class QueueClock(private val instants: List<Instant>) : Clock {
     private var index = 0
     override fun now(): Instant = instants[index++]
diff --git a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/SuspendingTests.kt b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/SuspendingTests.kt
index 58335e3..c8fc678 100644
--- a/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/SuspendingTests.kt
+++ b/src/commonTest/kotlin/io/github/kevincianfarini/cardiologist/SuspendingTests.kt
@@ -6,6 +6,7 @@ import kotlinx.coroutines.test.testTimeSource
 import kotlinx.datetime.*
 import kotlin.test.Test
 import kotlin.test.assertEquals
+import kotlin.time.Duration.Companion.hours
 import kotlin.time.Duration.Companion.minutes
 import kotlin.time.Duration.Companion.seconds
 import kotlin.time.measureTime
@@ -13,7 +14,8 @@ import kotlin.time.measureTime
 @OptIn(ExperimentalCoroutinesApi::class)
 class SuspendingTests {
 
-    @Test fun delays_for_proper_amount_of_time_until_future_instant() = runTest {
+    @Test
+    fun delays_for_proper_amount_of_time_until_future_instant() = runTest {
         val now = testClock.now()
         val future = now + 10.minutes
         val elapsed = testTimeSource.measureTime {
@@ -22,7 +24,8 @@ class SuspendingTests {
         assertEquals(expected = 10.minutes, actual = elapsed)
     }
 
-    @Test fun delayUntil_considers_positive_time_drift() = runTest {
+    @Test
+    fun delayUntil_considers_positive_time_drift() = runTest {
         val instants = listOf(
             Instant.fromEpochMilliseconds(0),
             Instant.fromEpochSeconds(65),
@@ -39,7 +42,8 @@ class SuspendingTests {
         assertEquals(expected = 115.seconds, actual = elapsed)
     }
 
-    @Test fun considers_negative_time_drift() = runTest {
+    @Test
+    fun considers_negative_time_drift() = runTest {
         val instants = listOf(
             Instant.fromEpochMilliseconds(0),
             Instant.fromEpochSeconds(55),
@@ -56,7 +60,8 @@ class SuspendingTests {
         assertEquals(expected = 65.seconds, actual = elapsed)
     }
 
-    @Test fun resumes_immediately_for_0_duration() = runTest {
+    @Test
+    fun resumes_immediately_for_0_duration() = runTest {
         val now = testClock.now()
         val elapsed = testTimeSource.measureTime {
             testClock.delayUntil(now)
@@ -64,7 +69,8 @@ class SuspendingTests {
         assertEquals(expected = 0.minutes, actual = elapsed)
     }
 
-    @Test fun resumes_immediately_for_negative_duration() = runTest {
+    @Test
+    fun resumes_immediately_for_negative_duration() = runTest {
         val now = testClock.now()
         val past = now - 10.minutes
         val elapsed = testTimeSource.measureTime {
@@ -72,4 +78,27 @@ class SuspendingTests {
         }
         assertEquals(expected = 0.minutes, actual = elapsed)
     }
+
+    @Test
+    fun executeAt_invokes_with_correct_occurred_instant() = runTest {
+        val tenSeconds = Instant.fromEpochSeconds(10)
+        val duration = testTimeSource.measureTime {
+            testClock.executeAt(tenSeconds) { occurred ->
+                assertEquals(expected = tenSeconds, actual = occurred)
+            }
+        }
+        assertEquals(expected = 10.seconds, actual = duration)
+    }
+
+    @Test
+    fun executeAt_invokes_with_correct_occurred_local_date_time() = runTest {
+        val localDateTime = LocalDateTime(1970, 1, 2, 0, 0)
+        val tz = TimeZone.UTC
+        val duration = testTimeSource.measureTime {
+            testClock.executeAt(localDateTime, tz) { occurred ->
+                assertEquals(expected = localDateTime, actual = occurred)
+            }
+        }
+        assertEquals(expected = 24.hours, actual = duration)
+    }
 }
\ No newline at end of file