From e2e40b228bedf6f8edc6722c05a0fccc6f1197f6 Mon Sep 17 00:00:00 2001 From: Flavio Brasil Date: Thu, 9 Jan 2025 13:15:41 -0800 Subject: [PATCH] [direct] improve error messages + test coverage (#985) --- .../shared/src/main/scala/kyo/Direct.scala | 6 +- .../shared/src/main/scala/kyo/Validate.scala | 241 ++++++----- .../shared/src/test/scala/kyo/CoreTest.scala | 195 +++++++++ .../src/test/scala/kyo/DirectTest.scala | 161 ------- .../src/test/scala/kyo/HygieneTest.scala | 24 +- .../src/test/scala/kyo/PreludeTest.scala | 401 ++++++++++++++++++ .../shared/src/test/scala/kyo/Test.scala | 17 +- .../shared/src/test/scala/kyo/WhileTest.scala | 166 +++++++- 8 files changed, 920 insertions(+), 291 deletions(-) create mode 100644 kyo-direct/shared/src/test/scala/kyo/CoreTest.scala delete mode 100644 kyo-direct/shared/src/test/scala/kyo/DirectTest.scala create mode 100644 kyo-direct/shared/src/test/scala/kyo/PreludeTest.scala diff --git a/kyo-direct/shared/src/main/scala/kyo/Direct.scala b/kyo-direct/shared/src/main/scala/kyo/Direct.scala index 938d7d041..5487ddf4c 100644 --- a/kyo-direct/shared/src/main/scala/kyo/Direct.scala +++ b/kyo-direct/shared/src/main/scala/kyo/Direct.scala @@ -70,7 +70,8 @@ private def nowImpl[A: Type, S: Type](self: Expr[A < S])(using Quotes): Expr[A] | val y = IO(2).now // Then get this result | x + y // Use both results |}""".stripMargin)} - |""".stripMargin + |""".stripMargin, + self.asTerm.pos ) end nowImpl @@ -95,7 +96,8 @@ private def laterImpl[A: Type, S: Type](self: Expr[A < S])(using Quotes): Expr[A | val (e1, e2) = combination.now // Get both effects | e1.now + e2.now // Sequence them here |}""".stripMargin)} - |""".stripMargin + |""".stripMargin, + self.asTerm.pos ) end laterImpl diff --git a/kyo-direct/shared/src/main/scala/kyo/Validate.scala b/kyo-direct/shared/src/main/scala/kyo/Validate.scala index 555c286c6..d89e2aefd 100644 --- a/kyo-direct/shared/src/main/scala/kyo/Validate.scala +++ b/kyo-direct/shared/src/main/scala/kyo/Validate.scala @@ -8,8 +8,6 @@ private[kyo] object Validate: def apply(expr: Expr[Any])(using Quotes): Unit = import quotes.reflect.* - val tree = expr.asTerm - def fail(tree: Tree, msg: String): Unit = report.error(msg, tree.pos) @@ -19,9 +17,46 @@ private[kyo] object Validate: case _ => false } - Trees.traverse(tree) { - case Apply(TypeApply(Ident("now"), _), _) => - case Apply(TypeApply(Ident("later"), _), _) => + Trees.traverse(expr.asTerm) { + case Apply(TypeApply(Ident("now" | "later"), _), List(qual)) => + Trees.traverse(qual) { + case tree @ Apply(TypeApply(Ident("now" | "later"), _), _) => + fail( + tree, + s"""${".now".cyan} and ${".later".cyan} can only be used directly inside a ${"`defer`".yellow} block. + | + |Common mistake: You may have forgotten to wrap an effectful computation in ${"`defer`".yellow}: + |${highlight(""" + |// Missing defer when handling effects: + |val result = Emit.run { // NOT OK - missing defer + | Emit(1).now + | Emit(2).now + |} + | + |// Correctly wrapped in defer: + |val result = Emit.run { + | defer { // OK - effects wrapped in defer + | Emit(1).now + | Emit(2).now + | } + |}""")} + | + |If you're seeing this inside a ${"`defer`".yellow} block, you may have nested ${".now".cyan}/${".later".cyan} calls: + |${highlight(""" + |// Instead of nested .now: + |defer { + | (counter.get.now + 1).now // NOT OK - nested .now + |} + | + |// Store intermediate results: + |defer { + | val value = counter.get.now // OK - get value first + | val incr = value + 1 // OK - pure operation + | IO(incr).now // OK - single .now + |}""".stripMargin)}""".stripMargin + ) + } + case tree: Term if tree.tpe.typeSymbol.name == "<" => fail( tree, @@ -47,30 +82,6 @@ private[kyo] object Validate: |""".stripMargin ) - case tree @ Apply(TypeApply(Ident("defer"), _), _) => - fail( - tree, - s"""Nested ${"`defer`".yellow} blocks are not allowed. - | - |Instead of nesting defer blocks: - |${highlight(""" - |defer { - | defer { // NOT OK - nested defer - | IO(1).now - | } - |}""".stripMargin)} - | - |Define separate operations: - |${highlight(""" - |def innerOperation = defer { - | IO(1).now - |} - | - |defer { - | innerOperation.now // OK - composing effects - |}""".stripMargin)}""".stripMargin - ) - case tree @ ValDef(_, _, _) if tree.show.startsWith("var ") => fail( tree, @@ -78,66 +89,45 @@ private[kyo] object Validate: | |Mutable state can lead to unexpected behavior with effects. Instead, use proper state management tools: | - |• Var (kyo-prelude) - |• Atomic* classes (kyo-core) - |• TRef and derivatives (kyo-stm)""".stripMargin - ) - - case Return(_, _) => - fail( - tree, - s"""${"`return`".yellow} statements are not allowed inside a ${"`defer`".yellow} block. - | - |Early returns can break effect sequencing. Instead: - |${highlight(""" - |// Instead of return: - |defer { - | if condition then - | return value // NOT OK - early return - | IO(1).now - |} - | - |// Use if expressions: - |defer { - | if condition then value - | else IO(1).now // OK - explicit flow - |}""".stripMargin)}""".stripMargin + |• Most common: Atomic* classes (kyo-core) + |• With transactional behavior: TRef and derivatives (kyo-stm) + |• Advanced pure state management: Var (kyo-prelude) + """.stripMargin ) case tree @ ValDef(_, _, _) if tree.show.startsWith("lazy val ") => fail( tree, - s"""${"`lazy val`".yellow} declarations are not allowed inside a ${"`defer`".yellow} block. - | - |Lazy evaluation can interfere with effect sequencing. Instead: - |${highlight(""" - |// Instead of lazy val: - |defer { - | lazy val x = IO(1).now // NOT OK - lazy - | x + 1 - |} - | - |// Use regular val: - |defer { - | val x = IO(1).now // OK - eager - | x + 1 - |}""".stripMargin)}""".stripMargin - ) - - case Lambda(_, body) if !pure(body) => - fail( - tree, - s"""Lambda functions containing ${".now".cyan} are not supported. - | - |Effects in lambdas can lead to unexpected ordering: - |${highlight(""" - |defer { - | val f = (x: Int) => IO(x).now // NOT OK - effects in lambda - | f(10) - |}""".stripMargin)}""".stripMargin + s"""${"`lazy val`".yellow} and ${"`object`".yellow} declarations are not allowed inside a ${"`defer`".yellow} block. + | + |These interfere with effect sequencing. Define them outside the defer block: + |${highlight(""" + |// Instead of lazy declarations in defer: + |defer { + | lazy val x = IO(1).now // NOT OK - lazy val + | object A // NOT OK - object + | x + 1 + |} + | + |// Define outside defer: + |lazy val x = IO(1) // OK - outside + |object A // OK - outside + | + |// Use inside defer: + |defer { + | val result = x.now // OK - proper sequencing + | A.method.now + |}""".stripMargin)} + | + |For expensive computations needing caching, consider ${"`Async.memoize`".cyan}: + |${highlight(""" + |defer { + | val memoized = Async.memoize(expensiveComputation).now + | memoized().now // First computes, then caches + |}""".stripMargin)}""".stripMargin.stripMargin ) - case DefDef(_, _, _, Some(body)) if !pure(body) => + case tree @ DefDef(_, _, _, Some(body)) if !pure(body) => fail( tree, s"""Method definitions containing ${".now".cyan} are not supported inside ${"`defer`".yellow} blocks. @@ -160,12 +150,12 @@ private[kyo] object Validate: |}""".stripMargin)}""".stripMargin ) - case Try(_, _, _) => + case tree @ Try(_, _, _) => fail( tree, - s"""${"`try`".yellow}/`catch`".yellow} blocks are not supported inside ${"`defer`".yellow} blocks. + s"""${"`try`".yellow}/${"`catch`".yellow} blocks are not supported inside ${"`defer`".yellow} blocks. | - |Use error handling effects instead. Handle each effect in a separate defer block: + |Use error handling effects instead. You can handle each effect in a separate defer block: |${highlight(""" |// Instead of try/catch: |defer { @@ -181,7 +171,7 @@ private[kyo] object Validate: | IO(1).now |} | - |// Handle the effect in a separate defer block: + |// Handle the effect defer block: |defer { | Abort.run(computation).now match { | case Result.Success(v) => v @@ -194,25 +184,74 @@ private[kyo] object Validate: |and clearer error handling boundaries.""".stripMargin ) - case ClassDef(_, _, _, _, _) => + case tree @ ClassDef(_, _, _, _, _) => fail( tree, - s"""${"`class`".yellow} declarations are not supported inside ${"`defer`".yellow} blocks. - | - |Define classes outside defer blocks: - |${highlight(""" - |// Instead of class in defer: - |defer { - | class MyClass(x: Int) // NOT OK - | new MyClass(10) - |} + s"""${"`class`".yellow} and ${"`trait`".yellow} declarations are not allowed inside ${"`defer`".yellow} blocks. + | + |Define them outside defer blocks: + |${highlight(""" + |// Instead of declarations in defer: + |defer { + | class MyClass(x: Int) // NOT OK + | trait MyTrait // NOT OK + | new MyClass(10) + |} + | + |// Define outside: + |class MyClass(x: Int) // OK - outside defer + |trait MyTrait // OK - outside defer + | + |defer { + | new MyClass(10) // OK - usage in defer + |}""".stripMargin)}""".stripMargin + ) + + case tree @ Apply(Ident("throw"), _) => + fail( + tree, + s"""${"`throw`".yellow} expressions are not allowed inside a ${"`defer`".yellow} block. + | + |Exception throwing can break effect sequencing. Use error handling effects instead: + |${highlight(""" + |// Instead of throw: + |defer { + | if condition then + | throw new Exception("error") // NOT OK - throws exception + | IO(1).now + |} + | + |// Use Abort effect: + |defer { + | if condition then + | Abort.fail("error").now // OK - proper error handling + | else IO(1).now + |}""".stripMargin)}""".stripMargin + ) + + case tree @ Select(_, "synchronized") => + fail( + tree, + s"""${"`synchronized`".yellow} blocks are not allowed inside a ${"`defer`".yellow} block. | - |// Define outside: - |class MyClass(x: Int) + |Synchronization can lead to deadlocks with effects. Instead, use proper concurrency primitives: | - |defer { - | new MyClass(10) // OK - |}""".stripMargin)}""".stripMargin + |• Most common: Atomic* classes for thread-safe values (kyo-core) + |• With mutual exclusion: Meter for controlled access (kyo-core) + |• Advanced transactional state: TRef and STM (kyo-stm) + """.stripMargin + ) + + case tree @ Select(_, _) if tree.symbol.flags.is(Flags.Mutable) => + fail( + tree, + s"""Mutable field access is not allowed inside a ${"`defer`".yellow} block. + | + |Mutable state can lead to race conditions. Use proper state management instead: + | + |• Most common: Atomic* classes (kyo-core) + |• With transactional behavior: TRef and derivatives (kyo-stm) + |• Advanced pure state management: Var (kyo-prelude)""".stripMargin ) } end apply diff --git a/kyo-direct/shared/src/test/scala/kyo/CoreTest.scala b/kyo-direct/shared/src/test/scala/kyo/CoreTest.scala new file mode 100644 index 000000000..aa896f786 --- /dev/null +++ b/kyo-direct/shared/src/test/scala/kyo/CoreTest.scala @@ -0,0 +1,195 @@ +package kyo + +class CoreTest extends Test: + + "atomic operations" - { + "AtomicInt" in run { + defer { + val counter = AtomicInt.init(0).now + counter.incrementAndGet.now + counter.incrementAndGet.now + counter.decrementAndGet.now + assert(counter.get.now == 1) + } + } + + "AtomicRef" in run { + defer { + val ref = AtomicRef.init("initial").now + ref.set("updated").now + assert(ref.get.now == "updated") + } + } + } + + "clock operations" - { + "sleep and timeout" in run { + defer { + val start = Clock.now.now + Async.sleep(5.millis).now + val elapsed = Clock.now.now - start + assert(elapsed >= 5.millis) + } + } + + "deadline" in run { + defer { + val deadline = Clock.deadline(1.second).now + assert(!deadline.isOverdue.now) + assert(deadline.timeLeft.now <= 1.second) + } + } + } + + // TODO Compiler crash because `Queue` is an opaque type without a type bound + // "queue operations" - { + // "basic queue" in run { + // defer { + // val queue = Queue.init[Int](3).now + // assert(queue.offer(1).now) + // assert(queue.offer(2).now) + // assert(queue.poll.now.contains(1)) + // assert(queue.size.now == 1) + // } + // } + + // "unbounded queue" in run { + // defer { + // val queue = Queue.Unbounded.init[Int]().now + // queue.add(1).now + // queue.add(2).now + // queue.add(3).now + // assert(queue.drain.now == Chunk(1, 2, 3)) + // } + // } + // } + + "random operations" - { + "basic random" in run { + defer { + val r1 = Random.nextInt(10).now + val r2 = Random.nextInt(10).now + assert(r1 >= 0 && r1 < 10) + assert(r2 >= 0 && r2 < 10) + } + } + + "with seed" in run { + defer { + val results1 = Random.withSeed(42) { + defer { + val a = Random.nextInt(100).now + val b = Random.nextInt(100).now + (a, b) + } + }.now + + val results2 = Random.withSeed(42) { + defer { + val a = Random.nextInt(100).now + val b = Random.nextInt(100).now + (a, b) + } + }.now + + assert(results1 == results2) + } + } + } + + "console operations" in run { + Console.withOut { + defer { + Console.printLine("test output").now + } + }.map { case (output, _) => + assert(output.stdOut == "test output\n") + assert(output.stdErr.isEmpty) + } + } + + "meter operations" - { + "semaphore" in run { + defer { + val sem = Meter.initSemaphore(2).now + assert(sem.availablePermits.now == 2) + sem.run { + defer { + assert(sem.availablePermits.now == 1) + } + }.now + assert(sem.availablePermits.now == 2) + } + } + + "mutex" in run { + defer { + val mutex = Meter.initMutex.now + assert(mutex.availablePermits.now == 1) + mutex.run { + defer { + assert(mutex.availablePermits.now == 0) + } + }.now + assert(mutex.availablePermits.now == 1) + } + } + } + + // TODO Compiler crash because `Queue` is an opaque type without a type bound + // "channel operations" in run { + // defer { + // val channel = Channel.init[Int](2).now + // assert(channel.offer(1).now) + // assert(channel.offer(2).now) + // assert(!channel.offer(3).now) // Should be full + // assert(channel.poll.now.contains(1)) + // assert(channel.poll.now.contains(2)) + // assert(channel.poll.now.isEmpty) + // } + // } + + "barrier operations" in run { + defer { + val barrier = Barrier.init(2).now + assert(barrier.pending.now == 2) + + // Start two fibers that will wait at the barrier + val fiber1 = Async.run { + defer { + barrier.await.now + true + } + }.now + + val fiber2 = Async.run { + defer { + barrier.await.now + true + } + }.now + + // Both fibers should complete successfully + assert(fiber1.get.now) + assert(fiber2.get.now) + assert(barrier.pending.now == 0) + } + } + + "latch operations" in run { + defer { + val latch = Latch.init(2).now + assert(latch.pending.now == 2) + latch.release.now + assert(latch.pending.now == 1) + latch.release.now + val awaited = Async.run { + defer { + latch.await.now + true + } + }.now + assert(awaited.get.now) + } + } +end CoreTest diff --git a/kyo-direct/shared/src/test/scala/kyo/DirectTest.scala b/kyo-direct/shared/src/test/scala/kyo/DirectTest.scala deleted file mode 100644 index 547276108..000000000 --- a/kyo-direct/shared/src/test/scala/kyo/DirectTest.scala +++ /dev/null @@ -1,161 +0,0 @@ -package kyo - -class DirectTest extends Test: - - "one run" in run { - val io = defer { - val a = IO("hello").now - a + " world" - } - io.map { result => - assert(result == "hello world") - } - } - - "two runs" in run { - val io = - defer { - val a = IO("hello").now - val b = IO("world").now - a + " " + b - } - io.map { result => - assert(result == "hello world") - } - } - - "two effects" in run { - val io: String < (Abort[Absent] & IO) = - defer { - val a = Abort.get(Some("hello")).now - val b = IO("world").now - a + " " + b - } - Abort.run(io).map { result => - assert(result == Result.success("hello world")) - } - } - - "if" in run { - var calls = Seq.empty[Int] - val io: Boolean < IO = - defer { - if IO { calls :+= 1; true }.now then - IO { calls :+= 2; true }.now - else - IO { calls :+= 3; true }.now - } - io.map { result => - assert(result) - assert(calls == Seq(1, 2)) - } - } - - "booleans" - { - "&&" in run { - var calls = Seq.empty[Int] - val io: Boolean < IO = - defer { - (IO { calls :+= 1; true }.now && IO { calls :+= 2; true }.now) - } - io.map { result => - assert(result) - assert(calls == Seq(1, 2)) - } - } - "||" in run { - var calls = Seq.empty[Int] - val io: Boolean < IO = - defer { - (IO { calls :+= 1; true }.now || IO { calls :+= 2; true }.now) - } - io.map { result => - assert(result) - assert(calls == Seq(1)) - } - } - } - - "while" in run { - val io = - defer { - val c = AtomicInt.init(1).now - while c.get.now < 100 do - c.incrementAndGet.now - () - c.get.now - } - io.map { result => - assert(result == 100) - } - } - - "options" in { - def test(opt: Option[Int]) = - assert(opt == Abort.run(defer(Abort.get(opt).now)).eval.fold(_ => None)(Some(_))) - test(Some(1)) - test(None) - } - "consoles" in run { - Console.withIn(List("hello"))(defer(Abort.run(Console.readLine).now)).map { result => - assert(result.contains("hello")) - } - } - - "kyo computations must be within a run block" in { - assertDoesNotCompile("defer(IO(1))") - assertDoesNotCompile(""" - defer { - val a = IO(1) - 10 - } - """) - assertDoesNotCompile(""" - defer { - val a = { - val b = IO(1) - 10 - } - 10 - } - """) - } - - "Choice" in { - - val x = Choice.get(Seq(1, -2, -3)) - val y = Choice.get(Seq("ab", "cde")) - - val v: Int < Choice = - defer { - val xx = x.now - xx + ( - if xx > 0 then y.now.length * x.now - else y.now.length - ) - } - - val a: Seq[Int] = Choice.run(v).eval - assert(a == Seq(3, -3, -5, 4, -5, -8, 0, 1, -1, 0)) - } - - "Choice + filter" in { - - val x = Choice.get(Seq(1, -2, -3)) - val y = Choice.get(Seq("ab", "cde")) - - val v: Int < Choice = - defer { - val xx = x.now - val r = - xx + ( - if xx > 0 then y.now.length * x.now - else y.now.length - ) - Choice.dropIf(r <= 0).now - r - } - - assert(Choice.run(v).eval == Seq(3, 4, 1)) - } -end DirectTest diff --git a/kyo-direct/shared/src/test/scala/kyo/HygieneTest.scala b/kyo-direct/shared/src/test/scala/kyo/HygieneTest.scala index ff08cf3cf..d9cd2e8e6 100644 --- a/kyo-direct/shared/src/test/scala/kyo/HygieneTest.scala +++ b/kyo-direct/shared/src/test/scala/kyo/HygieneTest.scala @@ -125,9 +125,9 @@ class HygieneTest extends AnyFreeSpec with Assertions: "new instance with by-name parameter" in { assertDoesNotCompile(""" + class A(x: => String) defer { - class A(x: => Int) - new A(IO(1).now) + new A(IO("blah").now) } """) } @@ -171,4 +171,24 @@ class HygieneTest extends AnyFreeSpec with Assertions: } """) } + + "throw" in { + assertDoesNotCompile(""" + defer { + if IO("foo").now == "bar" then + throw new Exception + else + 2 + } + """) + } + + "synchronized" in { + assertDoesNotCompile(""" + defer { + val x = synchronized(1) + IO(x).now + } + """) + } end HygieneTest diff --git a/kyo-direct/shared/src/test/scala/kyo/PreludeTest.scala b/kyo-direct/shared/src/test/scala/kyo/PreludeTest.scala new file mode 100644 index 000000000..1e859dab1 --- /dev/null +++ b/kyo-direct/shared/src/test/scala/kyo/PreludeTest.scala @@ -0,0 +1,401 @@ +package kyo + +class PreludeTest extends Test: + + "abort" - { + "basic usage" in run { + val effect: Int < Abort[String] = + defer { + if true then Abort.fail("error").now + else 42 + } + + Abort.run(effect).map { result => + assert(result == Result.fail("error")) + } + } + + "abort with recovery" in run { + val effect: Int < Abort[String] = + defer { + val result: Int = Abort.get[String](Left("first error")).now + result + 1 + } + + Abort.recover[String](_ => 42)(effect).map { result => + assert(result == 42) + } + } + } + + "env" - { + "basic usage" in run { + val effect = + defer { + val env = Env.get[Int].now + env * 2 + } + + Env.run(21)(effect).map { result => + assert(result == 42) + } + } + + "nested environments" in run { + val effect = + defer { + val outer = Env.get[String].now + val combined = Env.run(42) { + defer { + val inner = Env.get[Int].now + s"$outer: $inner" + } + }.now + combined + } + + Env.run("Answer")(effect).map { result => + assert(result == "Answer: 42") + } + } + + "multiple environments" in run { + val effect = + defer { + val str = Env.get[String].now + val num = Env.get[Int].now + s"$str: $num" + } + + val withString = Env.run("Test")(effect) + val withBoth = Env.run(42)(withString) + withBoth.map { result => + assert(result == "Test: 42") + } + } + } + + "var" - { + "basic operations" in run { + val effect = + defer { + val initial = Var.get[Int].now + Var.update[Int](_ + 1).now + val afterInc = Var.get[Int].now + Var.set(100).now + val afterSet = Var.get[Int].now + (initial, afterInc, afterSet) + } + + Var.run(41)(effect).map { case (initial, afterInc, afterSet) => + assert(initial == 41) + assert(afterInc == 42) + assert(afterSet == 100) + } + } + + "nested vars" in run { + val effect = + defer { + val outer = Var.get[Int].now + val nested = Var.run(outer * 2) { + defer { + val inner = Var.get[Int].now + Var.update[Int](_ + 1).now + Var.get[Int].now + } + }.now + (outer, nested) + } + + Var.run(21)(effect).map { case (outer, nested) => + assert(outer == 21) + assert(nested == 43) + } + } + + "var with other effects" in run { + val effect = + defer { + val env = Env.get[Int].now + val initial = Var.get[Int].now + Var.update[Int](_ + env).now + val result = Abort.run[String] { + defer { + val current = Var.get[Int].now + if current > 50 then Abort.fail("Too large").now + else current + } + }.now + (initial, result) + } + + Env.run(10) { + Var.run(42)(effect) + }.map { case (initial, result) => + assert(initial == 42) + assert(result == Result.fail("Too large")) + } + } + } + + "memo" - { + "basic memoization" in run { + var count = 0 + val f = Memo[Int, Int, Any] { x => + count += 1 + x * 2 + } + + val effect = + defer { + val a = f(5).now + val b = f(5).now + val c = f(6).now + (a, b, c, count) + } + + Memo.run(effect).map { case (a, b, c, callCount) => + assert(a == 10) + assert(b == 10) + assert(c == 12) + assert(callCount == 2) + } + } + + "memo with other effects" in run { + var count = 0 + val f = Memo[Int, Int, Env[Int]] { x => + count += 1 + Env.use[Int](_ + x) + } + + val effect = + defer { + val a = f(5).now + val b = f(5).now + val c = f(6).now + (a, b, c, count) + } + + Env.run(10) { + Memo.run(effect) + }.map { case (a, b, c, callCount) => + assert(a == 15) + assert(b == 15) + assert(c == 16) + assert(callCount == 2) + } + } + } + + "choice" - { + "basic choices" in run { + val effect = + defer { + val x = Choice.get(Seq(1, 2, 3)).now + val y = Choice.get(Seq("a", "b")).now + s"$x$y" + } + + Choice.run(effect).map { results => + assert(results == Seq("1a", "1b", "2a", "2b", "3a", "3b")) + } + } + + "choice with conditions" in run { + val effect = + defer { + val x = Choice.get(Seq(1, -2, -3)).now + val y = Choice.get(Seq("ab", "cde")).now + if x > 0 then y.length * x + else y.length + } + + Choice.run(effect).map { result => + assert(result == Seq(2, 3, 2, 3, 2, 3)) + } + } + + "choice with filtering" in run { + val effect = + defer { + val x = Choice.get(Seq(1, 2, 3, 4)).now + Choice.dropIf(x % 2 == 0).now + x + } + + Choice.run(effect).map { results => + assert(results == Seq(1, 3)) + } + } + } + + "emit" - { + "basic emissions" in run { + val effect = + defer { + Emit.value(1).now + Emit.value(2).now + Emit.value(3).now + "done" + } + + Emit.run(effect).map { case (emitted, result) => + assert(emitted == Chunk(1, 2, 3)) + assert(result == "done") + } + } + + "emit with conditions" in run { + val effect = + defer { + val a = Env.get[Int].now + if a > 5 then + Emit.value(a).now + () + val b = a * 2 + if b < 20 then + Emit.value(b).now + () + "done" + } + + Env.run(8) { + Emit.run(effect) + }.map { case (emitted, result) => + assert(emitted == Chunk(8, 16)) + assert(result == "done") + } + } + + "nested emit effects" in run { + val effect = + defer { + Emit.value(1).now + val nested = Emit.run { + defer { + Emit.value(2).now + Emit.value(3).now + "nested" + } + }.now + Emit.value(4).now + (nested._1, nested._2) + } + + Emit.run(effect).map { case (outer, (inner, result)) => + assert(outer == Chunk(1, 4)) + assert(inner == Chunk(2, 3)) + assert(result == "nested") + } + } + } + + "poll" - { + "basic polling" in run { + val effect = + defer { + val result = Poll.one[Int].now + val ack = if result.exists(_ > 5) then Ack.Stop else Ack.Continue() + (result, ack) + } + + Poll.run(Chunk(1, 2, 3))(effect).map { case (result, ack) => + assert(result == Maybe(1)) + assert(ack == Ack.Continue()) + } + } + + "poll with fold" in run { + val effect = Poll.fold[Int](0) { (acc, v) => + defer { + IO(acc).now + v + } + } + + Poll.run(Chunk(2, 4, 8, 16))(effect).map { result => + assert(result == 30) + } + } + } + + "stream" - { + "basic operations" in run { + defer { + val stream = + Stream.init(Seq(1, 2, 3, 4, 5)) + .map { x => + val doubled = x * 2 + doubled + } + .filter { x => + x % 3 == 0 + }.now + val results = stream.run.now + assert(results == Seq(6)) + } + } + + "stream with other effects" in run { + val effect = + defer { + val env = Env.get[Int].now + val stream = Stream.init(1 to 3) + .map { x => + defer { + val value = Var.get[Int].now + Var.update[Int](_ + x).now + x * env + value + } + }.now + stream.run.now + } + + Env.run(10) { + Var.run(1)(effect) + }.map { results => + assert(results == Seq(11, 22, 34)) + } + } + } + + "Choice" in run { + val x = Choice.get(Seq(1, -2, -3)) + val y = Choice.get(Seq("ab", "cde")) + + val v: Int < Choice = + defer { + val xx = x.now + xx + ( + if xx > 0 then y.now.length * x.now + else y.now.length + ) + } + + Choice.run(v).map { result => + assert(result == Seq(3, -3, -5, 4, -5, -8, 0, 1, -1, 0)) + } + } + + "Choice + filter" in run { + val x = Choice.get(Seq(1, -2, -3)) + val y = Choice.get(Seq("ab", "cde")) + + val v: Int < Choice = + defer { + val xx = x.now + val r = + xx + ( + if xx > 0 then y.now.length * x.now + else y.now.length + ) + Choice.dropIf(r <= 0).now + r + } + + Choice.run(v).map { result => + assert(result == Seq(3, 4, 1)) + } + } +end PreludeTest diff --git a/kyo-direct/shared/src/test/scala/kyo/Test.scala b/kyo-direct/shared/src/test/scala/kyo/Test.scala index a63a63992..7a41e8100 100644 --- a/kyo-direct/shared/src/test/scala/kyo/Test.scala +++ b/kyo-direct/shared/src/test/scala/kyo/Test.scala @@ -3,15 +3,26 @@ package kyo import kyo.internal.BaseKyoTest import kyo.kernel.Platform import org.scalatest.NonImplicitAssertions +import org.scalatest.Tag import org.scalatest.freespec.AsyncFreeSpec import scala.concurrent.ExecutionContext import scala.concurrent.Future -abstract class Test extends AsyncFreeSpec with BaseKyoTest[IO] with NonImplicitAssertions: +abstract class Test extends AsyncFreeSpec with BaseKyoTest[Abort[Any] & Async & Resource] with NonImplicitAssertions: - def run(v: Future[Assertion] < IO): Future[Assertion] = + def run(v: Future[Assertion] < (Abort[Any] & Async & Resource)): Future[Assertion] = import AllowUnsafe.embrace.danger - IO.Unsafe.evalOrThrow(v) + v.pipe( + Resource.run, + Abort.recover[Any] { + case ex: Throwable => throw ex + case e => throw new IllegalStateException(s"Test aborted with $e") + }, + Async.run, + _.map(_.toFuture).map(_.flatten), + IO.Unsafe.evalOrThrow + ) + end run type Assertion = org.scalatest.compatible.Assertion def success = succeed diff --git a/kyo-direct/shared/src/test/scala/kyo/WhileTest.scala b/kyo-direct/shared/src/test/scala/kyo/WhileTest.scala index 20d2e0760..2ec5659da 100644 --- a/kyo-direct/shared/src/test/scala/kyo/WhileTest.scala +++ b/kyo-direct/shared/src/test/scala/kyo/WhileTest.scala @@ -1,48 +1,170 @@ package kyo -import kyo.TestSupport.* -import org.scalatest.Assertions -import org.scalatest.freespec.AnyFreeSpec import scala.collection.mutable.ArrayBuffer -class WhileTest extends AnyFreeSpec with Assertions: +class WhileTest extends Test: - "with atomic" in { - runLiftTest(3) { - val i = AtomicInt.init(0).now - while i.get.now < 3 do - i.incrementAndGet.now + "atomic counter" in run { + defer { + val counter = AtomicInt.init(0).now + while counter.get.now < 3 do + counter.incrementAndGet.now () - i.get.now + val result = counter.get.now + assert(result == 3) } } - "double in tuple - strange case" in { - var i = 0 + + "accumulating buffers" in run { val buff1 = new ArrayBuffer[Int]() val buff2 = new ArrayBuffer[Int]() + var i = 0 + def incrementA() = i += 1 buff1 += i i end incrementA + def incrementB() = i += 1 buff2 += i i end incrementB - val out = + + defer { + while i < 3 do + IO(incrementA()).now + IO(incrementB()).now + () + end while + assert(i == 4) + assert(buff1.toList == List(1, 3)) + assert(buff2.toList == List(2, 4)) + } + } + + "effectful condition" - { + "simple condition" in run { + defer { + val counter = AtomicInt.init(0).now + while counter.get.now < 5 do + counter.incrementAndGet.now + () + assert(counter.get.now == 5) + } + } + + "compound condition" in run { + defer { + val counter1 = AtomicInt.init(0).now + val counter2 = AtomicInt.init(10).now + while counter1.get.now < 5 && counter2.get.now > 5 do + counter1.incrementAndGet.now + counter2.decrementAndGet.now + () + end while + val c1 = counter1.get.now + val c2 = counter2.get.now + assert(c1 == 5) + assert(c2 == 5) + } + } + } + + "nested effects" - { + "in condition and body" in run { + val results = ArrayBuffer[Int]() defer { - while i < 3 do - IO(incrementA()).now - IO(incrementB()).now + val counter = AtomicInt.init(0).now + while counter.get.now < 3 do + val current = counter.incrementAndGet.now + IO(results += current).now () end while - i + val finalCount = counter.get.now + assert(finalCount == 3) + assert(results.toList == List(1, 2, 3)) + } + } + + "with abort effect" in run { + defer { + val counter = AtomicInt.init(0).now + val result = Abort.run { + defer { + while counter.get.now < 2 do + if counter.get.now >= 5 then + Abort.fail("Too high").now + counter.incrementAndGet.now + () + end while + counter.get.now + } + }.now + assert(result == Result.success(2)) + } + } + } + + "complex control flow" - { + "break using abort" in run { + defer { + val counter = AtomicInt.init(0).now + val result = Abort.run { + defer { + while true do + val current = counter.incrementAndGet.now + if current >= 3 then + Abort.fail(current).now + () + end while + -1 + } + }.now + assert(result == Result.fail(3)) + } + } + + "continue pattern" in run { + val evens = ArrayBuffer[Int]() + defer { + val counter = AtomicInt.init(0).now + while counter.get.now < 5 do + val current = counter.incrementAndGet.now + if IO(current % 2 == 1).now then + () // Skip odd numbers + else + IO { evens += current }.now + () + end if + end while + val finalCount = counter.get.now + assert(finalCount == 5) + assert(evens.toList == List(2, 4)) } - for - a <- out - yield ( - assert(a == 4 && buff1.toList == List(1, 3) && buff2.toList == List(2, 4)) - ) + } + } + + "error handling" in run { + val operations = ArrayBuffer[String]() + defer { + val result = Abort.run { + defer { + val counter = AtomicInt.init(0).now + while counter.get.now < 5 do + val op = s"op${counter.get.now}" + IO { operations += op }.now + val current = counter.incrementAndGet.now + if current == 2 then + Abort.fail(s"Error at $current").now + () + end while + counter.get.now + } + }.now + assert(result == Result.fail("Error at 2")) + assert(operations == ArrayBuffer("op0", "op1")) + } } end WhileTest