From 4b96ae7f63823b0840ab9ed646b275bfc3671774 Mon Sep 17 00:00:00 2001 From: Marcin Polak Date: Thu, 4 Jan 2024 18:02:37 +0100 Subject: [PATCH] fix: forcing variation with variable overrides (#59) --- .../FeaturevisorSDK/Instance+Evaluation.swift | 17 +- Sources/FeaturevisorTypes/Types.swift | 38 +- .../FeaturevisorSDKTests/InstanceTests.swift | 392 ++++++++++++++++++ 3 files changed, 438 insertions(+), 9 deletions(-) diff --git a/Sources/FeaturevisorSDK/Instance+Evaluation.swift b/Sources/FeaturevisorSDK/Instance+Evaluation.swift index b1cb135..1aa905d 100644 --- a/Sources/FeaturevisorSDK/Instance+Evaluation.swift +++ b/Sources/FeaturevisorSDK/Instance+Evaluation.swift @@ -454,7 +454,7 @@ extension FeaturevisorInstance { // forced let force = findForceFromFeature(feature, context: context, datafileReader: datafileReader) - if let force, let variableValue = force.variables[variableKey] { + if let force, let variableValue = force.variables?[variableKey] { evaluation = Evaluation( featureKey: feature.key, reason: .forced, @@ -498,10 +498,21 @@ extension FeaturevisorInstance { } // regular allocation - if let matchedAllocation = matchedTrafficAndAllocation.matchedAllocation { + var variationValue: VariationValue? = nil + + if let forceVariation = force?.variation { + variationValue = forceVariation + } + else if let matchedAllocationVariation = matchedTrafficAndAllocation.matchedAllocation? + .variation + { + variationValue = matchedAllocationVariation + } + + if let variationValue { let variation = feature.variations.first(where: { variation in - return variation.value == matchedAllocation.variation + return variation.value == variationValue }) if let variationVariables = variation?.variables { diff --git a/Sources/FeaturevisorTypes/Types.swift b/Sources/FeaturevisorTypes/Types.swift index 8903b83..91b70f0 100644 --- a/Sources/FeaturevisorTypes/Types.swift +++ b/Sources/FeaturevisorTypes/Types.swift @@ -365,6 +365,17 @@ public struct VariableOverride: Codable { public let conditions: Condition? public let segments: GroupSegment? + internal init( + value: VariableValue, + conditions: Condition? = nil, + segments: GroupSegment? = nil + ) { + + self.value = value + self.conditions = conditions + self.segments = segments + } + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) value = try container.decode(VariableValue.self, forKey: .value) @@ -403,21 +414,36 @@ public typealias FeatureKey = String public typealias VariableValues = [VariableKey: VariableValue] public struct Force: Decodable { + public let variation: VariationValue? + public let variables: VariableValues? + // one of the below must be present in YAML public let conditions: Condition? public let segments: GroupSegment? public let enabled: Bool? - public let variation: VariationValue - public let variables: VariableValues + + internal init( + variation: VariationValue? = nil, + variables: VariableValues? = nil, + conditions: Condition? = nil, + segments: GroupSegment? = nil, + enabled: Bool? = nil + ) { + self.variation = variation + self.variables = variables + self.conditions = conditions + self.segments = segments + self.enabled = enabled + } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) enabled = try? container.decodeIfPresent(Bool.self, forKey: .enabled) - variation = try container.decode(VariationValue.self, forKey: .variation) - variables = try container.decode(VariableValues.self, forKey: .variables) - conditions = try container.decodeStringifiedIfPresent(Condition.self, forKey: .conditions) - segments = try container.decodeGroupSegmentIfPresent(forKey: .segments) + variation = try? container.decode(VariationValue.self, forKey: .variation) + variables = try? container.decode(VariableValues.self, forKey: .variables) + conditions = try? container.decodeStringifiedIfPresent(Condition.self, forKey: .conditions) + segments = try? container.decodeGroupSegmentIfPresent(forKey: .segments) } enum CodingKeys: CodingKey { diff --git a/Tests/FeaturevisorSDKTests/InstanceTests.swift b/Tests/FeaturevisorSDKTests/InstanceTests.swift index 0c4a6c4..9406e81 100644 --- a/Tests/FeaturevisorSDKTests/InstanceTests.swift +++ b/Tests/FeaturevisorSDKTests/InstanceTests.swift @@ -1172,6 +1172,398 @@ class FeaturevisorInstanceTests: XCTestCase { XCTAssertTrue(wasDatafileContentFetchErrorThrown) } + func testShouldGetVariable() { + + // GIVEN + var options: InstanceOptions = .default + options.datafile = DatafileContent( + schemaVersion: "1", + revision: "1.0", + attributes: [ + .init(key: "userId", type: "string", capture: true), + .init(key: "country", type: "string"), + ], + segments: [ + .init( + key: "netherlands", + conditions: .plain( + .init(attribute: "country", operator: .equals, value: .string("nl")) + ) + ), + .init( + key: "belgium", + conditions: .plain( + .init(attribute: "country", operator: .equals, value: .string("be")) + ) + ), + ], + features: [ + .init( + key: "test", + bucketBy: .single("userId"), + variablesSchema: [ + .init(key: "color", type: .string, defaultValue: .string("red")), + .init(key: "showSidebar", type: .boolean, defaultValue: .boolean(false)), + .init( + key: "sidebarTitle", + type: .string, + defaultValue: .string("sidebar title") + ), + .init(key: "count", type: .integer, defaultValue: .integer(0)), + .init(key: "price", type: .double, defaultValue: .double(9.99)), + .init( + key: "paymentMethods", + type: .array, + defaultValue: .array(["paypal", "creditcard"]) + ), + .init( + key: "flatConfig", + type: .object, + defaultValue: .object(["key": .string("value")]) + ), + .init( + key: "nestedConfig", + type: .json, + defaultValue: .json("{\"key\": {\"nested\": \"value\"}}") + ), + ], + variations: [ + .init(description: nil, value: "control", weight: nil, variables: nil), + .init( + description: nil, + value: "treatment", + weight: nil, + variables: [ + .init( + key: "showSidebar", + value: .boolean(true), + overrides: [ + .init( + value: .boolean(false), + conditions: .multiple([ + .plain( + .init( + attribute: "country", + operator: .equals, + value: .string("de") + ) + ) + ]) + ), + .init( + value: .boolean(false), + segments: .multiple([.plain("netherlands")]) + ), + ] + ), + .init( + key: "sidebarTitle", + value: .string("sidebar title from variation"), + overrides: [ + .init( + value: .string("German title"), + conditions: .multiple([ + .plain( + .init( + attribute: "country", + operator: .equals, + value: .string("de") + ) + ) + ]) + ), + .init( + value: .string("Dutch title"), + segments: .multiple([.plain("netherlands")]) + ), + ] + ), + ] + ), + ], + traffic: [ + .init( + key: "2", + segments: .plain("belgium"), + percentage: 100000, + allocation: [ + .init(variation: "control", range: .init(start: 0, end: 0)), + .init(variation: "treatment", range: .init(start: 0, end: 100000)), + ], + variation: "control", + variables: ["color": .string("black")] + ), + .init( + key: "1", + segments: .plain("*"), + percentage: 100000, + allocation: [ + .init(variation: "control", range: .init(start: 0, end: 0)), + .init(variation: "treatment", range: .init(start: 0, end: 100000)), + ] + ), + ], + force: [ + .init( + variation: "control", + variables: ["color": .string("red and white")], + conditions: .multiple([ + .plain( + .init( + attribute: "userId", + operator: .equals, + value: .string("user-ch") + ) + ) + ]), + enabled: true + ), + .init( + conditions: .multiple([ + .plain( + .init( + attribute: "userId", + operator: .equals, + value: .string("user-gb") + ) + ) + ]), + enabled: false + ), + .init( + variation: "treatment", + conditions: .multiple([ + .plain( + .init( + attribute: "userId", + operator: .equals, + value: .string("user-forced-variation") + ) + ) + ]), + enabled: true + ), + ] + ) + ] + ) + + // WHEN + let sdk = try! createInstance(options: options) + + // THEN + XCTAssertEqual( + sdk.getVariation(featureKey: "test", context: ["userId": .string("123")]), + "treatment" + ) + XCTAssertEqual( + sdk.getVariation( + featureKey: "test", + context: ["userId": .string("123"), "country": .string("be")] + ), + "control" + ) + XCTAssertEqual( + sdk.getVariation(featureKey: "test", context: ["userId": .string("user-ch")]), + "control" + ) + + XCTAssertEqual( + sdk.getVariable( + featureKey: "test", + variableKey: "color", + context: ["userId": .string("123")] + )? + .value as! String, + "red" + ) + XCTAssertEqual( + sdk.getVariableString( + featureKey: "test", + variableKey: "color", + context: ["userId": .string("123")] + ), + "red" + ) + XCTAssertEqual( + sdk.getVariable( + featureKey: "test", + variableKey: "color", + context: ["userId": .string("123"), "country": .string("be")] + )? + .value as! String, + "black" + ) + XCTAssertEqual( + sdk.getVariable( + featureKey: "test", + variableKey: "color", + context: ["userId": .string("user-ch")] + )? + .value as! String, + "red and white" + ) + + XCTAssertTrue( + sdk.getVariable( + featureKey: "test", + variableKey: "showSidebar", + context: ["userId": .string("123")] + )? + .value as! Bool + ) + XCTAssertTrue( + sdk.getVariableBoolean( + featureKey: "test", + variableKey: "showSidebar", + context: ["userId": .string("123")] + )! + ) + XCTAssertFalse( + sdk.getVariableBoolean( + featureKey: "test", + variableKey: "showSidebar", + context: ["userId": .string("123"), "country": .string("nl")] + )! + ) + XCTAssertFalse( + sdk.getVariableBoolean( + featureKey: "test", + variableKey: "showSidebar", + context: ["userId": .string("123"), "country": .string("de")] + )! + ) + + XCTAssertEqual( + sdk.getVariableString( + featureKey: "test", + variableKey: "sidebarTitle", + context: ["userId": .string("user-forced-variation"), "country": .string("de")] + )!, + "German title" + ) + XCTAssertEqual( + sdk.getVariableString( + featureKey: "test", + variableKey: "sidebarTitle", + context: ["userId": .string("user-forced-variation"), "country": .string("nl")] + )!, + "Dutch title" + ) + XCTAssertEqual( + sdk.getVariableString( + featureKey: "test", + variableKey: "sidebarTitle", + context: ["userId": .string("user-forced-variation"), "country": .string("be")] + )!, + "sidebar title from variation" + ) + + XCTAssertEqual( + sdk.getVariable( + featureKey: "test", + variableKey: "count", + context: ["userId": .string("123")] + )? + .value as! Int, + 0 + ) + XCTAssertEqual( + sdk.getVariableInteger( + featureKey: "test", + variableKey: "count", + context: ["userId": .string("123")] + ), + 0 + ) + + XCTAssertEqual( + sdk.getVariable( + featureKey: "test", + variableKey: "price", + context: ["userId": .string("123")] + )? + .value as! Double, + 9.99 + ) + XCTAssertEqual( + sdk.getVariableDouble( + featureKey: "test", + variableKey: "price", + context: ["userId": .string("123")] + ), + 9.99 + ) + + XCTAssertEqual( + sdk.getVariable( + featureKey: "test", + variableKey: "paymentMethods", + context: ["userId": .string("123")] + )? + .value as! [String], + ["paypal", "creditcard"] + ) + XCTAssertEqual( + sdk.getVariableArray( + featureKey: "test", + variableKey: "paymentMethods", + context: ["userId": .string("123")] + ), + ["paypal", "creditcard"] + ) + + XCTAssertEqual( + (sdk.getVariable( + featureKey: "test", + variableKey: "flatConfig", + context: ["userId": .string("123")] + )? + .value as! VariableObjectValue)["key"]? + .value as! String, + "value" + ) + XCTAssertEqual( + sdk.getVariableObject( + featureKey: "test", + variableKey: "flatConfig", + context: ["userId": .string("123")] + ), + ["key": "value"] + ) + + XCTAssertEqual( + sdk.getVariable( + featureKey: "test", + variableKey: "nestedConfig", + context: ["userId": .string("123")] + )? + .value as! String, + "{\"key\": {\"nested\": \"value\"}}" + ) + XCTAssertEqual( + sdk.getVariableJSON( + featureKey: "test", + variableKey: "nestedConfig", + context: ["userId": .string("123")] + ), + ["key": ["nested": "value"]] + ) + + // non existing + XCTAssertNil(sdk.getVariable(featureKey: "test", variableKey: "nonExisting")) + XCTAssertNil(sdk.getVariable(featureKey: "nonExistingFeature", variableKey: "nonExisting")) + + // disabled + XCTAssertNil( + sdk.getVariable( + featureKey: "test", + variableKey: "color", + context: ["userId": .string("user-gb")] + ) + ) + } + func testShouldGetVariablesWithoutAnyVariations() { // GIVEN