diff --git a/README.md b/README.md index 29f7bf6f..cbeee9b7 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,10 @@ object MySuite extends IOSuite { Weaver also includes support for -| Alias | Suite name | Provided by | Use case | -| ----------------- | ------------------------ | ------------------ | --------------------------------------------- | -| `SimpleIOSuite` | `SimpleMutableIOSuite` | `weaver-cats` | Each test is a standalone `IO` action | -| `IOSuite` | `MutableIOSuite` | `weaver-cats` | Each test needs access to a shared `Resource` | +| Alias | Suite name | Provided by | Use case | +| --------------- | ---------------------- | ------------- | --------------------------------------------- | +| `SimpleIOSuite` | `SimpleMutableIOSuite` | `weaver-cats` | Each test is a standalone `IO` action | +| `IOSuite` | `MutableIOSuite` | `weaver-cats` | Each test needs access to a shared `Resource` | ### Expectations (assertions) @@ -140,18 +140,44 @@ Something worth noting is that expectations are not throwing, and that if the us ### Filtering tests -When using the IOSuite variants, the user can call `sbt`'s test command as such: +Weaver supports powerful test filtering with pattern-based and tag-based filters. + +#### Pattern filtering + +Filter tests by their qualified name using the `-o` or `--only` flag: ```  > testOnly -- -o *foo* ``` -This filter will prevent the execution of any test that doesn't contain the string "foo" in is qualified name. For a test labeled "foo" in a "FooSuite" object, in the package "fooPackage", the qualified name of a test is: +This will run only tests containing "foo" in their qualified name (`fooPackage.FooSuite.foo`). + +#### Tag filtering +Filter tests using GitHub-style tag expressions with the `-t` or `--tags` flag: + +``` +> testOnly -- -t "bug-*,feature-*" ``` -fooPackage.FooSuite.foo + +Tag expressions support: + +- **`,` (comma)** - OR: `bug,feature` matches either tag +- **` ` (space)** - AND: `bug critical` matches both tags +- **`!` (exclamation)** - NOT: `!slow` excludes slow tests +- **`()` (parentheses)** - grouping: `(bug,feature) !wontfix` +- **Wildcards** - `*` (any chars), `?` (one char): `bug-*`, `test-?` + +Add tags to tests: + +```scala +test("my test".tagged("bug").tagged("critical")) { + expect(1 + 1 == 2) +} ``` +See the [filtering documentation](https://typelevel.org/weaver-test/features/filtering.html) for complete details. + ### Running suites in standalone It is possible to run suites outside of your build tool, via a good old `main` function. To do so, you can instantiate the `weaver.Runner`, create a `fs2.Stream` of the suites you want to run, and call `runner.run(stream)`. diff --git a/build.sbt b/build.sbt index 538a8697..b99bd672 100644 --- a/build.sbt +++ b/build.sbt @@ -42,6 +42,7 @@ ThisBuild / scalaVersion := scala213 // the default Scala val Version = new { val catsEffect = "3.6.3" val catsLaws = "2.11.0" + val catsParse = "1.0.0" // 1.1.0 doesn't exist for native 0.5 val discipline = "1.5.1" val fs2 = "3.12.2" val junit = "4.13.2" @@ -71,6 +72,7 @@ lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) libraryDependencies ++= Seq( "co.fs2" %%% "fs2-core" % Version.fs2, "org.typelevel" %%% "cats-effect" % Version.catsEffect, + "org.typelevel" %%% "cats-parse" % Version.catsParse, // 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, diff --git a/docs/features/filtering.md b/docs/features/filtering.md index 7bc3e36e..b88987ef 100644 --- a/docs/features/filtering.md +++ b/docs/features/filtering.md @@ -1,14 +1,160 @@ Filtering tests =============== -When using the IOSuite variants, the user can call `sbt`'s test command as such: +Weaver supports two types of test filtering: pattern-based filtering and tag-based filtering. Both can be used independently or combined together. -```  +## Pattern-based filtering + +When using the IOSuite variants, you can filter tests by their qualified name using the `-o` or `--only` flag: + +``` > testOnly -- -o *foo* +> testOnly -- --only *foo* ``` -This filter will prevent the execution of any test that doesn't contain the string "foo" in is qualified name. For a test labeled "foo" in a "FooSuite" object, in the package "fooPackage", the qualified name of a test is: +This filter will prevent the execution of any test that doesn't contain the string "foo" in its qualified name. For a test labeled "foo" in a "FooSuite" object, in the package "fooPackage", the qualified name of a test is: ``` fooPackage.FooSuite.foo ``` + +You can also filter by line number: + +``` +> testOnly -- -o FooSuite.line://42 +``` + +This will only run the test at line 42 in FooSuite. + +## Tag-based filtering + +Weaver supports powerful tag-based filtering using GitHub-style tag expressions with the `-t` or `--tags` flag: + +``` +> testOnly -- -t bug-123 +> testOnly -- --tags "bug-* !bug-wontfix" +``` + +### Tag expression syntax + +Tag expressions use GitHub's label filtering syntax: + +- **`,` (comma)** - OR operator: matches tests with any of the specified tags +- **` ` (space)** - AND operator: matches tests with all of the specified tags +- **`!` (exclamation)** - NOT operator: excludes tests with the specified tag +- **`()` (parentheses)** - grouping to control precedence + +### Examples + +**Match tests with either tag:** +``` +> testOnly -- -t "bug,feature" +``` +Matches tests tagged with `bug` OR `feature`. + +**Match tests with both tags:** +``` +> testOnly -- -t "bug critical" +``` +Matches tests tagged with both `bug` AND `critical`. + +**Exclude specific tags:** +``` +> testOnly -- -t "!slow" +``` +Matches all tests EXCEPT those tagged `slow`. + +**Complex expressions:** +``` +> testOnly -- -t "bug,feature !wontfix" +``` +Matches tests tagged with (`bug` OR `feature`) AND NOT `wontfix`. + +**Grouping with parentheses:** +``` +> testOnly -- -t "(bug,feature) critical" +``` +Matches tests tagged with (`bug` OR `feature`) AND `critical`. + +### Wildcard patterns + +Tag expressions support wildcards for flexible matching: + +- **`*` (asterisk)** - matches zero or more characters +- **`?` (question mark)** - matches exactly one character + +**Examples:** + +**Prefix matching:** +``` +> testOnly -- -t "bug-*" +``` +Matches tags like `bug-123`, `bug-critical`, `bug-feature-x`, etc. + +**Suffix matching:** +``` +> testOnly -- -t "*-prod" +``` +Matches tags like `env-prod`, `db-prod`, etc. + +**Single character:** +``` +> testOnly -- -t "test-?" +``` +Matches tags like `test-1`, `test-a`, but not `test-12`. + +**Infix matching:** +``` +> testOnly -- -t "first*bug" +``` +Matches tags like `firstbug`, `first-critical-bug`, etc. + +**Complex wildcards:** +``` +> testOnly -- -t "bug-*,feature-* !bug-wontfix" +``` +Matches all bug or feature tags except `bug-wontfix`. + +### Precedence rules + +Operators have the following precedence (highest to lowest): + +1. `!` (NOT) +2. ` ` (AND - space) +3. `,` (OR - comma) + +Use parentheses to override the default precedence: + +``` +> testOnly -- -t "tag1,tag2 tag3" # (tag1) OR (tag2 AND tag3) +> testOnly -- -t "(tag1,tag2) tag3" # (tag1 OR tag2) AND (tag3) +``` + +## Combining filters + +You can combine pattern-based (`-o`/`--only`) and tag-based (`-t`/`--tags`) filtering in the same command. When both are specified, **BOTH filters must match** for a test to run (AND logic): + +``` +> testOnly -- -t "bug-*" -o "*integration*" +``` + +This will only run tests that: +- Are tagged with a bug tag (like `bug-123`, `bug-critical`), **AND** +- Have "integration" in their qualified name + +**More examples:** + +``` +> testOnly -- -t "slow" -o "MySuite.*" +``` +Runs only slow tests from MySuite. + +``` +> testOnly -- -t "!flaky" -o "*database*" +``` +Runs all database-related tests that are NOT tagged as flaky. + +``` +> testOnly -- -t "(bug,feature) !wontfix" -o "FooSuite.line://42" +``` +Runs the test at line 42 only if it's tagged as bug or feature, but not wontfix. diff --git a/docs/features/tagging.md b/docs/features/tagging.md index adb1fce1..cb98c8e6 100644 --- a/docs/features/tagging.md +++ b/docs/features/tagging.md @@ -1,14 +1,177 @@ Tagging ======= -Weaver provides some constructs to dynamically tag tests as `ignored` : +Weaver provides constructs to tag tests for organization and selective execution. Tags can be used to categorize tests (e.g., by type, priority, environment) and filter which tests run. + +## Adding tags to tests + +Tests can be tagged using the `.tagged()` method on the test name: ```scala mdoc import weaver._ + +object TaggedSuite extends SimpleIOSuite { + + // Test with a single tag + pureTest("bug fix".tagged("bug")) { + expect(1 + 1 == 2) + } + + // Test with multiple tags (chain .tagged() calls) + pureTest("integration test".tagged("integration").tagged("slow").tagged("database")) { + expect(true) + } + + // Test without tags + pureTest("simple unit test") { + expect(42 == 42) + } +} +``` + +## Special tags: ignore and only + +Weaver provides convenient shortcuts for two special tags: + +### ignore + +The `.ignore` method is equivalent to `.tagged("ignore")`: + +```scala mdoc:reset +import weaver._ + +object IgnoreSuite extends SimpleIOSuite { + + // These two are equivalent + pureTest("skipped test".ignore) { + expect(1 + 1 == 2) + } + + pureTest("also skipped".tagged("ignore")) { + expect(1 + 1 == 2) + } +} +``` + +### only + +The `.only` method is equivalent to `.tagged("only")`: + +```scala mdoc:reset +import weaver._ + +object OnlySuite extends SimpleIOSuite { + + // These two are equivalent + pureTest("run only this".only) { + expect(1 + 1 == 2) + } + + pureTest("run only this too".tagged("only")) { + expect(1 + 1 == 2) + } + + pureTest("this will be skipped") { + expect(42 == 42) + } +} +``` + +When any test in a suite is tagged with `only`, only those tests will run. + +## Tag naming conventions + +Tags should consist of alphanumeric characters, hyphens, underscores, and colons: + +- **Valid characters**: `a-z`, `A-Z`, `0-9`, `_`, `-`, `:` +- **Examples**: `bug`, `bug-123`, `test_case`, `env:prod` + +**Important**: While you *can* create tags with spaces (e.g., `.tagged("foo bar")`), these tags **cannot be used with tag filtering expressions** because spaces are used as the AND operator in the filter syntax. Tags with spaces can only be matched by exact string matching, not through the `-t`/`--tags` filter. It's strongly recommended to use hyphens or underscores instead of spaces. + +```scala +// ✅ Recommended: use hyphens or underscores +test("test".tagged("foo-bar")) +test("test".tagged("foo_bar")) + +// ❌ Not recommended: spaces prevent filtering +test("test".tagged("foo bar")) // Cannot filter with: -t "foo bar" +``` + +Common tagging strategies: + +```scala +// By category +test("test name".tagged("unit")) +test("test name".tagged("integration")) +test("test name".tagged("e2e")) + +// By priority +test("test name".tagged("critical")) +test("test name".tagged("high")) +test("test name".tagged("low")) + +// By feature or issue +test("test name".tagged("bug-123")) +test("test name".tagged("feature-auth")) +test("test name".tagged("epic-billing")) + +// By environment +test("test name".tagged("env:dev")) +test("test name".tagged("env:staging")) +test("test name".tagged("env:prod")) + +// By speed +test("test name".tagged("fast")) +test("test name".tagged("slow")) + +// By stability +test("test name".tagged("flaky")) +test("test name".tagged("stable")) + +// Multiple tags +test("test name" + .tagged("integration") + .tagged("slow") + .tagged("database") + .tagged("env:staging")) +``` + +## Filtering tests by tags + +See [Filtering](filtering.md) for complete documentation on how to filter tests using tag expressions. + +Quick examples: + +``` +// Run only tests tagged as "bug" +> testOnly -- -t bug + +// Run tests tagged as "bug" OR "feature" +> testOnly -- -t "bug,feature" + +// Run tests tagged as both "integration" AND "database" +> testOnly -- -t "integration database" + +// Run all tests except "slow" ones +> testOnly -- -t "!slow" + +// Run bug tests with wildcards +> testOnly -- -t "bug-*" + +// Exclude ignored tests explicitly +> testOnly -- -t "!ignore" +``` + +## Dynamic tagging (conditional ignore) + +Weaver also provides constructs to dynamically tag tests as ignored at runtime: + +```scala mdoc:reset +import weaver._ import cats.effect.IO import cats.syntax.all._ -object TaggingSuite extends SimpleIOSuite { +object DynamicTaggingSuite extends SimpleIOSuite { test("Only on CI") { for { @@ -21,6 +184,8 @@ object TaggingSuite extends SimpleIOSuite { } ``` -```scala mdoc:passthrough -println(weaver.docs.Output.runSuites(TaggingSuite)) +```scala mdoc:passthrough +println(weaver.docs.Output.runSuites(DynamicTaggingSuite)) ``` + +This allows you to conditionally skip tests based on runtime conditions like environment variables, system properties, or other dynamic checks. diff --git a/modules/core/shared/src/main/scala/weaver/Filters.scala b/modules/core/shared/src/main/scala/weaver/Filters.scala index d525a48f..575b4bf1 100644 --- a/modules/core/shared/src/main/scala/weaver/Filters.scala +++ b/modules/core/shared/src/main/scala/weaver/Filters.scala @@ -1,6 +1,7 @@ package weaver import java.util.regex.Pattern +import weaver.internals.TagExprParser private[weaver] object Filters { @@ -29,6 +30,17 @@ private[weaver] object Filters { } } + private def createTagFilter(expr: String): TestName => Boolean = { + TagExprParser.parse(expr) match { + case Right(tagExpr) => + testName => tagExpr.matches(testName.tags) + case Left(error) => + throw new IllegalArgumentException( + s"Invalid tag expression '$expr': $error" + ) + } + } + private[weaver] def filterTests(suiteName: String)( args: List[String]): TestName => Boolean = { @@ -49,11 +61,20 @@ private[weaver] object Filters { import scala.util.Try def indexOfOption(opt: String): Option[Int] = Option(args.indexOf(opt)).filter(_ >= 0) - val maybePattern = for { + + val maybeTagFilter = for { + index <- indexOfOption("-t").orElse(indexOfOption("--tags")) + expr <- Try(args(index + 1)).toOption + } yield createTagFilter(expr) + + val maybePatternFilter = for { index <- indexOfOption("-o").orElse(indexOfOption("--only")) filter <- Try(args(index + 1)).toOption } yield toPredicate(filter) - testId => maybePattern.forall(_.apply(testId)) - } + testName => { + maybeTagFilter.forall(_.apply(testName)) && + maybePatternFilter.forall(_.apply(testName)) + } + } } diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala new file mode 100644 index 00000000..a2f1c572 --- /dev/null +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExpr.scala @@ -0,0 +1,103 @@ +package weaver.internals + +import cats.parse.{ Parser0, Parser } +import cats.syntax.all.* + +private[weaver] sealed trait TagExpr { + def matches(tags: Set[String]): Boolean +} + +private[weaver] object TagExpr { + + private[weaver] object Wildcard { + /* The complexity here comes from the fact that * at the beginning or in the middle needs to + * know the next expected parser to know when to stop consuming */ + val parser = { + val validCharP = Parser.charIn('a' to 'z') | + Parser.charIn('A' to 'Z') | + Parser.charIn('0' to '9') | + Parser.charIn("_-:") + + sealed trait Token + case class Literal(str: String) extends Token + case object Star extends Token + case object Question extends Token + + val literalP: Parser[Token] = + validCharP.rep.map(cs => Literal(cs.toList.mkString)) + val starP: Parser[Token] = Parser.char('*').as(Star) + val questionP: Parser[Token] = Parser.char('?').as(Question) + + val tokenP: Parser[Token] = starP | questionP | literalP + val tokensP: Parser[List[Token]] = tokenP.rep.map(_.toList) + + def loop(tokens: List[Token]): Parser0[Unit] = tokens match { + case Nil => + Parser.unit + + case Literal(str) :: rest => + (Parser.string(str) ~ loop(rest)).void + + case Question :: rest => + (validCharP ~ loop(rest)).void + + case Star :: Nil => + // Star at the end - consume everything + validCharP.rep0.void + + case Star :: rest => + // Star followed by something we need to use repUntil0 to stop before the next parser + val afterStar = loop(rest) + (validCharP.repUntil0(afterStar) ~ afterStar).void + } + + tokensP.map(loop) + }.withString.map { case (parser, pattern) => + Wildcard(pattern, parser) + } + + def fromPattern(pattern: String): Either[Parser.Error, Wildcard] = + parser.parseAll(pattern) + + def unsafeFromPattern(pattern: String): Wildcard = + fromPattern(pattern).leftMap { e => + new RuntimeException( + s"The pattern: ${pattern} is not a valid tag wildcard. ${e}") + }.fold(throw _, identity) + } + + private[weaver] case class Wildcard private ( + patternStr: String, + parser: Parser0[Unit]) + extends TagExpr { + def matches(tags: Set[String]): Boolean = { + tags.exists(tag => parser.parseAll(tag).isRight) + } + + // Override equals and hashCode to only consider patternStr, not parser + override def equals(obj: Any): Boolean = obj match { + case that: Wildcard => this.patternStr == that.patternStr + case _ => false + } + + override def hashCode(): Int = patternStr.hashCode() + + override def toString: String = s"Wildcard($patternStr)" + } + + private[weaver] case class Not(expr: TagExpr) extends TagExpr { + def matches(tags: Set[String]): Boolean = !expr.matches(tags) + } + + private[weaver] case class And(left: TagExpr, right: TagExpr) + extends TagExpr { + def matches(tags: Set[String]): Boolean = + left.matches(tags) && right.matches(tags) + } + + private[weaver] case class Or(left: TagExpr, right: TagExpr) extends TagExpr { + def matches(tags: Set[String]): Boolean = + left.matches(tags) || right.matches(tags) + } + +} diff --git a/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala new file mode 100644 index 00000000..cb6e896d --- /dev/null +++ b/modules/core/shared/src/main/scala/weaver/internals/TagExprParser.scala @@ -0,0 +1,78 @@ +package weaver.internals + +import cats.parse.{ Parser, Parser0 } +import cats.syntax.all.* + +private[weaver] object TagExprParser { + import TagExpr._ + + // In GitHub syntax, space is significant (it's the AND operator) + // So we only skip tabs, \r, \n for optional whitespace, NOT spaces + private val nonSpaceWhitespace: Parser[Unit] = Parser.charIn("\t\r\n").void + private val nonSpaceWhitespaces0: Parser0[Unit] = nonSpaceWhitespace.rep0.void + + private val leftParen: Parser[Unit] = + Parser.char('(').surroundedBy(nonSpaceWhitespaces0) + + private val rightParen: Parser[Unit] = + Parser.char(')').surroundedBy(nonSpaceWhitespaces0) + + private val andOperator: Parser[Unit] = + Parser.char(' ').surroundedBy(nonSpaceWhitespaces0) + + private val orOperator: Parser[Unit] = + Parser.char(',').surroundedBy(nonSpaceWhitespaces0) + + private val notOperator: Parser[Unit] = + Parser.char('!').surroundedBy(nonSpaceWhitespaces0) + + // Forward declaration for recursive grammar + private val expression: Parser[TagExpr] = Parser.recursive[TagExpr] { + recurse => + // either a tag name (with optional wildcards) or parenthesized expression + val wildcardP: Parser[TagExpr] = { + val parens = recurse.between(leftParen, rightParen) + + (parens | Wildcard.parser).surroundedBy(nonSpaceWhitespaces0) + }.withContext("wildcard") + + // Not expression (highest precedence) + // In GitHub syntax: !foo or !(expr) + val notExprP: Parser[TagExpr] = Parser.recursive[TagExpr] { recurseNot => + (notOperator *> recurseNot).map(Not.apply).backtrack | wildcardP + }.withContext("notExpr") + + // And expression (medium precedence) + // In GitHub syntax: space is AND operator + val andExprP: Parser[TagExpr] = { + // Use rep.sep for left-associative 'and' chains + Parser.repSep(notExprP, min = 1, sep = andOperator).map { exprs => + exprs.reduceLeft(And.apply) + } + }.withContext("andExpr") + + // Or expression (lowest precedence) + // In GitHub syntax: comma is OR operator + val orExprP: Parser[TagExpr] = { + // Use rep.sep for left-associative 'or' chains + Parser.repSep(andExprP, min = 1, sep = orOperator).map { exprs => + exprs.reduceLeft(Or.apply) + } + }.withContext("orExpr") + + orExprP.surroundedBy(nonSpaceWhitespaces0) + } + + def parse(input: String): Either[String, TagExpr] = { + + expression.parseAll(input) match { + case Right(result) => Right(result) + case Left(error) => + val pos = error.failedAtOffset + val expectation = error.expected + val snippet = input.take(pos + 10).drop(Math.max(0, pos - 10)) + Left(show"Parse error at position $pos near '...$snippet...', expecting one of: ${expectation}") + } + } + +} diff --git a/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala b/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala new file mode 100644 index 00000000..9731951f --- /dev/null +++ b/modules/framework-cats/shared/src/test/scala/TagExprParserTests.scala @@ -0,0 +1,108 @@ +package weaver +package framework +package test + +import weaver.internals.TagExprParser +import weaver.internals.TagExpr.* + +object TagExprParserTests extends SimpleIOSuite { + + List( + // Basic atom + "foo" -> Right(Wildcard.unsafeFromPattern("foo")), + "(foo)" -> Right(Wildcard.unsafeFromPattern("foo")), + + // NOT expressions + "!foo" -> Right(Not(Wildcard.unsafeFromPattern("foo"))), + "!(foo)" -> Right(Not(Wildcard.unsafeFromPattern("foo"))), + "!!foo" -> Right(Not(Not(Wildcard.unsafeFromPattern("foo")))), + + // OR expressions + "foo,bar" -> Right(Or(Wildcard.unsafeFromPattern("foo"), + Wildcard.unsafeFromPattern("bar"))), + + // AND expressions + "foo bar" -> Right(And(Wildcard.unsafeFromPattern("foo"), + Wildcard.unsafeFromPattern("bar"))), + + // Combined expressions + "foo,!bar" -> Right(Or(Wildcard.unsafeFromPattern("foo"), + Not(Wildcard.unsafeFromPattern("bar")))), + "!foo,bar" -> Right(Or(Not(Wildcard.unsafeFromPattern("foo")), + Wildcard.unsafeFromPattern("bar"))), + "!foo,!bar" -> Right(Or(Not(Wildcard.unsafeFromPattern("foo")), + Not(Wildcard.unsafeFromPattern("bar")))), + + // Complex precedence: OR has lower precedence than AND + "foo,bar baz" -> Right( + Or(Wildcard.unsafeFromPattern("foo"), + And(Wildcard.unsafeFromPattern("bar"), + Wildcard.unsafeFromPattern("baz"))) + ), + "foo,bar baz,qux" -> Right( + Or( + Or(Wildcard.unsafeFromPattern("foo"), + And(Wildcard.unsafeFromPattern("bar"), + Wildcard.unsafeFromPattern("baz"))), + Wildcard.unsafeFromPattern("qux") + ) + ), + + // Parentheses change precedence + "(foo,bar) (baz,qux)" -> Right( + And( + Or(Wildcard.unsafeFromPattern("foo"), + Wildcard.unsafeFromPattern("bar")), + Or(Wildcard.unsafeFromPattern("baz"), Wildcard.unsafeFromPattern("qux")) + ) + ), + + // Example: (x y) + "(x y)" -> Right(And(Wildcard.unsafeFromPattern("x"), + Wildcard.unsafeFromPattern("y"))), + + // Example: !(z,t) + "!(z,t)" -> Right(Not(Or(Wildcard.unsafeFromPattern("z"), + Wildcard.unsafeFromPattern("t")))), + + // Full example: foo,bar !baz,(x y),!(z,t) + "foo,bar !baz,(x y),!(z,t)" -> Right( + Or( + Or( + Or( + Wildcard.unsafeFromPattern("foo"), + And(Wildcard.unsafeFromPattern("bar"), + Not(Wildcard.unsafeFromPattern("baz"))) + ), + And(Wildcard.unsafeFromPattern("x"), Wildcard.unsafeFromPattern("y")) + ), + Not(Or(Wildcard.unsafeFromPattern("z"), + Wildcard.unsafeFromPattern("t"))) + ) + ), + + // Wildcard patterns + "bug*" -> Right(Wildcard.unsafeFromPattern("bug*")), + "*bug" -> Right(Wildcard.unsafeFromPattern("*bug")), + "bug?" -> Right(Wildcard.unsafeFromPattern("bug?")), + "?bug" -> Right(Wildcard.unsafeFromPattern("?bug")), + "first*bug" -> Right(Wildcard.unsafeFromPattern("first*bug")), + "a?c" -> Right(Wildcard.unsafeFromPattern("a?c")), + + // Wildcards with colon + "test:*" -> Right(Wildcard.unsafeFromPattern("test:*")), + "*:prod" -> Right(Wildcard.unsafeFromPattern("*:prod")), + + // Wildcards in combinations + "bug*,feature*" -> Right(Or(Wildcard.unsafeFromPattern("bug*"), + Wildcard.unsafeFromPattern("feature*"))), + "test* prod" -> Right(And(Wildcard.unsafeFromPattern("test*"), + Wildcard.unsafeFromPattern("prod"))), + "!bug*" -> Right(Not(Wildcard.unsafeFromPattern("bug*"))) + ).map { case (expr, expected) => + pureTest(s"'$expr' should be parsed to $expected") { + val result = TagExprParser.parse(expr) + expect.same(expected, result) + } + } +} diff --git a/modules/framework-cats/shared/src/test/scala/TagExprTests.scala b/modules/framework-cats/shared/src/test/scala/TagExprTests.scala new file mode 100644 index 00000000..5611879f --- /dev/null +++ b/modules/framework-cats/shared/src/test/scala/TagExprTests.scala @@ -0,0 +1,355 @@ +package weaver +package framework +package test + +import weaver.internals.TagExpr.* + +object TagExprTests extends SimpleIOSuite { + + pureTest("Atom matches exact tag") { + val expr = Wildcard.unsafeFromPattern("foo") + val tags = Set("foo", "bar", "baz") + expect(expr.matches(tags)) + } + + pureTest("Atom does not match different tag") { + val expr = Wildcard.unsafeFromPattern("foo") + val tags = Set("bar", "baz") + expect(!expr.matches(tags)) + } + + // Wildcard pattern parsing tests + pureTest("Wildcard.fromPattern should parse literal pattern") { + val result = Wildcard.fromPattern("foo") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with star") { + val result = Wildcard.fromPattern("bug*") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with leading star") { + val result = Wildcard.fromPattern("*bug") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with question mark") { + val result = Wildcard.fromPattern("bug?") + expect(clue(result).isRight) + } + + pureTest( + "Wildcard.fromPattern should parse pattern with leading question mark") { + val result = Wildcard.fromPattern("?bug") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with star in middle") { + val result = Wildcard.fromPattern("first*bug") + expect(clue(result).isRight) + } + + pureTest( + "Wildcard.fromPattern should parse pattern with question mark in middle") { + val result = Wildcard.fromPattern("a?c") + expect(clue(result).isRight) + } + + pureTest( + "Wildcard.fromPattern should parse pattern with multiple wildcards") { + val result = Wildcard.fromPattern("a*b*c") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with colon") { + val result = Wildcard.fromPattern("test:unit") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with colon and star") { + val result = Wildcard.fromPattern("test:*") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with hyphen") { + val result = Wildcard.fromPattern("bug-123") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with underscore") { + val result = Wildcard.fromPattern("test_case") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse pattern with only star") { + val result = Wildcard.fromPattern("*") + expect(clue(result).isRight) + } + + pureTest( + "Wildcard.fromPattern should parse pattern with only question mark") { + val result = Wildcard.fromPattern("?") + expect(clue(result).isRight) + } + + pureTest("Wildcard.fromPattern should parse complex pattern") { + val result = Wildcard.fromPattern("test-*:prod-?") + expect(clue(result).isRight) + } + + // Negative tests: invalid patterns + pureTest("Wildcard.fromPattern should reject empty pattern") { + val result = Wildcard.fromPattern("") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with spaces") { + val result = Wildcard.fromPattern("foo bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with comma") { + val result = Wildcard.fromPattern("foo,bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with exclamation mark") { + val result = Wildcard.fromPattern("!foo") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with parentheses") { + val result = Wildcard.fromPattern("(foo)") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with at sign") { + val result = Wildcard.fromPattern("foo@bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with hash") { + val result = Wildcard.fromPattern("foo#bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with dollar sign") { + val result = Wildcard.fromPattern("foo$bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with percent") { + val result = Wildcard.fromPattern("foo%bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with ampersand") { + val result = Wildcard.fromPattern("foo&bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with slash") { + val result = Wildcard.fromPattern("foo/bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with backslash") { + val result = Wildcard.fromPattern("foo\\bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with brackets") { + val result = Wildcard.fromPattern("foo[bar]") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with pipe") { + val result = Wildcard.fromPattern("foo|bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with semicolon") { + val result = Wildcard.fromPattern("foo;bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with dot") { + val result = Wildcard.fromPattern("foo.bar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with tab") { + val result = Wildcard.fromPattern("foo\tbar") + expect(clue(result).isLeft) + } + + pureTest("Wildcard.fromPattern should reject pattern with newline") { + val result = Wildcard.fromPattern("foo\nbar") + expect(clue(result).isLeft) + } + + // Wildcard matching tests + pureTest("Wildcard: bug* matches bug") { + whenSuccess(Wildcard.fromPattern("bug*")) { wildcard => + val tags = Set("bug") + expect(clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: bug* matches bug-123") { + whenSuccess(Wildcard.fromPattern("bug*")) { wildcard => + val tags = Set("bug-123") + expect(clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: bug* matches bugfix") { + whenSuccess(Wildcard.fromPattern("bug*")) { wildcard => + val tags = Set("bugfix") + expect(clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: bug* does not match foo") { + whenSuccess(Wildcard.fromPattern("bug*")) { wildcard => + val tags = Set("foo") + expect(!clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: *bug matches bug") { + whenSuccess(Wildcard.fromPattern("*bug")) { wildcard => + val tags = Set("bug") + expect(clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: *bug matches critical-bug") { + whenSuccess(Wildcard.fromPattern("*bug")) { wildcard => + val tags = Set("critical-bug") + expect(clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: *bug does not match bugfix") { + whenSuccess(Wildcard.fromPattern("*bug")) { wildcard => + val tags = Set("bugfix") + expect(!clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: bug? matches bug1") { + whenSuccess(Wildcard.fromPattern("bug?")) { wildcard => + val tags = Set("bug1") + expect(clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: bug? matches buga") { + whenSuccess(Wildcard.fromPattern("bug?")) { wildcard => + val tags = Set("buga") + expect(clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: bug? does not match bug") { + whenSuccess(Wildcard.fromPattern("bug?")) { wildcard => + val tags = Set("bug") + expect(!clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: bug? does not match bug12") { + whenSuccess(Wildcard.fromPattern("bug?")) { wildcard => + val tags = Set("bug12") + expect(!clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: ?bug matches abug") { + whenSuccess(Wildcard.fromPattern("?bug")) { wildcard => + val tags = Set("abug") + expect(clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: ?bug does not match bug") { + whenSuccess(Wildcard.fromPattern("?bug")) { wildcard => + val tags = Set("bug") + expect(!clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: first*bug matches firstbug") { + whenSuccess(Wildcard.fromPattern("first*bug")) { wildcard => + val tags = Set("firstbug") + expect(clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: first*bug matches first-critical-bug") { + whenSuccess(Wildcard.fromPattern("first*bug")) { wildcard => + val tags = Set("first-critical-bug") + expect(clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: first*bug does not match firstbugfix") { + whenSuccess(Wildcard.fromPattern("first*bug")) { wildcard => + val tags = Set("firstbugfix") + expect(!clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: a?c matches abc") { + whenSuccess(Wildcard.fromPattern("a?c")) { wildcard => + val tags = Set("abc") + expect(clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: a?c matches a1c") { + whenSuccess(Wildcard.fromPattern("a?c")) { wildcard => + val tags = Set("a1c") + expect(clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: a?c does not match ac") { + whenSuccess(Wildcard.fromPattern("a?c")) { wildcard => + val tags = Set("ac") + expect(!clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: a?c does not match abcd") { + whenSuccess(Wildcard.fromPattern("a?c")) { wildcard => + val tags = Set("abcd") + expect(!clue(wildcard).matches(tags)) + } + } + + // Wildcards with colon + pureTest("Wildcard: test:* matches test:unit") { + whenSuccess(Wildcard.fromPattern("test:*")) { wildcard => + val tags = Set("test:unit") + expect(clue(wildcard).matches(tags)) + } + } + + pureTest("Wildcard: *:prod matches env:prod") { + whenSuccess(Wildcard.fromPattern("*:prod")) { wildcard => + val tags = Set("env:prod") + expect(clue(wildcard).matches(tags)) + } + } + + // No wildcards (should work like exact match) + pureTest("Wildcard: foo matches foo exactly") { + whenSuccess(Wildcard.fromPattern("foo")) { wildcard => + val tags = Set("foo", "foobar") + expect(clue(wildcard).matches(tags)) + } + } +}