From 1bda9bb1c9745aafd3ddcfc64f0cb022eea2c867 Mon Sep 17 00:00:00 2001 From: zainab-ali Date: Wed, 4 Sep 2024 16:32:10 +0100 Subject: [PATCH] Replace expecty with clue. --- README.md | 6 +- build.sbt | 18 +- docs/assets/oops.png | Bin 130 -> 24349 bytes docs/features/expectations.md | 18 +- docs/samples/multiple_suites_failures.md | 2 +- .../src/main/scala-2/weaver/Expect.scala | 18 -- .../src/main/scala-2/weaver/ExpectMacro.scala | 162 +++++++++++++++++ .../scala-2/weaver/SourceLocationMacro.scala | 6 +- .../main/scala-2/weaver/internals/Clue.scala | 50 ++++++ .../weaver/internals/ClueHelpers.scala | 19 ++ .../scala-2/weaver/internals/ClueMacro.scala | 48 +++++ .../src/main/scala-3/weaver/Expect.scala | 18 -- .../src/main/scala-3/weaver/ExpectMacro.scala | 92 ++++++++++ .../main/scala-3/weaver/internals/Clue.scala | 31 ++++ .../weaver/internals/ClueHelpers.scala | 16 ++ .../scala-3/weaver/internals/ClueMacro.scala | 25 +++ .../shared/src/main/scala/weaver/Expect.scala | 5 + .../src/main/scala/weaver/Expectations.scala | 2 +- .../main/scala/weaver/internals/Clues.scala | 70 ++++++++ .../weaver/internals/ExpectyListener.scala | 43 ----- .../shared/src/test/scala/DogFoodTests.scala | 167 +++++++++++++++++- .../shared/src/test/scala/Meta.scala | 63 ++++++- 22 files changed, 770 insertions(+), 109 deletions(-) delete mode 100644 modules/core/shared/src/main/scala-2/weaver/Expect.scala create mode 100644 modules/core/shared/src/main/scala-2/weaver/ExpectMacro.scala create mode 100644 modules/core/shared/src/main/scala-2/weaver/internals/Clue.scala create mode 100644 modules/core/shared/src/main/scala-2/weaver/internals/ClueHelpers.scala create mode 100644 modules/core/shared/src/main/scala-2/weaver/internals/ClueMacro.scala delete mode 100644 modules/core/shared/src/main/scala-3/weaver/Expect.scala create mode 100644 modules/core/shared/src/main/scala-3/weaver/ExpectMacro.scala create mode 100644 modules/core/shared/src/main/scala-3/weaver/internals/Clue.scala create mode 100644 modules/core/shared/src/main/scala-3/weaver/internals/ClueHelpers.scala create mode 100644 modules/core/shared/src/main/scala-3/weaver/internals/ClueMacro.scala create mode 100644 modules/core/shared/src/main/scala/weaver/Expect.scala create mode 100644 modules/core/shared/src/main/scala/weaver/internals/Clues.scala delete mode 100644 modules/core/shared/src/main/scala/weaver/internals/ExpectyListener.scala diff --git a/README.md b/README.md index 491bcb51..800a6d9a 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,11 @@ Weaver also includes support for The various `test` functions have in common that they expect the developer to return a value of type `Expectations`, which is just a basic case class wrapping a `cats.data.Validated` value. -The most convenient way to build `Expectations` is to use the `expect` function. Based on [Eugene Yokota's](http://eed3si9n.com/about) excellent [expecty](https://github.com/eed3si9n/expecty), it captures the boolean expression at compile time and provides useful feedback on what goes wrong when it does : +The most convenient way to build `Expectations` is to use the `expect` and `clue` functions. `clue` captures the boolean expression at compile time and provides useful feedback on what goes wrong: + +```scala +expect(clue(List(1, 2, 3).size) == 4) +``` ![Oops](docs/assets/oops.png) diff --git a/build.sbt b/build.sbt index 8dcc1003..9afe9228 100644 --- a/build.sbt +++ b/build.sbt @@ -41,7 +41,6 @@ val Version = new { val catsEffect = "3.5.4" val catsLaws = "2.9.0" val discipline = "1.5.1" - val expecty = "0.16.0" val fs2 = "3.10.2" val junit = "4.13.2" val portableReflect = "1.1.2" @@ -67,13 +66,16 @@ lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) .settings( name := "weaver-core", libraryDependencies ++= Seq( - "co.fs2" %%% "fs2-core" % Version.fs2, - "org.typelevel" %%% "cats-effect" % Version.catsEffect, - "com.eed3si9n.expecty" %%% "expecty" % Version.expecty, + "co.fs2" %%% "fs2-core" % Version.fs2, + "org.typelevel" %%% "cats-effect" % Version.catsEffect, // https://github.com/portable-scala/portable-scala-reflect/issues/23 "org.portable-scala" %%% "portable-scala-reflect" % Version.portableReflect cross CrossVersion.for3Use2_13, "org.typelevel" %% "scalac-compat-annotation" % Version.scalacCompatAnnotation, - "org.scalameta" %%% "munit-diff" % Version.munitDiff + "org.scalameta" %%% "munit-diff" % Version.munitDiff, + if (scalaVersion.value.startsWith("3.")) + "org.scala-lang" % "scala-reflect" % scala213 + else + "org.scala-lang" % "scala-reflect" % scalaVersion.value ), // Shades the scala-diff dependency. shadedDependencies += "org.scalameta" %%% "munit-diff" % "", @@ -86,11 +88,7 @@ lazy val coreJVM = core.jvm .settings( libraryDependencies ++= Seq( "org.scala-js" %%% "scalajs-stubs" % Version.scalajsStubs % "provided" cross CrossVersion.for3Use2_13, - "junit" % "junit" % Version.junit % Optional, - if (scalaVersion.value.startsWith("3.")) - "org.scala-lang" % "scala-reflect" % scala213 - else - "org.scala-lang" % "scala-reflect" % scalaVersion.value + "junit" % "junit" % Version.junit % Optional ) ) diff --git a/docs/assets/oops.png b/docs/assets/oops.png index 5bc6fd3cc65806e947666646b2221b1114e34d56..2872a281647065e94ecbd5d7f6672206bc05a786 100644 GIT binary patch literal 24349 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

K{a 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 Zlf3CldVAeO>+IpkTI%}XvOCt#{|k^Ddp-aF literal 130 zcmWm3OA^8$3;@u5Pr(H&5`NO#gpdd`DjlI+czSj97VqTuX#J(?ocq|czPEXK$XI{Y zBd^q-dhAU4G)r$qjf&vI2GM|clxT8sC8|{Oo7DN8(Qnx0f+1o Nre(AbD<1)1`2r=JD2M<6 diff --git a/docs/features/expectations.md b/docs/features/expectations.md index b3196e0a..cbd0c365 100644 --- a/docs/features/expectations.md +++ b/docs/features/expectations.md @@ -3,7 +3,7 @@ Expectations (assertions) Expectations are pure, composable values. This forces developers to separate the test's checks from the scenario, which is generally cleaner/clearer. -The easiest way to construct expectactions is to call the `expect` macro, which is built using the [expecty](https://github.com/eed3si9n/expecty/) library. +The easiest way to construct expectactions is to call the `expect` macro. The `clue` function can be used to investigate failures. ## TL;DR @@ -13,6 +13,12 @@ The easiest way to construct expectactions is to call the `expect` macro, which expect(myVar == 25 && list.size == 4) ``` +- Investigate failures using `clue`: + + ```scala mdoc:compile-only + expect(clue(myVar) == 25 && clue(list).size == 4) + ``` + - Compose expectations using `and`/`or` ```scala mdoc:compile-only @@ -132,7 +138,7 @@ object ExpectationsSuite extends SimpleIOSuite { pureTest("Simple expectations (failure)") { val z = 15 - expect(A.B.C.test(z) % 7 == 0) + expect(clue(A.B.C.test(z)) % 7 == 0) } @@ -141,7 +147,7 @@ object ExpectationsSuite extends SimpleIOSuite { } pureTest("And/Or composition (failure)") { - (expect(1 != 2) and expect(2 == 1)) or expect(2 == 3) + (expect(1 != clue(2)) and expect(2 == clue(1))) or expect(2 == clue(3)) } pureTest("Varargs composition (success)") { @@ -151,7 +157,7 @@ object ExpectationsSuite extends SimpleIOSuite { pureTest("Varargs composition (failure)") { // expect(1 + 1 == 2) && expect (2 + 2 == 4) && expect(4 * 2 == 8) - expect.all(1 + 1 == 2, 2 + 2 == 5, 4 * 2 == 8) + expect.all(clue(1 + 1) == 2, clue(2 + 2) == 5, clue(4 * 2) == 8) } pureTest("Working with collections (success)") { @@ -166,7 +172,7 @@ object ExpectationsSuite extends SimpleIOSuite { } pureTest("Working with collections (failure 2)") { - exists(Option(39))(i => expect(i > 50)) + exists(Option(39))(i => expect(clue(i) > 50)) } import cats.Eq @@ -220,7 +226,7 @@ object ExpectationsSuite extends SimpleIOSuite { test("Failing fast expectations") { for { h <- IO.pure("hello") - _ <- expect(h.isEmpty).failFast + _ <- expect(clue(h).isEmpty).failFast } yield success } } diff --git a/docs/samples/multiple_suites_failures.md b/docs/samples/multiple_suites_failures.md index f66f234c..2f6451fc 100644 --- a/docs/samples/multiple_suites_failures.md +++ b/docs/samples/multiple_suites_failures.md @@ -26,7 +26,7 @@ object MyAnotherSuite extends SimpleIOSuite { } yield check(x).traced(here) } - def check(x : String) = expect(x.length > 10) + def check(x : String) = expect(clue(clue(x).length) > 10) } ``` diff --git a/modules/core/shared/src/main/scala-2/weaver/Expect.scala b/modules/core/shared/src/main/scala-2/weaver/Expect.scala deleted file mode 100644 index cef82988..00000000 --- a/modules/core/shared/src/main/scala-2/weaver/Expect.scala +++ /dev/null @@ -1,18 +0,0 @@ -package weaver - -import com.eed3si9n.expecty._ - -import internals._ - -class Expect - extends Recorder[Boolean, Expectations] - with UnaryRecorder[Boolean, Expectations] - with ExpectSame { - - def all(recordings: Boolean*): Expectations = - macro VarargsRecorderMacro.apply[Boolean, Expectations] - - override lazy val listener: RecorderListener[Boolean, Expectations] = - new ExpectyListener - -} diff --git a/modules/core/shared/src/main/scala-2/weaver/ExpectMacro.scala b/modules/core/shared/src/main/scala-2/weaver/ExpectMacro.scala new file mode 100644 index 00000000..8aab92d4 --- /dev/null +++ b/modules/core/shared/src/main/scala-2/weaver/ExpectMacro.scala @@ -0,0 +1,162 @@ +package weaver + +import scala.reflect.macros.blackbox +import weaver.internals.ClueHelpers + +private[weaver] trait ExpectMacro { + + /** + * Asserts that a boolean value is true. + * + * Use the [[Expectations.Helpers.clue]] function to investigate any failures. + */ + def apply(value: Boolean): Expectations = macro ExpectMacro.applyImpl + + /** + * Asserts that a boolean value is true and displays a failure message if not. + * + * Use the [[Expectations.Helpers.clue]] function to investigate any failures. + */ + def apply(value: Boolean, message: => String): Expectations = + macro ExpectMacro.messageImpl + + /** + * Asserts that boolean values are all true. + * + * Use the [[Expectations.Helpers.clue]] function to investigate any failures. + */ + def all(values: Boolean*): Expectations = macro ExpectMacro.allImpl +} + +private[weaver] object ExpectMacro { + + /** + * Constructs [[Expectations]] from several boolean values. + * + * If any value evaluates to false, all generated clues are displayed as part + * of the failed expectation. + */ + def allImpl(c: blackbox.Context)(values: c.Tree*): c.Tree = { + import c.universe._ + val sourceLoc = new weaver.macros.Macros(c).fromContext.asInstanceOf[c.Tree] + val (cluesName, cluesValDef) = makeClues(c) + val clueMethodSymbol = getClueMethodSymbol(c) + + val transformedValues = + values.toList.map(replaceClueMethodCalls(c)(clueMethodSymbol, + cluesName, + _)) + makeExpectations(c)(cluesName, + cluesValDef, + transformedValues, + sourceLoc, + q"None") + } + + /** + * Constructs [[Expectations]] from a boolean value and message. + * + * If the value evaluates to false, the message is displayed as part of the + * failed expectation. + */ + def messageImpl(c: blackbox.Context)( + value: c.Tree, + message: c.Tree): c.Tree = { + import c.universe._ + val sourceLoc = new weaver.macros.Macros(c).fromContext.asInstanceOf[c.Tree] + val (cluesName, cluesValDef) = makeClues(c) + val clueMethodSymbol = getClueMethodSymbol(c) + + val transformedValue = + replaceClueMethodCalls(c)(clueMethodSymbol, cluesName, value) + makeExpectations(c)(cluesName, + cluesValDef, + List(transformedValue), + sourceLoc, + q"Some($message)") + } + + /** + * Constructs [[Expectations]] from a boolean value. + * + * A macro is needed to support clues. The value expression may contain calls + * to [[ClueHelpers.clue]], which generate clues for values under test. + * + * This macro constructs a local collection of [[Clues]] and adds the + * generated clues to it. Calls to [[ClueHelpers.clue]] are rewritten to calls + * to [[Clues.addClue]]. + * + * After the value is evaluated, the [[Clues]] collection is used to contruct + * [[Expectations]]. + */ + def applyImpl(c: blackbox.Context)(value: c.Tree): c.Tree = { + + import c.universe._ + val sourceLoc = new weaver.macros.Macros(c).fromContext.asInstanceOf[c.Tree] + val (cluesName, cluesValDef) = makeClues(c) + val clueMethodSymbol = getClueMethodSymbol(c) + + val transformedValue = + replaceClueMethodCalls(c)(clueMethodSymbol, cluesName, value) + makeExpectations(c)(cluesName, + cluesValDef, + List(transformedValue), + sourceLoc, + q"None") + } + + /** Constructs [[Expectations]] from the local [[Clues]] collection. */ + private def makeExpectations(c: blackbox.Context)( + cluesName: c.TermName, + cluesValDef: c.Tree, + values: List[c.Tree], + sourceLoc: c.Tree, + message: c.Tree): c.Tree = { + import c.universe._ + val block = + q"$cluesValDef; _root_.weaver.internals.Clues.toExpectations($sourceLoc, $message, $cluesName, ..$values)" + val untyped = c.untypecheck(block) + val retyped = c.typecheck(untyped, pt = c.typeOf[Expectations]) + retyped + + } + + /** Get the [[ClueHelpers.clue]] symbol. */ + private def getClueMethodSymbol(c: blackbox.Context): c.Symbol = { + import c.universe._ + symbolOf[ClueHelpers].info.member(TermName("clue")) + } + + /** Construct a [[Clues]] collection local to the `expect` call. */ + private def makeClues(c: blackbox.Context): (c.TermName, c.Tree) = { + import c.universe._ + val cluesName = TermName(c.freshName("clues$")) + val cluesValDef = + q"val $cluesName: _root_.weaver.internals.Clues = new _root_.weaver.internals.Clues()" + (cluesName, cluesValDef) + } + + /** + * Replaces all calls to [[ClueHelpers.clue]] with calls to [[Clues.addClue]]. + */ + private def replaceClueMethodCalls(c: blackbox.Context)( + clueMethodSymbol: c.Symbol, + cluesName: c.TermName, + value: c.Tree): c.Tree = { + + import c.universe._ + object transformer extends Transformer { + + override def transform(input: Tree): Tree = input match { + case c.universe.Apply(fun, List(clueValue)) + if fun.symbol == clueMethodSymbol => + val transformedClueValue = super.transform(clueValue) + val clueName = TermName(c.freshName("clue$")) + q"""{val $clueName = ${transformedClueValue}; ${cluesName}.addClue($clueName)}""" + case o => super.transform(o) + } + } + + transformer.transform(value) + } +} diff --git a/modules/core/shared/src/main/scala-2/weaver/SourceLocationMacro.scala b/modules/core/shared/src/main/scala-2/weaver/SourceLocationMacro.scala index e188b65b..aeccffe3 100644 --- a/modules/core/shared/src/main/scala-2/weaver/SourceLocationMacro.scala +++ b/modules/core/shared/src/main/scala-2/weaver/SourceLocationMacro.scala @@ -2,7 +2,7 @@ package weaver // kudos to https://github.com/monix/minitest // format: off -import scala.reflect.macros.whitebox +import scala.reflect.macros.blackbox trait SourceLocationMacro { @@ -22,10 +22,10 @@ trait SourceLocationMacro { } object macros { - class Macros(val c: whitebox.Context) { + class Macros(val c: blackbox.Context) { import c.universe._ - def fromContext: Tree = { + def fromContext: c.Tree = { val (pathExpr, relPathExpr, lineExpr) = getSourceLocation val SourceLocationSym = symbolOf[SourceLocation].companion q"""$SourceLocationSym($pathExpr, $relPathExpr, $lineExpr)""" diff --git a/modules/core/shared/src/main/scala-2/weaver/internals/Clue.scala b/modules/core/shared/src/main/scala-2/weaver/internals/Clue.scala new file mode 100644 index 00000000..4b865cfe --- /dev/null +++ b/modules/core/shared/src/main/scala-2/weaver/internals/Clue.scala @@ -0,0 +1,50 @@ +package weaver.internals + +import cats.Show + +/** + * Captures the source code, type information, and runtime representation of a + * value. + * + * Clues are useful for investigating failed assertions. A clue for a given + * value is summoned with the [[ClueHelpers.clue]] function. This constructs a + * clue for a given value using an implicit conversion. + * + * @param source + * The source code of the value + * @param value + * The runtime value + * @param valueType + * The string representation of the type of the value + * @param show + * The [[cats.Show]] typeclass used to display the value. + */ +private[weaver] class Clue[T]( + source: String, + private[internals] val value: T, + valueType: String, + show: Show[T] +) { + private[internals] def prettyPrint: String = + s"${source}: ${valueType} = ${show.show(value)}" +} + +private[internals] trait LowPriorityClueImplicits { + + /** + * Generates a clue for a given value using the [[toString]] function to print + * the value. + */ + implicit def generateClueFromToString[A](value: A): Clue[A] = + macro ClueMacro.showFromToStringImpl +} +private[weaver] object Clue extends LowPriorityClueImplicits { + + /** + * Generates a clue for a given value using a [[Show]] instance to print the + * value. + */ + implicit def generateClue[A](value: A)(implicit catsShow: Show[A]): Clue[A] = + macro ClueMacro.impl + +} diff --git a/modules/core/shared/src/main/scala-2/weaver/internals/ClueHelpers.scala b/modules/core/shared/src/main/scala-2/weaver/internals/ClueHelpers.scala new file mode 100644 index 00000000..a1ff4069 --- /dev/null +++ b/modules/core/shared/src/main/scala-2/weaver/internals/ClueHelpers.scala @@ -0,0 +1,19 @@ +package weaver.internals + +import scala.annotation.compileTimeOnly +import org.typelevel.scalaccompat.annotation.unused + +private[weaver] trait ClueHelpers { + + /** + * Used to investigate failures in `expect` or `assert` statements. + * + * Surround a value with a call to `clue` to display it on failure. + */ + @compileTimeOnly( + "This function can only be used within `expect` or `assert`.") + final def clue[A](@unused a: Clue[A]): A = { + // This function is removed as part of the `expect` macro expansion. + throw new Error("compileTimeOnly annotation not respected! This is likely to be a bug in weaver-test. Report it at https://github.com/typelevel/weaver-test/issues/new") + } +} diff --git a/modules/core/shared/src/main/scala-2/weaver/internals/ClueMacro.scala b/modules/core/shared/src/main/scala-2/weaver/internals/ClueMacro.scala new file mode 100644 index 00000000..9b97fe03 --- /dev/null +++ b/modules/core/shared/src/main/scala-2/weaver/internals/ClueMacro.scala @@ -0,0 +1,48 @@ +package weaver.internals + +import scala.reflect.macros.blackbox.Context + +// This code is heavily borrowed from munit's Clue macro: https://github.com/scalameta/munit/blob/426e79708accb5b7136689d781f7593b473589f4/munit/shared/src/main/scala/munit/internal/MacroCompatScala2.scala#L25 +private[weaver] object ClueMacro { + def showFromToStringImpl(c: Context)(value: c.Tree): c.Tree = { + import c.universe._ + impl(c)(value)(q"cats.Show.fromToString[${value.tpe}]") + } + + /** + * Constructs a clue by extracting the source code and type information of a + * value. + */ + def impl(c: Context)(value: c.Tree)(catsShow: c.Tree): c.Tree = { + import c.universe._ + val text: String = + if (value.pos != null && value.pos.isRange) { + val chars = value.pos.source.content + val start = value.pos.start + val end = value.pos.end + if (end > start && + start >= 0 && start < chars.length && + end >= 0 && end < chars.length) { + new String(chars, start, end - start) + } else { + "" + } + } else { + "" + } + def simplifyType(tpe: Type): Type = tpe match { + case TypeRef(ThisType(pre), sym, args) if pre == sym.owner => + simplifyType(c.internal.typeRef(NoPrefix, sym, args)) + case t => + t.widen + } + val source = Literal(Constant(text.trim)) + val valueType = Literal(Constant(simplifyType(value.tpe).toString())) + val clueTpe = c.internal.typeRef( + NoPrefix, + c.mirror.staticClass(classOf[Clue[_]].getName()), + List(value.tpe.widen) + ) + q"new $clueTpe(..$source, $value, $valueType, $catsShow)" + } +} diff --git a/modules/core/shared/src/main/scala-3/weaver/Expect.scala b/modules/core/shared/src/main/scala-3/weaver/Expect.scala deleted file mode 100644 index dfc9f4bb..00000000 --- a/modules/core/shared/src/main/scala-3/weaver/Expect.scala +++ /dev/null @@ -1,18 +0,0 @@ -package weaver - -import com.eed3si9n.expecty._ -import internals._ - -import scala.quoted._ - -class Expect - extends Recorder[Boolean, Expectations] - with UnaryRecorder[Boolean, Expectations] - with ExpectSame { - - inline def all(inline recordings: Boolean*): Expectations = - ${ RecorderMacro.varargs('recordings, 'listener) } - - override lazy val listener = new ExpectyListener - -} diff --git a/modules/core/shared/src/main/scala-3/weaver/ExpectMacro.scala b/modules/core/shared/src/main/scala-3/weaver/ExpectMacro.scala new file mode 100644 index 00000000..a509832f --- /dev/null +++ b/modules/core/shared/src/main/scala-3/weaver/ExpectMacro.scala @@ -0,0 +1,92 @@ +package weaver + +import scala.quoted._ +import scala.language.experimental.macros +import weaver.internals.Clues + +private[weaver] trait ExpectMacro { + + /** + * Asserts that a boolean value is true. + * + * Use the [[ClueHelpers.clue]] function to investigate any failures. + */ + inline def apply(assertion: Clues ?=> Boolean): Expectations = + ${ ExpectMacro.applyImpl('assertion) } + + /** + * Asserts that a boolean value is true and displays a failure message if + * not. + * + * Use the [[ClueHelpers.clue]] function to investigate any failures. + */ + inline def apply( + assertion: Clues ?=> Boolean, + message: => String): Expectations = + ${ ExpectMacro.applyMessageImpl('assertion, 'message) } + + /** + * Asserts that boolean values are all true. + * + * Use the [[ClueHelpers.clue]] function to investigate any failures. + */ + inline def all(assertions: (Clues ?=> Boolean)*): Expectations = + ${ ExpectMacro.allImpl('assertions) } +} +private[weaver] object ExpectMacro { + + /** + * Constructs [[Expectations]] from an assertion. + * + * A macro is needed to extract the source location of the `expect` call. + * + * This macro constructs a local collection of [[Clues]]. Calls to + * [[ClueHelpers.clue]] add to this collection. + * + * After the assertion is evaluated, the [[Clues]] collection is used to + * contruct [[Expectations]]. + */ + def applyImpl[T: Type](assertion: Expr[Clues ?=> Boolean])(using + q: Quotes): Expr[Expectations] = { + val sourceLoc = weaver.macros.fromContextImpl(using q) + '{ + val clues = new Clues + val result = ${ assertion }(using clues) + Clues.toExpectations($sourceLoc, None, clues, result) + } + } + + /** + * Constructs [[Expectations]] from an assertion and message. + * + * If the assertion evaluates to false, the message is displayed as part of + * the failed expectation. + */ + def applyMessageImpl[T: Type]( + assertion: Expr[Clues ?=> Boolean], + message: => Expr[String])(using q: Quotes): Expr[Expectations] = { + val sourceLoc = weaver.macros.fromContextImpl(using q) + '{ + val clues = new Clues + val result = ${ assertion }(using clues) + Clues.toExpectations($sourceLoc, Some($message), clues, result) + } + } + + /** + * Constructs [[Expectations]] from several assertions. + * + * If any assertion evaluates to false, all generated clues are displayed as + * part of the failed expectation. + */ + def allImpl[T: Type](assertions: Expr[Seq[(Clues ?=> Boolean)]])(using + q: Quotes): Expr[Expectations] = { + val sourceLoc = weaver.macros.fromContextImpl(using q) + '{ + val clues = new Clues + val results = ${ assertions }.map(assertion => assertion(using clues)) + Clues.toExpectations($sourceLoc, None, clues, results: _*) + } + } + +} diff --git a/modules/core/shared/src/main/scala-3/weaver/internals/Clue.scala b/modules/core/shared/src/main/scala-3/weaver/internals/Clue.scala new file mode 100644 index 00000000..ee1c18d3 --- /dev/null +++ b/modules/core/shared/src/main/scala-3/weaver/internals/Clue.scala @@ -0,0 +1,31 @@ +package weaver.internals + +import cats.Show + +/** + * Captures the source code, type information, and runtime representation of a + * value. + * + * Clues are useful for investigating failed assertions. A clue for a given + * value is summoned with the [[ClueHelpers.clue]] function. This constructs a + * clue for a given value using an implicit conversion. + * + * @param source + * The source code of the value + * @param value + * The runtime value + * @param valueType + * The string representation of the type of the value + * @param show + * The [[cats.Show]] typeclass used to display the value. + */ +private[weaver] class Clue[T]( + source: String, + private[internals] val value: T, + valueType: String, + show: Show[T] +) { + + private[internals] def prettyPrint: String = + s"${source}: ${valueType} = ${show.show(value)}" +} diff --git a/modules/core/shared/src/main/scala-3/weaver/internals/ClueHelpers.scala b/modules/core/shared/src/main/scala-3/weaver/internals/ClueHelpers.scala new file mode 100644 index 00000000..645cc7c7 --- /dev/null +++ b/modules/core/shared/src/main/scala-3/weaver/internals/ClueHelpers.scala @@ -0,0 +1,16 @@ +package weaver.internals +import cats.Show +import scala.quoted._ +import scala.language.experimental.macros + +private[weaver] trait ClueHelpers { + + /** + * Used to investigate failures in `expect` or `assert` statements. + * + * Surround a value with a call to `clue` to display it on failure. + */ + inline def clue[A](value: A)( + using catsShow: Show[A] = Show.fromToString[A], + clues: Clues): A = ${ ClueMacro.clueImpl('value, 'catsShow, 'clues) } +} diff --git a/modules/core/shared/src/main/scala-3/weaver/internals/ClueMacro.scala b/modules/core/shared/src/main/scala-3/weaver/internals/ClueMacro.scala new file mode 100644 index 00000000..410c82cb --- /dev/null +++ b/modules/core/shared/src/main/scala-3/weaver/internals/ClueMacro.scala @@ -0,0 +1,25 @@ +package weaver.internals +import cats.Show +import scala.quoted._ +import scala.language.experimental.macros + +private[weaver] object ClueMacro { + + /** + * Constructs a clue for a given value and adds it to a collection of + * [[Clues]], then returns the value. + */ + def clueImpl[T: Type]( + value: Expr[T], + catsShow: Expr[Show[T]], + clues: Expr[Clues])(using Quotes): Expr[T] = { + import quotes.reflect._ + val source = value.asTerm.pos.sourceCode.getOrElse("") + val valueType = TypeTree.of[T].show(using Printer.TreeShortCode) + '{ + val clue = + new Clue(${ Expr(source) }, $value, ${ Expr(valueType) }, $catsShow) + $clues.addClue(clue) + } + } +} diff --git a/modules/core/shared/src/main/scala/weaver/Expect.scala b/modules/core/shared/src/main/scala/weaver/Expect.scala new file mode 100644 index 00000000..e58e2596 --- /dev/null +++ b/modules/core/shared/src/main/scala/weaver/Expect.scala @@ -0,0 +1,5 @@ +package weaver + +import internals._ + +class Expect extends ExpectSame with ExpectMacro diff --git a/modules/core/shared/src/main/scala/weaver/Expectations.scala b/modules/core/shared/src/main/scala/weaver/Expectations.scala index 6480b4fe..d52d2a3f 100644 --- a/modules/core/shared/src/main/scala/weaver/Expectations.scala +++ b/modules/core/shared/src/main/scala/weaver/Expectations.scala @@ -157,7 +157,7 @@ object Expectations { )) } - trait Helpers { + trait Helpers extends weaver.internals.ClueHelpers { /** * Expect macros diff --git a/modules/core/shared/src/main/scala/weaver/internals/Clues.scala b/modules/core/shared/src/main/scala/weaver/internals/Clues.scala new file mode 100644 index 00000000..e9508ed3 --- /dev/null +++ b/modules/core/shared/src/main/scala/weaver/internals/Clues.scala @@ -0,0 +1,70 @@ +package weaver.internals +import scala.annotation.implicitNotFound +import cats.data.{ NonEmptyList, Validated } + +import weaver.Expectations +import weaver.AssertionException +import weaver.SourceLocation +import cats.data.Chain + +// For Scala 3, the Clues collection is provided implicitly using a context function. +// If users attempt to call the `clue` function outside of the `expect` context, they will get this implicitNotFound error. +@implicitNotFound( + "The `clue` function can only be called within `expect` or `assert`.") +/** + * A collection of all the clues defined within a call to `expect`. + * + * Each call to `expect` has its own unique clue collection. When a [[Clue]] is + * evaluated, it is added to the collection with [[addClue]]. + */ +final class Clues { + private var clues: Chain[Clue[?]] = Chain.empty + + /** + * Adds a clue to the collection. + * + * This function is called as part of the expansion of the `expect` macro. It + * should not be called explicitly. + */ + def addClue[A](clue: Clue[A]): A = { + clues = clues :+ clue + clue.value + } + + private[Clues] def getClues: List[Clue[?]] = clues.toList +} + +object Clues { + + /** + * Constructs [[Expectations]] from the collection of clues. + * + * If the results are successful, the clues are discarded. If any result has + * failed, the clues are printed as part of the failure message. + * + * This function is called as part of the expansion of the `expect` macro. It + * should not be called explicitly. + */ + def toExpectations( + sourceLoc: SourceLocation, + message: Option[String], + clues: Clues, + results: Boolean*): Expectations = { + val success = results.toList.forall(identity) + if (success) { + Expectations(Validated.valid(())) + } else { + val header = "assertion failed" + message.fold("")(msg => s": $msg") + val clueList = clues.getClues + val cluesMessage = if (clueList.nonEmpty) { + val lines = clueList.map(clue => s" ${clue.prettyPrint}") + lines.mkString("Clues {\n", "\n", "\n}") + } else "" + val fullMessage = header + "\n\n" + cluesMessage + + val exception = + new AssertionException(fullMessage, NonEmptyList.of(sourceLoc)) + Expectations(Validated.invalidNel(exception)) + } + } +} diff --git a/modules/core/shared/src/main/scala/weaver/internals/ExpectyListener.scala b/modules/core/shared/src/main/scala/weaver/internals/ExpectyListener.scala deleted file mode 100644 index 17ae27e8..00000000 --- a/modules/core/shared/src/main/scala/weaver/internals/ExpectyListener.scala +++ /dev/null @@ -1,43 +0,0 @@ -package weaver -package internals - -import cats.data.{ NonEmptyList, ValidatedNel } -import cats.syntax.all._ - -import com.eed3si9n.expecty._ - -private[weaver] class ExpectyListener - extends RecorderListener[Boolean, Expectations] { - def sourceLocation(loc: Location): SourceLocation = { - SourceLocation(loc.path, loc.relativePath, loc.line) - } - - override def expressionRecorded( - recordedExpr: RecordedExpression[Boolean], - recordedMessage: Function0[String]): Unit = {} - - override def recordingCompleted( - recording: Recording[Boolean], - recordedMessage: Function0[String]): Expectations = { - type Exp = ValidatedNel[AssertionException, Unit] - val res = recording.recordedExprs.foldMap[Exp] { - expr => - lazy val rendering: String = - new ExpressionRenderer(showTypes = false, shortString = true).render( - expr) - - if (!expr.value) { - val msg = recordedMessage() - val header = - "assertion failed" + - (if (msg == "") "" - else ": " + msg) - val fullMessage = header + "\n\n" + rendering - val sourceLoc = sourceLocation(expr.location) - val sourceLocs = NonEmptyList.of(sourceLoc) - new AssertionException(fullMessage, sourceLocs).invalidNel - } else ().validNel - } - Expectations(res) - } -} diff --git a/modules/framework-cats/shared/src/test/scala/DogFoodTests.scala b/modules/framework-cats/shared/src/test/scala/DogFoodTests.scala index ffa4affb..89d91b34 100644 --- a/modules/framework-cats/shared/src/test/scala/DogFoodTests.scala +++ b/modules/framework-cats/shared/src/test/scala/DogFoodTests.scala @@ -175,18 +175,16 @@ object DogFoodTests extends IOSuite { case LoggedEvent.Error(msg) => msg }.get - // HONESTLY. - val (location, capturedExpression) = - if (Platform.isScala3) (31, "1 == 2") else (32, "expect(1 == 2)") - val expected = s""" |- lots 0ms | of | multiline | (failure) - | assertion failed (modules/framework-cats/shared/src/test/scala/Meta.scala:$location) + | assertion failed (modules/framework-cats/shared/src/test/scala/Meta.scala:34) | - | $capturedExpression + | Clues { + | x: Int = 1 + | } | """.stripMargin.trim @@ -300,6 +298,163 @@ object DogFoodTests extends IOSuite { } } + test("successes with clues are rendered correctly") { + _.runSuite(Meta.Clue).map { + case (logs, _) => + val actual = extractLogEventBeforeFailures(logs) { + case LoggedEvent.Info(msg) if msg.contains("(success)") => + msg + }.get + + val expected = s""" + |+ (success) 0ms + | + """.stripMargin.trim + + expect.same(expected, actual) + } + } + + test("failures with clues are rendered correctly") { + _.runSuite(Meta.Clue).map { + case (logs, _) => + val actual = extractLogEventAfterFailures(logs) { + case LoggedEvent.Error(msg) if msg.contains("(failure)") => + msg + }.get + + val expected = s""" + |- (failure) 0ms + | assertion failed (modules/framework-cats/shared/src/test/scala/Meta.scala:83) + | + | Clues { + | x: Int = 1 + | y: Int = 2 + | } + | + """.stripMargin.trim + + expect.same(expected, actual) + } + } + + test("failures with nested clues are rendered correctly") { + _.runSuite(Meta.Clue).map { + case (logs, _) => + val actual = extractLogEventAfterFailures(logs) { + case LoggedEvent.Error(msg) if msg.contains("(nested)") => + msg + }.get + + val expected = s""" + |- (nested) 0ms + | assertion failed (modules/framework-cats/shared/src/test/scala/Meta.scala:89) + | + | Clues { + | x: Int = 1 + | y: Int = 2 + | List(clue(x), clue(y)): List[Int] = List(1, 2) + | } + | + """.stripMargin.trim + + expect.same(expected, actual) + } + } + + test("failures with identical clue expressions are rendered correctly") { + _.runSuite(Meta.Clue).map { + case (logs, _) => + val actual = extractLogEventAfterFailures(logs) { + case LoggedEvent.Error(msg) if msg.contains("(map)") => + msg + }.get + + val expected = s""" + |- (map) 0ms + | assertion failed (modules/framework-cats/shared/src/test/scala/Meta.scala:95) + | + | Clues { + | v: Int = 1 + | v: Int = 2 + | } + | + """.stripMargin.trim + + expect.same(expected, actual) + } + } + + test("values of clues are rendered with the given show") { + _.runSuite(Meta.Clue).map { + case (logs, _) => + val actual = extractLogEventAfterFailures(logs) { + case LoggedEvent.Error(msg) if msg.contains("(show)") => + msg + }.get + + val expected = s""" + |- (show) 0ms + | assertion failed (modules/framework-cats/shared/src/test/scala/Meta.scala:102) + | + | Clues { + | x: Int = int-1 + | y: Int = int-2 + | } + | + """.stripMargin.trim + + expect.same(expected, actual) + } + } + + test("values of clues are rendered with show constructed from toString if no show is given") { + _.runSuite(Meta.Clue).map { + case (logs, _) => + val actual = extractLogEventAfterFailures(logs) { + case LoggedEvent.Error(msg) + if msg.contains("(show-from-to-string)") => + msg + }.get + + val expected = s""" + |- (show-from-to-string) 0ms + | assertion failed (modules/framework-cats/shared/src/test/scala/Meta.scala:112) + | + | Clues { + | x: Foo = foo-1 + | y: Foo = foo-2 + | } + | + """.stripMargin.trim + + expect.same(expected, actual) + } + } + test("clue calls are replaced when using helper objects") { + _.runSuite(Meta.Clue).map { + case (logs, _) => + val actual = extractLogEventAfterFailures(logs) { + case LoggedEvent.Error(msg) if msg.contains("(helpers)") => + msg + }.get + + val expected = s""" + |- (helpers) 0ms + | assertion failed (modules/framework-cats/shared/src/test/scala/Meta.scala:121) + | + | Clues { + | x: Int = 1 + | y: Int = 2 + | z: Int = 3 + | } + | + """.stripMargin.trim + + expect.same(expected, actual) + } + } + private def outputBeforeFailures(logs: Chain[LoggedEvent]): Chain[String] = { logs .takeWhile { diff --git a/modules/framework-cats/shared/src/test/scala/Meta.scala b/modules/framework-cats/shared/src/test/scala/Meta.scala index 6d6b5e33..7fc7a7f4 100644 --- a/modules/framework-cats/shared/src/test/scala/Meta.scala +++ b/modules/framework-cats/shared/src/test/scala/Meta.scala @@ -3,9 +3,9 @@ package framework package test import org.typelevel.scalaccompat.annotation._ +import cats.Show import cats.effect._ - // The build tool will only detect and run top-level test suites. We can however nest objects // that contain failing tests, to allow for testing the framework without failing the build // because the framework will have ran the tests on its own. @@ -29,7 +29,9 @@ object Meta { } pureTest("lots\nof\nmultiline\n(failure)") { - expect(1 == 2) + val x = 1 + val y = 2 + expect(clue(x) == y) } test("lots\nof\nmultiline\n(ignored)") { @@ -64,6 +66,63 @@ object Meta { } } + object Clue extends SimpleIOSuite { + override implicit protected def effectCompat: UnsafeRun[IO] = + SetTimeUnsafeRun + implicit val sourceLocation: SourceLocation = TimeCop.sourceLocation + + pureTest("(success)") { + val x = 1 + val y = 1 + expect(clue(x) == clue(y)) + } + + pureTest("(failure)") { + val x = 1 + val y = 2 + expect(clue(x) == clue(y)) + } + + pureTest("(nested)") { + val x = 1 + val y = 2 + expect(clue(List(clue(x), clue(y))) == List(x, x)) + } + + pureTest("(map)") { + val x = 1 + val y = 2 + expect(List(x, y).map(v => clue(v)) == List(x, x)) + } + + pureTest("(show)") { + implicit val intShow: Show[Int] = i => s"int-$i" + val x = 1 + val y = 2 + expect(clue(x) == clue(y)) + } + + pureTest("(show-from-to-string)") { + class Foo(i: Int) { + override def toString = s"foo-$i" + } + val x: Foo = new Foo(1) + val y: Foo = new Foo(2) + + expect(clue(x) == clue(y)) + } + + pureTest("(helpers)") { + val x = 1 + val y = 2 + val z = 3 + import Expectations.Helpers.{ clue => otherclue } + object CustomHelpers extends Expectations.Helpers + expect(CustomHelpers.clue(x) == otherclue(y) || x == clue(z)) + } + + } + object FailingTestStatusReporting extends SimpleIOSuite { override implicit protected def effectCompat: UnsafeRun[IO] = SetTimeUnsafeRun