From 163957ad279eb6a66786f86d99cce332973f57a9 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Wed, 5 Nov 2025 21:56:13 -0600 Subject: [PATCH] Full coverage for key-value. --- .../gs/predicate/v0/kv/StringContains.scala | 14 ++- .../gs/predicate/v0/kv/StringEndsWith.scala | 14 ++- .../gs/predicate/v0/kv/StringStartsWith.scala | 14 ++- .../gs/predicate/v0/kv/ValueNotEquals.scala | 23 ++++- .../v0/kv/KeyValueProviderTests.scala | 11 +++ .../predicate/v0/kv/StringContainsTests.scala | 84 +++++++++++++++++ .../predicate/v0/kv/StringEndsWithTests.scala | 93 ++++++++++++++++++ .../v0/kv/StringStartsWithTests.scala | 94 +++++++++++++++++++ .../predicate/v0/kv/ValueNotEqualsTests.scala | 59 ++++++++++++ 9 files changed, 395 insertions(+), 11 deletions(-) create mode 100644 modules/kv/src/test/scala/gs/predicate/v0/kv/KeyValueProviderTests.scala create mode 100644 modules/kv/src/test/scala/gs/predicate/v0/kv/StringContainsTests.scala create mode 100644 modules/kv/src/test/scala/gs/predicate/v0/kv/StringEndsWithTests.scala create mode 100644 modules/kv/src/test/scala/gs/predicate/v0/kv/StringStartsWithTests.scala create mode 100644 modules/kv/src/test/scala/gs/predicate/v0/kv/ValueNotEqualsTests.scala diff --git a/modules/kv/src/main/scala/gs/predicate/v0/kv/StringContains.scala b/modules/kv/src/main/scala/gs/predicate/v0/kv/StringContains.scala index b001fce..f285eb0 100644 --- a/modules/kv/src/main/scala/gs/predicate/v0/kv/StringContains.scala +++ b/modules/kv/src/main/scala/gs/predicate/v0/kv/StringContains.scala @@ -1,6 +1,6 @@ package gs.predicate.v0.kv -import cats.effect.Sync +import cats.Functor import cats.syntax.all.* import gs.predicate.v0.api.Predicate import gs.uuid.v0.UUID @@ -16,7 +16,7 @@ import gs.uuid.v0.UUID * @param containedValue * The substring that must be contained in the value. */ -final class StringContains[F[_]: Sync, K]( +final class StringContains[F[_]: Functor, K]( val id: UUID, val key: K, val containedValue: String @@ -30,3 +30,13 @@ final class StringContains[F[_]: Sync, K]( case Some(value) => Predicate.Result(value.contains(containedValue)) case _ => Predicate.Result.missed() } + +object StringContains: + + def apply[F[_]: Functor, K]( + key: K, + containedValue: String + ): StringContains[F, K] = + new StringContains[F, K](UUID.v7(), key, containedValue) + +end StringContains diff --git a/modules/kv/src/main/scala/gs/predicate/v0/kv/StringEndsWith.scala b/modules/kv/src/main/scala/gs/predicate/v0/kv/StringEndsWith.scala index 4ca24c1..0369ac2 100644 --- a/modules/kv/src/main/scala/gs/predicate/v0/kv/StringEndsWith.scala +++ b/modules/kv/src/main/scala/gs/predicate/v0/kv/StringEndsWith.scala @@ -1,6 +1,6 @@ package gs.predicate.v0.kv -import cats.effect.Sync +import cats.Functor import cats.syntax.all.* import gs.predicate.v0.api.Predicate import gs.uuid.v0.UUID @@ -16,7 +16,7 @@ import gs.uuid.v0.UUID * @param suffix * The substring that must be the suffix of the value. */ -final class StringEndsWith[F[_]: Sync, K]( +final class StringEndsWith[F[_]: Functor, K]( val id: UUID, val key: K, val suffix: String @@ -30,3 +30,13 @@ final class StringEndsWith[F[_]: Sync, K]( case Some(value) => Predicate.Result(value.endsWith(suffix)) case _ => Predicate.Result.missed() } + +object StringEndsWith: + + def apply[F[_]: Functor, K]( + key: K, + suffix: String + ): StringEndsWith[F, K] = + new StringEndsWith[F, K](UUID.v7(), key, suffix) + +end StringEndsWith diff --git a/modules/kv/src/main/scala/gs/predicate/v0/kv/StringStartsWith.scala b/modules/kv/src/main/scala/gs/predicate/v0/kv/StringStartsWith.scala index 3ef1f6a..93c556e 100644 --- a/modules/kv/src/main/scala/gs/predicate/v0/kv/StringStartsWith.scala +++ b/modules/kv/src/main/scala/gs/predicate/v0/kv/StringStartsWith.scala @@ -1,6 +1,6 @@ package gs.predicate.v0.kv -import cats.effect.Sync +import cats.Functor import cats.syntax.all.* import gs.predicate.v0.api.Predicate import gs.uuid.v0.UUID @@ -16,7 +16,7 @@ import gs.uuid.v0.UUID * @param prefix * The substring that must be the prefix of the value. */ -final class StringStartsWith[F[_]: Sync, K]( +final class StringStartsWith[F[_]: Functor, K]( val id: UUID, val key: K, val prefix: String @@ -30,3 +30,13 @@ final class StringStartsWith[F[_]: Sync, K]( case Some(value) => Predicate.Result(value.startsWith(prefix)) case _ => Predicate.Result.missed() } + +object StringStartsWith: + + def apply[F[_]: Functor, K]( + key: K, + prefix: String + ): StringStartsWith[F, K] = + new StringStartsWith[F, K](UUID.v7(), key, prefix) + +end StringStartsWith diff --git a/modules/kv/src/main/scala/gs/predicate/v0/kv/ValueNotEquals.scala b/modules/kv/src/main/scala/gs/predicate/v0/kv/ValueNotEquals.scala index 78030bb..bee2e6b 100644 --- a/modules/kv/src/main/scala/gs/predicate/v0/kv/ValueNotEquals.scala +++ b/modules/kv/src/main/scala/gs/predicate/v0/kv/ValueNotEquals.scala @@ -1,6 +1,6 @@ package gs.predicate.v0.kv -import cats.effect.Sync +import cats.Functor import cats.syntax.all.* import gs.predicate.v0.api.Predicate import gs.uuid.v0.UUID @@ -15,10 +15,10 @@ import gs.uuid.v0.UUID * @param value * The value that must not be associated with the key. */ -final class ValueNotEquals[F[_]: Sync, K, V]( +final class ValueNotEquals[F[_]: Functor, K, V]( val id: UUID, val key: K, - val expectedValue: V + val value: V )( using CanEqual[V, V] @@ -28,6 +28,19 @@ final class ValueNotEquals[F[_]: Sync, K, V]( */ override def eval(input: KeyValueProvider[F, K, V]): F[Predicate.Result] = input.get(key).map { - case Some(value) => Predicate.Result(value != expectedValue) - case _ => Predicate.Result.missed() + case Some(v) => Predicate.Result(v != value) + case _ => Predicate.Result.missed() } + +object ValueNotEquals: + + def apply[F[_]: Functor, K, V]( + key: K, + value: V + )( + using + CanEqual[V, V] + ): ValueNotEquals[F, K, V] = + new ValueNotEquals[F, K, V](UUID.v7(), key, value) + +end ValueNotEquals diff --git a/modules/kv/src/test/scala/gs/predicate/v0/kv/KeyValueProviderTests.scala b/modules/kv/src/test/scala/gs/predicate/v0/kv/KeyValueProviderTests.scala new file mode 100644 index 0000000..14fc2d8 --- /dev/null +++ b/modules/kv/src/test/scala/gs/predicate/v0/kv/KeyValueProviderTests.scala @@ -0,0 +1,11 @@ +package gs.predicate.v0.kv + +import cats.Id + +class KeyValueProviderTests extends munit.FunSuite: + + test("should provide a no-op implementation") { + val kvp = KeyValueProvider.noop[Id] + assertEquals(kvp.exists("something"), false) + assertEquals(kvp.get("something"), None) + } diff --git a/modules/kv/src/test/scala/gs/predicate/v0/kv/StringContainsTests.scala b/modules/kv/src/test/scala/gs/predicate/v0/kv/StringContainsTests.scala new file mode 100644 index 0000000..ac4be8a --- /dev/null +++ b/modules/kv/src/test/scala/gs/predicate/v0/kv/StringContainsTests.scala @@ -0,0 +1,84 @@ +package gs.predicate.v0.kv + +import cats.effect.IO +import cats.effect.std.MapRef +import gs.datagen.v0.Gen +import gs.datagen.v0.generators.Size +import gs.predicate.v0.api.Predicate +import gs.predicate.v0.kv.StringContainsTests.Data.Substring1 +import support.IOSuite + +class StringContainsTests extends IOSuite: + + import StringContainsTests.Data + + iotest("should find a contained string in any position") { + val p1 = StringContains[IO, String](Data.PassingKey, Data.Substring1) + val p2 = StringContains[IO, String](Data.PassingKey, Data.Substring2) + val p3 = StringContains[IO, String](Data.PassingKey, Data.Substring3) + for + provider <- StringContainsTests.newProvider(Data.KeyValues) + r1 <- p1.eval(provider) + r2 <- p2.eval(provider) + r3 <- p3.eval(provider) + yield + assertEquals(r1, Predicate.Result.matched()) + assertEquals(r2, Predicate.Result.matched()) + assertEquals(r3, Predicate.Result.matched()) + } + + iotest("should not find a key that does not exist within some provider") { + val p = StringContains[IO, String](Data.NotExistingKey, "") + for + provider <- StringContainsTests.newProvider(Data.KeyValues) + result <- p.eval(provider) + yield assertEquals(result, Predicate.Result.missed()) + } + + iotest("should match if an empty substring is provided") { + val p1 = StringContains[IO, String](Data.EmptyStringKey, "") + val p2 = StringContains[IO, String](Data.PassingKey, "") + for + provider <- StringContainsTests.newProvider(Data.KeyValues) + r1 <- p1.eval(provider) + r2 <- p2.eval(provider) + yield + assertEquals(r1, Predicate.Result.matched()) + assertEquals(r2, Predicate.Result.matched()) + } + + iotest("should not match if the target value is not contained in the input") { + val p = StringContains[IO, String](Data.PassingKey, Substring1.reverse) + for + provider <- StringContainsTests.newProvider(Data.KeyValues) + result <- p.eval(provider) + yield assertEquals(result, Predicate.Result.missed()) + } + +object StringContainsTests: + + object Data: + + val PassingKey: String = Gen.string.alphaNumeric(Size.Fixed(8)).gen() + val PassingValue: String = "abcdefhij" + val Substring1: String = "abc" + val Substring2: String = "def" + val Substring3: String = "hij" + + val NotExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(6)).gen() + val NotExistingValue: String = Gen.string.alphaNumeric(Size.Fixed(4)).gen() + + val EmptyStringKey: String = "empty" + + val KeyValues: Map[String, String] = Map( + PassingKey -> PassingValue, + EmptyStringKey -> "" + ) + + end Data + + def newProvider(data: Map[String, String]): IO[KeyValueStringProvider[IO]] = + for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) + yield new MemoryMapStringProvider(map) + +end StringContainsTests diff --git a/modules/kv/src/test/scala/gs/predicate/v0/kv/StringEndsWithTests.scala b/modules/kv/src/test/scala/gs/predicate/v0/kv/StringEndsWithTests.scala new file mode 100644 index 0000000..89eda3a --- /dev/null +++ b/modules/kv/src/test/scala/gs/predicate/v0/kv/StringEndsWithTests.scala @@ -0,0 +1,93 @@ +package gs.predicate.v0.kv + +import cats.effect.IO +import cats.effect.std.MapRef +import gs.datagen.v0.Gen +import gs.datagen.v0.generators.Size +import gs.predicate.v0.api.Predicate +import support.IOSuite + +class StringEndsWithTests extends IOSuite: + + import StringEndsWithTests.Data + + iotest("should find a string as a prefix of the input") { + val p1 = StringEndsWith[IO, String](Data.PassingKey, Data.Substring1) + val p2 = StringEndsWith[IO, String](Data.PassingKey, Data.Substring2) + val p3 = StringEndsWith[IO, String](Data.PassingKey, Data.Substring3) + for + provider <- StringEndsWithTests.newProvider(Data.KeyValues) + r1 <- p1.eval(provider) + r2 <- p2.eval(provider) + r3 <- p3.eval(provider) + yield + assertEquals(r1, Predicate.Result.matched()) + assertEquals(r2, Predicate.Result.matched()) + assertEquals(r3, Predicate.Result.matched()) + } + + iotest("should not find a key that does not exist within some provider") { + val p = StringEndsWith[IO, String](Data.NotExistingKey, "") + for + provider <- StringEndsWithTests.newProvider(Data.KeyValues) + result <- p.eval(provider) + yield assertEquals(result, Predicate.Result.missed()) + } + + iotest("should match if an empty substring is provided") { + val p1 = StringEndsWith[IO, String](Data.EmptyStringKey, "") + val p2 = StringEndsWith[IO, String](Data.PassingKey, "") + for + provider <- StringEndsWithTests.newProvider(Data.KeyValues) + r1 <- p1.eval(provider) + r2 <- p2.eval(provider) + yield + assertEquals(r1, Predicate.Result.matched()) + assertEquals(r2, Predicate.Result.matched()) + } + + iotest( + "should not match if the target value is not the suffix of the input" + ) { + val p1 = StringEndsWith[IO, String](Data.PassingKey, Data.Substring3 + "z") + val p2 = + StringEndsWith[IO, String](Data.PassingKey, Data.Substring3.reverse) + val p3 = + StringEndsWith[IO, String](Data.PassingKey, Data.Substring2.reverse) + for + provider <- StringEndsWithTests.newProvider(Data.KeyValues) + r1 <- p1.eval(provider) + r2 <- p2.eval(provider) + r3 <- p3.eval(provider) + yield + assertEquals(r1, Predicate.Result.missed()) + assertEquals(r2, Predicate.Result.missed()) + assertEquals(r3, Predicate.Result.missed()) + } + +object StringEndsWithTests: + + object Data: + + val PassingKey: String = Gen.string.alphaNumeric(Size.Fixed(8)).gen() + val PassingValue: String = "abcdefghi" + val Substring1: String = "i" + val Substring2: String = "hi" + val Substring3: String = "abcdefghi" + + val NotExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(6)).gen() + + val EmptyStringKey: String = "empty" + + val KeyValues: Map[String, String] = Map( + PassingKey -> PassingValue, + EmptyStringKey -> "" + ) + + end Data + + def newProvider(data: Map[String, String]): IO[KeyValueStringProvider[IO]] = + for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) + yield new MemoryMapStringProvider(map) + +end StringEndsWithTests diff --git a/modules/kv/src/test/scala/gs/predicate/v0/kv/StringStartsWithTests.scala b/modules/kv/src/test/scala/gs/predicate/v0/kv/StringStartsWithTests.scala new file mode 100644 index 0000000..b2ccf65 --- /dev/null +++ b/modules/kv/src/test/scala/gs/predicate/v0/kv/StringStartsWithTests.scala @@ -0,0 +1,94 @@ +package gs.predicate.v0.kv + +import cats.effect.IO +import cats.effect.std.MapRef +import gs.datagen.v0.Gen +import gs.datagen.v0.generators.Size +import gs.predicate.v0.api.Predicate +import support.IOSuite + +class StringStartsWithTests extends IOSuite: + + import StringStartsWithTests.Data + + iotest("should find a string as a prefix of the input") { + val p1 = StringStartsWith[IO, String](Data.PassingKey, Data.Substring1) + val p2 = StringStartsWith[IO, String](Data.PassingKey, Data.Substring2) + val p3 = StringStartsWith[IO, String](Data.PassingKey, Data.Substring3) + for + provider <- StringStartsWithTests.newProvider(Data.KeyValues) + r1 <- p1.eval(provider) + r2 <- p2.eval(provider) + r3 <- p3.eval(provider) + yield + assertEquals(r1, Predicate.Result.matched()) + assertEquals(r2, Predicate.Result.matched()) + assertEquals(r3, Predicate.Result.matched()) + } + + iotest("should not find a key that does not exist within some provider") { + val p = StringStartsWith[IO, String](Data.NotExistingKey, "") + for + provider <- StringStartsWithTests.newProvider(Data.KeyValues) + result <- p.eval(provider) + yield assertEquals(result, Predicate.Result.missed()) + } + + iotest("should match if an empty substring is provided") { + val p1 = StringStartsWith[IO, String](Data.EmptyStringKey, "") + val p2 = StringStartsWith[IO, String](Data.PassingKey, "") + for + provider <- StringStartsWithTests.newProvider(Data.KeyValues) + r1 <- p1.eval(provider) + r2 <- p2.eval(provider) + yield + assertEquals(r1, Predicate.Result.matched()) + assertEquals(r2, Predicate.Result.matched()) + } + + iotest( + "should not match if the target value is not the prefix of the input" + ) { + val p1 = + StringStartsWith[IO, String](Data.PassingKey, Data.Substring3 + "k") + val p2 = + StringStartsWith[IO, String](Data.PassingKey, Data.Substring3.reverse) + val p3 = + StringStartsWith[IO, String](Data.PassingKey, Data.Substring2.reverse) + for + provider <- StringStartsWithTests.newProvider(Data.KeyValues) + r1 <- p1.eval(provider) + r2 <- p2.eval(provider) + r3 <- p3.eval(provider) + yield + assertEquals(r1, Predicate.Result.missed()) + assertEquals(r2, Predicate.Result.missed()) + assertEquals(r3, Predicate.Result.missed()) + } + +object StringStartsWithTests: + + object Data: + + val PassingKey: String = Gen.string.alphaNumeric(Size.Fixed(8)).gen() + val PassingValue: String = "abcdefghi" + val Substring1: String = "a" + val Substring2: String = "ab" + val Substring3: String = "abcdefghi" + + val NotExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(6)).gen() + + val EmptyStringKey: String = "empty" + + val KeyValues: Map[String, String] = Map( + PassingKey -> PassingValue, + EmptyStringKey -> "" + ) + + end Data + + def newProvider(data: Map[String, String]): IO[KeyValueStringProvider[IO]] = + for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) + yield new MemoryMapStringProvider(map) + +end StringStartsWithTests diff --git a/modules/kv/src/test/scala/gs/predicate/v0/kv/ValueNotEqualsTests.scala b/modules/kv/src/test/scala/gs/predicate/v0/kv/ValueNotEqualsTests.scala new file mode 100644 index 0000000..71c5d28 --- /dev/null +++ b/modules/kv/src/test/scala/gs/predicate/v0/kv/ValueNotEqualsTests.scala @@ -0,0 +1,59 @@ +package gs.predicate.v0.kv + +import cats.effect.IO +import cats.effect.std.MapRef +import gs.datagen.v0.Gen +import gs.datagen.v0.generators.Size +import gs.predicate.v0.api.Predicate +import support.IOSuite + +class ValueNotEqualsTests extends IOSuite: + + import ValueNotEqualsTests.Data + + iotest("should NOT find an exact match against some value") { + val p = + ValueNotEquals[IO, String, String](Data.ExistingKey, Data.ExistingValue) + for + provider <- ValueNotEqualsTests.newProvider(Data.KeyValues) + result <- p.eval(provider) + yield assertEquals(result, Predicate.Result.missed()) + } + + iotest("should match a value if that value is not equal to the target") { + val p = + ValueNotEquals[IO, String, String]( + Data.ExistingKey, + Data.NotExistingValue + ) + for + provider <- ValueNotEqualsTests.newProvider(Data.KeyValues) + result <- p.eval(provider) + yield assertEquals(result, Predicate.Result.matched()) + } + + iotest("should not find a key that does not exist within some provider") { + val p = ValueNotEquals[IO, String, String](Data.NotExistingKey, "") + for + provider <- ValueNotEqualsTests.newProvider(Data.KeyValues) + result <- p.eval(provider) + yield assertEquals(result, Predicate.Result.missed()) + } + +object ValueNotEqualsTests: + + object Data: + + val ExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(8)).gen() + val NotExistingKey: String = Gen.string.alphaNumeric(Size.Fixed(6)).gen() + val ExistingValue: String = Gen.string.alphaNumeric(Size.Fixed(10)).gen() + val NotExistingValue: String = Gen.string.alphaNumeric(Size.Fixed(4)).gen() + val KeyValues: Map[String, String] = Map(ExistingKey -> ExistingValue) + + end Data + + def newProvider(data: Map[String, String]): IO[KeyValueStringProvider[IO]] = + for map <- MapRef.ofSingleImmutableMap[IO, String, String](data) + yield new MemoryMapStringProvider(map) + +end ValueNotEqualsTests