From 595d07bb47ba9f0158cbe91c77ae4a42829c94c0 Mon Sep 17 00:00:00 2001 From: Tanmay Ranjan Date: Thu, 7 Mar 2024 14:45:31 +0530 Subject: [PATCH 1/3] Test Runner Implementation --- build.gradle.kts | 13 ++ .../com/featurevisor/cli/AssertionHelper.kt | 81 +++++++ .../com/featurevisor/cli/CommandExecuter.kt | 31 +++ .../kotlin/com/featurevisor/cli/Matrix.kt | 169 ++++++++++++++ .../kotlin/com/featurevisor/cli/Parser.kt | 207 ++++++++++++++++++ .../com/featurevisor/cli/TestExecuter.kt | 185 ++++++++++++++++ .../com/featurevisor/cli/TestFeature.kt | 198 +++++++++++++++++ .../com/featurevisor/cli/TestSegment.kt | 54 +++++ src/main/kotlin/com/featurevisor/cli/Utils.kt | 195 +++++++++++++++++ .../kotlin/com/featurevisor/sdk/Conditions.kt | 34 +-- .../featurevisor/sdk/Instance+Evaluation.kt | 2 +- .../com/featurevisor/sdk/Instance+Segments.kt | 2 +- .../sdk/serializers/Serializers.kt | 31 ++- .../kotlin/com/featurevisor/types/Types.kt | 159 +++++++++----- 14 files changed, 1287 insertions(+), 74 deletions(-) create mode 100644 src/main/kotlin/com/featurevisor/cli/AssertionHelper.kt create mode 100644 src/main/kotlin/com/featurevisor/cli/CommandExecuter.kt create mode 100644 src/main/kotlin/com/featurevisor/cli/Matrix.kt create mode 100644 src/main/kotlin/com/featurevisor/cli/Parser.kt create mode 100644 src/main/kotlin/com/featurevisor/cli/TestExecuter.kt create mode 100644 src/main/kotlin/com/featurevisor/cli/TestFeature.kt create mode 100644 src/main/kotlin/com/featurevisor/cli/TestSegment.kt create mode 100644 src/main/kotlin/com/featurevisor/cli/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/cli/AssertionHelper.kt b/src/main/kotlin/com/featurevisor/cli/AssertionHelper.kt new file mode 100644 index 0000000..ce0b116 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/cli/AssertionHelper.kt @@ -0,0 +1,81 @@ +package com.featurevisor.cli + +import com.featurevisor.sdk.* +import com.featurevisor.types.FeatureAssertion +import com.featurevisor.types.VariableValue + + internal fun assertExpectedToBeEnabled(actualValue: Boolean?, expectedValue: Boolean?) = + (actualValue ?: false) == (expectedValue ?: false) + +internal fun assertVariables(featureName: String, assertion: FeatureAssertion, featurevisorInstance: FeaturevisorInstance?) = + assertion.expectedVariables?.map { (key, value) -> + when (value) { + is VariableValue.BooleanValue -> { + val actualValue = featurevisorInstance?.getVariableBoolean( + featureKey = featureName, + variableKey = key, + context = assertion.context + ) + value.value == actualValue + } + + is VariableValue.IntValue -> { + value.value == featurevisorInstance?.getVariableInteger( + featureKey = featureName, + variableKey = key, + context = assertion.context + ) + } + + is VariableValue.DoubleValue -> { + value.value == featurevisorInstance?.getVariableDouble( + featureKey = featureName, + variableKey = key, + context = assertion.context + ) + } + + is VariableValue.StringValue -> { + value.value == featurevisorInstance?.getVariableString( + featureKey = featureName, + variableKey = key, + context = assertion.context + ) + } + + is VariableValue.ArrayValue -> { + val variableValue = featurevisorInstance?.getVariableArray( + featureKey = featureName, + variableKey = key, + context = assertion.context + ) + + if ((variableValue as List<*>).isEmpty()) { + true + } else { + variableValue == value.values + } + } + + is VariableValue.JsonValue -> { + val variableValue = featurevisorInstance?.getVariable( + featureKey = featureName, + variableKey = key, + context = assertion.context + ) + (variableValue as VariableValue.JsonValue).value.equals(value.toString(), true) + } + + is VariableValue.ObjectValue -> { + val variableValue = featurevisorInstance?.getVariable( + featureKey = featureName, + variableKey = key, + context = assertion.context + ) + variableValue == value.value + } + } + } + + internal fun assertVariation(actualVariation: String?, expectedVariation: String?) = + actualVariation?.equals(expectedVariation, true) ?: false diff --git a/src/main/kotlin/com/featurevisor/cli/CommandExecuter.kt b/src/main/kotlin/com/featurevisor/cli/CommandExecuter.kt new file mode 100644 index 0000000..56aaddd --- /dev/null +++ b/src/main/kotlin/com/featurevisor/cli/CommandExecuter.kt @@ -0,0 +1,31 @@ +package com.featurevisor.cli + +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/cli/Matrix.kt b/src/main/kotlin/com/featurevisor/cli/Matrix.kt new file mode 100644 index 0000000..8787eab --- /dev/null +++ b/src/main/kotlin/com/featurevisor/cli/Matrix.kt @@ -0,0 +1,169 @@ +package com.featurevisor.cli + +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/cli/Parser.kt b/src/main/kotlin/com/featurevisor/cli/Parser.kt new file mode 100644 index 0000000..9ab57ca --- /dev/null +++ b/src/main/kotlin/com/featurevisor/cli/Parser.kt @@ -0,0 +1,207 @@ +package com.featurevisor.cli + +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.time.format.DateTimeFormatter +import java.util.Date + +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) +// (value == "") -> AttributeValue.StringValue("") + is String -> { + if (value.equals("",true)){ + AttributeValue.StringValue("") + }else{ + 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/cli/TestExecuter.kt b/src/main/kotlin/com/featurevisor/cli/TestExecuter.kt new file mode 100644 index 0000000..1dd642b --- /dev/null +++ b/src/main/kotlin/com/featurevisor/cli/TestExecuter.kt @@ -0,0 +1,185 @@ +@file:JvmName("TestExecuter") + +package com.featurevisor.cli + +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/cli/TestFeature.kt b/src/main/kotlin/com/featurevisor/cli/TestFeature.kt new file mode 100644 index 0000000..5cc8311 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/cli/TestFeature.kt @@ -0,0 +1,198 @@ +package com.featurevisor.cli + +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 -> checkMapIsEquals((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/cli/TestSegment.kt b/src/main/kotlin/com/featurevisor/cli/TestSegment.kt new file mode 100644 index 0000000..c56be38 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/cli/TestSegment.kt @@ -0,0 +1,54 @@ +package com.featurevisor.cli + +import com.featurevisor.sdk.segmentIsMatched +import com.featurevisor.types.* + +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/cli/Utils.kt b/src/main/kotlin/com/featurevisor/cli/Utils.kt new file mode 100644 index 0000000..68961c3 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/cli/Utils.kt @@ -0,0 +1,195 @@ +package com.featurevisor.cli + +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.Date + + +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 checkMapIsEquals(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/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..7294418 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt @@ -337,7 +337,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 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..9c629d3 100644 --- a/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt +++ b/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt @@ -10,6 +10,7 @@ import com.featurevisor.types.NotGroupSegment import com.featurevisor.types.Operator import com.featurevisor.types.OrGroupSegment import com.featurevisor.types.VariableValue +import com.featurevisor.types.Required import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException @@ -34,6 +35,34 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.serializer + + +@OptIn(InternalSerializationApi::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) object ConditionSerializer : KSerializer { @@ -324,7 +353,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/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 +) From 2086939716f9d33af2079786607db652577480f1 Mon Sep 17 00:00:00 2001 From: Tanmay Ranjan Date: Thu, 7 Mar 2024 15:13:30 +0530 Subject: [PATCH 2/3] unit test case fixes for ConditionsTest --- .../com/featurevisor/sdk/ConditionsTest.kt | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) 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 ) From 5f8b078366f948a96e23cdcd921e143752e4ebca Mon Sep 17 00:00:00 2001 From: Tanmay Ranjan Date: Fri, 8 Mar 2024 15:09:30 +0530 Subject: [PATCH 3/3] sdk fixes in all scenarios --- .../com/featurevisor/cli/AssertionHelper.kt | 81 ------- .../com/featurevisor/cli/TestFeature.kt | 198 ---------------- .../featurevisor/sdk/Instance+Evaluation.kt | 34 ++- .../sdk/serializers/Serializers.kt | 26 +-- .../{cli => testRunner}/CommandExecuter.kt | 2 +- .../{cli => testRunner}/Matrix.kt | 17 +- .../{cli => testRunner}/Parser.kt | 91 ++++---- .../{cli => testRunner}/TestExecuter.kt | 3 +- .../featurevisor/testRunner/TestFeature.kt | 218 ++++++++++++++++++ .../{cli => testRunner}/TestSegment.kt | 11 +- .../featurevisor/{cli => testRunner}/Utils.kt | 30 +-- 11 files changed, 333 insertions(+), 378 deletions(-) delete mode 100644 src/main/kotlin/com/featurevisor/cli/AssertionHelper.kt delete mode 100644 src/main/kotlin/com/featurevisor/cli/TestFeature.kt rename src/main/kotlin/com/featurevisor/{cli => testRunner}/CommandExecuter.kt (97%) rename src/main/kotlin/com/featurevisor/{cli => testRunner}/Matrix.kt (95%) rename src/main/kotlin/com/featurevisor/{cli => testRunner}/Parser.kt (67%) rename src/main/kotlin/com/featurevisor/{cli => testRunner}/TestExecuter.kt (99%) create mode 100644 src/main/kotlin/com/featurevisor/testRunner/TestFeature.kt rename src/main/kotlin/com/featurevisor/{cli => testRunner}/TestSegment.kt (82%) rename src/main/kotlin/com/featurevisor/{cli => testRunner}/Utils.kt (89%) diff --git a/src/main/kotlin/com/featurevisor/cli/AssertionHelper.kt b/src/main/kotlin/com/featurevisor/cli/AssertionHelper.kt deleted file mode 100644 index ce0b116..0000000 --- a/src/main/kotlin/com/featurevisor/cli/AssertionHelper.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.featurevisor.cli - -import com.featurevisor.sdk.* -import com.featurevisor.types.FeatureAssertion -import com.featurevisor.types.VariableValue - - internal fun assertExpectedToBeEnabled(actualValue: Boolean?, expectedValue: Boolean?) = - (actualValue ?: false) == (expectedValue ?: false) - -internal fun assertVariables(featureName: String, assertion: FeatureAssertion, featurevisorInstance: FeaturevisorInstance?) = - assertion.expectedVariables?.map { (key, value) -> - when (value) { - is VariableValue.BooleanValue -> { - val actualValue = featurevisorInstance?.getVariableBoolean( - featureKey = featureName, - variableKey = key, - context = assertion.context - ) - value.value == actualValue - } - - is VariableValue.IntValue -> { - value.value == featurevisorInstance?.getVariableInteger( - featureKey = featureName, - variableKey = key, - context = assertion.context - ) - } - - is VariableValue.DoubleValue -> { - value.value == featurevisorInstance?.getVariableDouble( - featureKey = featureName, - variableKey = key, - context = assertion.context - ) - } - - is VariableValue.StringValue -> { - value.value == featurevisorInstance?.getVariableString( - featureKey = featureName, - variableKey = key, - context = assertion.context - ) - } - - is VariableValue.ArrayValue -> { - val variableValue = featurevisorInstance?.getVariableArray( - featureKey = featureName, - variableKey = key, - context = assertion.context - ) - - if ((variableValue as List<*>).isEmpty()) { - true - } else { - variableValue == value.values - } - } - - is VariableValue.JsonValue -> { - val variableValue = featurevisorInstance?.getVariable( - featureKey = featureName, - variableKey = key, - context = assertion.context - ) - (variableValue as VariableValue.JsonValue).value.equals(value.toString(), true) - } - - is VariableValue.ObjectValue -> { - val variableValue = featurevisorInstance?.getVariable( - featureKey = featureName, - variableKey = key, - context = assertion.context - ) - variableValue == value.value - } - } - } - - internal fun assertVariation(actualVariation: String?, expectedVariation: String?) = - actualVariation?.equals(expectedVariation, true) ?: false diff --git a/src/main/kotlin/com/featurevisor/cli/TestFeature.kt b/src/main/kotlin/com/featurevisor/cli/TestFeature.kt deleted file mode 100644 index 5cc8311..0000000 --- a/src/main/kotlin/com/featurevisor/cli/TestFeature.kt +++ /dev/null @@ -1,198 +0,0 @@ -package com.featurevisor.cli - -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 -> checkMapIsEquals((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/sdk/Instance+Evaluation.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt index 7294418..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, @@ -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/serializers/Serializers.kt b/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt index 9c629d3..f760563 100644 --- a/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt +++ b/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt @@ -11,11 +11,7 @@ import com.featurevisor.types.Operator import com.featurevisor.types.OrGroupSegment import com.featurevisor.types.VariableValue import com.featurevisor.types.Required -import kotlinx.serialization.InternalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException -import kotlinx.serialization.Serializer -import kotlinx.serialization.decodeFromString +import kotlinx.serialization.* import kotlinx.serialization.descriptors.PolymorphicKind import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildSerialDescriptor @@ -33,11 +29,9 @@ 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) +@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) @Serializer(forClass = Required::class) object RequiredSerializer: KSerializer{ override val descriptor: SerialDescriptor = @@ -59,10 +53,6 @@ object RequiredSerializer: KSerializer{ } } - - - - @OptIn(InternalSerializationApi::class) @Serializer(forClass = Condition::class) object ConditionSerializer : KSerializer { @@ -276,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) + } } } diff --git a/src/main/kotlin/com/featurevisor/cli/CommandExecuter.kt b/src/main/kotlin/com/featurevisor/testRunner/CommandExecuter.kt similarity index 97% rename from src/main/kotlin/com/featurevisor/cli/CommandExecuter.kt rename to src/main/kotlin/com/featurevisor/testRunner/CommandExecuter.kt index 56aaddd..cbcb677 100644 --- a/src/main/kotlin/com/featurevisor/cli/CommandExecuter.kt +++ b/src/main/kotlin/com/featurevisor/testRunner/CommandExecuter.kt @@ -1,4 +1,4 @@ -package com.featurevisor.cli +package com.featurevisor.testRunner import java.io.File import java.io.IOException diff --git a/src/main/kotlin/com/featurevisor/cli/Matrix.kt b/src/main/kotlin/com/featurevisor/testRunner/Matrix.kt similarity index 95% rename from src/main/kotlin/com/featurevisor/cli/Matrix.kt rename to src/main/kotlin/com/featurevisor/testRunner/Matrix.kt index 8787eab..ab3e82d 100644 --- a/src/main/kotlin/com/featurevisor/cli/Matrix.kt +++ b/src/main/kotlin/com/featurevisor/testRunner/Matrix.kt @@ -1,4 +1,4 @@ -package com.featurevisor.cli +package com.featurevisor.testRunner import com.featurevisor.types.* @@ -43,8 +43,6 @@ fun applyCombinationToValue(value: Any?, combination: Map): Any? { return value } - - return variableKeysInValue.fold(value) { acc, result -> val key = result.groupValues[1].trim() val regex = Regex("""\$\{\{\s*([^\s}]+)\s*}}""") @@ -68,13 +66,13 @@ fun applyCombinationToFeatureAssertion( flattenedAssertion.context = flattenedAssertion.context.mapValues { (_, value) -> getContextValue(applyCombinationToValue(getContextValues(value), combination)) - } as Context + } as Context flattenedAssertion.at = applyCombinationToValue(getAtValue(flattenedAssertion.at).toString(), combination)?.let { if (it is String) { - if (it.contains(".")){ - WeightType.DoubleType( it.toDouble()) - }else{ + if (it.contains(".")) { + WeightType.DoubleType(it.toDouble()) + } else { WeightType.IntType(it.toInt()) } } else it @@ -95,7 +93,8 @@ fun getFeatureAssertionsFromMatrix( if (assertionWithMatrix.matrix == null) { val assertion = assertionWithMatrix.copy() assertion.description = "Assertion #${aIndex + 1}: (${assertion.environment}) ${ - assertion.description ?: "at ${getAtValue(assertion.at)}%"}" + assertion.description ?: "at ${getAtValue(assertion.at)}%" + }" return listOf(assertion) } @@ -114,7 +113,7 @@ fun getFeatureAssertionsFromMatrix( } @Suppress("IMPLICIT_CAST_TO_ANY") -fun getAtValue(at:WeightType) = when (at) { +fun getAtValue(at: WeightType) = when (at) { is WeightType.IntType -> { at.value } diff --git a/src/main/kotlin/com/featurevisor/cli/Parser.kt b/src/main/kotlin/com/featurevisor/testRunner/Parser.kt similarity index 67% rename from src/main/kotlin/com/featurevisor/cli/Parser.kt rename to src/main/kotlin/com/featurevisor/testRunner/Parser.kt index 9ab57ca..9d4a8d7 100644 --- a/src/main/kotlin/com/featurevisor/cli/Parser.kt +++ b/src/main/kotlin/com/featurevisor/testRunner/Parser.kt @@ -1,4 +1,4 @@ -package com.featurevisor.cli +package com.featurevisor.testRunner import com.featurevisor.sdk.serializers.isValidJson import com.featurevisor.sdk.serializers.mapOperator @@ -8,8 +8,7 @@ import kotlinx.serialization.builtins.serializer import kotlinx.serialization.json.Json import org.yaml.snakeyaml.Yaml import java.io.File -import java.time.format.DateTimeFormatter -import java.util.Date +import java.util.* internal fun parseTestFeatureAssertions(yamlFilePath: String) = try { @@ -21,37 +20,37 @@ internal fun parseTestFeatureAssertions(yamlFilePath: String) = val feature = data["feature"] as? String val segment = data["segment"] as? String - if (!segment.isNullOrEmpty()){ + 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 - ) + 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 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 - ) + 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) + val testFeature = TestFeature(key = feature, assertions = featureAssertion) + Test.Feature(testFeature) } else { null } @@ -73,47 +72,55 @@ private fun parseVariableValue(value: Any?): VariableValue { return when (value) { is Boolean -> VariableValue.BooleanValue(value) is String -> { - if (isValidJson(value)){ + if (isValidJson(value)) { VariableValue.JsonValue(value) - }else{ + } 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) + 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 AttributeValue.StringValue(null) } return when (value) { is Int -> AttributeValue.IntValue(value) is Double -> AttributeValue.DoubleValue(value) is Boolean -> AttributeValue.BooleanValue(value) -// (value == "") -> AttributeValue.StringValue("") is String -> { - if (value.equals("",true)){ + if (value.equals("", true)) { AttributeValue.StringValue("") - }else{ - AttributeValue.StringValue(value) + } 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") } @@ -166,9 +173,9 @@ private fun parseCondition(conditionData: Any?): Condition { } else -> { - val attributeKey = mapData["attribute"] as AttributeKey - val operatorValue = mapOperator(mapData["operator"] as String) - val value = parseConditionValue(mapData["value"]) + val attributeKey = mapData["attribute"] as AttributeKey + val operatorValue = mapOperator(mapData["operator"] as String) + val value = parseConditionValue(mapData["value"]) Condition.Plain(attributeKey, operatorValue, value) } } @@ -186,12 +193,12 @@ private fun parseCondition(conditionData: Any?): Condition { } -private fun parseConditionValue(value: Any?):ConditionValue{ +private fun parseConditionValue(value: Any?): ConditionValue { if (value == null) { return ConditionValue.StringValue(null) } - return when (value) { + return when (value) { is String -> ConditionValue.StringValue(value) is Int -> ConditionValue.IntValue(value) is Double -> ConditionValue.DoubleValue(value) diff --git a/src/main/kotlin/com/featurevisor/cli/TestExecuter.kt b/src/main/kotlin/com/featurevisor/testRunner/TestExecuter.kt similarity index 99% rename from src/main/kotlin/com/featurevisor/cli/TestExecuter.kt rename to src/main/kotlin/com/featurevisor/testRunner/TestExecuter.kt index 1dd642b..db3d604 100644 --- a/src/main/kotlin/com/featurevisor/cli/TestExecuter.kt +++ b/src/main/kotlin/com/featurevisor/testRunner/TestExecuter.kt @@ -1,6 +1,6 @@ @file:JvmName("TestExecuter") -package com.featurevisor.cli +package com.featurevisor.testRunner import com.featurevisor.types.* import java.io.File @@ -95,7 +95,6 @@ fun testSingleFeature(featureKey: String, projectRootPath: String) { printTestResult(testResult) - if (!testResult.passed) { executionResult.passed = false 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/cli/TestSegment.kt b/src/main/kotlin/com/featurevisor/testRunner/TestSegment.kt similarity index 82% rename from src/main/kotlin/com/featurevisor/cli/TestSegment.kt rename to src/main/kotlin/com/featurevisor/testRunner/TestSegment.kt index c56be38..3ec1dfc 100644 --- a/src/main/kotlin/com/featurevisor/cli/TestSegment.kt +++ b/src/main/kotlin/com/featurevisor/testRunner/TestSegment.kt @@ -1,9 +1,12 @@ -package com.featurevisor.cli +package com.featurevisor.testRunner import com.featurevisor.sdk.segmentIsMatched -import com.featurevisor.types.* +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{ +fun testSegment(test: TestSegment, segmentFilePath: String): TestResult { val segmentKey = test.key val testResult = TestResult( @@ -32,7 +35,7 @@ fun testSegment(test: TestSegment, segmentFilePath:String):TestResult{ val actual = segmentIsMatched(yamlSegment!!, it.context) val passed = actual == expected - if (!passed){ + if (!passed) { val testResultAssertionError = TestResultAssertionError( type = "segment", expected = expected, diff --git a/src/main/kotlin/com/featurevisor/cli/Utils.kt b/src/main/kotlin/com/featurevisor/testRunner/Utils.kt similarity index 89% rename from src/main/kotlin/com/featurevisor/cli/Utils.kt rename to src/main/kotlin/com/featurevisor/testRunner/Utils.kt index 68961c3..434bfc1 100644 --- a/src/main/kotlin/com/featurevisor/cli/Utils.kt +++ b/src/main/kotlin/com/featurevisor/testRunner/Utils.kt @@ -1,4 +1,4 @@ -package com.featurevisor.cli +package com.featurevisor.testRunner import com.featurevisor.sdk.FeaturevisorInstance import com.featurevisor.sdk.InstanceOptions @@ -7,7 +7,7 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import java.io.File -import java.util.Date +import java.util.* internal const val tick = "\u2713" @@ -24,7 +24,7 @@ internal val json = Json { isLenient = true } -internal fun printMessageInGreenColor(message:String) = +internal fun printMessageInGreenColor(message: String) = println("$ANSI_GREEN$message$ANSI_RESET") internal fun printMessageInRedColor(message: String) = @@ -43,18 +43,18 @@ internal fun getSdkInstance(datafileContent: DatafileContent?, assertion: Featur 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() + 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 fun getFileForSpecificPath(path: String) = File(path) - internal inline fun String.convertToDataClass() = json.decodeFromString(this) +internal inline fun String.convertToDataClass() = json.decodeFromString(this) internal fun getRootProjectDir(): String { var currentDir = File("").absoluteFile @@ -74,7 +74,7 @@ fun printTestResult(testResult: TestResult) { printNormalMessage("Testing: ${testResult.key}") if (testResult.notFound == true) { - println(ANSI_RED + " => ${testResult.type} ${testResult.key} not found"+ ANSI_RED) + println(ANSI_RED + " => ${testResult.type} ${testResult.key} not found" + ANSI_RED) return } @@ -91,6 +91,7 @@ fun printTestResult(testResult: TestResult) { error.message != null -> { printMessageInRedColor(" => ${error.message}") } + error.type == "variable" -> { val variableKey = (error.details as Map<*, *>)["variableKey"] @@ -98,6 +99,7 @@ fun printTestResult(testResult: TestResult) { printMessageInRedColor(" => expected: ${error.expected}") printMessageInRedColor(" => received: ${error.actual}") } + else -> { printMessageInRedColor( " => ${error.type}: expected \"${error.expected}\", received \"${error.actual}\"" @@ -110,7 +112,7 @@ fun printTestResult(testResult: TestResult) { } fun getContextValue(contextValue: Any?) = - when(contextValue){ + when (contextValue) { is Boolean -> AttributeValue.BooleanValue(contextValue) is Int -> AttributeValue.IntValue(contextValue) is Double -> AttributeValue.DoubleValue(contextValue) @@ -121,16 +123,14 @@ fun getContextValue(contextValue: Any?) = } fun getContextValues(contextValue: AttributeValue) = - when(contextValue){ + when (contextValue) { is AttributeValue.IntValue -> contextValue.value is AttributeValue.DoubleValue -> contextValue.value is AttributeValue.StringValue -> contextValue.value - is AttributeValue.BooleanValue ->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 @@ -186,7 +186,7 @@ fun stringToArray(input: String): List? { return null } -fun checkMapIsEquals(a: String, b: String): Boolean { +fun checkJsonIsEquals(a: String, b: String): Boolean { val map1 = Json.decodeFromString>(a) val map2 = Json.decodeFromString>(b) return map1 == map2