From 361fe5ed567420ca85b365345fb2704f9ac62e26 Mon Sep 17 00:00:00 2001 From: Tanmay Ranjan <42682768+Tan108@users.noreply.github.com> Date: Fri, 8 Mar 2024 17:08:34 +0530 Subject: [PATCH] Test Runner Implementation (#19) --- build.gradle.kts | 13 ++ .../kotlin/com/featurevisor/sdk/Conditions.kt | 34 +-- .../featurevisor/sdk/Instance+Evaluation.kt | 36 ++- .../com/featurevisor/sdk/Instance+Segments.kt | 2 +- .../sdk/serializers/Serializers.kt | 43 +++- .../testRunner/CommandExecuter.kt | 31 +++ .../com/featurevisor/testRunner/Matrix.kt | 168 ++++++++++++++ .../com/featurevisor/testRunner/Parser.kt | 214 +++++++++++++++++ .../featurevisor/testRunner/TestExecuter.kt | 184 +++++++++++++++ .../featurevisor/testRunner/TestFeature.kt | 218 ++++++++++++++++++ .../featurevisor/testRunner/TestSegment.kt | 57 +++++ .../com/featurevisor/testRunner/Utils.kt | 195 ++++++++++++++++ .../kotlin/com/featurevisor/types/Types.kt | 159 ++++++++----- .../com/featurevisor/sdk/ConditionsTest.kt | 26 ++- 14 files changed, 1277 insertions(+), 103 deletions(-) create mode 100644 src/main/kotlin/com/featurevisor/testRunner/CommandExecuter.kt create mode 100644 src/main/kotlin/com/featurevisor/testRunner/Matrix.kt create mode 100644 src/main/kotlin/com/featurevisor/testRunner/Parser.kt create mode 100644 src/main/kotlin/com/featurevisor/testRunner/TestExecuter.kt create mode 100644 src/main/kotlin/com/featurevisor/testRunner/TestFeature.kt create mode 100644 src/main/kotlin/com/featurevisor/testRunner/TestSegment.kt create mode 100644 src/main/kotlin/com/featurevisor/testRunner/Utils.kt diff --git a/build.gradle.kts b/build.gradle.kts index ccbdad5..d9d368e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -64,6 +64,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0") implementation("com.squareup.okhttp3:okhttp:4.11.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + implementation("org.yaml:snakeyaml:2.2") } // Apply a specific Java toolchain to ease working on different environments. @@ -83,3 +84,15 @@ tasks.named("test") { showStandardStreams = true } } + +tasks.register("run-test") { + classpath = sourceSets.main.get().runtimeClasspath + mainClass = "com.featurevisor.cli.TestExecuter" + + if (project.hasProperty("args")) { + val argsList = project.property("args") as String + val argsArray = argsList.split("\\s+".toRegex()).toTypedArray() + args(*argsArray) + } +} + diff --git a/src/main/kotlin/com/featurevisor/sdk/Conditions.kt b/src/main/kotlin/com/featurevisor/sdk/Conditions.kt index 0898647..2bda4ec 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Conditions.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Conditions.kt @@ -34,45 +34,45 @@ object Conditions { fun conditionIsMatched(condition: Plain, context: Context): Boolean { val (attributeKey, operator, conditionValue) = condition - val attributeValue = context.getOrDefault(attributeKey, null) ?: return false + val attributeValue = context.getOrDefault(attributeKey, null) return when { attributeValue is AttributeValue.StringValue && conditionValue is ConditionValue.StringValue -> { when (operator) { EQUALS -> attributeValue.value == conditionValue.value NOT_EQUALS -> attributeValue.value != conditionValue.value - CONTAINS -> attributeValue.value.contains(conditionValue.value) - NOT_CONTAINS -> attributeValue.value.contains(conditionValue.value).not() - STARTS_WITH -> attributeValue.value.startsWith(conditionValue.value) - ENDS_WITH -> attributeValue.value.endsWith(conditionValue.value) + CONTAINS -> attributeValue.value?.contains(conditionValue.value.orEmpty()) ?: false + NOT_CONTAINS -> attributeValue.value?.contains(conditionValue.value.orEmpty())?.not() ?: false + STARTS_WITH -> attributeValue.value?.startsWith(conditionValue.value.orEmpty()) ?: false + ENDS_WITH -> attributeValue.value?.endsWith(conditionValue.value.orEmpty()) ?: false SEMVER_EQUALS -> compareVersions( - attributeValue.value, - conditionValue.value, + attributeValue.value.orEmpty(), + conditionValue.value.orEmpty(), ) == 0 SEMVER_NOT_EQUALS -> compareVersions( - attributeValue.value, - conditionValue.value, + attributeValue.value.orEmpty(), + conditionValue.value.orEmpty(), ) != 0 SEMVER_GREATER_THAN -> compareVersions( - attributeValue.value, - conditionValue.value + attributeValue.value.orEmpty(), + conditionValue.value.orEmpty() ) == 1 SEMVER_GREATER_THAN_OR_EQUALS -> compareVersions( - attributeValue.value, - conditionValue.value + attributeValue.value.orEmpty(), + conditionValue.value.orEmpty() ) >= 0 SEMVER_LESS_THAN -> compareVersions( - attributeValue.value, - conditionValue.value + attributeValue.value.orEmpty(), + conditionValue.value.orEmpty() ) == -1 SEMVER_LESS_THAN_OR_EQUALS -> compareVersions( - attributeValue.value, - conditionValue.value + attributeValue.value.orEmpty(), + conditionValue.value.orEmpty() ) <= 0 else -> false diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt index 1c81bd2..6539442 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt @@ -286,9 +286,9 @@ fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = // required if (feature.required.isNullOrEmpty().not()) { - val requiredFeaturesAreEnabled = feature.required!!.all { item -> - var requiredKey: FeatureKey - var requiredVariation: VariationValue? + val requiredFeaturesAreEnabled = feature.required?.all { item -> + var requiredKey: FeatureKey? = null + var requiredVariation: VariationValue? = null when (item) { is Required.FeatureKey -> { requiredKey = item.required @@ -307,12 +307,16 @@ fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = return@all false } - val requiredVariationValue = getVariation(requiredKey, finalContext) + if (requiredVariation != null){ + val requiredVariationValue = getVariation(requiredKey, finalContext) - return@all requiredVariationValue == requiredVariation + return@all requiredVariationValue == requiredVariation + } + + return@all true } - if (requiredFeaturesAreEnabled.not()) { + if ((requiredFeaturesAreEnabled == false)) { evaluation = Evaluation( featureKey = feature.key, reason = REQUIRED, @@ -337,7 +341,7 @@ fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = if (feature.ranges.isNullOrEmpty().not()) { val matchedRange = feature.ranges!!.firstOrNull { range -> - bucketValue >= range.start && bucketValue < range.end + bucketValue >= range.first() && bucketValue < range.last() } // matched @@ -482,9 +486,11 @@ fun FeaturevisorInstance.evaluateVariable( val finalContext = interceptContext?.invoke(context) ?: context // forced - findForceFromFeature(feature, context, datafileReader)?.let { force -> - if (force.variables?.containsKey(variableKey) == true) { - val variableValue = force.variables[variableKey] + val force = findForceFromFeature(feature, context, datafileReader) + + force?.let { + if (it.variables?.containsKey(variableKey) == true) { + val variableValue = it.variables[variableKey] evaluation = Evaluation( featureKey = feature.key, reason = FORCED, @@ -500,6 +506,7 @@ fun FeaturevisorInstance.evaluateVariable( // bucketing val bucketValue = getBucketValue(feature, finalContext) + val matchedTrafficAndAllocation = getMatchedTrafficAndAllocation( traffic = feature.traffic, context = finalContext, @@ -528,8 +535,15 @@ fun FeaturevisorInstance.evaluateVariable( // regular allocation matchedTrafficAndAllocation.matchedAllocation?.let { matchedAllocation -> + + val variationValue: String = if (force?.variation != null) { + force.variation + } else { + matchedAllocation.variation + } + val variation = feature.variations?.firstOrNull { variation -> - variation.value == matchedAllocation.variation + variation.value == variationValue } val variableFromVariation = variation?.variables?.firstOrNull { variable -> diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Segments.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Segments.kt index 06a4c10..b71d893 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Segments.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Segments.kt @@ -25,7 +25,7 @@ internal fun FeaturevisorInstance.segmentIsMatched( return null } -internal fun FeaturevisorInstance.segmentIsMatched(segment: Segment, context: Context): Boolean { +internal fun segmentIsMatched(segment: Segment, context: Context): Boolean { return allConditionsAreMatched(segment.conditions, context) } diff --git a/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt b/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt index 7001f69..f760563 100644 --- a/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt +++ b/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt @@ -10,11 +10,8 @@ import com.featurevisor.types.NotGroupSegment import com.featurevisor.types.Operator import com.featurevisor.types.OrGroupSegment import com.featurevisor.types.VariableValue -import kotlinx.serialization.InternalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException -import kotlinx.serialization.Serializer -import kotlinx.serialization.decodeFromString +import com.featurevisor.types.Required +import kotlinx.serialization.* import kotlinx.serialization.descriptors.PolymorphicKind import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildSerialDescriptor @@ -32,7 +29,29 @@ import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.serializer +import java.text.SimpleDateFormat + +@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) +@Serializer(forClass = Required::class) +object RequiredSerializer: KSerializer{ + override val descriptor: SerialDescriptor = + buildSerialDescriptor("package.Required", PolymorphicKind.SEALED) + + override fun deserialize(decoder: Decoder): Required { + val input = decoder as? JsonDecoder + ?: throw SerializationException("This class can be decoded only by Json format") + return when (val tree = input.decodeJsonElement()) { + is JsonPrimitive ->{ + Required.FeatureKey(tree.content) + } + is JsonArray -> { + Required.FeatureKey(tree.toString()) + } + + else -> Required.FeatureKey("abc") + } + } +} @OptIn(InternalSerializationApi::class) @Serializer(forClass = Condition::class) @@ -247,9 +266,13 @@ object ConditionValueSerializer : KSerializer { } ?: tree.doubleOrNull?.let { ConditionValue.DoubleValue(it) } ?: tree.content.let { - ConditionValue.StringValue(it) - // TODO: -// ConditionValue.DateTimeValue + try { + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + val date = dateFormat.parse(it) + ConditionValue.DateTimeValue(date) + }catch (e:Exception){ + ConditionValue.StringValue(it) + } } } @@ -324,7 +347,7 @@ fun isValidJson(jsonString: String): Boolean { } } -private fun mapOperator(value: String): Operator { +internal fun mapOperator(value: String): Operator { return when (value.trim()) { "equals" -> Operator.EQUALS "notEquals" -> Operator.NOT_EQUALS diff --git a/src/main/kotlin/com/featurevisor/testRunner/CommandExecuter.kt b/src/main/kotlin/com/featurevisor/testRunner/CommandExecuter.kt new file mode 100644 index 0000000..cbcb677 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/testRunner/CommandExecuter.kt @@ -0,0 +1,31 @@ +package com.featurevisor.testRunner + +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit + +internal fun getJsonForFeatureUsingCommand(featureName: String, environment: String, projectRootPath: String) = + try { + createCommand(featureName, environment).runCommand(getFileForSpecificPath(projectRootPath)) + } catch (e: Exception) { + printMessageInRedColor("Exception in Commandline execution --> ${e.message}") + null + } + +private fun createCommand(featureName: String, environment: String) = + "npx featurevisor build --feature=$featureName --environment=$environment --print --pretty" + +private fun String.runCommand(workingDir: File): String? = + try { + val parts = this.split("\\s".toRegex()) + val process = ProcessBuilder(*parts.toTypedArray()) + .directory(workingDir) + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + process.waitFor(60, TimeUnit.MINUTES) + process.inputStream.bufferedReader().readText() + } catch (e: IOException) { + printMessageInRedColor("Exception while executing command -> ${e.message}") + null + } diff --git a/src/main/kotlin/com/featurevisor/testRunner/Matrix.kt b/src/main/kotlin/com/featurevisor/testRunner/Matrix.kt new file mode 100644 index 0000000..ab3e82d --- /dev/null +++ b/src/main/kotlin/com/featurevisor/testRunner/Matrix.kt @@ -0,0 +1,168 @@ +package com.featurevisor.testRunner + +import com.featurevisor.types.* + +fun generateCombinations( + keys: List, + matrix: AssertionMatrix, + idx: Int, + prev: MutableMap, + combinations: MutableList> +) { + val key = keys[idx] + val values = matrix[key] ?: emptyList() + + for (i in values.indices) { + val combination = prev.toMutableMap().apply { put(key, values[i]) } + + if (idx == keys.size - 1) { + combinations.add(combination) + } else { + generateCombinations(keys, matrix, idx + 1, combination, combinations) + } + } +} + +fun getMatrixCombinations(matrix: AssertionMatrix): List> { + val keys = matrix.keys.toList() + + if (keys.isEmpty()) { + return emptyList() + } + + val combinations = mutableListOf>() + generateCombinations(keys, matrix, 0, mutableMapOf(), combinations) + return combinations +} + +fun applyCombinationToValue(value: Any?, combination: Map): Any? { + if (value is String) { + val variableKeysInValue = Regex("""\$\{\{\s*([^\s}]+)\s*}}""").findAll(value) + + if (variableKeysInValue.none()) { + return value + } + + return variableKeysInValue.fold(value) { acc, result -> + val key = result.groupValues[1].trim() + val regex = Regex("""\$\{\{\s*([^\s}]+)\s*}}""") + acc.replace(regex, combination[key].toString()) + } + } + return value +} + +fun applyCombinationToFeatureAssertion( + combination: Map, + assertion: FeatureAssertion +): FeatureAssertion { + val flattenedAssertion = assertion.copy() + + flattenedAssertion.environment = applyCombinationToValue( + flattenedAssertion.environment, + combination + ) as EnvironmentKey + + flattenedAssertion.context = + flattenedAssertion.context.mapValues { (_, value) -> + getContextValue(applyCombinationToValue(getContextValues(value), combination)) + } as Context + + flattenedAssertion.at = applyCombinationToValue(getAtValue(flattenedAssertion.at).toString(), combination)?.let { + if (it is String) { + if (it.contains(".")) { + WeightType.DoubleType(it.toDouble()) + } else { + WeightType.IntType(it.toInt()) + } + } else it + } as WeightType + + flattenedAssertion.description = applyCombinationToValue( + flattenedAssertion.description, + combination + ) as? String + + return flattenedAssertion +} + +fun getFeatureAssertionsFromMatrix( + aIndex: Int, + assertionWithMatrix: FeatureAssertion +): List { + if (assertionWithMatrix.matrix == null) { + val assertion = assertionWithMatrix.copy() + assertion.description = "Assertion #${aIndex + 1}: (${assertion.environment}) ${ + assertion.description ?: "at ${getAtValue(assertion.at)}%" + }" + return listOf(assertion) + } + + val assertions = mutableListOf() + val combinations = getMatrixCombinations(assertionWithMatrix.matrix) + + for (combination in combinations) { + val assertion = applyCombinationToFeatureAssertion(combination, assertionWithMatrix) + assertion.description = "Assertion #${aIndex + 1}: (${assertion.environment}) ${ + assertion.description ?: "at ${getAtValue(assertion.at)}%" + }" + assertions.add(assertion) + } + + return assertions +} + +@Suppress("IMPLICIT_CAST_TO_ANY") +fun getAtValue(at: WeightType) = when (at) { + is WeightType.IntType -> { + at.value + } + + is WeightType.DoubleType -> { + at.value + } + + is WeightType.StringType -> { + at.value + } +} + +fun applyCombinationToSegmentAssertion( + combination: Map, + assertion: SegmentAssertion +): SegmentAssertion { + val flattenedAssertion = assertion.copy() + + flattenedAssertion.context = flattenedAssertion.context.mapValues { (_, value) -> + getContextValue(applyCombinationToValue(getContextValues(value), combination)) + } as Context + + flattenedAssertion.description = applyCombinationToValue( + flattenedAssertion.description, + combination + ) as? String + + return flattenedAssertion +} + +fun getSegmentAssertionsFromMatrix( + aIndex: Int, + assertionWithMatrix: SegmentAssertion +): List { + if (assertionWithMatrix.matrix == null) { + val assertion = assertionWithMatrix.copy() + assertion.description = "Assertion #${aIndex + 1}: ${assertion.description ?: "#${aIndex + 1}"}" + return listOf(assertion) + } + + val assertions = mutableListOf() + val combinations = getMatrixCombinations(assertionWithMatrix.matrix) + + for (combination in combinations) { + val assertion = applyCombinationToSegmentAssertion(combination, assertionWithMatrix) + assertion.description = "Assertion #${aIndex + 1}: ${assertion.description ?: "#${aIndex + 1}"}" + assertions.add(assertion) + } + + return assertions +} diff --git a/src/main/kotlin/com/featurevisor/testRunner/Parser.kt b/src/main/kotlin/com/featurevisor/testRunner/Parser.kt new file mode 100644 index 0000000..9d4a8d7 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/testRunner/Parser.kt @@ -0,0 +1,214 @@ +package com.featurevisor.testRunner + +import com.featurevisor.sdk.serializers.isValidJson +import com.featurevisor.sdk.serializers.mapOperator +import com.featurevisor.types.* +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import org.yaml.snakeyaml.Yaml +import java.io.File +import java.util.* + +internal fun parseTestFeatureAssertions(yamlFilePath: String) = + try { + val yamlContent = File(yamlFilePath).readText() + + val yaml = Yaml() + val data = yaml.load>(yamlContent) + + val feature = data["feature"] as? String + val segment = data["segment"] as? String + + if (!segment.isNullOrEmpty()) { + val segmentAssertion = (data["assertions"] as? List>)!!.map { assertionMap -> + SegmentAssertion( + description = assertionMap["description"] as? String, + context = (assertionMap["context"] as Map).mapValues { parseAttributeValue(it.value) }, + expectedToMatch = assertionMap["expectedToMatch"] as Boolean, + matrix = assertionMap["matrix"] as? AssertionMatrix + ) + } + val testSegment = TestSegment(key = segment, assertions = segmentAssertion) + Test.Segment(testSegment) + } else if (!feature.isNullOrEmpty()) { + val featureAssertion = (data["assertions"] as? List>)!!.map { assertionMap -> + FeatureAssertion( + description = assertionMap["description"] as? String, + environment = assertionMap["environment"] as String, + at = parseWeightValue((assertionMap["at"] as Any)), + context = (assertionMap["context"] as Map).mapValues { parseAttributeValue(it.value) }, + expectedToBeEnabled = assertionMap["expectedToBeEnabled"] as Boolean, + expectedVariables = (assertionMap["expectedVariables"] as? Map)?.mapValues { + parseVariableValue( + it.value + ) + }, + expectedVariation = assertionMap["expectedVariation"] as? String, + matrix = assertionMap["matrix"] as? AssertionMatrix + ) + } + + val testFeature = TestFeature(key = feature, assertions = featureAssertion) + Test.Feature(testFeature) + } else { + null + } + } catch (e: Exception) { + printMessageInRedColor("Exception while parsing Yaml Assertion File --> ${e.message}") + null + } + +private fun parseWeightValue(value: Any): WeightType { + return when (value) { + is Int -> WeightType.IntType(value) + is Double -> WeightType.DoubleType(value) + else -> WeightType.StringType(value.toString()) + } +} + +private fun parseVariableValue(value: Any?): VariableValue { + + return when (value) { + is Boolean -> VariableValue.BooleanValue(value) + is String -> { + if (isValidJson(value)) { + VariableValue.JsonValue(value) + } else { + VariableValue.StringValue(value) + } + } + + is Int -> VariableValue.IntValue(value) + is Double -> VariableValue.DoubleValue(value) + is List<*> -> { + val stringList = value.filterIsInstance() + VariableValue.ArrayValue(stringList) + } + + is Map<*, *> -> { + val mapData = value as Map + val json1 = Json.encodeToString(MapSerializer(String.serializer(), String.serializer()), mapData) + VariableValue.JsonValue(json1) + } + + else -> throw IllegalArgumentException("Unsupported variable value type") + } +} + +private fun parseAttributeValue(value: Any?): AttributeValue { + if (value == null) { + return AttributeValue.StringValue(null) + } + + return when (value) { + is Int -> AttributeValue.IntValue(value) + is Double -> AttributeValue.DoubleValue(value) + is Boolean -> AttributeValue.BooleanValue(value) + is String -> { + if (value.equals("", true)) { + AttributeValue.StringValue("") + } else { + value.toIntOrNull()?.let { + AttributeValue.IntValue(it) + } ?: value.toDoubleOrNull()?.let { + AttributeValue.DoubleValue(it) + } ?: AttributeValue.StringValue(value) + } + } + + is Date -> { + AttributeValue.DateValue(value) + } + + else -> { + throw IllegalArgumentException("Unsupported attribute value type") + } + } +} + +internal fun parseYamlSegment(segmentFilePath: String) = + try { + val yamlContent = File(segmentFilePath).readText() + + val yaml = Yaml() + val data = yaml.load>(yamlContent) + + val archived = data["archived"] as? Boolean + val description = data["description"] as? String + + val conditionsData = data["conditions"] + + Segment( + archived = archived, + key = "", + conditions = parseCondition(conditionsData) + ) + + } catch (e: Exception) { + printMessageInRedColor("Exception while parsing Yaml segment Assertion File --> ${e.message}") + null + } + + +private fun parseCondition(conditionData: Any?): Condition { + return when (conditionData) { + is Map<*, *> -> { + val mapData = conditionData as Map<*, *> + val operator = mapData.keys.firstOrNull() + when (operator) { + "and" -> { + val andConditions = (mapData[operator] as List<*>).map { parseCondition(it) } + Condition.And(andConditions) + } + + "or" -> { + val orConditions = (mapData[operator] as List<*>).map { parseCondition(it) } + Condition.Or(orConditions) + } + + "not" -> { + val notConditions = (mapData[operator] as List<*>).map { parseCondition(it) } + Condition.Not(notConditions) + } + + else -> { + val attributeKey = mapData["attribute"] as AttributeKey + val operatorValue = mapOperator(mapData["operator"] as String) + val value = parseConditionValue(mapData["value"]) + Condition.Plain(attributeKey, operatorValue, value) + } + } + } + + is List<*> -> { + val conditionsList = conditionData as List<*> + val conditions = conditionsList.map { parseCondition(it) } + Condition.And(conditions) + } + + else -> throw IllegalArgumentException("Invalid condition format") + } + + +} + +private fun parseConditionValue(value: Any?): ConditionValue { + if (value == null) { + return ConditionValue.StringValue(null) + } + + return when (value) { + is String -> ConditionValue.StringValue(value) + is Int -> ConditionValue.IntValue(value) + is Double -> ConditionValue.DoubleValue(value) + is Boolean -> ConditionValue.BooleanValue(value) + is List<*> -> { + val stringList = value.filterIsInstance() + ConditionValue.ArrayValue(stringList) + } + + else -> throw IllegalArgumentException("Unsupported condition value type") + } +} + diff --git a/src/main/kotlin/com/featurevisor/testRunner/TestExecuter.kt b/src/main/kotlin/com/featurevisor/testRunner/TestExecuter.kt new file mode 100644 index 0000000..db3d604 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/testRunner/TestExecuter.kt @@ -0,0 +1,184 @@ +@file:JvmName("TestExecuter") + +package com.featurevisor.testRunner + +import com.featurevisor.types.* +import java.io.File + +fun main(args: Array) { + when (args.size) { + 0 -> { + startTest() + } + + 1 -> { + val rootPathInParam = args[0] + startTest(rootPathInParam) + } + + else -> { + val rootPathInParam = args[0] + val testDirInParam = args[1] + startTest(rootPathInParam, testDirInParam) + } + } +} + +internal fun startTest(projectRootPath: String = "", testDirPath: String = "") { + val rootPath = projectRootPath.ifEmpty { + getRootProjectDir() + } + val testDir = testDirPath.ifEmpty { + "tests" + } + getAllFilesInDirectory(rootPath, testDir) +} + +internal fun getAllFilesInDirectory(projectRootPath: String, testDirPath: String) { + val folder = File("$projectRootPath/$testDirPath") + val listOfFiles = folder.listFiles() + var executionResult: ExecutionResult? = null + + var passedTestsCount = 0 + var failedTestsCount = 0 + + var passedAssertionsCount = 0 + var failedAssertionsCount = 0 + + if (!listOfFiles.isNullOrEmpty()) { + for (file in listOfFiles) { + if (file.isFile) { + if (file.extension.equals("yml", true)) { + val filePath = file.absoluteFile.path + try { + executionResult = testAssertion(filePath, projectRootPath) + } catch (e: Exception) { + printMessageInRedColor("Exception in $filePath --> ${e.message}") + } + + + if (executionResult?.passed == true) { + passedTestsCount++ + } else { + failedTestsCount++ + } + + passedAssertionsCount += executionResult?.assertionsCount?.passed ?: 0 + failedAssertionsCount += executionResult?.assertionsCount?.failed ?: 0 + + } else { + printMessageInRedColor("The file is not valid yml file") + } + } + } + printMessageInGreenColor("Test specs: $passedTestsCount passed, $failedTestsCount failed") + printMessageInGreenColor("Test Assertion: $passedAssertionsCount passed, $failedAssertionsCount failed") + } else { + printMessageInRedColor("Directory is Empty or not exists") + } +} + +fun testSingleFeature(featureKey: String, projectRootPath: String) { + val test = parseTestFeatureAssertions("$projectRootPath/tests/$featureKey.spec.yml") + + val executionResult = ExecutionResult( + passed = false, + assertionsCount = AssertionsCount(0, 0) + ) + + if (test == null) { + println("No File available") + return + } + + val testResult = testFeature(testFeature = (test as Test.Feature).value, projectRootPath) + + printTestResult(testResult) + + if (!testResult.passed) { + executionResult.passed = false + + executionResult.assertionsCount.failed = testResult.assertions.count { !it.passed } + executionResult.assertionsCount.passed += testResult.assertions.size - executionResult.assertionsCount.failed + } else { + executionResult.assertionsCount.passed = testResult.assertions.size + } + + printMessageInGreenColor("Test Assertion: ${executionResult.assertionsCount.passed} passed, ${executionResult.assertionsCount.failed} failed") +} + +fun testSingleSegment(featureKey: String, projectRootPath: String) { + val test = parseTestFeatureAssertions("$projectRootPath/tests/$featureKey.segment.yml") + + val executionResult = ExecutionResult( + passed = false, + assertionsCount = AssertionsCount(0, 0) + ) + + val testResult = testSegment(test = (test as Test.Segment).value, projectRootPath) + + printTestResult(testResult) + + if (!testResult.passed) { + executionResult.passed = false + + executionResult.assertionsCount.failed = testResult.assertions.count { !it.passed } + executionResult.assertionsCount.passed += testResult.assertions.size - executionResult.assertionsCount.failed + } else { + executionResult.assertionsCount.passed = testResult.assertions.size + } + + printMessageInGreenColor("Test Assertion: ${executionResult.assertionsCount.passed} passed, ${executionResult.assertionsCount.failed} failed") +} + +private fun testAssertion(filePath: String, projectRootPath: String): ExecutionResult { + val test = parseTestFeatureAssertions(filePath) + + val executionResult = ExecutionResult( + passed = true, + assertionsCount = AssertionsCount(0, 0) + ) + + test?.let { + val testResult: TestResult = when (test) { + is Test.Feature -> { + testFeature(test.value, projectRootPath) + } + + is Test.Segment -> { + testSegment(test.value, projectRootPath) + } + } + + printTestResult(testResult) + + if (!testResult.passed) { + executionResult.passed = false + + executionResult.assertionsCount.failed = testResult.assertions.count { !it.passed } + executionResult.assertionsCount.passed += testResult.assertions.size - executionResult.assertionsCount.failed + } else { + executionResult.assertionsCount.passed = testResult.assertions.size + } + } + return executionResult +} + +fun getDataFileContent(featureName: String, environment: String, projectRootPath: String) = + try { + getJsonForFeatureUsingCommand( + featureName = featureName, + environment = environment, + projectRootPath = projectRootPath + )?.run { + convertToDataClass() + } + } catch (e: Exception) { + printMessageInRedColor("Exception while parsing data file --> ${e.message}") + null + } + + + + + diff --git a/src/main/kotlin/com/featurevisor/testRunner/TestFeature.kt b/src/main/kotlin/com/featurevisor/testRunner/TestFeature.kt new file mode 100644 index 0000000..1cc0edf --- /dev/null +++ b/src/main/kotlin/com/featurevisor/testRunner/TestFeature.kt @@ -0,0 +1,218 @@ +package com.featurevisor.testRunner + +import com.featurevisor.sdk.getVariable +import com.featurevisor.sdk.getVariation +import com.featurevisor.sdk.isEnabled +import com.featurevisor.types.* +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +fun testFeature(testFeature: TestFeature, projectRootPath: String): TestResult { + val featureKey = testFeature.key + + val testResult = TestResult( + type = "feature", + key = featureKey, + notFound = false, + duration = 0, + passed = true, + assertions = mutableListOf() + ) + + testFeature.assertions.forEachIndexed { index, assertion -> + val assertions = getFeatureAssertionsFromMatrix(index, assertion) + + assertions.forEach { + + val testResultAssertion = TestResultAssertion( + description = it.description.orEmpty(), + environment = it.environment, + duration = 0, + passed = true, + errors = mutableListOf() + ) + + + val datafileContent = getDataFileContent( + featureName = testFeature.key, + environment = it.environment, + projectRootPath = projectRootPath + ) + + if (datafileContent != null) { + + val featurevisorInstance = getSdkInstance(datafileContent, it) + + if (testFeature.key.isEmpty()) { + testResult.notFound = true + testResult.passed = false + + return testResult + } + + // isEnabled + if (it.expectedToBeEnabled != null) { + val isEnabled = featurevisorInstance.isEnabled(testFeature.key, it.context) + + if (isEnabled != it.expectedToBeEnabled) { + testResult.passed = false + testResultAssertion.passed = false + + (testResultAssertion.errors as MutableList).add( + TestResultAssertionError( + type = "flag", + expected = it.expectedToBeEnabled, + actual = isEnabled + ) + ) + } + } + + //Variation + if (!it.expectedVariation.isNullOrEmpty()) { + val variation = featurevisorInstance.getVariation(testFeature.key, it.context) + + if (variation != it.expectedVariation) { + testResult.passed = false + testResultAssertion.passed = false + + (testResultAssertion.errors as MutableList).add( + TestResultAssertionError( + type = "variation", + expected = it.expectedVariation, + actual = variation + ) + ) + } + } + + //Variables + if (assertion.expectedVariables is Map<*, *>) { + + assertion.expectedVariables.forEach { (variableKey, expectedValue) -> + val actualValue = featurevisorInstance.getVariable(featureKey, variableKey, it.context) + val passed: Boolean + + val variableSchema = datafileContent.features.find { feature -> + feature.key == testFeature.key + }?.variablesSchema?.find { variableSchema -> + variableSchema.key.equals(variableKey, ignoreCase = true) + } + + if (variableSchema == null) { + testResult.passed = false + testResultAssertion.passed = false + + + (testResultAssertion.errors as MutableList).add( + TestResultAssertionError( + type = "variable", + expected = it.expectedVariation, + actual = null, + message = "schema for variable \"${variableKey}\" not found in feature" + ) + ) + return@forEach + } + + + if (variableSchema.type == VariableType.JSON) { + // JSON type + val parsedExpectedValue = if (expectedValue is VariableValue.StringValue) { + try { + Json.decodeFromString>(expectedValue.value) + } catch (e: Exception) { + expectedValue + } + } else { + expectedValue + } + + passed = when (actualValue) { + is VariableValue.ArrayValue -> checkIfArraysAreEqual( + stringToArray(parsedExpectedValue.toString()).orEmpty().toTypedArray(), + actualValue.values.toTypedArray() + ) + + is VariableValue.ObjectValue -> checkIfObjectsAreEqual( + (parsedExpectedValue as VariableValue.ObjectValue).value, + (actualValue as VariableValue.ObjectValue).value + ) + + is VariableValue.JsonValue -> checkJsonIsEquals( + (expectedValue as VariableValue.JsonValue).value, + (actualValue as VariableValue.JsonValue).value + ) + + else -> parsedExpectedValue == actualValue + } + + if (!passed) { + testResult.passed = false + testResultAssertion.passed = false + + val expectedValueString = + if (expectedValue !is VariableValue.StringValue) expectedValue.toString() else expectedValue + val actualValueString = + if (actualValue !is VariableValue.StringValue) actualValue.toString() else actualValue + + (testResultAssertion.errors as MutableList).add( + TestResultAssertionError( + type = "variable", + expected = expectedValueString, + actual = actualValueString, + details = mapOf("variableKey" to variableKey) + ) + ) + } + } else { + passed = when (expectedValue) { + is VariableValue.ArrayValue -> checkIfArraysAreEqual( + (expectedValue as VariableValue.ArrayValue).values.toTypedArray(), + (actualValue as VariableValue.ArrayValue).values.toTypedArray() + ) + + is VariableValue.ObjectValue -> checkIfObjectsAreEqual(expectedValue, actualValue) + else -> expectedValue == actualValue + } + + if (!passed) { + testResult.passed = false + testResultAssertion.passed = false + + val expectedValueString = + if (expectedValue !is VariableValue.StringValue) expectedValue.toString() else expectedValue + val actualValueString = + if (actualValue !is VariableValue.StringValue) actualValue.toString() else actualValue + + (testResultAssertion.errors as MutableList).add( + TestResultAssertionError( + type = "variable", + expected = expectedValueString, + actual = actualValueString, + details = mapOf("variableKey" to variableKey) + ) + ) + } + } + } + } + } else { + testResult.passed = false + testResultAssertion.passed = false + + (testResultAssertion.errors as MutableList).add( + TestResultAssertionError( + type = "Data File", + expected = null, + actual = null, + message = "Unable to generate Data File" + ) + ) + } + (testResult.assertions as MutableList).add(testResultAssertion) + } + } + return testResult +} diff --git a/src/main/kotlin/com/featurevisor/testRunner/TestSegment.kt b/src/main/kotlin/com/featurevisor/testRunner/TestSegment.kt new file mode 100644 index 0000000..3ec1dfc --- /dev/null +++ b/src/main/kotlin/com/featurevisor/testRunner/TestSegment.kt @@ -0,0 +1,57 @@ +package com.featurevisor.testRunner + +import com.featurevisor.sdk.segmentIsMatched +import com.featurevisor.types.TestResult +import com.featurevisor.types.TestResultAssertion +import com.featurevisor.types.TestResultAssertionError +import com.featurevisor.types.TestSegment + +fun testSegment(test: TestSegment, segmentFilePath: String): TestResult { + val segmentKey = test.key + + val testResult = TestResult( + type = "segment", + key = segmentKey, + notFound = false, + duration = 0, + passed = true, + assertions = mutableListOf() + ) + + test.assertions.forEachIndexed { index, segmentAssertion -> + val assertions = getSegmentAssertionsFromMatrix(index, segmentAssertion) + + assertions.forEach { + + val testResultAssertion = TestResultAssertion( + description = it.description.orEmpty(), + duration = 0, + passed = true, + errors = mutableListOf() + ) + + val yamlSegment = parseYamlSegment("$segmentFilePath/segments/$segmentKey.yml") + val expected = it.expectedToMatch + val actual = segmentIsMatched(yamlSegment!!, it.context) + val passed = actual == expected + + if (!passed) { + val testResultAssertionError = TestResultAssertionError( + type = "segment", + expected = expected, + actual = actual + ) + + (testResultAssertion.errors as MutableList).add(testResultAssertionError) + + testResult.passed = false + testResultAssertion.passed = false + } + + (testResult.assertions as MutableList).add(testResultAssertion) + + } + } + + return testResult +} diff --git a/src/main/kotlin/com/featurevisor/testRunner/Utils.kt b/src/main/kotlin/com/featurevisor/testRunner/Utils.kt new file mode 100644 index 0000000..434bfc1 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/testRunner/Utils.kt @@ -0,0 +1,195 @@ +package com.featurevisor.testRunner + +import com.featurevisor.sdk.FeaturevisorInstance +import com.featurevisor.sdk.InstanceOptions +import com.featurevisor.types.* +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import java.io.File +import java.util.* + + +internal const val tick = "\u2713" +internal const val cross = "\u2717" + +internal const val ANSI_RESET = "\u001B[0m" +internal const val ANSI_RED = "\u001B[31m" +internal const val ANSI_GREEN = "\u001B[32m" + +internal const val MAX_BUCKETED_NUMBER = 100000 + +internal val json = Json { + ignoreUnknownKeys = true + isLenient = true +} + +internal fun printMessageInGreenColor(message: String) = + println("$ANSI_GREEN$message$ANSI_RESET") + +internal fun printMessageInRedColor(message: String) = + println("$ANSI_RED$message$ANSI_RESET") + +internal fun printAssertionSuccessfulMessage(message: String) = println("\t$tick $message") + + +internal fun printAssertionFailedMessage(message: String) = + println("$ANSI_RED\t$cross $message $ANSI_RESET") + +internal fun printNormalMessage(message: String) = println(message) + +internal fun getSdkInstance(datafileContent: DatafileContent?, assertion: FeatureAssertion) = + FeaturevisorInstance.createInstance( + InstanceOptions( + datafile = datafileContent, + configureBucketValue = { _, _, _ -> + when (assertion.at) { + is WeightType.IntType -> ((assertion.at as WeightType.IntType).value * (MAX_BUCKETED_NUMBER / 100)) + is WeightType.DoubleType -> ((assertion.at as WeightType.DoubleType).value * (MAX_BUCKETED_NUMBER / 100)).toInt() + else -> (MAX_BUCKETED_NUMBER / 100) + } + } + ) + ) + +internal fun getFileForSpecificPath(path: String) = File(path) + +internal inline fun String.convertToDataClass() = json.decodeFromString(this) + +internal fun getRootProjectDir(): String { + var currentDir = File("").absoluteFile + while (currentDir.parentFile != null) { + if (File(currentDir, "build.gradle.kts").exists()) { + return currentDir.absolutePath + } + currentDir = currentDir.parentFile + } + throw IllegalStateException("Root project directory not found.") +} + + +fun printTestResult(testResult: TestResult) { + println("") + + printNormalMessage("Testing: ${testResult.key}") + + if (testResult.notFound == true) { + println(ANSI_RED + " => ${testResult.type} ${testResult.key} not found" + ANSI_RED) + return + } + + printNormalMessage(" ${testResult.type} \"${testResult.key}\":") + + testResult.assertions.forEachIndexed { index, assertion -> + if (assertion.passed) { + printAssertionSuccessfulMessage(assertion.description) + } else { + printAssertionFailedMessage(assertion.description) + + assertion.errors?.forEach { error -> + when { + error.message != null -> { + printMessageInRedColor(" => ${error.message}") + } + + error.type == "variable" -> { + val variableKey = (error.details as Map<*, *>)["variableKey"] + + printMessageInRedColor(" => variable key: $variableKey") + printMessageInRedColor(" => expected: ${error.expected}") + printMessageInRedColor(" => received: ${error.actual}") + } + + else -> { + printMessageInRedColor( + " => ${error.type}: expected \"${error.expected}\", received \"${error.actual}\"" + ) + } + } + } + } + } +} + +fun getContextValue(contextValue: Any?) = + when (contextValue) { + is Boolean -> AttributeValue.BooleanValue(contextValue) + is Int -> AttributeValue.IntValue(contextValue) + is Double -> AttributeValue.DoubleValue(contextValue) + is String -> AttributeValue.StringValue(contextValue) + is Date -> AttributeValue.DateValue(contextValue) + + else -> throw Exception("Unsupported context value") + } + +fun getContextValues(contextValue: AttributeValue) = + when (contextValue) { + is AttributeValue.IntValue -> contextValue.value + is AttributeValue.DoubleValue -> contextValue.value + is AttributeValue.StringValue -> contextValue.value + is AttributeValue.BooleanValue -> contextValue.value + is AttributeValue.DateValue -> contextValue.value + } + +fun checkIfArraysAreEqual(a: Array, b: Array): Boolean { + if (a.size != b.size) return false + + for (i in a.indices) { + if (a[i] != b[i]) { + return false + } + } + return true +} + +fun checkIfObjectsAreEqual(a: Any?, b: Any?): Boolean { + if (a === b) { + return true + } + + if (a !is Map<*, *> || b !is Map<*, *>) { + return false + } + + val keysA = a.keys + val keysB = b.keys + + if (keysA.size != keysB.size || !keysA.containsAll(keysB)) { + return false + } + + for (key in keysA) { + val valueA = a[key] + val valueB = b[key] + + if (!checkIfObjectsAreEqual(valueA, valueB)) { + return false + } + } + + return true +} + +fun stringToArray(input: String): List? { + if (input.trim().startsWith("[") && input.trim().endsWith("]")) { + val trimmed = input.trim().substring(1, input.length - 1) + val elements = trimmed.split(",").map { it.trim() } + return elements.map { element -> + when { + element == "true" || element == "false" -> element.toBoolean() + element.toIntOrNull() != null -> element.toInt() + element.toDoubleOrNull() != null -> element.toDouble() + else -> element + } + } + } + return null +} + +fun checkJsonIsEquals(a: String, b: String): Boolean { + val map1 = Json.decodeFromString>(a) + val map2 = Json.decodeFromString>(b) + return map1 == map2 +} + + diff --git a/src/main/kotlin/com/featurevisor/types/Types.kt b/src/main/kotlin/com/featurevisor/types/Types.kt index 870316a..d9aae2f 100644 --- a/src/main/kotlin/com/featurevisor/types/Types.kt +++ b/src/main/kotlin/com/featurevisor/types/Types.kt @@ -1,13 +1,8 @@ package com.featurevisor.types -import com.featurevisor.sdk.serializers.BucketBySerializer -import com.featurevisor.sdk.serializers.ConditionSerializer -import com.featurevisor.sdk.serializers.ConditionValueSerializer -import com.featurevisor.sdk.serializers.GroupSegmentSerializer -import com.featurevisor.sdk.serializers.VariableValueSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import java.time.LocalDate +import com.featurevisor.sdk.serializers.* +import kotlinx.serialization.* +import java.util.Date typealias Context = Map typealias VariationValue = String @@ -160,41 +155,6 @@ data class ParsedFeature( val environments: Environments, ) -/** - * Tests - */ -data class FeatureAssertion( - val description: String?, - val environment: EnvironmentKey, - // bucket weight: 0 to 100 - val at: Weight, - val context: Context, - val expectedToBeEnabled: Boolean, - val expectedVariation: VariationValue?, - val expectedVariables: VariableValues?, -) - -data class TestFeature( - val key: FeatureKey, - val assertions: List, -) - -data class SegmentAssertion( - val description: String?, - val context: Context, - val expectedToMatch: Boolean, -) - -data class TestSegment( - val key: SegmentKey, - val assertions: List, -) - -sealed class Test { - data class Feature(val value: TestFeature) : Test() - data class Segment(val value: TestSegment) : Test() -} - typealias AttributeKey = String @Serializable @@ -206,11 +166,11 @@ data class Attribute( ) sealed class AttributeValue { - data class StringValue(val value: String) : AttributeValue() + data class StringValue(val value: String?) : AttributeValue() data class IntValue(val value: Int) : AttributeValue() data class DoubleValue(val value: Double) : AttributeValue() data class BooleanValue(val value: Boolean) : AttributeValue() - data class DateValue(val value: LocalDate) : AttributeValue() + data class DateValue(val value: Date) : AttributeValue() } @Serializable(with = ConditionSerializer::class) @@ -230,12 +190,12 @@ const val TAG = "FeaturevisorService" @Serializable(with = ConditionValueSerializer::class) sealed class ConditionValue { - data class StringValue(val value: String) : ConditionValue() + data class StringValue(val value: String?) : ConditionValue() data class IntValue(val value: Int) : ConditionValue() data class DoubleValue(val value: Double) : ConditionValue() data class BooleanValue(val value: Boolean) : ConditionValue() data class ArrayValue(val values: List) : ConditionValue() - data class DateTimeValue(val value: LocalDate) : ConditionValue() + data class DateTimeValue(val value: Date) : ConditionValue() } typealias SegmentKey = String @@ -315,16 +275,12 @@ enum class EventName { // 0 to 100,000 typealias Percentage = Int -@Serializable -data class Range( - val start: Percentage, - val end: Percentage, -) +typealias Range = List @Serializable data class Allocation( val variation: VariationValue, - val range: List, + val range: Range, ) @Serializable @@ -352,7 +308,7 @@ data class RequiredWithVariation( val variation: VariationValue, ) -@Serializable +@Serializable(with = RequiredSerializer::class) sealed class Required { data class FeatureKey(val required: com.featurevisor.types.FeatureKey) : Required() data class WithVariation(val required: RequiredWithVariation) : Required() @@ -388,3 +344,98 @@ data class OverrideFeature( val variation: VariationValue? = null, val variables: VariableValues? = null, ) + +/** + * Tests + */ + +typealias AssertionMatrix = Map> + + +data class FeatureAssertion( + var description: String?=null, + var environment: EnvironmentKey="staging", + // bucket weight: 0 to 100 + var at: WeightType = WeightType.IntType(40), + var context: Context = mapOf("devMode" to AttributeValue.BooleanValue(false)), + val expectedToBeEnabled: Boolean?=null, + val expectedVariation: VariationValue?=null, + val expectedVariables: VariableValues?=null, + val matrix: AssertionMatrix? = null +) + +data class TestFeature( + val key: FeatureKey, + val assertions: List, +) + +data class SegmentAssertion( + var description: String?=null, + var context: Context, + val expectedToMatch: Boolean, + val matrix: AssertionMatrix? = null +) + +data class TestSegment( + val key: SegmentKey, + val assertions: List, +) + +sealed class Test { + data class Feature(val value: TestFeature) : Test() + data class Segment(val value: TestSegment) : Test() +} + +sealed class WeightType{ + data class IntType(val value: Int):WeightType() + + data class DoubleType(val value: Double):WeightType() + + data class StringType(val value: String):WeightType() +} + +data class Assertion( + val description: String? = null, + val environment: String? = null, + val at: Double? = null, + val context: Context, + val expectedToBeEnabled: Boolean? = null, + val expectedVariables: Map? = null, + val expectedToMatch: Boolean? = null, + val expectedVariation: String? = null, +) + +data class TestResultAssertionError( + val type: String, + val expected: Any?=null, + val actual: Any?=null, + val message: String?=null, + val details: Map?=null +) + +data class TestResultAssertion( + val description: String, + val environment: EnvironmentKey? = null, + val duration: Long, + var passed: Boolean, + val errors: List? +) + +data class TestResult( + val type: String, + val key: FeatureKey, + var notFound: Boolean?=null, + var passed: Boolean, + val duration: Long, + val assertions: List +) + +data class ExecutionResult( + var passed: Boolean, + val assertionsCount: AssertionsCount +) + +data class AssertionsCount( + var passed: Int=0, + var failed: Int=0 +) diff --git a/src/test/kotlin/com/featurevisor/sdk/ConditionsTest.kt b/src/test/kotlin/com/featurevisor/sdk/ConditionsTest.kt index efba5c8..cc367f4 100644 --- a/src/test/kotlin/com/featurevisor/sdk/ConditionsTest.kt +++ b/src/test/kotlin/com/featurevisor/sdk/ConditionsTest.kt @@ -23,10 +23,16 @@ import com.featurevisor.types.Operator.SEMVER_LESS_THAN_OR_EQUALS import com.featurevisor.types.Operator.SEMVER_NOT_EQUALS import com.featurevisor.types.Operator.STARTS_WITH import io.kotest.matchers.shouldBe +import java.sql.Date import java.time.LocalDate +import java.util.* +import kotlin.test.BeforeTest import kotlin.test.Test class ConditionsTest { + + val calendar = Calendar.getInstance() + @Test fun `EQUALS operator works for strings`() { val condition = @@ -372,22 +378,22 @@ class ConditionsTest { val condition = Condition.Plain( attributeKey = "date", operator = BEFORE, - value = ConditionValue.DateTimeValue(LocalDate.of(2023, 10, 5)), + value = ConditionValue.DateTimeValue(Date.valueOf("2023-10-4")), ) Conditions.conditionIsMatched( condition = condition, - context = mapOf("date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 4))) + context = mapOf("date" to AttributeValue.DateValue(Date.valueOf("2022-11-4"))) ) shouldBe true Conditions.conditionIsMatched( condition = condition, - context = mapOf("date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 5))) + context = mapOf("date" to AttributeValue.DateValue(Date.valueOf("2024-10-4"))) ) shouldBe false Conditions.conditionIsMatched( condition = condition, - context = mapOf("date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 6))) + context = mapOf("date" to AttributeValue.DateValue(Date.valueOf("2024-10-4"))) ) shouldBe false } @@ -396,22 +402,22 @@ class ConditionsTest { val condition = Condition.Plain( attributeKey = "date", operator = AFTER, - value = ConditionValue.DateTimeValue(LocalDate.of(2023, 10, 5)), + value = ConditionValue.DateTimeValue(Date.valueOf("2023-10-4")), ) Conditions.conditionIsMatched( condition = condition, - context = mapOf("date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 4))) + context = mapOf("date" to AttributeValue.DateValue(Date.valueOf("2022-10-4"))) ) shouldBe false Conditions.conditionIsMatched( condition = condition, - context = mapOf("date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 5))) + context = mapOf("date" to AttributeValue.DateValue(Date.valueOf("2022-10-4"))) ) shouldBe false Conditions.conditionIsMatched( condition = condition, - context = mapOf("date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 6))) + context = mapOf("date" to AttributeValue.DateValue(Date.valueOf("2024-10-4"))) ) shouldBe true } @@ -477,7 +483,7 @@ class ConditionsTest { val beforeCondition = Condition.Plain( attributeKey = "date", operator = BEFORE, - value = ConditionValue.DateTimeValue(LocalDate.of(2023, 10, 5)), + value = ConditionValue.DateTimeValue(Date(1632307200000)), ) val inArrayCondition = Condition.Plain( @@ -511,7 +517,7 @@ class ConditionsTest { val context = mapOf( "browser_type" to AttributeValue.StringValue("chrome"), // true "version" to AttributeValue.StringValue("1.2.4"), // true - "date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 6)), // false + "date" to AttributeValue.DateValue(Date.valueOf("2023-10-4")), // false "letter" to AttributeValue.StringValue("x"), // false "age" to AttributeValue.IntValue(19), // true )