Adding SourcePosition support and tests to prove functionality.
This commit is contained in:
parent
79e6672bdf
commit
6ba43746ac
8 changed files with 273 additions and 20 deletions
|
@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
|
@ -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)
|
||||||
|
|
|
@ -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())
|
|
@ -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
|
Loading…
Add table
Reference in a new issue