diff --git a/modules/effectie-time/shared/src/main/scala/effectie/time/TimeSource.scala b/modules/effectie-time/shared/src/main/scala/effectie/time/TimeSource.scala new file mode 100644 index 00000000..aae4f522 --- /dev/null +++ b/modules/effectie-time/shared/src/main/scala/effectie/time/TimeSource.scala @@ -0,0 +1,94 @@ +package effectie.time + +import cats._ +import cats.syntax.all._ +import effectie.core._ +import effectie.syntax.all._ +import effectie.time.TimeSource.TimeSpent + +import java.time.Instant +import scala.concurrent.duration.{FiniteDuration, MILLISECONDS, NANOSECONDS, SECONDS, TimeUnit} + +/** @author Kevin Lee + */ +trait TimeSource[F[*]] { + + implicit protected def M: Monad[F] + + def name: String + + def currentTime(): F[Instant] + + def realTimeTo(unit: TimeUnit): F[FiniteDuration] + + def monotonicTo(unit: TimeUnit): F[FiniteDuration] + + def realTime: F[FiniteDuration] + + def monotonic: F[FiniteDuration] + + def timeSpent[A](fa: => F[A]): F[(A, TimeSpent)] = + for { + start <- monotonic + a <- fa + end <- monotonic + } yield (a, TimeSpent(end - start)) + + override def toString: String = s"TimeSource(name=$name)" +} + +object TimeSource { + + def apply[F[*]: TimeSource]: TimeSource[F] = implicitly[TimeSource[F]] + + implicit def showTimeSource[F[*]]: Show[TimeSource[F]] = Show.fromToString + + def withSources[F[*]: Fx: Monad]( + timeSourceName: String, + realTimeSource: F[Instant], + monotonicSource: F[Long], + ): TimeSource[F] = + new DefaultTimeSource[F](timeSourceName, realTimeSource, monotonicSource) + + private class DefaultTimeSource[F[_]: Fx]( + timeSourceName: String, + realTimeSource: F[Instant], + monotonicSource: F[Long], + )(implicit protected val M: Monad[F]) + extends TimeSource[F] { + + override val name: String = timeSourceName + + override val toString: String = s"DefaultTimeSource(name=$name)" + + override def currentTime(): F[Instant] = realTimeSource + + override def realTimeTo(unit: TimeUnit): F[FiniteDuration] = + for { + now <- currentTime() + real <- effectOf(unit.convert(now.getEpochSecond, SECONDS) + unit.convert(now.getNano.toLong, NANOSECONDS)) + } yield FiniteDuration(real, unit) + + override def monotonicTo(unit: TimeUnit): F[FiniteDuration] = + monotonicSource.map(nano => FiniteDuration(unit.convert(nano, NANOSECONDS), unit)) + + override def realTime: F[FiniteDuration] = realTimeTo(MILLISECONDS) + + override def monotonic: F[FiniteDuration] = monotonicTo(NANOSECONDS) + } + + /** The default behaviours of TimeSource depend on Instant and System. + * For realTime, it uses Instant.now() to get the epoch seconds and nano seconds. + * For monotonic, it uses System.nanoTime() to get the nano seconds. + * + * NOTE: To get the current time (Instant.now()), you should use realTime not monotonic. + */ + def default[F[*]: Fx: Monad](timeSourceName: String): TimeSource[F] = + withSources( + timeSourceName, + effectOf(Instant.now()), + effectOf(System.nanoTime()), + ) + + final case class TimeSpent(timeSpent: FiniteDuration) extends AnyVal +} diff --git a/modules/effectie-time/shared/src/main/scala/effectie/time/syntax.scala b/modules/effectie-time/shared/src/main/scala/effectie/time/syntax.scala index 80ddc018..3de3d01e 100644 --- a/modules/effectie-time/shared/src/main/scala/effectie/time/syntax.scala +++ b/modules/effectie-time/shared/src/main/scala/effectie/time/syntax.scala @@ -1,5 +1,7 @@ package effectie.time +import effectie.time.TimeSource.TimeSpent + import scala.concurrent.duration.FiniteDuration /** @author Kevin Lee @@ -8,6 +10,9 @@ import scala.concurrent.duration.FiniteDuration trait syntax { implicit def FiniteDurationExtraOps(finiteDuration: FiniteDuration): syntax.FiniteDurationExtraOps = new syntax.FiniteDurationExtraOps(finiteDuration) + + implicit def fAWithTimeOps[F[*], A](fa: F[A]): syntax.FAWithTimeOps[F[*], A] = new syntax.FAWithTimeOps[F[*], A](fa) + } object syntax extends syntax { final class FiniteDurationExtraOps(private val finiteDuration: FiniteDuration) extends AnyVal { @@ -18,4 +23,8 @@ object syntax extends syntax { finiteDuration >= approxFiniteDuration.min && finiteDuration <= approxFiniteDuration.max } + final class FAWithTimeOps[F[*], A](private val fa: F[A]) extends AnyVal { + def withTimeSpent(implicit timeSource: TimeSource[F]): F[(A, TimeSpent)] = timeSource.timeSpent(fa) + } + } diff --git a/modules/effectie-time/shared/src/test/scala/effectie/time/TimeSourceSpec.scala b/modules/effectie-time/shared/src/test/scala/effectie/time/TimeSourceSpec.scala new file mode 100644 index 00000000..7b29e7d5 --- /dev/null +++ b/modules/effectie-time/shared/src/test/scala/effectie/time/TimeSourceSpec.scala @@ -0,0 +1,241 @@ +package effectie.time + +import cats.effect._ +import cats.syntax.all._ +import effectie.syntax.all._ +import effectie.time.syntax._ +import hedgehog._ +import hedgehog.runner._ + +import java.time.Instant +import scala.concurrent.duration._ + +object TimeSourceSpec extends Properties { + import effectie.instances.ce2.fx.ioFx + + type F[A] = IO[A] + val F = IO + + implicit val timer: Timer[F] = F.timer(scala.concurrent.ExecutionContext.global) + + override def tests: List[Test] = List( + example("test Show[TimeSource]", testShowTimeSource), + example("example test realTime and monotonic", testRealTimeAndMonotonicExample), + property("property test realTime and monotonic", testRealTimeAndMonotonicProperty), + example("test default TimeSource - realTime", testDefaultTimeSourceRealTime), + example("test default TimeSource - monotonic", testDefaultTimeSourceMonotonic), + property("test default TimeSource - timeSpent", testDefaultTimeSourceTimeSpent).withTests(count = 5), + ) + + private def runIO(test: F[Result]): Result = test.unsafeRunSync() + + def testShowTimeSource: Result = { + val name = "test TimeSource" + val expected = s"DefaultTimeSource(name=$name)" + val actual = TimeSource.default[F](name).show + actual ==== expected + } + + def testRealTimeAndMonotonicExample: Result = runIO { + val epochSeconds = 123456789L + val nanoSeconds = 123456789L + val fullNanos = epochSeconds * 1000000000L + nanoSeconds + + val expectedMillis = fullNanos / 1000000L + val expectedNanos = fullNanos + + implicit val sourcedTimeSource: TimeSource[F] = TimeSource.withSources[F]( + "Test", + pureOf(Instant.ofEpochSecond(epochSeconds, nanoSeconds)), + pureOf(fullNanos), + ) + + val timeSource = TimeSource[F] + + import scala.concurrent.duration._ + List( + timeSource + .realTime + .map(time => (time ==== expectedMillis.milliseconds).log("timeSource.realTime")), + timeSource + .realTimeTo(MILLISECONDS) + .map(time => (time ==== expectedMillis.milliseconds).log("timeSource.realTimeTo(MILLISECONDS)")), + timeSource + .realTimeTo(NANOSECONDS) + .map(time => (time ==== expectedNanos.nanoseconds).log("timeSource.realTimeTo(NANOSECONDS)")), + timeSource + .monotonic + .map(time => (time ==== expectedNanos.nanoseconds).log("timeSource.monotonic")), + timeSource + .monotonicTo(MILLISECONDS) + .map(time => (time ==== expectedMillis.milliseconds).log("timeSource.monotonicTo(MILLISECONDS)")), + timeSource + .monotonicTo(NANOSECONDS) + .map(time => (time ==== expectedNanos.nanoseconds).log("timeSource.monotonicTo(NANOSECONDS)")), + ).sequence + .map(Result.all) + } + + def testRealTimeAndMonotonicProperty: Property = { + for { + epochSeconds <- Gen.long(Range.linear(1L, 999999999L)).log("epochSeconds") + nanoSeconds <- Gen.long(Range.linear(1L, 999999999L)).log("nanoSeconds") + fullNanos <- Gen.constant(epochSeconds * 1000000000L + nanoSeconds).log("fullNanos") + + } yield runIO { + val expectedMillis = fullNanos / 1000000L + val expectedNanos = fullNanos + + val timeSource = TimeSource.withSources[F]( + "Test", + pureOf(Instant.ofEpochSecond(epochSeconds, nanoSeconds)), + pureOf(fullNanos), + ) + + import scala.concurrent.duration._ + List( + timeSource + .realTime + .map(time => (time ==== expectedMillis.milliseconds).log("timeSource.realTime")), + timeSource + .realTimeTo(MILLISECONDS) + .map(time => (time ==== expectedMillis.milliseconds).log("timeSource.realTimeTo(MILLISECONDS)")), + timeSource + .realTimeTo(NANOSECONDS) + .map(time => (time ==== expectedNanos.nanoseconds).log("timeSource.realTimeTo(NANOSECONDS)")), + timeSource + .monotonic + .map(time => (time ==== expectedNanos.nanoseconds).log("timeSource.monotonic")), + timeSource + .monotonicTo(MILLISECONDS) + .map(time => (time ==== expectedMillis.milliseconds).log("timeSource.monotonicTo(MILLISECONDS)")), + timeSource + .monotonicTo(NANOSECONDS) + .map(time => (time ==== expectedNanos.nanoseconds).log("timeSource.monotonicTo(NANOSECONDS)")), + ).sequence + .map(Result.all) + } + } + + def testDefaultTimeSourceRealTime: Result = runIO { + + val now = Instant.now() + val expectedNanos = now.getEpochSecond * 1000000000L + now.getNano + val expectedMillis = expectedNanos / 1000000L + + val timeSource = TimeSource.default[F]( + "Test" + ) + + List( + timeSource + .realTime + .map(time => + Result + .diff(time, (expectedMillis.milliseconds +- 1000.milliseconds))(_.isWithIn(_)) + .log("timeSource.realTime"), + ), + timeSource + .realTimeTo(MILLISECONDS) + .map(time => + Result + .diff(time, (expectedMillis.milliseconds +- 1000.milliseconds))(_.isWithIn(_)) + .log("timeSource.realTimeTo(MILLISECONDS)"), + ), + timeSource + .realTimeTo(NANOSECONDS) + .map(time => + Result + .diff(time, (expectedNanos.nanoseconds +- 1000.milliseconds))(_.isWithIn(_)) + .log("timeSource.realTimeTo(NANOSECONDS)"), + ), + ).sequence + .map(Result.all) + } + + def testDefaultTimeSourceMonotonic: Result = runIO { + + val expectedNanos = System.nanoTime() + val expectedMillis = expectedNanos / 1000000L + + val timeSource = TimeSource.default[F]( + "Test" + ) + + List( + timeSource + .monotonic + .map(time => + Result + .diff(time, (expectedNanos.nanoseconds +- 1000.milliseconds))(_.isWithIn(_)) + .log("timeSource.monotonic"), + ), + timeSource + .monotonicTo(MILLISECONDS) + .map(time => + Result + .diff(time, (expectedMillis.milliseconds +- 1000.milliseconds))(_.isWithIn(_)) + .log("timeSource.monotonicTo(MILLISECONDS)"), + ), + timeSource + .monotonicTo(NANOSECONDS) + .map(time => + Result + .diff(time, (expectedNanos.nanoseconds +- 1000.milliseconds))(_.isWithIn(_)) + .log("timeSource.monotonicTo(NANOSECONDS)"), + ), + ).sequence + .map(Result.all) + } + + def testDefaultTimeSourceTimeSpent: Property = { + for { + waitFor <- Gen.int(Range.linear(200, 700)).map(_.milliseconds).log("waitFor") + diff <- Gen.constant(180.milliseconds).log("diff") + } yield runIO { + val timeSource = TimeSource.default[F]( + "Test" + ) + + for { + _ <- F.sleep(500.milliseconds) // warm up + resultAndTimeSpent <- timeSource.timeSpent { + F.sleep(waitFor) *> + pureOf("Done") + } + (result, timeSpent) = resultAndTimeSpent + _ <- effectOf( + println( + s""">>> waitFor: ${waitFor.show} + |>>> timeSpent: ${timeSpent.timeSpent.toMillis.show} milliseconds + |>>> diff: ${(timeSpent.timeSpent - waitFor).toMillis.show} milliseconds + |""".stripMargin + ) + ) + } yield { + Result.all( + List( + result ==== "Done", + Result + .diffNamed( + s"timeSpent (${timeSpent.timeSpent.toMillis.show} milliseconds) should be " + + s"within ${(waitFor - diff).show} to ${(waitFor + diff).show}.", + timeSpent, + (waitFor +- diff), + )(_.timeSpent.isWithIn(_)) + .log( + s"""--- diff test log --- + |> actual: ${timeSpent.timeSpent.toMillis.show} milliseconds + |> expected range: ${(waitFor - diff).show} to ${(waitFor + diff).show} + |> waitFor: ${waitFor.show} + |> expected diff: +- ${diff.show}) + |> actual diff: ${(timeSpent.timeSpent - waitFor).toMillis.show} milliseconds + |""".stripMargin + ), + ) + ) + } + } + } + +} diff --git a/modules/effectie-time/shared/src/test/scala/effectie/time/syntaxSpec.scala b/modules/effectie-time/shared/src/test/scala/effectie/time/syntaxSpec.scala index b0c40035..4d308fd5 100644 --- a/modules/effectie-time/shared/src/test/scala/effectie/time/syntaxSpec.scala +++ b/modules/effectie-time/shared/src/test/scala/effectie/time/syntaxSpec.scala @@ -1,6 +1,8 @@ package effectie.time +import cats.effect.{IO, Timer} import cats.syntax.all._ +import effectie.syntax.all._ import effectie.time.syntax._ import hedgehog._ import hedgehog.runner._ @@ -12,12 +14,22 @@ import scala.concurrent.duration._ */ object syntaxSpec extends Properties { + import effectie.instances.ce2.fx.ioFx + + type F[A] = IO[A] + val F = IO + + implicit val timer: Timer[F] = F.timer(scala.concurrent.ExecutionContext.global) + override def tests: List[Test] = List( property("test FiniteDuration +- FiniteDuration to create ApproxFiniteDuration", testPlusMinus), property("test FiniteDuration.isWithIn(ApproxFiniteDuration) with valid FiniteDuration", testIsWithInValid), property("test FiniteDuration.isWithIn(ApproxFiniteDuration) with invalid FiniteDuration", testIsWithInInvalid), + property("test F[A].withTimeSpent", testWithTimeSpent).withTests(count = 5), ) + private def runIO(test: F[Result]): Result = test.unsafeRunSync() + def testPlusMinus: Property = for { tolerance <- Gen.int(Range.linear(0, Int.MaxValue >> 1)).log("tolerance") @@ -96,4 +108,54 @@ object syntaxSpec extends Properties { ) } + def testWithTimeSpent: Property = { + for { + waitFor <- Gen.int(Range.linear(200, 700)).map(_.milliseconds).log("waitFor") + diff <- Gen.constant(180.milliseconds).log("diff") + approx <- Gen.constant(waitFor +- diff).log("approx") + } yield runIO { + implicit val timeSource: TimeSource[F] = TimeSource.default[F]("Test") + + for { + _ <- F.sleep(500.milliseconds) // warm up + resultAndTimeSpent <- { + F.sleep(waitFor) *> + pureOf("Done") + }.withTimeSpent + (result, timeSpent) = resultAndTimeSpent + _ <- effectOf( + println( + show""">>> waitFor: $waitFor + |>>> timeSpent: ${timeSpent.timeSpent.toMillis} milliseconds + |>>> diff: ${(timeSpent.timeSpent - waitFor).toMillis} milliseconds + |>>> expected range: $approx + |""".stripMargin + ) + ) + } yield { + Result.all( + List( + result ==== "Done", + Result + .diffNamed( + s"timeSpent (${timeSpent.timeSpent.toMillis.show} milliseconds) should be " + + show"within $approx.", + timeSpent, + approx, + )(_.timeSpent.isWithIn(_)) + .log( + show"""--- diff test log --- + |> actual: ${timeSpent.timeSpent.toMillis.show} milliseconds + |> expected range: $approx + |> waitFor: ${waitFor.show} + |> expected diff: +- ${diff.show}) + |> actual diff: ${(timeSpent.timeSpent - waitFor).toMillis.show} milliseconds + |""".stripMargin + ), + ) + ) + } + } + } + }