Adding SourcePosition support and tests to prove functionality.

This commit is contained in:
Pat Garrity 2024-09-05 22:25:44 -05:00
parent 79e6672bdf
commit 6ba43746ac
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
8 changed files with 273 additions and 20 deletions

View file

@ -1,5 +1,6 @@
package gs.test.v0.definition package gs.test.v0.definition
import gs.test.v0.definition.pos.SourcePosition
import scala.reflect.* import scala.reflect.*
sealed abstract class Assertion(val name: String) sealed abstract class Assertion(val name: String)
@ -10,8 +11,6 @@ object Assertion:
def renderInput[A](value: A): String = "" def renderInput[A](value: A): String = ""
// TODO: Code Position
case object IsEqualTo extends Assertion("isEqualTo"): case object IsEqualTo extends Assertion("isEqualTo"):
def evaluate[A: ClassTag]( def evaluate[A: ClassTag](
@ -20,6 +19,9 @@ object Assertion:
)( )(
using using
CanEqual[A, A] CanEqual[A, A]
)(
using
pos: SourcePosition
): Either[TestFailure, Unit] = ): Either[TestFailure, Unit] =
if candidate == expected then success() if candidate == expected then success()
else else
@ -32,20 +34,27 @@ object Assertion:
"expected" -> runtimeType "expected" -> runtimeType
), ),
message = 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"): 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() if candidate then success()
else else
Left( Left(
TestFailure.AssertionFailed( TestFailure.AssertionFailed(
assertionName = name, assertionName = name,
inputs = Map("candidate" -> "Boolean"), inputs = Map("candidate" -> "Boolean"),
message = s"Expected '$candidate' to be 'true'." message = s"Expected '$candidate' to be 'true'.",
pos = pos
) )
) )

View file

@ -1,44 +1,107 @@
package gs.test.v0.definition package gs.test.v0.definition
import cats.effect.Sync import cats.effect.Sync
import gs.test.v0.definition.pos.SourcePosition
import scala.reflect.ClassTag import scala.reflect.ClassTag
opaque type Check[A] = A opaque type Check[A] = A
object Check: object Check:
type TestResult = Either[TestFailure, Unit]
def apply[A](candidate: A): Check[A] = candidate def apply[A](candidate: A): Check[A] = candidate
extension [A: ClassTag](check: Check[A]) extension [A: ClassTag](candidate: Check[A])
/** @return /** @return
* The unwrapped value of this [[Check]]. * 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( def isEqualTo(
expected: A expected: A
)( )(
using using
CanEqual[A, A] CanEqual[A, A],
SourcePosition
): TestResult = ): 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]( def isEqualToF[F[_]: Sync](
expected: A expected: A
)( )(
using using
CanEqual[A, A] CanEqual[A, A],
SourcePosition
): F[TestResult] = ): F[TestResult] =
Sync[F].delay(isEqualTo(expected)) Sync[F].delay(isEqualTo(expected))
extension (check: Check[Boolean]) 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) 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()) Sync[F].delay(isTrue())
end Check end Check

View file

@ -1,5 +1,7 @@
package gs.test.v0.definition package gs.test.v0.definition
import gs.test.v0.definition.pos.SourcePosition
/** Base trait for all failures recognized by gs-test. /** Base trait for all failures recognized by gs-test.
*/ */
sealed trait TestFailure sealed trait TestFailure
@ -15,20 +17,26 @@ object TestFailure:
* The names and calculated types of each input to the assertion. * The names and calculated types of each input to the assertion.
* @param message * @param message
* The message produced by the assertion. * The message produced by the assertion.
* @param pos
* The [[SourcePosition]] where the assertion failed.
*/ */
case class AssertionFailed( case class AssertionFailed(
assertionName: String, assertionName: String,
inputs: Map[String, String], inputs: Map[String, String],
message: String message: String,
pos: SourcePosition
) extends TestFailure ) extends TestFailure
/** Return when a test explicitly calls `fail("...")` or some variant thereof. /** Return when a test explicitly calls `fail("...")` or some variant thereof.
* *
* @param message * @param message
* The failure message provided by the test author. * The failure message provided by the test author.
* @param pos
* The [[SourcePosition]] where failure was requested.
*/ */
case class TestRequestedFailure( case class TestRequestedFailure(
message: String message: String,
pos: SourcePosition
) extends TestFailure ) extends TestFailure
/** Used when the test fails due to an exception. /** Used when the test fails due to an exception.

View file

@ -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

View file

@ -4,6 +4,12 @@ import cats.Applicative
import cats.data.EitherT import cats.data.EitherT
import cats.effect.Sync import cats.effect.Sync
import cats.syntax.all.* 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]] /** String interpolator for [[Tag]]. Shorthand for producing new [[Tag]]
* instances. * instances.
@ -33,8 +39,13 @@ extension (sc: StringContext)
* @return * @return
* The failing test result. * The failing test result.
*/ */
def fail(message: String): Either[TestFailure, Unit] = def fail(
Left(TestFailure.TestRequestedFailure(message)) message: String
)(
using
pos: SourcePosition
): Either[TestFailure, Unit] =
Left(TestFailure.TestRequestedFailure(message, pos))
/** Request this test to fail (lifted into F). /** Request this test to fail (lifted into F).
* *
@ -43,7 +54,12 @@ def fail(message: String): Either[TestFailure, Unit] =
* @return * @return
* The failing test result. * 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)) Applicative[F].pure(fail(message))
/** Request this test to fail (lifted into EitherT). /** Request this test to fail (lifted into EitherT).
@ -53,7 +69,12 @@ def failF[F[_]: Applicative](message: String): F[Either[TestFailure, Unit]] =
* @return * @return
* The failing test result. * 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)) EitherT(failF(message))
/** Shorthand for indicating a passing test (pure form). /** Shorthand for indicating a passing test (pure form).
@ -134,6 +155,17 @@ def checkAll(
case Right(_) => result 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]( def checkAllF[F[_]: Sync](
checks: F[Either[TestFailure, Unit]]* checks: F[Either[TestFailure, Unit]]*
): F[Either[TestFailure, Unit]] = ): F[Either[TestFailure, Unit]] =
@ -148,3 +180,5 @@ def checkAllF[F[_]: Sync](
case err => Sync[F].pure(err) case err => Sync[F].pure(err)
} }
} }
def check[A](candidate: A): Check[A] = Check(candidate)

View file

@ -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())

View file

@ -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