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 5bc6fd3c..2872a281 100644 Binary files a/docs/assets/oops.png and b/docs/assets/oops.png differ 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..eb40c43f --- /dev/null +++ b/modules/core/shared/src/main/scala-2/weaver/ExpectMacro.scala @@ -0,0 +1,170 @@ +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._ + + // This transformation outputs code that adds clues to a local + // clues collection `cluesName`. It recurses over the input code and replaces + // all calls of `ClueHelpers.clue` with `cluesName.addClue`. + object transformer extends Transformer { + + override def transform(input: Tree): Tree = input match { + case c.universe.Apply(fun, List(clueValue)) + if fun.symbol == clueMethodSymbol => + // The input tree corresponds to `ClueHelpers.clue(clueValue)` . + // Transform it into `clueName.addClue(clueValue)` + // Apply the transformation recursively to `clueValue` to support nested clues. + val transformedClueValue = super.transform(clueValue) + q"""${cluesName}.addClue($transformedClueValue)""" + case o => + // Otherwise, recurse over the input. + 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..8f2349ee --- /dev/null +++ b/modules/core/shared/src/main/scala-3/weaver/ExpectMacro.scala @@ -0,0 +1,93 @@ +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 [[Expectations.Helpers.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 [[Expectations.Helpers.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 [[Expectations.Helpers.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