From 6ba43746accfeeb233310316d1c8486aefcd2f13 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Thu, 5 Sep 2024 22:25:44 -0500 Subject: [PATCH] Adding SourcePosition support and tests to prove functionality. --- .../gs/test/v0/definition/Assertion.scala | 19 +++-- .../scala/gs/test/v0/definition/Check.scala | 81 ++++++++++++++++--- .../gs/test/v0/definition/TestFailure.scala | 12 ++- .../v0/definition/pos/SourcePosition.scala | 55 +++++++++++++ .../scala/gs/test/v0/definition/syntax.scala | 42 +++++++++- .../src/test/scala/gs/test/v0/IOSuite.scala | 19 +++++ .../GroupImplementationTests.scala | 0 .../definition/pos/SourcePositionTests.scala | 65 +++++++++++++++ 8 files changed, 273 insertions(+), 20 deletions(-) create mode 100644 modules/api-definition/src/main/scala/gs/test/v0/definition/pos/SourcePosition.scala create mode 100644 modules/api-definition/src/test/scala/gs/test/v0/IOSuite.scala rename modules/api-definition/src/test/scala/gs/test/v0/{ => definition}/GroupImplementationTests.scala (100%) create mode 100644 modules/api-definition/src/test/scala/gs/test/v0/definition/pos/SourcePositionTests.scala diff --git a/modules/api-definition/src/main/scala/gs/test/v0/definition/Assertion.scala b/modules/api-definition/src/main/scala/gs/test/v0/definition/Assertion.scala index db8a16f..53bf6c4 100644 --- a/modules/api-definition/src/main/scala/gs/test/v0/definition/Assertion.scala +++ b/modules/api-definition/src/main/scala/gs/test/v0/definition/Assertion.scala @@ -1,5 +1,6 @@ package gs.test.v0.definition +import gs.test.v0.definition.pos.SourcePosition import scala.reflect.* sealed abstract class Assertion(val name: String) @@ -10,8 +11,6 @@ object Assertion: def renderInput[A](value: A): String = "" - // TODO: Code Position - case object IsEqualTo extends Assertion("isEqualTo"): def evaluate[A: ClassTag]( @@ -20,6 +19,9 @@ object Assertion: )( using CanEqual[A, A] + )( + using + pos: SourcePosition ): Either[TestFailure, Unit] = if candidate == expected then success() else @@ -32,20 +34,27 @@ object Assertion: "expected" -> runtimeType ), message = - s"'${renderInput(candidate)}' was not equal to '${renderInput(candidate)}'" + s"'${renderInput(candidate)}' was not equal to '${renderInput(candidate)}'", + pos = pos ) ) case object IsTrue extends Assertion("isTrue"): - def evaluate(candidate: Boolean): Either[TestFailure, Unit] = + def evaluate( + candidate: Boolean + )( + using + pos: SourcePosition + ): Either[TestFailure, Unit] = if candidate then success() else Left( TestFailure.AssertionFailed( assertionName = name, inputs = Map("candidate" -> "Boolean"), - message = s"Expected '$candidate' to be 'true'." + message = s"Expected '$candidate' to be 'true'.", + pos = pos ) ) diff --git a/modules/api-definition/src/main/scala/gs/test/v0/definition/Check.scala b/modules/api-definition/src/main/scala/gs/test/v0/definition/Check.scala index 4ff9398..ce4d6eb 100644 --- a/modules/api-definition/src/main/scala/gs/test/v0/definition/Check.scala +++ b/modules/api-definition/src/main/scala/gs/test/v0/definition/Check.scala @@ -1,44 +1,107 @@ package gs.test.v0.definition import cats.effect.Sync +import gs.test.v0.definition.pos.SourcePosition import scala.reflect.ClassTag opaque type Check[A] = A object Check: - type TestResult = Either[TestFailure, Unit] - def apply[A](candidate: A): Check[A] = candidate - extension [A: ClassTag](check: Check[A]) + extension [A: ClassTag](candidate: Check[A]) /** @return * The unwrapped value of this [[Check]]. */ - def unwrap(): A = check + def unwrap(): A = candidate + /** ## Usage + * + * {{{ + * val x = 10 + * val result: TestResult = check(x).isEqualTo(10) + * }}} + * + * @param expected + * The expected value. + * @return + * Successful test result if the candidate value is equal to the expected + * value, an error describing the test failure otherwise. + */ def isEqualTo( expected: A )( using - CanEqual[A, A] + CanEqual[A, A], + SourcePosition ): TestResult = - Assertion.IsEqualTo.evaluate(check, expected) + Assertion.IsEqualTo.evaluate(candidate, expected) + /** ## Usage + * + * This is an effectful/lazy version of `isEqualTo`. + * + * {{{ + * val x = 10 + * val effect: F[TestResult] = check(x).isEqualToF(10) + * }}} + * + * @param expected + * The expected value. + * @return + * Effect that when evaluated will produce: successful test result if the + * candidate value is equal to the expected value, an error describing + * the test failure otherwise. + */ def isEqualToF[F[_]: Sync]( expected: A )( using - CanEqual[A, A] + CanEqual[A, A], + SourcePosition ): F[TestResult] = Sync[F].delay(isEqualTo(expected)) extension (check: Check[Boolean]) - def isTrue(): TestResult = + /** ## Usage + * + * {{{ + * val x = false + * val result: TestResult = check(x).isTrue() + * }}} + * + * @return + * Successful test result if the candidate value is `true`, an error + * describing the test failure otherwise. + */ + def isTrue( + )( + using + SourcePosition + ): TestResult = Assertion.IsTrue.evaluate(check) - def isTrueF[F[_]: Sync](): F[TestResult] = + /** ## Usage + * + * This is an effectful/lazy version of `isTrue`. + * + * {{{ + * val x = false + * val effect: F[TestResult] = check(x).isTrueF() + * }}} + * + * @return + * Effect that when evaluated will produce: successful test result if the + * candidate value is `true`, an error describing the test failure + * otherwise. + */ + def isTrueF[F[_]: Sync]( + )( + using + SourcePosition + ): F[TestResult] = Sync[F].delay(isTrue()) end Check diff --git a/modules/api-definition/src/main/scala/gs/test/v0/definition/TestFailure.scala b/modules/api-definition/src/main/scala/gs/test/v0/definition/TestFailure.scala index a2d9a2d..186dadf 100644 --- a/modules/api-definition/src/main/scala/gs/test/v0/definition/TestFailure.scala +++ b/modules/api-definition/src/main/scala/gs/test/v0/definition/TestFailure.scala @@ -1,5 +1,7 @@ package gs.test.v0.definition +import gs.test.v0.definition.pos.SourcePosition + /** Base trait for all failures recognized by gs-test. */ sealed trait TestFailure @@ -15,20 +17,26 @@ object TestFailure: * The names and calculated types of each input to the assertion. * @param message * The message produced by the assertion. + * @param pos + * The [[SourcePosition]] where the assertion failed. */ case class AssertionFailed( assertionName: String, inputs: Map[String, String], - message: String + message: String, + pos: SourcePosition ) extends TestFailure /** Return when a test explicitly calls `fail("...")` or some variant thereof. * * @param message * The failure message provided by the test author. + * @param pos + * The [[SourcePosition]] where failure was requested. */ case class TestRequestedFailure( - message: String + message: String, + pos: SourcePosition ) extends TestFailure /** Used when the test fails due to an exception. diff --git a/modules/api-definition/src/main/scala/gs/test/v0/definition/pos/SourcePosition.scala b/modules/api-definition/src/main/scala/gs/test/v0/definition/pos/SourcePosition.scala new file mode 100644 index 0000000..f52ee4b --- /dev/null +++ b/modules/api-definition/src/main/scala/gs/test/v0/definition/pos/SourcePosition.scala @@ -0,0 +1,55 @@ +package gs.test.v0.definition.pos + +import scala.quoted.* + +/** Represents a specific location in source code. + * + * ## Credit + * + * This code was taken directly from https://github.com/tpolecat/SourcePos + * (also under the MIT License). + * + * ## Usage + * + * {{{ + * def example()(using sp: SourcePosition): Unit = + * println(s"Called from $sp") + * }}} + * + * @param file + * The specific source file being referenced. + * @param line + * The line number within the source file. + */ +case class SourcePosition( + file: String, + line: Int +): + override def toString(): String = s"$file:$line" + +object SourcePosition: + + /** Implicit, inlined function to receive the source position at the inlined + * location. + */ + implicit inline def instance: SourcePosition = + ${ sourcePositionImpl } + + /** Implementation for getting the [[SourcePosition]] using macros. + * + * @param context + * The current quotation context. + * @return + * Expression that produces the source position at the point of macro + * expansion. + */ + def sourcePositionImpl( + using + context: Quotes + ): Expr[SourcePosition] = + val rootPosition = context.reflect.Position.ofMacroExpansion + val file = Expr(rootPosition.sourceFile.path) + val line = Expr(rootPosition.startLine + 1) + '{ SourcePosition($file, $line) } + +end SourcePosition diff --git a/modules/api-definition/src/main/scala/gs/test/v0/definition/syntax.scala b/modules/api-definition/src/main/scala/gs/test/v0/definition/syntax.scala index 3e8da9c..81e3b9f 100644 --- a/modules/api-definition/src/main/scala/gs/test/v0/definition/syntax.scala +++ b/modules/api-definition/src/main/scala/gs/test/v0/definition/syntax.scala @@ -4,6 +4,12 @@ import cats.Applicative import cats.data.EitherT import cats.effect.Sync import cats.syntax.all.* +import gs.test.v0.definition.pos.SourcePosition + +/** Type alias for test results. Test results are either a [[TestFailure]] or a + * unit value (indicating success). + */ +type TestResult = Either[TestFailure, Unit] /** String interpolator for [[Tag]]. Shorthand for producing new [[Tag]] * instances. @@ -33,8 +39,13 @@ extension (sc: StringContext) * @return * The failing test result. */ -def fail(message: String): Either[TestFailure, Unit] = - Left(TestFailure.TestRequestedFailure(message)) +def fail( + message: String +)( + using + pos: SourcePosition +): Either[TestFailure, Unit] = + Left(TestFailure.TestRequestedFailure(message, pos)) /** Request this test to fail (lifted into F). * @@ -43,7 +54,12 @@ def fail(message: String): Either[TestFailure, Unit] = * @return * The failing test result. */ -def failF[F[_]: Applicative](message: String): F[Either[TestFailure, Unit]] = +def failF[F[_]: Applicative]( + message: String +)( + using + pos: SourcePosition +): F[Either[TestFailure, Unit]] = Applicative[F].pure(fail(message)) /** Request this test to fail (lifted into EitherT). @@ -53,7 +69,12 @@ def failF[F[_]: Applicative](message: String): F[Either[TestFailure, Unit]] = * @return * The failing test result. */ -def failT[F[_]: Applicative](message: String): EitherT[F, TestFailure, Unit] = +def failT[F[_]: Applicative]( + message: String +)( + using + pos: SourcePosition +): EitherT[F, TestFailure, Unit] = EitherT(failF(message)) /** Shorthand for indicating a passing test (pure form). @@ -134,6 +155,17 @@ def checkAll( case Right(_) => result } +/** Check all of the given results, returning the first failure, or a successful + * result if no result failed. + * + * In this call, each check is expressed as an effect that is evaluated to + * determine success/failure - this call is lazy. + * + * @param results + * The list of results to check. + * @return + * Successful result or the first failure. + */ def checkAllF[F[_]: Sync]( checks: F[Either[TestFailure, Unit]]* ): F[Either[TestFailure, Unit]] = @@ -148,3 +180,5 @@ def checkAllF[F[_]: Sync]( case err => Sync[F].pure(err) } } + +def check[A](candidate: A): Check[A] = Check(candidate) diff --git a/modules/api-definition/src/test/scala/gs/test/v0/IOSuite.scala b/modules/api-definition/src/test/scala/gs/test/v0/IOSuite.scala new file mode 100644 index 0000000..86d771e --- /dev/null +++ b/modules/api-definition/src/test/scala/gs/test/v0/IOSuite.scala @@ -0,0 +1,19 @@ +package gs.test.v0 + +import cats.effect.IO +import cats.effect.unsafe.IORuntime +import munit.FunSuite +import munit.Location + +abstract class IOSuite extends FunSuite: + implicit val runtime: IORuntime = IORuntime.global + + def iotest( + name: String + )( + body: => IO[Any] + )( + implicit + loc: Location + ): Unit = + test(name)(body.unsafeRunSync()) diff --git a/modules/api-definition/src/test/scala/gs/test/v0/GroupImplementationTests.scala b/modules/api-definition/src/test/scala/gs/test/v0/definition/GroupImplementationTests.scala similarity index 100% rename from modules/api-definition/src/test/scala/gs/test/v0/GroupImplementationTests.scala rename to modules/api-definition/src/test/scala/gs/test/v0/definition/GroupImplementationTests.scala diff --git a/modules/api-definition/src/test/scala/gs/test/v0/definition/pos/SourcePositionTests.scala b/modules/api-definition/src/test/scala/gs/test/v0/definition/pos/SourcePositionTests.scala new file mode 100644 index 0000000..dea9913 --- /dev/null +++ b/modules/api-definition/src/test/scala/gs/test/v0/definition/pos/SourcePositionTests.scala @@ -0,0 +1,65 @@ +package gs.test.v0.definition.pos + +import gs.test.v0.IOSuite +import gs.test.v0.definition.* +import munit.* + +/** These tests are sensitive to changes, even in formatting! They are looking + * for specific line numbers in this source code, so any sort of newline that + * occurs prior to the sub-test-definitions will cause the checked positions to + * change! + */ +class SourcePositionTests extends IOSuite: + + import SourcePositionTests.* + + test("should provide the source position of a failed check") { + lookForSourcePosition(new G1, 46) + } + + test("should provide the source position of an explicit failure") { + lookForSourcePosition(new G2, 55) + } + + private def lookForSourcePosition( + groupDef: TestGroup.IO, + line: Int + ): Unit = + val group = groupDef.compile() + group.tests match + case t1 :: Nil => + t1.unitOfWork.value.unsafeRunSync() match + case Left(TestFailure.AssertionFailed(_, _, _, pos)) => + assertEquals(pos.file.endsWith(SourceFileName), true) + assertEquals(pos.line, line) + case Left(TestFailure.TestRequestedFailure(_, pos)) => + assertEquals(pos.file.endsWith(SourceFileName), true) + assertEquals(pos.line, line) + case _ => fail("Sub-test did not fail as expected.") + case _ => + fail("Wrong number of tests - position tests need one test per group.") + +object SourcePositionTests: + + class G1 extends TestGroup.IO: + override def name: String = "G1" + + test(pid"t1", "pos").pure { + check(false).isTrue() + } + + end G1 + + class G2 extends TestGroup.IO: + override def name: String = "G2" + + test(pid"t2", "pos").pure { + gs.test.v0.definition.fail("Expected Failure") + } + + end G2 + + val SourceFileName: String = + "modules/api-definition/src/test/scala/gs/test/v0/definition/pos/SourcePositionTests.scala" + +end SourcePositionTests