From 5ac1a869ecb2a2f8eea920d3615c868c2cd2c219 Mon Sep 17 00:00:00 2001 From: Flavio Brasil Date: Sat, 19 Oct 2024 10:24:10 -0700 Subject: [PATCH] data: Schedule (#733) Inspired by ZIO's `Schedule` but as pure data structure. --- README.md | 25 +- .../src/main/scala/kyo/Combinators.scala | 29 +- .../test/scala/kyo/EffectCombinatorTest.scala | 22 +- .../shared/src/main/scala/kyo/Retry.scala | 80 +- .../shared/src/test/scala/kyo/RetryTest.scala | 10 +- .../shared/src/main/scala/kyo/Duration.scala | 4 + .../shared/src/main/scala/kyo/Instant.scala | 18 + .../shared/src/main/scala/kyo/Schedule.scala | 348 ++++++++ .../src/test/scala/kyo/DurationSpec.scala | 17 + .../src/test/scala/kyo/InstantTest.scala | 52 ++ .../src/test/scala/kyo/ScheduleTest.scala | 835 ++++++++++++++++++ 11 files changed, 1307 insertions(+), 133 deletions(-) create mode 100644 kyo-data/shared/src/main/scala/kyo/Schedule.scala create mode 100644 kyo-data/shared/src/test/scala/kyo/ScheduleTest.scala diff --git a/README.md b/README.md index 0ecc9b288..210472873 100644 --- a/README.md +++ b/README.md @@ -2038,30 +2038,17 @@ import scala.concurrent.duration.* val unreliableComputation: Int < Abort[Exception] = Abort.catching[Exception](throw new Exception("Temporary failure")) -// Customize retry policy -val customPolicy = Retry.Policy.default - .limit(5) - .exponential(100.millis, maxBackoff = 5.seconds) +// Customize retry schedule +val shedule = + Schedule.exponentialBackoff(initial = 100.millis, factor = 2, maxBackoff = 5.seconds) + .take(5) val a: Int < (Abort[Exception] & Async) = - Retry[Exception](customPolicy)(unreliableComputation) + Retry[Exception](shedule)(unreliableComputation) -// Use a custom policy builder -val b: Int < (Abort[Exception] & Async) = - Retry[Exception] { policy => - policy - .limit(10) - .backoff(attempt => (attempt * 100).millis) - }(unreliableComputation) ``` -The `Retry` effect automatically adds the `Async` effect to handle the backoff delays between retry attempts. The `Policy` class allows for fine-tuning of the retry behavior: - -- `limit`: Sets the maximum number of retry attempts. -- `exponential`: Configures exponential backoff with a starting delay and optional maximum delay. -- `backoff`: Allows for custom backoff strategies based on the attempt number. - -`Retry` will continue attempting the computation until it succeeds, the retry limit is reached, or an unhandled exception is thrown. If all retries fail, the last failure is propagated. +The `Retry` effect automatically adds the `Async` effect to handle the provided `Schedule`. `Retry` will continue attempting the computation until it succeeds, the retry schedule is done, or an unhandled exception is thrown. If all retries fail, the last failure is propagated. ### Queue: Concurrent Queuing diff --git a/kyo-combinators/shared/src/main/scala/kyo/Combinators.scala b/kyo-combinators/shared/src/main/scala/kyo/Combinators.scala index 164883bd1..bdc345ab7 100644 --- a/kyo-combinators/shared/src/main/scala/kyo/Combinators.scala +++ b/kyo-combinators/shared/src/main/scala/kyo/Combinators.scala @@ -72,10 +72,13 @@ extension [A, S](effect: A < S) * @return * A computation that produces the result of the last execution */ - def repeat(policy: Retry.Policy)(using Flat[A], Frame): A < (S & Async) = - Loop.indexed { i => - if i >= policy.limit then effect.map(Loop.done) - else effect.delayed(policy.backoff(i)).as(Loop.continue) + def repeat(schedule: Schedule)(using Flat[A], Frame): A < (S & Async) = + Loop(schedule) { schedule => + schedule.next.map { (delay, nextSchedule) => + effect.delayed(delay).as(Loop.continue(nextSchedule)) + }.getOrElse { + effect.map(Loop.done) + } } /** Performs this computation repeatedly with a limit. @@ -186,8 +189,8 @@ extension [A, S](effect: A < S) * @return * A computation that produces the result of this computation with Async and Abort[Throwable] effects */ - def retry(policy: Retry.Policy)(using Flat[A], Frame): A < (S & Async & Abort[Throwable]) = - Retry[Throwable](policy)(effect) + def retry(schedule: Schedule)(using Flat[A], Frame): A < (S & Async & Abort[Throwable]) = + Retry[Throwable](schedule)(effect) /** Performs this computation repeatedly with a limit in case of failures. * @@ -197,19 +200,7 @@ extension [A, S](effect: A < S) * A computation that produces the result of this computation with Async and Abort[Throwable] effects */ def retry(n: Int)(using Flat[A], Frame): A < (S & Async & Abort[Throwable]) = - Retry[Throwable](Retry.Policy(_ => Duration.Zero, n))(effect) - - /** Performs this computation repeatedly with a backoff policy and a limit in case of failures. - * - * @param backoff - * The backoff policy to use - * @param limit - * The limit to use - * @return - * A computation that produces the result of this computation with Async and Abort[Throwable] effects - */ - def retry(backoff: Int => Duration, n: Int)(using Flat[A], Frame): A < (S & Async & Abort[Throwable]) = - Retry[Throwable](Retry.Policy(backoff, n))(effect) + Retry[Throwable](Schedule.repeat(n))(effect) /** Performs this computation indefinitely. * diff --git a/kyo-combinators/shared/src/test/scala/kyo/EffectCombinatorTest.scala b/kyo-combinators/shared/src/test/scala/kyo/EffectCombinatorTest.scala index 4db91b2e6..e840d63e8 100644 --- a/kyo-combinators/shared/src/test/scala/kyo/EffectCombinatorTest.scala +++ b/kyo-combinators/shared/src/test/scala/kyo/EffectCombinatorTest.scala @@ -208,9 +208,9 @@ class EffectCombinatorTest extends Test: "repeat with policy" - { "repeat with custom policy" in run { - var count = 0 - val policy = Retry.Policy(_ => Duration.Zero, 3) - val effect = IO { count += 1; count }.repeat(policy) + var count = 0 + val schedule = Schedule.repeat(3) + val effect = IO { count += 1; count }.repeat(schedule) Async.run(effect).map(_.toFuture).map { handled => handled.map { v => assert(v == 4) @@ -321,7 +321,7 @@ class EffectCombinatorTest extends Test: "retry with policy" - { "successful after retries with custom policy" in run { var count = 0 - val policy = Retry.Policy(_ => 10.millis, 3) + val policy = Schedule.fixed(10.millis).take(3) val effect = IO { count += 1 if count < 3 then throw new Exception("Retry") @@ -333,20 +333,6 @@ class EffectCombinatorTest extends Test: } } - "retry with backoff and limit" - { - "successful after retries with exponential backoff" in run { - var count = 0 - val backoff = (i: Int) => Math.pow(2, i).toLong.millis - val effect = IO { - count += 1 - if count < 3 then throw new Exception("Retry") - else count - }.retry(backoff, 3) - Async.run(effect).map(_.toFuture).map { handled => - handled.map(v => assert(v == 3)) - } - } - } } "explicitThrowable" - { diff --git a/kyo-core/shared/src/main/scala/kyo/Retry.scala b/kyo-core/shared/src/main/scala/kyo/Retry.scala index 478a3e160..fdc318aff 100644 --- a/kyo-core/shared/src/main/scala/kyo/Retry.scala +++ b/kyo-core/shared/src/main/scala/kyo/Retry.scala @@ -6,74 +6,12 @@ import scala.util.* /** Provides utilities for retrying operations with customizable policies. */ object Retry: - /** Represents a retry policy with backoff strategy and attempt limit. */ - final case class Policy(backoff: Int => Duration, limit: Int): - - /** Creates an exponential backoff strategy. - * - * @param startBackoff - * The initial backoff duration. - * @param factor - * The multiplier for each subsequent backoff. - * @param maxBackoff - * The maximum backoff duration. - * @return - * A new Policy with exponential backoff. - */ - def exponential( - startBackoff: Duration, - factor: Int = 2, - maxBackoff: Duration = Duration.Infinity - ): Policy = - backoff { i => - (startBackoff * factor * (i + 1)).min(maxBackoff) - } - - /** Sets a custom backoff function. - * - * @param f - * A function that takes the attempt number and returns a Duration. - * @return - * A new Policy with the custom backoff function. - */ - def backoff(f: Int => Duration): Policy = - copy(backoff = f) - - /** Sets the maximum number of retry attempts. - * - * @param v - * The maximum number of attempts. - * @return - * A new Policy with the specified attempt limit. - */ - def limit(v: Int): Policy = - copy(limit = v) - end Policy - - object Policy: - /** The default retry policy with no backoff and 3 attempts. */ - val default = Policy(_ => Duration.Zero, 3) + /** The default retry schedule. */ + val defaultSchedule = Schedule.exponentialBackoff(initial = 100.millis, factor = 2, maxBackoff = 5.seconds).take(3) /** Provides retry operations for a specific error type. */ final class RetryOps[E >: Nothing](dummy: Unit) extends AnyVal: - /** Retries an operation using the specified policy. - * - * @param policy - * The retry policy to use. - * @param v - * The operation to retry. - * @return - * The result of the operation, or an abort if all retries fail. - */ - def apply[A: Flat, S](policy: Policy)(v: => A < S)( - using - SafeClassTag[E], - Tag[E], - Frame - ): A < (Async & Abort[E] & S) = - apply(_ => policy)(v) - /** Retries an operation using a custom policy builder. * * @param builder @@ -83,21 +21,19 @@ object Retry: * @return * The result of the operation, or an abort if all retries fail. */ - def apply[A: Flat, S](builder: Policy => Policy)(v: => A < (Abort[E] & S))( + def apply[A: Flat, S](schedule: Schedule)(v: => A < (Abort[E] & S))( using SafeClassTag[E], Tag[E], Frame ): A < (Async & Abort[E] & S) = - val b = builder(Policy.default) - Loop.indexed { attempt => + Loop(schedule) { schedule => Abort.run[E](v).map(_.fold { r => - if attempt < b.limit then - Async.sleep(b.backoff(attempt)).andThen { - Loop.continue - } - else + schedule.next.map { (delay, nextSchedule) => + Async.delay(delay)(Loop.continue(nextSchedule)) + }.getOrElse { Abort.get(r) + } }(Loop.done(_))) } end apply diff --git a/kyo-core/shared/src/test/scala/kyo/RetryTest.scala b/kyo-core/shared/src/test/scala/kyo/RetryTest.scala index efef7e4fb..5824af6fb 100644 --- a/kyo-core/shared/src/test/scala/kyo/RetryTest.scala +++ b/kyo-core/shared/src/test/scala/kyo/RetryTest.scala @@ -7,7 +7,7 @@ class RetryTest extends Test: "no retries" - { "ok" in run { var calls = 0 - Retry[Any](_.limit(0)) { + Retry[Any](Schedule.never) { calls += 1 42 }.map { v => @@ -17,7 +17,7 @@ class RetryTest extends Test: "nok" in run { var calls = 0 Abort.run[Exception] { - Retry[Exception](_.limit(0)) { + Retry[Exception](Schedule.never) { calls += 1 throw ex } @@ -30,7 +30,7 @@ class RetryTest extends Test: "retries" - { "ok" in run { var calls = 0 - Retry[Any](_.limit(3)) { + Retry[Any](Schedule.repeat(3)) { calls += 1 42 }.map { v => @@ -40,7 +40,7 @@ class RetryTest extends Test: "nok" in run { var calls = 0 Abort.run[Exception] { - Retry[Exception](_.limit(3)) { + Retry[Exception](Schedule.repeat(3)) { calls += 1 throw ex } @@ -54,7 +54,7 @@ class RetryTest extends Test: var calls = 0 val start = java.lang.System.currentTimeMillis() Abort.run[Exception] { - Retry[Exception](_.limit(4).exponential(1.milli)) { + Retry[Exception](Schedule.exponentialBackoff(1.milli, 2.0, Duration.Infinity).take(4)) { calls += 1 throw ex } diff --git a/kyo-data/shared/src/main/scala/kyo/Duration.scala b/kyo-data/shared/src/main/scala/kyo/Duration.scala index d6c653aab..c16a33ba0 100644 --- a/kyo-data/shared/src/main/scala/kyo/Duration.scala +++ b/kyo-data/shared/src/main/scala/kyo/Duration.scala @@ -131,6 +131,10 @@ object Duration: val sum: Long = self.toLong + that.toLong if sum >= 0 then sum else Duration.Infinity + inline infix def -(that: Duration): Duration = + val diff: Long = self.toLong - that.toLong + if diff > 0 then diff else Duration.Zero + inline infix def *(factor: Double): Duration = if factor <= 0 || self.toLong <= 0L then Duration.Zero else if factor <= Long.MaxValue / self.toLong.toDouble then Math.round(self.toLong.toDouble * factor) diff --git a/kyo-data/shared/src/main/scala/kyo/Instant.scala b/kyo-data/shared/src/main/scala/kyo/Instant.scala index 9b13171bf..63c7f7e77 100644 --- a/kyo-data/shared/src/main/scala/kyo/Instant.scala +++ b/kyo-data/shared/src/main/scala/kyo/Instant.scala @@ -138,6 +138,24 @@ object Instant: def truncatedTo(unit: Duration.Units & Duration.Truncatable): Instant = instant.truncatedTo(unit.chronoUnit) + /** Returns the minimum of this Instant and another. + * + * @param other + * The other Instant to compare with. + * @return + * The earlier of the two Instants. + */ + infix def min(other: Instant): Instant = if instant.isBefore(other) then instant else other + + /** Returns the maximum of this Instant and another. + * + * @param other + * The other Instant to compare with. + * @return + * The later of the two Instants. + */ + infix def max(other: Instant): Instant = if instant.isAfter(other) then instant else other + /** Converts this Instant to a human-readable ISO-8601 formatted string. * * @return diff --git a/kyo-data/shared/src/main/scala/kyo/Schedule.scala b/kyo-data/shared/src/main/scala/kyo/Schedule.scala new file mode 100644 index 000000000..cd13742d7 --- /dev/null +++ b/kyo-data/shared/src/main/scala/kyo/Schedule.scala @@ -0,0 +1,348 @@ +package kyo + +import Schedule.internal.* +import kyo.Duration +import kyo.Instant + +/** An immutable, composable scheduling policy. + * + * Schedule provides various combinators for creating complex scheduling policies. It can be used to define retry policies, periodic tasks, + * or any other time-based scheduling logic. + */ +sealed abstract class Schedule derives CanEqual: + + /** Returns the next delay and the updated schedule. + * + * @return + * a tuple containing the next delay duration and the updated schedule + */ + def next: Maybe[(Duration, Schedule)] + + /** Combines this schedule with another, taking the maximum delay of both. + * + * @param that + * the schedule to combine with + * @return + * a new schedule that produces the maximum delay of both schedules + */ + final infix def max(that: Schedule): Schedule = + this match + case Never => this + case Done | Immediate => that + case _ => + that match + case Never => that + case Done | Immediate => this + case _ => Max(this, that) + + /** Combines this schedule with another, taking the minimum delay of both. + * + * @param that + * the schedule to combine with + * @return + * a new schedule that produces the minimum delay of both schedules + */ + final infix def min(that: Schedule): Schedule = + this match + case Never => that + case Done | Immediate => this + case _ => + that match + case Never => this + case Done | Immediate => that + case _ => Min(this, that) + + /** Limits the number of repetitions of this schedule. + * + * @param n + * the maximum number of repetitions + * @return + * a new schedule that stops after n repetitions + */ + final def take(n: Int): Schedule = + if n <= 0 then Schedule.done + else + this match + case Never | Done => this + case _ => Take(this, n) + + /** Chains this schedule with another, running the second after the first completes. + * + * @param that + * the schedule to run after this one + * @return + * a new schedule that runs this schedule followed by the other + */ + final def andThen(that: Schedule): Schedule = + this match + case Never => Never + case Done => that + case _ => + that match + case Done | Never | Immediate => this + case _ => AndThen(this, that) + + /** Repeats this schedule a specified number of times. + * + * @param n + * the number of times to repeat + * @return + * a new schedule that repeats this schedule n times + */ + final def repeat(n: Int): Schedule = + if n <= 0 then Schedule.done + else if n == 1 then this + else + this match + case Never | Done => this + case _ => Repeat(this, n) + + /** Limits the total duration of this schedule. + * + * @param maxDuration + * the maximum total duration + * @return + * a new schedule that stops after the specified duration + */ + final def maxDuration(maxDuration: Duration): Schedule = + if !maxDuration.isFinite then this + else + this match + case Never | Done | Immediate => this + case _ => MaxDuration(this, maxDuration) + + /** Repeats this schedule indefinitely. + * + * @return + * a new schedule that repeats this schedule forever + */ + final def forever: Schedule = + this match + case Never | Done => this + case _: Forever => this + case _ => Forever(this) + + /** Adds a fixed delay before each iteration of this schedule. + * + * @param duration + * the delay to add + * @return + * a new schedule with the added delay + */ + final def delay(duration: Duration): Schedule = + if duration == Duration.Zero then this + else + this match + case Never | Done => this + case _ => Delay(this, duration) + + /** Returns a string representation of the schedule as it would appear in source code. + * + * @return + * a string representation of the schedule + */ + def show: String + + override def toString() = show + +end Schedule + +object Schedule: + + /** A schedule that completes once immediately. */ + val immediate: Schedule = Immediate + + /** A schedule that never completes. */ + val never: Schedule = Never + + /** A schedule that is already done. */ + val done: Schedule = Done + + /** A schedule that forever repeats immediately. */ + val forever: Schedule = immediate.forever + + /** Creates a schedule that executes once after a fixed duration. + * + * @param duration + * the delay duration + * @return + * a new schedule with the specified delay + */ + def delay(duration: Duration): Schedule = + immediate.delay(duration) + + /** Creates a schedule that immediately repeats a specified number of times. + * + * @param n + * the number of repetitions + * @return + * a new schedule that repeats n times + */ + def repeat(n: Int): Schedule = + immediate.repeat(n) + + /** Creates a schedule with a fixed interval between iterations. + * + * @param interval + * the fixed interval + * @return + * a new schedule with the specified fixed interval + */ + def fixed(interval: Duration): Schedule = Fixed(interval) + + /** Creates a schedule with linearly increasing intervals. + * + * @param base + * the initial interval + * @return + * a new schedule with linearly increasing intervals + */ + def linear(base: Duration): Schedule = + if base == Duration.Zero then immediate.forever + else Linear(base) + + /** Creates a schedule with intervals following the Fibonacci sequence. + * + * @param a + * the first interval + * @param b + * the second interval + * @return + * a new schedule with Fibonacci sequence intervals + */ + def fibonacci(a: Duration, b: Duration): Schedule = + if a == Duration.Zero && b == Duration.Zero then immediate.forever + else Fibonacci(a, b) + + /** Creates a schedule with exponentially increasing intervals. + * + * @param initial + * the initial interval + * @param factor + * the factor by which to increase the interval + * @return + * a new schedule with exponentially increasing intervals + */ + def exponential(initial: Duration, factor: Double): Schedule = + if initial == Duration.Zero then immediate + else if factor == 1.0 then fixed(initial) + else Exponential(initial, factor) + + /** Creates a schedule with exponential backoff and a maximum delay. + * + * @param initial + * the initial interval + * @param factor + * the factor by which to increase the interval + * @param maxBackoff + * the maximum delay allowed + * @return + * a new schedule with exponential backoff and a maximum delay + */ + def exponentialBackoff(initial: Duration, factor: Double, maxBackoff: Duration): Schedule = + if initial == Duration.Zero then immediate + else if factor == 1.0 then fixed(initial) + else ExponentialBackoff(initial, factor, maxBackoff) + + private[kyo] object internal: + + case object Immediate extends Schedule: + val next = Maybe((Duration.Zero, Done)) + def show = "Schedule.immediate" + + case object Never extends Schedule: + def next = Maybe.empty + def show = "Schedule.never" + + case object Done extends Schedule: + def next = Maybe.empty + def show = "Schedule.done" + + final case class Fixed(interval: Duration) extends Schedule: + val next = Maybe((interval, this)) + def show = s"Schedule.fixed(${interval.show})" + + final case class Exponential(initial: Duration, factor: Double) extends Schedule: + def next = Maybe((initial, Exponential(initial * factor, factor))) + def show = s"Schedule.exponential(${initial.show}, ${formatDouble(factor)})" + + final case class Fibonacci(a: Duration, b: Duration) extends Schedule: + def next = Maybe((a, Fibonacci(b, a + b))) + def show = s"Schedule.fibonacci(${a.show}, ${b.show})" + + final case class ExponentialBackoff(initial: Duration, factor: Double, maxBackoff: Duration) extends Schedule: + def next = + val nextDelay = initial.min(maxBackoff) + Maybe((nextDelay, exponentialBackoff(nextDelay * factor, factor, maxBackoff))) + def show = s"Schedule.exponentialBackoff(${initial.show}, ${formatDouble(factor)}, ${maxBackoff.show})" + end ExponentialBackoff + + final case class Linear(base: Duration) extends Schedule: + def next = Maybe((base, linear(base + base))) + def show = s"Schedule.linear(${base.show})" + + final case class Max(a: Schedule, b: Schedule) extends Schedule: + def next = + for + (d1, s1) <- a.next + (d2, s2) <- b.next + yield (d1.max(d2), s1.max(s2)) + def show = s"(${a.show}).max(${b.show})" + end Max + + final case class Min(a: Schedule, b: Schedule) extends Schedule: + def next = + a.next match + case Absent => b.next + case n @ Present((d1, s1)) => + b.next match + case Absent => n + case Present((d2, s2)) => + Maybe((d1.min(d2), s1.min(s2))) + def show = s"(${a.show}).min(${b.show})" + end Min + + final case class Take(schedule: Schedule, remaining: Int) extends Schedule: + def next = + schedule.next.map((d, s) => (d, s.take(remaining - 1))) + def show = s"(${schedule.show}).take($remaining)" + end Take + + final case class AndThen(a: Schedule, b: Schedule) extends Schedule: + def next = + a.next.map((d, s) => (d, s.andThen(b))).orElse(b.next) + def show = s"(${a.show}).andThen(${b.show})" + end AndThen + + final case class MaxDuration(schedule: Schedule, duration: Duration) extends Schedule: + def next = + schedule.next.flatMap { (d, s) => + if d > duration then Maybe.empty + else Maybe((d, s.maxDuration(duration - d))) + } + def show = s"(${schedule.show}).maxDuration(${duration.show})" + end MaxDuration + + final case class Repeat(schedule: Schedule, remaining: Int) extends Schedule: + def next = + schedule.next.map((d, s) => (d, s.andThen(schedule.repeat(remaining - 1)))) + def show = s"(${schedule.show}).repeat($remaining)" + end Repeat + + final case class Forever(schedule: Schedule) extends Schedule: + def next = + schedule.next.map((d, s) => (d, s.andThen(this))) + def show = s"(${schedule.show}).forever" + end Forever + + final case class Delay(schedule: Schedule, duration: Duration) extends Schedule: + def next = + schedule.next.map((d, s) => (duration + d, s.delay(duration))) + def show = s"(${schedule.show}).delay(${duration.show})" + end Delay + + private def formatDouble(d: Double): String = + if d == d.toLong then f"$d%.1f" else d.toString + + end internal +end Schedule diff --git a/kyo-data/shared/src/test/scala/kyo/DurationSpec.scala b/kyo-data/shared/src/test/scala/kyo/DurationSpec.scala index d07a36b48..d5fc8445b 100644 --- a/kyo-data/shared/src/test/scala/kyo/DurationSpec.scala +++ b/kyo-data/shared/src/test/scala/kyo/DurationSpec.scala @@ -239,6 +239,23 @@ object DurationSpec extends ZIOSpecDefault: assertTrue((1000.nanos).show == "1.micros") ) } + ), + suite("Duration subtraction")( + test("subtracting smaller from larger") { + assertTrue(5.seconds - 2.seconds == 3.seconds) + }, + test("subtracting larger from smaller") { + assertTrue(2.seconds - 5.seconds == Duration.Zero) + }, + test("subtracting equal durations") { + assertTrue(3.minutes - 3.minutes == Duration.Zero) + }, + test("subtracting from zero") { + assertTrue(Duration.Zero - 1.second == Duration.Zero) + }, + test("subtracting zero") { + assertTrue(10.hours - Duration.Zero == 10.hours) + } ) ) @@ TestAspect.exceptNative end DurationSpec diff --git a/kyo-data/shared/src/test/scala/kyo/InstantTest.scala b/kyo-data/shared/src/test/scala/kyo/InstantTest.scala index 0f79cc779..b7daf6e6a 100644 --- a/kyo-data/shared/src/test/scala/kyo/InstantTest.scala +++ b/kyo-data/shared/src/test/scala/kyo/InstantTest.scala @@ -267,4 +267,56 @@ class InstantTest extends Test: } } + "min" - { + "earlier instant" in { + val instant1 = Instant.Epoch + val instant2 = instant1 + 1000.seconds + assert(instant1.min(instant2) == instant1) + } + + "later instant" in { + val instant1 = Instant.Epoch + val instant2 = instant1 - 1000.seconds + assert(instant1.min(instant2) == instant2) + } + + "equal instants" in { + val instant1 = Instant.Epoch + val instant2 = Instant.Epoch + assert(instant1.min(instant2) == instant1) + } + + "with Min and Max" in { + val instant = Instant.Epoch + assert(instant.min(Instant.Min) == Instant.Min) + assert(instant.min(Instant.Max) == instant) + } + } + + "max" - { + "earlier instant" in { + val instant1 = Instant.Epoch + val instant2 = instant1 + 1000.seconds + assert(instant1.max(instant2) == instant2) + } + + "later instant" in { + val instant1 = Instant.Epoch + val instant2 = instant1 - 1000.seconds + assert(instant1.max(instant2) == instant1) + } + + "equal instants" in { + val instant1 = Instant.Epoch + val instant2 = Instant.Epoch + assert(instant1.max(instant2) == instant1) + } + + "with Min and Max" in { + val instant = Instant.Epoch + assert(instant.max(Instant.Min) == instant) + assert(instant.max(Instant.Max) == Instant.Max) + } + } + end InstantTest diff --git a/kyo-data/shared/src/test/scala/kyo/ScheduleTest.scala b/kyo-data/shared/src/test/scala/kyo/ScheduleTest.scala new file mode 100644 index 000000000..00d54cb92 --- /dev/null +++ b/kyo-data/shared/src/test/scala/kyo/ScheduleTest.scala @@ -0,0 +1,835 @@ +package kyo + +class ScheduleTest extends Test: + + "fixed" - { + "returns correct next duration and same schedule" in { + val interval = 5.seconds + val schedule = Schedule.fixed(interval) + val (next, nextSchedule) = schedule.next.get + assert(next == interval) + assert(nextSchedule == schedule) + } + + "works with zero interval" in { + val (next, _) = Schedule.fixed(Duration.Zero).next.get + assert(next == Duration.Zero) + } + } + + "exponential" - { + "increases interval exponentially" in { + val schedule = Schedule.exponential(1.second, 2.0) + val (next1, schedule2) = schedule.next.get + val (next2, _) = schedule2.next.get + assert(next1 == 1.second) + assert(next2 == 2.seconds) + } + + "works with factor less than 1" in { + val schedule = Schedule.exponential(1.second, 0.5) + val (next1, schedule2) = schedule.next.get + val (next2, _) = schedule2.next.get + assert(next1 == 1.second) + assert(next2 == 500.millis) + } + + "handles very large intervals" in { + val schedule = Schedule.exponential(365.days, 2.0) + val (next1, schedule2) = schedule.next.get + val (next2, _) = schedule2.next.get + assert(next1 == 365.days) + assert(next2 == 730.days) + } + + "works with factor of 1" in { + val schedule = Schedule.exponential(1.second, 1.0) + val (next1, schedule2) = schedule.next.get + val (next2, _) = schedule2.next.get + assert(next1 == 1.second) + assert(next2 == 1.second) + } + } + + "fibonacci" - { + "follows fibonacci sequence" in { + val schedule = Schedule.fibonacci(1.second, 1.second) + val (next1, schedule2) = schedule.next.get + val (next2, schedule3) = schedule2.next.get + val (next3, _) = schedule3.next.get + assert(next1 == 1.second) + assert(next2 == 1.second) + assert(next3 == 2.seconds) + } + + "works with different initial values" in { + val schedule = Schedule.fibonacci(1.second, 2.seconds) + val (next1, schedule2) = schedule.next.get + val (next2, schedule3) = schedule2.next.get + val (next3, _) = schedule3.next.get + assert(next1 == 1.second) + assert(next2 == 2.seconds) + assert(next3 == 3.seconds) + } + + "works with zero initial values" in { + val schedule = Schedule.fibonacci(Duration.Zero, Duration.Zero) + val (next1, schedule2) = schedule.next.get + val (next2, schedule3) = schedule2.next.get + val (next3, _) = schedule3.next.get + assert(next1 == Duration.Zero) + assert(next2 == Duration.Zero) + assert(next3 == Duration.Zero) + } + } + + "immediate" - { + "returns zero duration" in { + val (next, nextSchedule) = Schedule.immediate.next.get + assert(next == Duration.Zero) + assert(nextSchedule == Schedule.done) + } + + "always returns never as next schedule" in { + assert(Schedule.immediate.next.flatMap(_._2.next).isEmpty) + } + } + + "never" - { + "always returns infinite duration" in { + assert(Schedule.never.next.isEmpty) + } + } + + "exponentialBackoff" - { + "respects maxDelay" in { + val initial = 1.second + val factor = 2.0 + val maxDelay = 4.seconds + val schedule = Schedule.exponentialBackoff(initial, factor, maxDelay) + val (next1, schedule2) = schedule.next.get + val (next2, schedule3) = schedule2.next.get + val (next3, _) = schedule3.next.get + assert(next1 == 1.second) + assert(next2 == 2.seconds) + assert(next3 == 4.seconds) + } + + "caps at maxDelay" in { + val initial = 1.second + val factor = 2.0 + val maxDelay = 4.seconds + val schedule = Schedule.exponentialBackoff(initial, factor, maxDelay) + var current = schedule + for _ <- 1 to 5 do + val (nextDuration, nextSchedule) = current.next.get + assert(nextDuration <= maxDelay) + current = nextSchedule + end for + succeed + } + + "works with factor less than 1" in { + val initial = 4.seconds + val factor = 0.5 + val maxDelay = 4.seconds + val schedule = Schedule.exponentialBackoff(initial, factor, maxDelay) + val (next1, schedule2) = schedule.next.get + val (next2, _) = schedule2.next.get + assert(next1 == 4.seconds) + assert(next2 == 2.seconds) + } + } + + "repeat" - { + "repeats specified number of times" in { + val schedule = Schedule.repeat(3) + val (next1, schedule2) = schedule.next.get + val (next2, schedule3) = schedule2.next.get + val (next3, schedule4) = schedule3.next.get + val next4 = schedule4.next + assert(next1 == Duration.Zero) + assert(next2 == Duration.Zero) + assert(next3 == Duration.Zero) + assert(next4.isEmpty) + } + + "works with zero repetitions" in { + val schedule = Schedule.repeat(0) + assert(schedule.next.isEmpty) + } + + "works with finite inner schedule" in { + val innerSchedule = Schedule.fixed(1.second).take(2) + val s = innerSchedule.repeat(3) + val results = List.unfold(s) { schedule => + schedule.next.map((next, newSchedule) => Some((next, newSchedule))).getOrElse(None) + } + assert(results == List(1.second, 1.second, 1.second, 1.second, 1.second, 1.second)) + } + + "repeats correct number of times with complex inner schedule" in { + val s = Schedule.immediate.andThen(Schedule.fixed(1.second).take(1)).repeat(2) + val (next1, s2) = s.next.get + val (next2, s3) = s2.next.get + val (next3, s4) = s3.next.get + val (next4, s5) = s4.next.get + val next5 = s5.next + assert(next1 == Duration.Zero) + assert(next2 == 1.second) + assert(next3 == Duration.Zero) + assert(next4 == 1.second) + assert(next5.isEmpty) + } + + "Schedule.repeat" - { + "repeats immediate schedule specified number of times" in { + val s = Schedule.repeat(3) + val (next1, s2) = s.next.get + val (next2, s3) = s2.next.get + val (next3, s4) = s3.next.get + val next4 = s4.next + + assert(next1 == Duration.Zero) + assert(next2 == Duration.Zero) + assert(next3 == Duration.Zero) + assert(next4.isEmpty) + } + + "works with zero repetitions" in { + assert(Schedule.repeat(0).next.isEmpty) + } + + "can be chained with other schedules" in { + val s = Schedule.repeat(2).andThen(Schedule.fixed(1.second)) + val (next1, s2) = s.next.get + val (next2, s3) = s2.next.get + val (next3, s4) = s3.next.get + val (next4, _) = s4.next.get + + assert(next1 == Duration.Zero) + assert(next2 == Duration.Zero) + assert(next3 == 1.second) + assert(next4 == 1.second) + } + + } + } + + "linear" - { + "increases interval linearly" in { + val base = 1.second + val schedule = Schedule.linear(base) + val (next1, schedule2) = schedule.next.get + val (next2, schedule3) = schedule2.next.get + val (next3, _) = schedule3.next.get + assert(next1 == 1.second) + assert(next2 == 2.seconds) + assert(next3 == 4.seconds) + } + + "works with zero base" in { + val schedule = Schedule.linear(Duration.Zero) + val (next1, schedule2) = schedule.next.get + val (next2, _) = schedule2.next.get + assert(next1 == Duration.Zero) + assert(next2 == Duration.Zero) + } + } + + "max" - { + "returns later of two schedules" in { + val s1 = Schedule.fixed(1.second) + val s2 = Schedule.fixed(2.seconds) + val combined = s1.max(s2) + val (next, _) = combined.next.get + assert(next == 2.seconds) + } + + "handles one schedule being never" in { + val s1 = Schedule.fixed(1.second) + val s2 = Schedule.never + assert(s1.max(s2).next.isEmpty) + } + + "handles both schedules being never" in { + val combined = Schedule.never.max(Schedule.never) + assert(combined.next.isEmpty) + } + } + + "min" - { + "returns earlier of two schedules" in { + val s1 = Schedule.fixed(1.second) + val s2 = Schedule.fixed(2.seconds) + val combined = s1.min(s2) + val (next, _) = combined.next.get + assert(next == 1.second) + } + + "handles one schedule being immediate" in { + val s1 = Schedule.fixed(1.second) + val s2 = Schedule.immediate + val combined = s1.min(s2) + val (next, _) = combined.next.get + assert(next == Duration.Zero) + } + + "handles both schedules being immediate" in { + val combined = Schedule.immediate.min(Schedule.immediate) + val (next, _) = combined.next.get + assert(next == Duration.Zero) + } + } + + "take" - { + "limits number of executions" in { + val s = Schedule.fixed(1.second).take(2) + val (next1, s2) = s.next.get + val (next2, s3) = s2.next.get + val next3 = s3.next + assert(next1 == 1.second) + assert(next2 == 1.second) + assert(next3.isEmpty) + } + + "returns never for non-positive count" in { + val s = Schedule.fixed(1.second).take(0) + assert(s == Schedule.done) + } + } + + "andThen" - { + "switches to second schedule after first completes" in { + val s1 = Schedule.repeat(2) + val s2 = Schedule.fixed(1.second) + val combined = s1.andThen(s2) + val (next1, c2) = combined.next.get + val (next2, c3) = c2.next.get + val (next3, _) = c3.next.get + assert(next1 == Duration.Zero) + assert(next2 == Duration.Zero) + assert(next3 == 1.second) + } + + "works with never as first schedule" in { + val s1 = Schedule.never + val s2 = Schedule.fixed(1.second) + val combined = s1.andThen(s2) + assert(combined.next.isEmpty) + } + + "works with never as second schedule" in { + val s1 = Schedule.immediate + val s2 = Schedule.never + val combined = s1.andThen(s2) + val (next1, c2) = combined.next.get + val next2 = c2.next + assert(next1 == Duration.Zero) + assert(next2.isEmpty) + } + + "chains multiple schedules" in { + val s = Schedule.immediate.andThen(Schedule.fixed(1.second).take(1)).andThen(Schedule.fixed(2.seconds).take(1)) + val (next1, s2) = s.next.get + val (next2, s3) = s2.next.get + val (next3, s4) = s3.next.get + val next4 = s4.next + assert(next1 == Duration.Zero) + assert(next2 == 1.second) + assert(next3 == 2.seconds) + assert(next4.isEmpty) + } + } + + "maxDuration" - { + "stops after specified duration" in { + val s = Schedule.fixed(1.second).maxDuration(2.seconds + 500.millis) + val (next1, s2) = s.next.get + val (next2, s3) = s2.next.get + val next3 = s3.next + assert(next1 == 1.second) + assert(next2 == 1.second) + assert(next3.isEmpty) + } + + "works with zero duration" in { + val s = Schedule.fixed(1.second).maxDuration(Duration.Zero) + assert(s.next.isEmpty) + } + + "works with complex schedule" in { + val s = Schedule.exponential(1.second, 2.0).repeat(5).maxDuration(7.seconds) + val results = List.unfold(s) { schedule => + schedule.next.map((next, newSchedule) => Some((next, newSchedule))).getOrElse(None) + } + assert(results == List(1.second, 2.seconds, 4.seconds)) + } + + "limits duration correctly with delayed start" in { + val s = Schedule.fixed(2.seconds).take(1).andThen(Schedule.linear(1.second)).maxDuration(5.seconds) + val (next1, s2) = s.next.get + val (next2, s3) = s2.next.get + val (next3, s4) = s3.next.get + val next4 = s4.next + assert(next1 == 2.seconds) + assert(next2 == 1.second) + assert(next3 == 2.seconds) + assert(next4.isEmpty) + } + } + + "forever" - { + "repeats indefinitely" in { + val s = Schedule.repeat(1).forever + val (next1, s2) = s.next.get + val (next2, s3) = s2.next.get + val (next3, _) = s3.next.get + assert(next1 == Duration.Zero) + assert(next2 == Duration.Zero) + assert(next3 == Duration.Zero) + } + + "works with never schedule" in { + assert(Schedule.never.forever.next.isEmpty) + } + + "works with immediate schedule" in { + val s = Schedule.immediate.forever + val (next1, s2) = s.next.get + val (next2, _) = s2.next.get + assert(next1 == Duration.Zero) + assert(next2 == Duration.Zero) + } + + "works with fixed schedule" in { + val s = Schedule.fixed(1.second).forever + val (next1, s2) = s.next.get + val (next2, s3) = s2.next.get + val (next3, _) = s3.next.get + assert(next1 == 1.second) + assert(next2 == 1.second) + assert(next3 == 1.second) + } + + "works with exponential schedule" in { + val s = Schedule.exponential(1.second, 2.0).forever + val (next1, s2) = s.next.get + val (next2, s3) = s2.next.get + val (next3, _) = s3.next.get + assert(next1 == 1.second) + assert(next2 == 2.seconds) + assert(next3 == 4.seconds) + } + + "works with complex schedule" in { + val s = (Schedule.immediate.andThen(Schedule.fixed(1.second).take(1))).forever + val (next1, s2) = s.next.get + val (next2, s3) = s2.next.get + val (next3, s4) = s3.next.get + val (next4, _) = s4.next.get + assert(next1 == Duration.Zero) + assert(next2 == 1.second) + assert(next3 == Duration.Zero) + assert(next4 == 1.second) + } + } + + "delay" - { + "adds fixed delay to each interval" in { + val original = Schedule.fixed(1.second) + val delayed = original.delay(500.millis) + + val (next1, s2) = delayed.next.get + val (next2, _) = s2.next.get + + assert(next1 == 1500.millis) + assert(next2 == 1500.millis) + } + + "works with zero delay" in { + val original = Schedule.fixed(1.second) + val delayed = original.delay(Duration.Zero) + + val (next, _) = delayed.next.get + + assert(next == 1.second) + } + + "works with immediate schedule" in { + val delayed = Schedule.immediate.delay(1.second) + + val (next1, s1) = delayed.next.get + val next2 = s1.next + + assert(next1 == 1.second) + assert(next2.isEmpty) + } + + "works with never schedule" in { + val delayed = Schedule.never.delay(1.second) + assert(delayed.next.isEmpty) + } + + "works with complex schedule" in { + val original = Schedule.exponential(1.second, 2.0).take(3) + val delayed = original.delay(500.millis) + + val (next1, s2) = delayed.next.get + val (next2, s3) = s2.next.get + val (next3, s4) = s3.next.get + val next4 = s4.next + + assert(next1 == 1500.millis) + assert(next2 == 2500.millis) + assert(next3 == 4500.millis) + assert(next4.isEmpty) + } + + "Schedule.delay" - { + "creates a delayed immediate schedule" in { + val s = Schedule.delay(1.second) + val (next1, s2) = s.next.get + val next2 = s2.next + + assert(next1 == 1.second) + assert(next2.isEmpty) + } + + "works with zero delay" in { + val s = Schedule.delay(Duration.Zero) + val (next, _) = s.next.get + + assert(next == Duration.Zero) + } + + "can be chained with other schedules" in { + val s = Schedule.delay(500.millis).andThen(Schedule.fixed(1.second)) + val (next1, s2) = s.next.get + val (next2, _) = s2.next.get + + assert(next1 == 500.millis) + assert(next2 == 1.second) + } + + "works in combination with other schedules" in { + val s1 = Schedule.fixed(1.second).take(2) + val s2 = Schedule.exponential(2.seconds, 2.0).take(2) + val combined = s1.andThen(Schedule.delay(3.seconds)).andThen(s2) + + val (next1, c2) = combined.next.get + val (next2, c3) = c2.next.get + val (next3, c4) = c3.next.get + val (next4, c5) = c4.next.get + val (next5, _) = c5.next.get + + assert(next1 == 1.second) + assert(next2 == 1.second) + assert(next3 == 3.seconds) + assert(next4 == 2.seconds) + assert(next5 == 4.seconds) + } + } + } + + "complex schedules" - { + "combines max and min schedules" in { + val s1 = Schedule.fixed(1.second) + val s2 = Schedule.fixed(2.seconds) + val s3 = Schedule.fixed(3.seconds) + val combined = s1.max(s2).min(s3) + + val (next1, c2) = combined.next.get + val (next2, _) = c2.next.get + + assert(next1 == 2.seconds) + assert(next2 == 2.seconds) + } + + "limits a forever schedule" in { + val s = Schedule.exponential(1.second, 2.0).forever.maxDuration(5.seconds) + + val (next1, s2) = s.next.get + val (next2, s3) = s2.next.get + val next3 = s3.next + + assert(next1 == 1.second) + assert(next2 == 2.seconds) + assert(next3.isEmpty) + } + + "combines repeat with exponential backoff" in { + val s = Schedule.repeat(3).andThen(Schedule.exponentialBackoff(1.second, 2.0, 8.seconds)) + + val (next1, s2) = s.next.get + val (next2, s3) = s2.next.get + val (next3, s4) = s3.next.get + val (next4, s5) = s4.next.get + val (next5, _) = s5.next.get + + assert(next1 == Duration.Zero) + assert(next2 == Duration.Zero) + assert(next3 == Duration.Zero) + assert(next4 == 1.second) + assert(next5 == 2.seconds) + } + } + + "schedule reduction and equality" - { + "max reduction" in { + val s1 = Schedule.fixed(1.second) + val s2 = Schedule.fixed(2.seconds) + val s3 = Schedule.never + val s4 = Schedule.immediate + + assert(s1.max(s3) == s3) + assert(s3.max(s1) == s3) + assert(s1.max(s4) == s1) + assert(s4.max(s1) == s1) + assert(s3.max(s4) == s3) + assert(s4.max(s3) == s3) + } + + "min reduction" in { + val s1 = Schedule.fixed(1.second) + val s2 = Schedule.fixed(2.seconds) + val s3 = Schedule.never + val s4 = Schedule.immediate + + assert(s1.min(s3) == s1) + assert(s3.min(s1) == s1) + assert(s1.min(s4) == s4) + assert(s4.min(s1) == s4) + assert(s3.min(s4) == s4) + assert(s4.min(s3) == s4) + } + + "take reduction" in { + val s1 = Schedule.fixed(1.second) + + assert(s1.take(0) == Schedule.done) + assert(Schedule.never.take(3) == Schedule.never) + } + + "andThen reduction" in { + val s1 = Schedule.fixed(1.second) + val s2 = Schedule.fixed(2.seconds) + + assert(Schedule.never.andThen(s1) == Schedule.never) + } + + "maxDuration reduction" in { + val s1 = Schedule.fixed(1.second) + val duration = 5.seconds + + assert(Schedule.never.maxDuration(duration) == Schedule.never) + } + + "forever reduction" in { + val s1 = Schedule.fixed(1.second) + + assert(Schedule.never.forever == Schedule.never) + assert(Schedule.done.forever == Schedule.done) + } + + "correctly compares complex schedules" in { + val s1 = Schedule.exponential(1.second, 2.0).take(3) + val s2 = Schedule.exponential(1.second, 2.0).take(3) + val s3 = Schedule.exponential(1.second, 2.0).take(4) + + assert(s1 == s2) + assert(s1 != s3) + } + + "handles equality with forever schedules" in { + val s1 = Schedule.fixed(1.second).forever + val s2 = Schedule.fixed(1.second).forever + val s3 = Schedule.fixed(2.seconds).forever + + assert(s1 == s2) + assert(s1 != s3) + } + + "reduces exponential schedule with factor 1 to fixed schedule" in { + val s1 = Schedule.exponential(1.second, 1.0) + val s2 = Schedule.fixed(1.second) + + assert(s1 == s2) + } + + "reduces exponential backoff schedule with factor 1 to fixed schedule" in { + val s1 = Schedule.exponentialBackoff(1.second, 1.0, 10.seconds) + val s2 = Schedule.fixed(1.second) + + assert(s1 == s2) + } + + "reduces linear schedule with zero base to immediate schedule" in { + val s1 = Schedule.linear(Duration.Zero) + val s2 = Schedule.immediate.forever + + assert(s1 == s2) + } + + "reduces fibonacci schedule with zero initial values to immediate forever" in { + val s1 = Schedule.fibonacci(Duration.Zero, Duration.Zero) + val s2 = Schedule.immediate.forever + + assert(s1 == s2) + } + + "reduces exponential schedule with zero initial to immediate" in { + val s1 = Schedule.exponential(Duration.Zero, 2.0) + val s2 = Schedule.immediate + + assert(s1 == s2) + } + + "reduces delay with zero duration to original schedule" in { + val original = Schedule.fixed(1.second) + val delayed = original.delay(Duration.Zero) + assert(delayed == original) + } + + "reduces maxDuration with infinite duration to original schedule" in { + val original = Schedule.fixed(1.second) + val limited = original.maxDuration(Duration.Infinity) + assert(limited == original) + } + + "reduces repeat with count 1 to original schedule" in { + val original = Schedule.fixed(1.second) + val repeated = original.repeat(1) + assert(repeated == original) + } + + "reduces andThen with immediate to original schedule" in { + val original = Schedule.fixed(1.second) + val chained = original.andThen(Schedule.immediate) + assert(chained == original) + } + + "reduces forever of forever to single forever" in { + val original = Schedule.fixed(1.second) + val doubleForever = original.forever + assert(doubleForever == original.forever) + } + + "reduces delay of never to never" in { + val delayed = Schedule.never.delay(1.second) + assert(delayed == Schedule.never) + } + + "reduces maxDuration of immediate to immediate" in { + val limited = Schedule.immediate.maxDuration(1.second) + assert(limited == Schedule.immediate) + } + + "reduces andThen of done and any schedule to that schedule" in { + val s = Schedule.fixed(1.second) + val chained = Schedule.done.andThen(s) + assert(chained == s) + } + + "reduces repeat with count 0 to done" in { + val original = Schedule.fixed(1.second) + val repeated = original.repeat(0) + assert(repeated == Schedule.done) + } + + "reduces take with count 0 to done" in { + val original = Schedule.fixed(1.second) + val taken = original.take(0) + assert(taken == Schedule.done) + } + + "reduces andThen with never to original schedule" in { + val original = Schedule.fixed(1.second) + val chained = original.andThen(Schedule.never) + assert(chained == original) + } + + "reduces max of done" in { + val s = Schedule.fixed(1.second) + val maxed = Schedule.done.max(s) + assert(maxed == s) + } + + "reduces min of never and any schedule to that schedule" in { + val s = Schedule.fixed(1.second) + val minned = Schedule.never.min(s) + assert(minned == s) + } + + "reduces delay of done to done" in { + val delayed = Schedule.done.delay(1.second) + assert(delayed == Schedule.done) + } + + "reduces delay of immediate to fixed delay" in { + val delayed = Schedule.immediate.delay(1.second) + assert(delayed == Schedule.delay(1.second)) + } + + "reduces maxDuration of never to never" in { + val limited = Schedule.never.maxDuration(1.second) + assert(limited == Schedule.never) + } + + "reduces forever of never to never" in { + val foreverNever = Schedule.never.forever + assert(foreverNever == Schedule.never) + } + + "reduces forever of done to done" in { + val foreverDone = Schedule.done.forever + assert(foreverDone == Schedule.done) + } + } + + "show" - { + "correctly represents simple schedules" in { + assert(Schedule.immediate.show == "Schedule.immediate") + assert(Schedule.never.show == "Schedule.never") + assert(Schedule.done.show == "Schedule.done") + assert(Schedule.fixed(1.second).show == s"Schedule.fixed(${1.second.show})") + assert(Schedule.linear(2.seconds).show == s"Schedule.linear(${2.seconds.show})") + assert(Schedule.exponential(1.second, 2.0).show == s"Schedule.exponential(${1.second.show}, 2.0)") + assert(Schedule.fibonacci(1.second, 2.seconds).show == s"Schedule.fibonacci(${1.second.show}, ${2.seconds.show})") + assert(Schedule.exponentialBackoff(1.second, 2.0, 10.seconds) + .show == s"Schedule.exponentialBackoff(${1.second.show}, 2.0, ${10.seconds.show})") + } + + "correctly represents composite schedules" in { + val s1 = Schedule.fixed(1.second).take(3) + assert(s1.show == s"(Schedule.fixed(${1.second.show})).take(3)") + + val s2 = Schedule.exponential(1.second, 2.0).forever + assert(s2.show == s"(Schedule.exponential(${1.second.show}, 2.0)).forever") + + val s3 = Schedule.fixed(1.second).max(Schedule.fixed(2.seconds)) + assert(s3.show == s"(Schedule.fixed(${1.second.show})).max(Schedule.fixed(${2.seconds.show}))") + + val s4 = Schedule.immediate.andThen(Schedule.fixed(1.second)) + assert(s4.show == s"(Schedule.immediate).andThen(Schedule.fixed(${1.second.show}))") + } + + "correctly represents complex composite schedules" in { + val s = Schedule.exponential(1.second, 2.0) + .take(5) + .andThen(Schedule.fixed(10.seconds)) + .forever + .maxDuration(1.minute) + + assert( + s.show == s"((((Schedule.exponential(${1.second.show}, 2.0)).take(5)).andThen(Schedule.fixed(${10.seconds.show}))).forever).maxDuration(${1.minute.show})" + ) + } + + "correctly represents schedules with delay" in { + val s1 = Schedule.fixed(1.second).delay(500.millis) + assert(s1.show == s"(Schedule.fixed(${1.second.show})).delay(${500.millis.show})") + } + } + +end ScheduleTest