diff --git a/build.sbt b/build.sbt index 1e447f8c..d80b9754 100644 --- a/build.sbt +++ b/build.sbt @@ -116,7 +116,7 @@ lazy val effect3 = project libraryDependencies ++= Seq( "org.typelevel" %% "cats-effect" % catsEffect3Version, "org.typelevel" %% "cats-effect-laws" % catsEffect3Version % Test, - "org.typelevel" %% "cats-effect-testkit" % catsEffect3Version % Test + "org.typelevel" %% "cats-effect-kernel-testkit" % catsEffect3Version % Test ), (Test / scalacOptions) ~= { _.filterNot(Set("-Yno-imports", "-Yno-predef")) diff --git a/effect3/src/main/scala/io/catbird/util/effect/RerunnableInstances.scala b/effect3/src/main/scala/io/catbird/util/effect/RerunnableInstances.scala index df6ff12e..ed7d9195 100644 --- a/effect3/src/main/scala/io/catbird/util/effect/RerunnableInstances.scala +++ b/effect3/src/main/scala/io/catbird/util/effect/RerunnableInstances.scala @@ -2,12 +2,11 @@ package io.catbird.util.effect import cats.effect.Clock import cats.effect.kernel.{ MonadCancel, Outcome, Sync } -import com.twitter.util.{ Future, Monitor } +import com.twitter.util.{ Future, Monitor, Stopwatch, Time } import io.catbird.util.{ Rerunnable, RerunnableMonadError } import java.lang.Throwable import java.util.concurrent.TimeUnit -import java.lang.System import scala.Unit import scala.concurrent.duration.FiniteDuration @@ -24,10 +23,10 @@ trait RerunnableInstances { Rerunnable(thunk) final override def realTime: Rerunnable[FiniteDuration] = - Rerunnable(FiniteDuration(System.currentTimeMillis(), TimeUnit.MILLISECONDS)) + Rerunnable(FiniteDuration(Time.nowNanoPrecision.inNanoseconds, TimeUnit.NANOSECONDS)) final override def monotonic: Rerunnable[FiniteDuration] = - Rerunnable(FiniteDuration(System.nanoTime(), TimeUnit.NANOSECONDS)) + Rerunnable(FiniteDuration(Stopwatch.timeNanos(), TimeUnit.NANOSECONDS)) final override def forceR[A, B](fa: Rerunnable[A])(fb: Rerunnable[B]): Rerunnable[B] = fa.liftToTry.flatMap { resultA => diff --git a/effect3/src/test/scala/io/catbird/util/effect/BaseLawSuite.scala b/effect3/src/test/scala/io/catbird/util/effect/BaseLawSuite.scala new file mode 100644 index 00000000..c9060baf --- /dev/null +++ b/effect3/src/test/scala/io/catbird/util/effect/BaseLawSuite.scala @@ -0,0 +1,39 @@ +package io.catbird.util +package effect + +import com.twitter.util.Monitor +import org.scalatest.funsuite.AnyFunSuite +import org.scalacheck.{ Arbitrary, Prop, Test } +import org.scalacheck.rng.Seed +import org.scalacheck.util.Pretty +import org.scalatest.prop.Configuration +import org.scalatestplus.scalacheck.Checkers +import org.typelevel.discipline.scalatest.FunSuiteDiscipline + +abstract class BaseLawSuite extends AnyFunSuite with FunSuiteDiscipline with Configuration with EqInstances { + protected val monitor = Monitor.mk { case e => println("Monitored: " + e); true } + + override protected def withFixture(test: NoArgTest) = + Monitor.using(monitor)(test()) + + // For debugging an individual failing law. Call this and use `testOnly -- -z withSeed`. Eg: + // + // cats law: + // import cats.kernel.laws.discipline._ + // testLawWithSeed( + // cats.laws.ApplicativeLaws[Rerunnable].applicativeIdentity[Int], + // "NRzb_Wsi6ki82wDgbifBkUvntAPN5kaO8FbKSYpKXiF=", + // ) + // + // cats-effect law: + // testLawWithSeed( + // cats.effect.laws.MonadCancelLaws[Rerunnable, Throwable].uncancelablePollInverseNestIsUncancelable[Int], + // "957izWLFn5kHIug2eCpFHBfajl4zwQGrNdVvC5XWtFK=", + // ) + // + // You may need to wrangle the law into a Function1 with .tupled. + protected def testLawWithSeed[A: Arbitrary: * => Pretty, L: * => Prop](law: A => L, seed: String) = + test("withSeed") { + Checkers.check(Prop.forAll(law), Test.Parameters.default.withInitialSeed(Seed.fromBase64(seed).get)) + } +} diff --git a/effect3/src/test/scala/io/catbird/util/effect/FutureSuite.scala b/effect3/src/test/scala/io/catbird/util/effect/FutureSuite.scala index 8a61fa22..d09e8e63 100644 --- a/effect3/src/test/scala/io/catbird/util/effect/FutureSuite.scala +++ b/effect3/src/test/scala/io/catbird/util/effect/FutureSuite.scala @@ -3,25 +3,15 @@ package io.catbird.util.effect import cats.Eq import cats.effect.laws.MonadCancelTests import cats.instances.all._ -import cats.laws.discipline.MonadErrorTests import cats.laws.discipline.arbitrary._ import com.twitter.conversions.DurationOps._ import com.twitter.util.Future -import io.catbird.util.{ ArbitraryInstances, EqInstances, futureEqWithFailure } -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.prop.Configuration -import org.typelevel.discipline.scalatest.FunSuiteDiscipline +import io.catbird.util.{ ArbitraryInstances, futureEqWithFailure } -class FutureSuite - extends AnyFunSuite - with FunSuiteDiscipline - with Configuration - with ArbitraryInstances - with EqInstances { +class FutureSuite extends BaseLawSuite with ArbitraryInstances { implicit def futureEq[A](implicit A: Eq[A]): Eq[Future[A]] = futureEqWithFailure(1.seconds) - checkAll("Future[Int]", MonadErrorTests[Future, Throwable].monadError[Int, Int, Int]) checkAll("Future[Int]", MonadCancelTests[Future, Throwable].monadCancel[Int, Int, Int]) } diff --git a/effect3/src/test/scala/io/catbird/util/effect/RerunnableClockSuite.scala b/effect3/src/test/scala/io/catbird/util/effect/RerunnableClockSuite.scala deleted file mode 100644 index c1011ac0..00000000 --- a/effect3/src/test/scala/io/catbird/util/effect/RerunnableClockSuite.scala +++ /dev/null @@ -1,48 +0,0 @@ -package io.catbird.util.effect - -import java.time.Instant -import java.util.concurrent.TimeUnit - -import cats.effect.Clock -import com.twitter.util.Await -import io.catbird.util.Rerunnable -import org.scalatest.{ Outcome } -import org.scalatest.concurrent.{ Eventually, IntegrationPatience } -import org.scalatest.funsuite.FixtureAnyFunSuite - -/** - * We'll use `eventually` and a reasonably big tolerance here to prevent CI from failing if it is a bit slow. - * - * Technically the implementation is just an extremely thin wrapper around `System.currentTimeMillis()` - * and `System.nanoTime()` so as long as the result is the same order of magnitude (and therefore the - * unit-conversion is correct) we should be fine. - */ -class RerunnableClockSuite extends FixtureAnyFunSuite with Eventually with IntegrationPatience { - - protected final class FixtureParam { - def now: Instant = Instant.now() - } - - test("Retrieval of real time") { f => - eventually { - val result = Await.result( - Clock[Rerunnable].realTime.map(duration => Instant.ofEpochMilli(duration.toMillis)).run - ) - - assert(java.time.Duration.between(result, f.now).abs().toMillis < 50) - } - } - - test("Retrieval of monotonic time") { f => - eventually { - val result = Await.result( - Clock[Rerunnable].monotonic.map(duration => duration.toNanos).run - ) - - val durationBetween = Math.abs(System.nanoTime() - result) - assert(TimeUnit.MILLISECONDS.convert(durationBetween, TimeUnit.NANOSECONDS) < 5) - } - } - - override protected def withFixture(test: OneArgTest): Outcome = withFixture(test.toNoArgTest(new FixtureParam)) -} diff --git a/effect3/src/test/scala/io/catbird/util/effect/RerunnableSuite.scala b/effect3/src/test/scala/io/catbird/util/effect/RerunnableSuite.scala index e08deb91..2e777685 100644 --- a/effect3/src/test/scala/io/catbird/util/effect/RerunnableSuite.scala +++ b/effect3/src/test/scala/io/catbird/util/effect/RerunnableSuite.scala @@ -1,6 +1,6 @@ package io.catbird.util.effect -import cats.effect.MonadCancel +import cats.effect.kernel.{ Clock, MonadCancel, Outcome } import cats.effect.kernel.testkit.SyncTypeGenerators import cats.effect.laws.SyncTests import cats.instances.either._ @@ -8,24 +8,30 @@ import cats.instances.int._ import cats.instances.tuple._ import cats.instances.unit._ import cats.laws.discipline.arbitrary._ -import com.twitter.util.{ Await, Monitor, Throw } -import io.catbird.util.{ ArbitraryInstances, EqInstances, Rerunnable } -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.prop.Configuration -import org.typelevel.discipline.scalatest.FunSuiteDiscipline - -class RerunnableSuite - extends AnyFunSuite - with FunSuiteDiscipline - with Configuration - with ArbitraryInstances - with SyncTypeGenerators - with EqInstances - with Runners { +import com.twitter.util.{ Await, Monitor, Throw, Time } +import io.catbird.util.Rerunnable + +class RerunnableSuite extends BaseLawSuite with SyncTypeGenerators with Runners { // This includes tests for Clock, MonadCancel, and MonadError checkAll("Rerunnable[Int]", SyncTests[Rerunnable].sync[Int, Int, Int]) + test("Retrieval of real time") { + val nanos = 123456789L + val result = Time.withTimeAt(Time.fromNanoseconds(nanos)) { _ => + unsafeRun(Clock[Rerunnable].realTime.map(_.toNanos)) + } + assert(result == Outcome.succeeded(Some(nanos))) + } + + test("Retrieval of monotonic time") { + val nanos = 123456789L + val result = Time.withTimeAt(Time.fromNanoseconds(nanos)) { _ => + unsafeRun(Clock[Rerunnable].monotonic.map(_.toNanos)) + } + assert(result == Outcome.succeeded(Some(nanos))) + } + test("Exceptions thrown by release are handled by Monitor") { val useException = new Exception("thrown by use") val releaseException = new Exception("thrown by release") diff --git a/effect3/src/test/scala/io/catbird/util/effect/Runners.scala b/effect3/src/test/scala/io/catbird/util/effect/Runners.scala index 4061c304..4953f237 100644 --- a/effect3/src/test/scala/io/catbird/util/effect/Runners.scala +++ b/effect3/src/test/scala/io/catbird/util/effect/Runners.scala @@ -1,64 +1,41 @@ package io.catbird.util.effect import cats.Eq -import cats.effect.{ IO, Outcome, unsafe } -import cats.effect.testkit.TestContext -import cats.effect.unsafe.IORuntimeConfig +import cats.effect.kernel.Outcome +import cats.effect.kernel.testkit.SyncGenerators +import com.twitter.util.{ Await, Return, Throw } import io.catbird.util.{ EqInstances, Rerunnable } -import org.scalacheck.Prop +import org.scalacheck.{ Arbitrary, Cogen, Prop } -import scala.annotation.implicitNotFound -import scala.concurrent.duration.FiniteDuration -import scala.concurrent.duration._ import scala.language.implicitConversions -/** - * Test helpers mostly taken from the cats-effect IOSpec. - */ trait Runners { self: EqInstances => - implicit val ticker: Ticker = Ticker(TestContext()) + implicit def arbitraryRerunnable[A: Arbitrary: Cogen]: Arbitrary[Rerunnable[A]] = { + val generators = new SyncGenerators[Rerunnable] { + val F = rerunnableInstance - implicit def eqIOA[A: Eq](implicit ticker: Ticker): Eq[IO[A]] = - Eq.by(unsafeRun(_)) - - implicit def rerunnableEq[A](implicit A: Eq[A]): Eq[Rerunnable[A]] = - Eq.by[Rerunnable[A], IO[A]](rerunnableToIO) - - implicit def boolRunnings(rerunnableB: Rerunnable[Boolean])(implicit ticker: Ticker): Prop = - Prop(unsafeRun(rerunnableToIO(rerunnableB)).fold(false, _ => false, _.getOrElse(false))) - - def unsafeRun[A](ioa: IO[A])(implicit ticker: Ticker): Outcome[Option, Throwable, A] = - try { - var results: Outcome[Option, Throwable, A] = Outcome.Succeeded(None) - - ioa.unsafeRunAsync { - case Left(t) => results = Outcome.Errored(t) - case Right(a) => results = Outcome.Succeeded(Some(a)) - }(unsafe.IORuntime(ticker.ctx, ticker.ctx, scheduler, () => (), IORuntimeConfig())) + val arbitraryE = Arbitrary.arbThrowable + val cogenE = Cogen.cogenThrowable - ticker.ctx.tickAll(1.days) - - results - } catch { - case t: Throwable => - t.printStackTrace() - throw t + val arbitraryFD = Arbitrary.arbFiniteDuration } + Arbitrary(generators.generators[A]) + } - def scheduler(implicit ticker: Ticker): unsafe.Scheduler = - new unsafe.Scheduler { - import ticker.ctx - - def sleep(delay: FiniteDuration, action: Runnable): Runnable = { - val cancel = ctx.schedule(delay, action) - new Runnable { def run() = cancel() } - } - - def nowMillis() = ctx.now().toMillis - def monotonicNanos() = ctx.now().toNanos - } + implicit def eqRerunnableA[A: Eq]: Eq[Rerunnable[A]] = + Eq.by(unsafeRun(_)) - @implicitNotFound("could not find an instance of Ticker; try using `in ticked { implicit ticker =>`") - case class Ticker(ctx: TestContext) + implicit def boolRunnings(rerunnableB: Rerunnable[Boolean]): Prop = + Prop(unsafeRun(rerunnableB).fold(false, _ => false, _.getOrElse(false))) + + def unsafeRun[A](fa: Rerunnable[A]): Outcome[Option, Throwable, A] = + Await.result( + fa.liftToTry + .map[Outcome[Option, Throwable, A]] { + case Return(a) => Outcome.succeeded(Some(a)) + case Throw(e) => Outcome.errored(e) + } + .run + ) }