diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index 9770d72b..e9c921b8 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2034,6 +2034,24 @@ 984FE51E2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; 984FE5202CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 98AC97E22DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E32DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E42DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E52DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E62DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E72DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E82DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97E92DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97EA2DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97EB2DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97EC2DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97ED2DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97EE2DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97EF2DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97F02DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97F12DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; + 98AC97F32DAE9685001405DD /* HoldoutConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */; }; + 98AC97F42DAE9685001405DD /* HoldoutConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */; }; BD1C3E8524E4399C0084B4DA /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B97DD93249D327F003DE606 /* SemanticVersion.swift */; }; BD64853C2491474500F30986 /* Optimizely.h in Headers */ = {isa = PBXBuildFile; fileRef = 6E75167A22C520D400B2B157 /* Optimizely.h */; settings = {ATTRIBUTES = (Public, ); }; }; BD64853E2491474500F30986 /* Audience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75169822C520D400B2B157 /* Audience.swift */; }; @@ -2477,6 +2495,8 @@ 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutTests.swift; sourceTree = ""; }; 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileTracker.swift; sourceTree = ""; }; 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutConfig.swift; sourceTree = ""; }; + 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutConfigTests.swift; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyJSON.swift; sourceTree = ""; }; C78CAF652446DB91009FE876 /* OptimizelyClientTests_OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_OptimizelyJSON.swift; sourceTree = ""; }; @@ -2857,6 +2877,7 @@ 6E75169022C520D400B2B157 /* Variation.swift */, 6E75169122C520D400B2B157 /* TrafficAllocation.swift */, 6E75169222C520D400B2B157 /* Project.swift */, + 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */, 6E75169322C520D400B2B157 /* Experiment.swift */, 980CC9072D833F2800E07D24 /* ExperimentCore.swift */, 980CC8F62D833F0D00E07D24 /* Holdout.swift */, @@ -3048,6 +3069,7 @@ 6E7519A622C5211100B2B157 /* AttributeValueTests_Evaluate.swift */, 6E7519A722C5211100B2B157 /* RolloutTests.swift */, 6E7519A822C5211100B2B157 /* ProjectConfigTests.swift */, + 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */, 6E7519A922C5211100B2B157 /* UserAttributeTests.swift */, 6E7519AA22C5211100B2B157 /* GroupTests.swift */, 6E7519AB22C5211100B2B157 /* VariationTests.swift */, @@ -4181,6 +4203,7 @@ 845945C3287758A100D13E11 /* OdpConfig.swift in Sources */, 6E14CD832423F9A100010234 /* DataStoreQueueStackImpl.swift in Sources */, 848617F12863E21200B7F41B /* OdpEventApiManager.swift in Sources */, + 98AC97E52DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E14CD812423F9A100010234 /* DataStoreUserDefaults.swift in Sources */, 6E14CD802423F9A100010234 /* DataStoreMemory.swift in Sources */, 6E14CDA02423F9C300010234 /* OptimizelyClient+Extension.swift in Sources */, @@ -4369,6 +4392,7 @@ 6E424CB926324B1D0081004A /* Constants.swift in Sources */, 6E424CBA26324B1D0081004A /* Notifications.swift in Sources */, 6E424CBB26324B1D0081004A /* MurmurHash3.swift in Sources */, + 98AC97EC2DAE4579001405DD /* HoldoutConfig.swift in Sources */, 8464087628130D3200CCF97D /* Integration.swift in Sources */, 848617CE2863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, 848617F02863E21200B7F41B /* OdpEventApiManager.swift in Sources */, @@ -4410,6 +4434,7 @@ 845945BD2877589E00D13E11 /* OdpConfig.swift in Sources */, 984FE51C2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75171322C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, + 98AC97EF2DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E75191922C520D500B2B157 /* OPTNotificationCenter.swift in Sources */, 6E7518A122C520D400B2B157 /* FeatureFlag.swift in Sources */, 6E994B3525A3E6EA00999262 /* DecisionResponse.swift in Sources */, @@ -4494,6 +4519,7 @@ 845945C7287758A300D13E11 /* OdpConfig.swift in Sources */, 6E75175622C520D400B2B157 /* LogMessage.swift in Sources */, 848617F62863E21200B7F41B /* OdpEventApiManager.swift in Sources */, + 98AC97EB2DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E75193822C520D500B2B157 /* OPTDataStore.swift in Sources */, 6E75191422C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 6E75172622C520D400B2B157 /* OptimizelyResult.swift in Sources */, @@ -4596,6 +4622,7 @@ 6EF8DE2024BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 6E7517D822C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E75177622C520D400B2B157 /* SDKVersion.swift in Sources */, + 98AC97E32DAE4579001405DD /* HoldoutConfig.swift in Sources */, 84518B1F287665020023F104 /* OptimizelyClientTests_ODP.swift in Sources */, 6E7516FE22C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */, 6E75173A22C520D400B2B157 /* MurmurHash3.swift in Sources */, @@ -4720,6 +4747,7 @@ 6EC6DD4A24ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */, 6E75170122C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */, 8464087B28130D3200CCF97D /* Integration.swift in Sources */, + 98AC97EA2DAE4579001405DD /* HoldoutConfig.swift in Sources */, 84E2E96C28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, 6EF8DE3A24BF7D69008B9488 /* DecisionReasons.swift in Sources */, 6E7516B922C520D400B2B157 /* DefaultUserProfileService.swift in Sources */, @@ -4866,6 +4894,7 @@ 6E7517C522C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */, 6E75190922C520D500B2B157 /* Attribute.swift in Sources */, 6E75177B22C520D400B2B157 /* SDKVersion.swift in Sources */, + 98AC97F12DAE4579001405DD /* HoldoutConfig.swift in Sources */, 84E7ABC827D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E7E9B562523F8C6009E4426 /* OptimizelyUserContextTests_Decide_Legacy.swift in Sources */, 6EC6DD3C24ABF6990017D296 /* OptimizelyClient+Decide.swift in Sources */, @@ -5035,6 +5064,7 @@ 84E7ABC927D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E9B119122C5488300C22D81 /* EventForDispatchTests.swift in Sources */, 6E7517EA22C520D400B2B157 /* DefaultDecisionService.swift in Sources */, + 98AC97F02DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E75171C22C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E7516B022C520D400B2B157 /* DefaultLogger.swift in Sources */, 0B97DD9C249D3735003DE606 /* SemanticVersion.swift in Sources */, @@ -5059,6 +5089,7 @@ 6E8A3D522637408500DAEA13 /* MockDatafileHandler.swift in Sources */, 6E75180E22C520D400B2B157 /* DataStoreFile.swift in Sources */, 6E9B11B822C5489600C22D81 /* OTUtils.swift in Sources */, + 98AC97F42DAE9685001405DD /* HoldoutConfigTests.swift in Sources */, 6E9B119022C5488300C22D81 /* AttributeValueTests.swift in Sources */, 6E994B4025A3E6EA00999262 /* DecisionResponse.swift in Sources */, 84E2E9802855875E001114AB /* OdpEventManager.swift in Sources */, @@ -5159,6 +5190,7 @@ 6E7516B522C520D400B2B157 /* DefaultUserProfileService.swift in Sources */, 6E7516A922C520D400B2B157 /* DefaultLogger.swift in Sources */, 6E7517D722C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, + 98AC97E72DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E75181F22C520D400B2B157 /* BatchEventBuilder.swift in Sources */, 84F6BADD27FD011B004BE62A /* OptimizelyUserContextTests_ODP_Decide.swift in Sources */, 84E2E9472852A378001114AB /* VuidManager.swift in Sources */, @@ -5309,6 +5341,7 @@ 84E7ABC427D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E9B117B22C5488100C22D81 /* EventForDispatchTests.swift in Sources */, 6E7517E522C520D400B2B157 /* DefaultDecisionService.swift in Sources */, + 98AC97E92DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E75171722C520D400B2B157 /* OptimizelyClient+ObjC.swift in Sources */, 6E7516AB22C520D400B2B157 /* DefaultLogger.swift in Sources */, 0B97DD9B249D3733003DE606 /* SemanticVersion.swift in Sources */, @@ -5333,6 +5366,7 @@ 6E8A3D4D2637408500DAEA13 /* MockDatafileHandler.swift in Sources */, 6E75180922C520D400B2B157 /* DataStoreFile.swift in Sources */, 6E9B11AE22C5489300C22D81 /* OTUtils.swift in Sources */, + 98AC97F32DAE9685001405DD /* HoldoutConfigTests.swift in Sources */, 6E9B117A22C5488100C22D81 /* AttributeValueTests.swift in Sources */, 6E994B3B25A3E6EA00999262 /* DecisionResponse.swift in Sources */, 84E2E97B2855875E001114AB /* OdpEventManager.swift in Sources */, @@ -5433,6 +5467,7 @@ 84E7ABC527D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E7518BE22C520D400B2B157 /* Variable.swift in Sources */, 6E7518CA22C520D400B2B157 /* Audience.swift in Sources */, + 98AC97E62DAE4579001405DD /* HoldoutConfig.swift in Sources */, 848617E42863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, 6E75187622C520D400B2B157 /* Variation.swift in Sources */, 6E7517F222C520D400B2B157 /* DataStoreMemory.swift in Sources */, @@ -5537,6 +5572,7 @@ 84E7ABCA27D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E7518C322C520D400B2B157 /* Variable.swift in Sources */, 6E7518CF22C520D400B2B157 /* Audience.swift in Sources */, + 98AC97E42DAE4579001405DD /* HoldoutConfig.swift in Sources */, 848617E92863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, 6E75187B22C520D400B2B157 /* Variation.swift in Sources */, 6E7517F722C520D400B2B157 /* DataStoreMemory.swift in Sources */, @@ -5592,6 +5628,7 @@ 845945BC2877589D00D13E11 /* OdpConfig.swift in Sources */, 984FE5192CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, 6E75184022C520D400B2B157 /* Event.swift in Sources */, + 98AC97EE2DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E7516E222C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E7517D422C520D400B2B157 /* DefaultNotificationCenter.swift in Sources */, 6E994B3425A3E6EA00999262 /* DecisionResponse.swift in Sources */, @@ -5676,6 +5713,7 @@ 845945C02877589F00D13E11 /* OdpConfig.swift in Sources */, 6E75175022C520D400B2B157 /* LogMessage.swift in Sources */, 848617EE2863E21200B7F41B /* OdpEventApiManager.swift in Sources */, + 98AC97E82DAE4579001405DD /* HoldoutConfig.swift in Sources */, 6E75193222C520D500B2B157 /* OPTDataStore.swift in Sources */, 6E75190E22C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 6E75172022C520D400B2B157 /* OptimizelyResult.swift in Sources */, @@ -5841,6 +5879,7 @@ 75C71A3125E454460084187E /* Group.swift in Sources */, 75C71A3225E454460084187E /* Variable.swift in Sources */, 848617DD2863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, + 98AC97E22DAE4579001405DD /* HoldoutConfig.swift in Sources */, 75C71A3325E454460084187E /* Attribute.swift in Sources */, 75C71A3425E454460084187E /* BackgroundingCallbacks.swift in Sources */, 75C71A3525E454460084187E /* OPTNotificationCenter.swift in Sources */, @@ -5890,6 +5929,7 @@ 845945BE2877589E00D13E11 /* OdpConfig.swift in Sources */, 984FE51A2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */, BD6485462491474500F30986 /* Event.swift in Sources */, + 98AC97ED2DAE4579001405DD /* HoldoutConfig.swift in Sources */, BD6485472491474500F30986 /* OPTEventDispatcher.swift in Sources */, BD6485482491474500F30986 /* DefaultNotificationCenter.swift in Sources */, 6E994B3625A3E6EA00999262 /* DecisionResponse.swift in Sources */, diff --git a/Sources/Data Model/FeatureFlag.swift b/Sources/Data Model/FeatureFlag.swift index f0650561..e402825a 100644 --- a/Sources/Data Model/FeatureFlag.swift +++ b/Sources/Data Model/FeatureFlag.swift @@ -35,6 +35,8 @@ struct FeatureFlag: Codable, Equatable, OptimizelyFeature { case variables } +// var holdoutIds: [String] = [] + // MARK: - OptimizelyConfig var experimentsMap: [String: OptimizelyExperiment] = [:] diff --git a/Sources/Data Model/Holdout.swift b/Sources/Data Model/Holdout.swift index 16a24ba0..8a230bc9 100644 --- a/Sources/Data Model/Holdout.swift +++ b/Sources/Data Model/Holdout.swift @@ -39,11 +39,10 @@ struct Holdout: Codable, ExperimentCore { case id, key, status, layerId, variations, trafficAllocation, audienceIds, audienceConditions, includedFlags, excludedFlags } - var variationsMap: [String : OptimizelyVariation] = [:] + var variationsMap: [String: OptimizelyVariation] = [:] // replace with serialized string representation with audience names when ProjectConfig is ready var audiences: String = "" - init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -76,7 +75,6 @@ extension Holdout: Equatable { } } - extension Holdout { var isActivated: Bool { return status == .running diff --git a/Sources/Data Model/HoldoutConfig.swift b/Sources/Data Model/HoldoutConfig.swift new file mode 100644 index 00000000..dae915a4 --- /dev/null +++ b/Sources/Data Model/HoldoutConfig.swift @@ -0,0 +1,118 @@ +// +// Copyright 2025, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct HoldoutConfig { + var allHoldouts: [Holdout] { + didSet { + updateHoldoutMapping() + } + } + private(set) var global: [Holdout] = [] + private(set) var holdoutIdMap: [String: Holdout] = [:] + private(set) var flagHoldoutsMap: [String: [Holdout]] = [:] + private(set) var includedHoldouts: [String: [Holdout]] = [:] + private(set) var excludedHoldouts: [String: [Holdout]] = [:] + + init(allholdouts: [Holdout] = []) { + self.allHoldouts = allholdouts + updateHoldoutMapping() + } + + /// Updates internal mappings of holdouts including the id map, global list, and per-flag inclusion/exclusion maps. + mutating func updateHoldoutMapping() { + holdoutIdMap = { + var map = [String: Holdout]() + allHoldouts.forEach { map[$0.id] = $0 } + return map + }() + + flagHoldoutsMap = [:] + global = [] + includedHoldouts = [:] + excludedHoldouts = [:] + + for holdout in allHoldouts { + switch (holdout.includedFlags.isEmpty, holdout.excludedFlags.isEmpty) { + case (true, true): + global.append(holdout) + + case (false, _): + holdout.includedFlags.forEach { flagId in + if var existing = includedHoldouts[flagId] { + existing.append(holdout) + includedHoldouts[flagId] = existing + } else { + includedHoldouts[flagId] = [holdout] + } + } + + case (true, false): + global.append(holdout) + + holdout.excludedFlags.forEach { flagId in + if var existing = excludedHoldouts[flagId] { + existing.append(holdout) + excludedHoldouts[flagId] = existing + } else { + excludedHoldouts[flagId] = [holdout] + } + } + } + } + } + + /// Returns the applicable holdouts for the given flag ID by combining global holdouts (excluding any specified) and included holdouts, in that order. + /// Caches the result for future calls. + /// - Parameter id: The flag identifier. + /// - Returns: An array of `Holdout` objects relevant to the given flag. + mutating func getHoldoutForFlag(id: String) -> [Holdout] { + guard !allHoldouts.isEmpty else { return [] } + + // Check cache and return persistent holdouts + if let holdouts = flagHoldoutsMap[id] { + return holdouts + } + + // Prioritize global holdouts first + var activeHoldouts: [Holdout] = [] + + let excluded = excludedHoldouts[id] ?? [] + + if !excluded.isEmpty { + activeHoldouts = global.filter { holdout in + return !excluded.contains(holdout) + } + } else { + activeHoldouts = global + } + + let includedHoldouts = includedHoldouts[id] ?? [] + + activeHoldouts += includedHoldouts + + flagHoldoutsMap[id] = activeHoldouts + + return flagHoldoutsMap[id] ?? [] + } + + /// Get a Holdout object for an Id. + func getHoldout(id: String) -> Holdout? { + return holdoutIdMap[id] + } +} + diff --git a/Sources/Data Model/Project.swift b/Sources/Data Model/Project.swift index c85ad6a2..3a518c25 100644 --- a/Sources/Data Model/Project.swift +++ b/Sources/Data Model/Project.swift @@ -46,7 +46,8 @@ struct Project: Codable, Equatable { var sendFlagDecisions: Bool? var sdkKey: String? var environmentKey: String? - + // Holdouts + var holdouts: [Holdout] let logger = OPTLoggerFactory.getLogger() // Required since logger is not decodable @@ -56,12 +57,42 @@ struct Project: Codable, Equatable { // V3 case anonymizeIP // V4 - case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey + case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey, holdouts + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // V2 + version = try container.decode(String.self, forKey: .version) + projectId = try container.decode(String.self, forKey: .projectId) + experiments = try container.decode([Experiment].self, forKey: .experiments) + audiences = try container.decode([Audience].self, forKey: .audiences) + groups = try container.decode([Group].self, forKey: .groups) + attributes = try container.decode([Attribute].self, forKey: .attributes) + accountId = try container.decode(String.self, forKey: .accountId) + events = try container.decode([Event].self, forKey: .events) + revision = try container.decode(String.self, forKey: .revision) + + // V3 + anonymizeIP = try container.decode(Bool.self, forKey: .anonymizeIP) + + // V4 + rollouts = try container.decode([Rollout].self, forKey: .rollouts) + integrations = try container.decodeIfPresent([Integration].self, forKey: .integrations) + typedAudiences = try container.decodeIfPresent([Audience].self, forKey: .typedAudiences) + featureFlags = try container.decode([FeatureFlag].self, forKey: .featureFlags) + botFiltering = try container.decodeIfPresent(Bool.self, forKey: .botFiltering) + sendFlagDecisions = try container.decodeIfPresent(Bool.self, forKey: .sendFlagDecisions) + sdkKey = try container.decodeIfPresent(String.self, forKey: .sdkKey) + environmentKey = try container.decodeIfPresent(String.self, forKey: .environmentKey) + // Holdouts - defaults to empty array if key is not present + holdouts = try container.decodeIfPresent([Holdout].self, forKey: .holdouts) ?? [] } // Required since logger is not equatable static func == (lhs: Project, rhs: Project) -> Bool { - return lhs.version == rhs.version && lhs.projectId == rhs.projectId && lhs.experiments == rhs.experiments && + return lhs.version == rhs.version && lhs.projectId == rhs.projectId && lhs.experiments == rhs.experiments && lhs.holdouts == rhs.holdouts && lhs.audiences == rhs.audiences && lhs.groups == rhs.groups && lhs.attributes == rhs.attributes && lhs.accountId == rhs.accountId && lhs.events == rhs.events && lhs.revision == rhs.revision && lhs.anonymizeIP == rhs.anonymizeIP && lhs.rollouts == rhs.rollouts && diff --git a/Sources/Data Model/ProjectConfig.swift b/Sources/Data Model/ProjectConfig.swift index c1faa357..28474a77 100644 --- a/Sources/Data Model/ProjectConfig.swift +++ b/Sources/Data Model/ProjectConfig.swift @@ -17,12 +17,12 @@ import Foundation class ProjectConfig { - var project: Project! { didSet { updateProjectDependentProps() } } + let logger = OPTLoggerFactory.getLogger() // local runtime forcedVariations [UserId: [ExperimentId: VariationId]] @@ -40,6 +40,7 @@ class ProjectConfig { var allExperiments = [Experiment]() var flagVariationsMap = [String: [Variation]]() var allSegments = [String]() + var holdoutConfig = HoldoutConfig() // MARK: - Init @@ -66,8 +67,11 @@ class ProjectConfig { init() {} func updateProjectDependentProps() { + self.allExperiments = project.experiments + project.groups.map { $0.experiments }.flatMap { $0 } + holdoutConfig.allHoldouts = project.holdouts + self.experimentKeyMap = { var map = [String: Experiment]() allExperiments.forEach { exp in @@ -155,6 +159,10 @@ class ProjectConfig { } + func getHoldoutForFlag(id: String) -> [Holdout] { + return holdoutConfig.getHoldoutForFlag(id: id) + } + func getAllRulesForFlag(_ flag: FeatureFlag) -> [Experiment] { var rules = flag.experimentIds.compactMap { experimentIdMap[$0] } let rollout = self.rolloutIdMap[flag.rolloutId] @@ -270,6 +278,13 @@ extension ProjectConfig { return rolloutIdMap[id] } + /** + * Get a Holdout object for an Id. + */ + func getHoldout(id: String) -> Holdout? { + return holdoutConfig.getHoldout(id: id) + } + /** * Gets an event for a corresponding event key */ diff --git a/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift new file mode 100644 index 00000000..b4c7f9ed --- /dev/null +++ b/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift @@ -0,0 +1,155 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +class HoldoutConfigTests: XCTestCase { + func testEmptyHoldouts_shouldHaveEmptyMaps() { + let config = HoldoutConfig(allholdouts: []) + + XCTAssertTrue(config.holdoutIdMap.isEmpty) + XCTAssertTrue(config.global.isEmpty) + XCTAssertTrue(config.includedHoldouts.isEmpty) + XCTAssertTrue(config.excludedHoldouts.isEmpty) + } + + func testHoldoutMap() { + let holdout0: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + let holdout1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedFlags) + let holdout2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithExcludedFlags) + + let allHoldouts = [holdout0, holdout1, holdout2] + let holdoutConfig = HoldoutConfig(allholdouts: allHoldouts) + + XCTAssertEqual(holdoutConfig.holdoutIdMap["11111"]?.includedFlags, []) + XCTAssertEqual(holdoutConfig.holdoutIdMap["11111"]?.excludedFlags, []) + + XCTAssertEqual(holdoutConfig.holdoutIdMap["55555"]?.includedFlags, ["4444", "5555"]) + XCTAssertEqual(holdoutConfig.holdoutIdMap["55555"]?.excludedFlags, []) + + XCTAssertEqual(holdoutConfig.holdoutIdMap["3333"]?.includedFlags, []) + XCTAssertEqual(holdoutConfig.holdoutIdMap["3333"]?.excludedFlags, ["8888", "9999"]) + + XCTAssertEqual(holdoutConfig.global, [holdout0, holdout2]) + + XCTAssertEqual(holdoutConfig.includedHoldouts["4444"], [holdout1]) + XCTAssertEqual(holdoutConfig.excludedHoldouts["8888"], [holdout2]) + + } + + func testGetHoldoutById() { + var holdout0: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + holdout0.id = "00000" + var holdout1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedFlags) + holdout1.id = "11111" + var holdout2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithExcludedFlags) + holdout2.id = "22222" + + let allHoldouts = [holdout0, holdout1, holdout2] + let holdoutConfig = HoldoutConfig(allholdouts: allHoldouts) + + XCTAssertEqual(holdoutConfig.getHoldout(id: "00000"), holdout0) + XCTAssertEqual(holdoutConfig.getHoldout(id: "11111"), holdout1) + XCTAssertEqual(holdoutConfig.getHoldout(id: "22222"), holdout2) + + } + + func testHoldoutOrdering_globalThenIncluded() { + var global1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + global1.id = "g1" + + var global2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + global2.id = "g2" + + var included: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + included.id = "i1" + included.includedFlags = ["f"] + + var config = HoldoutConfig(allholdouts: [included, global1, global2]) + + let result = config.getHoldoutForFlag(id: "f").map(\.id) + XCTAssertEqual(result, ["g1", "g2", "i1"]) + } + + func testHoldoutOrdering_with_Both_IncludedAndExcludedFlags() { + let flag1 = "11111" + let flag2 = "22222" + let flag3 = "33333" + let flag4 = "44444" + + var inc: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + inc.id = "i1" + inc.includedFlags = [flag1] + + var exc: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + exc.id = "e1" + exc.excludedFlags = [flag2] + + var gh1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + gh1.id = "gh1" + gh1.includedFlags = [] + gh1.excludedFlags = [] + + var gh2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + gh2.id = "gh2" + gh2.includedFlags = [] + gh2.excludedFlags = [] + + + let allHoldouts = [inc, exc, gh1, gh2] + var holdoutConfig = HoldoutConfig(allholdouts: allHoldouts) + + XCTAssertEqual(holdoutConfig.getHoldoutForFlag(id: flag1), [exc, gh1, gh2, inc]) + XCTAssertEqual(holdoutConfig.getHoldoutForFlag(id: flag2), [gh1, gh2]) + + XCTAssertEqual(holdoutConfig.getHoldoutForFlag(id: flag3), [exc, gh1, gh2]) + XCTAssertEqual(holdoutConfig.getHoldoutForFlag(id: flag4), [exc, gh1, gh2]) + + } + + func testExcludedHoldout_shouldNotAppearInGlobalForFlag() { + var global: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + global.id = "global" + + var excluded: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + excluded.id = "excluded" + excluded.excludedFlags = ["f"] + + var config = HoldoutConfig(allholdouts: [global, excluded]) + + let result = config.getHoldoutForFlag(id: "f").map(\.id) + XCTAssertEqual(result, ["global"]) // excluded should not appear + } + + func testGetHoldoutForFlag_shouldUseCacheOnSecondCall() { + var ho1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + ho1.id = "h1" + ho1.includedFlags = ["f1"] + + var config = HoldoutConfig(allholdouts: [ho1]) + + // Initially no cache + XCTAssertEqual(config.flagHoldoutsMap.count, 0) + + let _ = config.getHoldoutForFlag(id: "f1") + XCTAssertEqual(config.flagHoldoutsMap.count, 1) + + let cache_v = config.getHoldoutForFlag(id: "f1") + XCTAssertEqual(config.flagHoldoutsMap.count, 1) + XCTAssertEqual(cache_v, config.flagHoldoutsMap["f1"]) + } + +} diff --git a/Tests/OptimizelyTests-DataModel/HoldoutTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift index b065165b..8e5f6e3a 100644 --- a/Tests/OptimizelyTests-DataModel/HoldoutTests.swift +++ b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift @@ -20,15 +20,49 @@ import XCTest // MARK: - Sample Data class HoldoutTests: XCTestCase { + static var variationData: [String: Any] = ["id": "553339214", + "key": "house", + "featureEnabled": true, + "variables": [["id": "553339214", "value": "100"]]] + + static var trafficAllocationData: [String: Any] = ["entityId": "553339214", "endOfRange": 5000] + + static var conditionHolderData: [Any] = ["or", ["name": "geo", + "type": "custom_attribute", + "match": "exact", + "value": 30]] + /// Global holoout without included and excluded key static var sampleData: [String: Any] = ["id": "11111", "key": "background", "status": "Running", "layerId": "22222", - "variations": [VariationTests.sampleData], - "trafficAllocation": [TrafficAllocationTests.sampleData], + "variations": [HoldoutTests.variationData], + "trafficAllocation": [HoldoutTests.trafficAllocationData], + "audienceIds": ["33333"], + "audienceConditions": HoldoutTests.conditionHolderData] + + static var sampleDataWithIncludedFlags: [String: Any] = ["id": "55555", + "key": "background", + "status": "Running", + "layerId": "22222", + "variations": [HoldoutTests.variationData], + "trafficAllocation": [HoldoutTests.trafficAllocationData], "audienceIds": ["33333"], - "audienceConditions": ConditionHolderTests.sampleData] + "audienceConditions": HoldoutTests.conditionHolderData, + "includedFlags": ["4444", "5555"]] + + static var sampleDataWithExcludedFlags: [String: Any] = ["id": "3333", + "key": "background", + "status": "Running", + "layerId": "22222", + "variations": [HoldoutTests.variationData], + "trafficAllocation": [HoldoutTests.trafficAllocationData], + "audienceIds": ["33333"], + "audienceConditions": HoldoutTests.conditionHolderData, + "excludedFlags": ["8888", "9999"]] + + } @@ -44,44 +78,43 @@ extension HoldoutTests { XCTAssert(model.key == "background") XCTAssert(model.status == .running) XCTAssert(model.layerId == "22222") - XCTAssert(model.variations == [try! OTUtils.model(from: VariationTests.sampleData)]) - XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: TrafficAllocationTests.sampleData)]) + XCTAssert(model.variations == [try! OTUtils.model(from: HoldoutTests.variationData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: HoldoutTests.trafficAllocationData)]) XCTAssert(model.audienceIds == ["33333"]) - XCTAssert(model.audienceConditions == (try! OTUtils.model(from: ConditionHolderTests.sampleData))) + XCTAssert(model.audienceConditions == (try! OTUtils.model(from: HoldoutTests.conditionHolderData))) } func testDecodeSuccessWithIncludedFlags() { - var data: [String: Any] = HoldoutTests.sampleData - data["includedFlags"] = ["4444", "5555"] + let data: [String: Any] = HoldoutTests.sampleDataWithIncludedFlags let model: Holdout = try! OTUtils.model(from: data) - XCTAssert(model.id == "11111") + XCTAssert(model.id == "55555") XCTAssert(model.key == "background") XCTAssert(model.status == .running) XCTAssert(model.layerId == "22222") - XCTAssert(model.variations == [try! OTUtils.model(from: VariationTests.sampleData)]) - XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: TrafficAllocationTests.sampleData)]) + XCTAssert(model.variations == [try! OTUtils.model(from: HoldoutTests.variationData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: HoldoutTests.trafficAllocationData)]) XCTAssert(model.audienceIds == ["33333"]) - XCTAssert(model.audienceConditions == (try! OTUtils.model(from: ConditionHolderTests.sampleData))) + XCTAssert(model.audienceConditions == (try! OTUtils.model(from: HoldoutTests.conditionHolderData))) XCTAssertEqual(model.includedFlags, ["4444", "5555"]) } func testDecodeSuccessWithExcludedFlags() { - var data: [String: Any] = HoldoutTests.sampleData - data["excludedFlags"] = ["4444", "5555"] + let data: [String: Any] = HoldoutTests.sampleDataWithExcludedFlags let model: Holdout = try! OTUtils.model(from: data) - XCTAssert(model.id == "11111") + XCTAssert(model.id == "3333") XCTAssert(model.key == "background") XCTAssert(model.status == .running) XCTAssert(model.layerId == "22222") - XCTAssert(model.variations == [try! OTUtils.model(from: VariationTests.sampleData)]) - XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: TrafficAllocationTests.sampleData)]) + XCTAssert(model.variations == [try! OTUtils.model(from: HoldoutTests.variationData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: HoldoutTests.trafficAllocationData)]) XCTAssert(model.audienceIds == ["33333"]) - XCTAssert(model.audienceConditions == (try! OTUtils.model(from: ConditionHolderTests.sampleData))) - XCTAssertEqual(model.excludedFlags, ["4444", "5555"]) + XCTAssert(model.audienceConditions == (try! OTUtils.model(from: HoldoutTests.conditionHolderData))) + XCTAssertEqual(model.includedFlags, []) + XCTAssertEqual(model.excludedFlags, ["8888", "9999"]) } @@ -95,8 +128,8 @@ extension HoldoutTests { XCTAssert(model.key == "background") XCTAssert(model.status == .running) XCTAssert(model.layerId == "22222") - XCTAssert(model.variations == [try! OTUtils.model(from: VariationTests.sampleData)]) - XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: TrafficAllocationTests.sampleData)]) + XCTAssert(model.variations == [try! OTUtils.model(from: HoldoutTests.variationData)]) + XCTAssert(model.trafficAllocation == [try! OTUtils.model(from: HoldoutTests.trafficAllocationData)]) XCTAssert(model.audienceIds == ["33333"]) } @@ -172,8 +205,8 @@ extension HoldoutTests { "key": "background", "status": "Running", "layerId": "22222", - "variations": [VariationTests.sampleData], - "trafficAllocation": [TrafficAllocationTests.sampleData], + "variations": [HoldoutTests.variationData], + "trafficAllocation": [HoldoutTests.trafficAllocationData], "audienceIds": [], "audienceConditions": [], "forcedVariations": ["12345": "1234567890"]] diff --git a/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift b/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift index aebde55f..9faa1046 100644 --- a/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift +++ b/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift @@ -80,6 +80,97 @@ class ProjectConfigTests: XCTestCase { XCTAssertEqual(featureMap["1004"], ["2002"]) } + func testHoldoutIdMapIsBuiltFromProject() { + var exp0 = ExperimentTests.sampleData + var exp1 = ExperimentTests.sampleData + var exp2 = ExperimentTests.sampleData + var exp3 = ExperimentTests.sampleData + var exp4 = ExperimentTests.sampleData + exp0["id"] = "1000" + exp1["id"] = "1001" + exp2["id"] = "1002" + exp3["id"] = "1003" + exp4["id"] = "1004" + + + var holdout0 = HoldoutTests.sampleData + var holdout1 = HoldoutTests.sampleData + var holdout2 = HoldoutTests.sampleData + var holdout3 = HoldoutTests.sampleData + var holdout4 = HoldoutTests.sampleData + + holdout0["id"] = "3000" // Global holdout (no included or excluded flags) + holdout1["id"] = "3001" // Global holdout (no included or excluded flags) + holdout2["id"] = "3002" // Global holdout (no included or excluded flags) + holdout3["id"] = "3003" // Included flagids ["2000", "2002"] + holdout4["id"] = "3004" // Excluded flagids ["2001"] + + holdout3["includedFlags"] = ["2000", "2002"] + holdout4["excludedFlags"] = ["2001"] + + var feature0 = FeatureFlagTests.sampleData + var feature1 = FeatureFlagTests.sampleData + var feature2 = FeatureFlagTests.sampleData + var feature3 = FeatureFlagTests.sampleData + + feature0["id"] = "2000" + feature0["key"] = "key_2000" + + feature1["id"] = "2001" + feature1["key"] = "key_2001" + + feature2["id"] = "2002" + feature2["key"] = "key_2002" + + feature3["id"] = "2003" + feature3["key"] = "key_2003" + + feature0["experimentIds"] = ["1000"] + feature1["experimentIds"] = ["1000", "1001", "1002"] + feature2["experimentIds"] = ["1000", "1003", "1004"] + feature3["experimentIds"] = ["1000", "1003", "1004"] + + var projectData = ProjectTests.sampleData + projectData["experiments"] = [exp0, exp1, exp2, exp3, exp4] + projectData["featureFlags"] = [feature0, feature1, feature2, feature3] + projectData["holdouts"] = [holdout0, holdout1, holdout2, holdout3, holdout4] + + // check experimentFeatureMap extracted properly + + let model: Project = try! OTUtils.model(from: projectData) + let projectConfig = ProjectConfig() + projectConfig.project = model + + let holdoutIdMap = projectConfig.holdoutConfig.holdoutIdMap + + XCTAssertEqual(holdoutIdMap["3000"]?.includedFlags, []) + XCTAssertEqual(holdoutIdMap["3000"]?.excludedFlags, []) + + XCTAssertEqual(holdoutIdMap["3001"]?.includedFlags, []) + XCTAssertEqual(holdoutIdMap["3001"]?.excludedFlags, []) + + XCTAssertEqual(holdoutIdMap["3002"]?.includedFlags, []) + XCTAssertEqual(holdoutIdMap["3002"]?.excludedFlags, []) + + XCTAssertEqual(holdoutIdMap["3003"]?.includedFlags, ["2000", "2002"]) + XCTAssertEqual(holdoutIdMap["3003"]?.excludedFlags, []) + + + XCTAssertEqual(holdoutIdMap["3004"]?.includedFlags, []) + XCTAssertEqual(holdoutIdMap["3004"]?.excludedFlags, ["2001"]) + + /// Test Global holdout + included + + XCTAssertEqual(projectConfig.holdoutConfig.getHoldoutForFlag(id: "2000").map { $0.id }, ["3000", "3001", "3002", "3004", "3003"]) + XCTAssertEqual(projectConfig.holdoutConfig.getHoldoutForFlag(id: "2002").map { $0.id }, ["3000", "3001", "3002", "3004","3003"]) + + /// Test Global holdout - excluded + XCTAssertEqual(projectConfig.holdoutConfig.getHoldoutForFlag(id: "2001").map { $0.id }, ["3000", "3001", "3002"]) + + /// Test Global holdout + others + XCTAssertEqual(projectConfig.holdoutConfig.getHoldoutForFlag(id: "2003").map { $0.id }, ["3000", "3001", "3002", "3004"]) + } + func testFlagVariations() { let datafile = OTUtils.loadJSONDatafile("decide_datafile")! let optimizely = OptimizelyClient(sdkKey: "12345", diff --git a/Tests/OptimizelyTests-DataModel/ProjectTests.swift b/Tests/OptimizelyTests-DataModel/ProjectTests.swift index dbf5ba4c..e0d10fb9 100644 --- a/Tests/OptimizelyTests-DataModel/ProjectTests.swift +++ b/Tests/OptimizelyTests-DataModel/ProjectTests.swift @@ -22,6 +22,7 @@ class ProjectTests: XCTestCase { static var sampleData: [String: Any] = ["version": "4", "projectId": "11111", "experiments": [ExperimentTests.sampleData], + "holdouts": [HoldoutTests.sampleData], "audiences": [AudienceTests.sampleData], "groups": [GroupTests.sampleData], "attributes": [AttributeTests.sampleData], @@ -49,6 +50,7 @@ extension ProjectTests { XCTAssert(model.version == "4") XCTAssert(model.projectId == "11111") XCTAssert(model.experiments == [try! OTUtils.model(from: ExperimentTests.sampleData)]) + XCTAssert(model.holdouts == [try! OTUtils.model(from: HoldoutTests.sampleData)]) XCTAssert(model.audiences == [try! OTUtils.model(from: AudienceTests.sampleData)]) XCTAssert(model.groups == [try! OTUtils.model(from: GroupTests.sampleData)]) XCTAssert(model.attributes == [try! OTUtils.model(from: AttributeTests.sampleData)]) @@ -210,6 +212,16 @@ extension ProjectTests { XCTAssertNil(model.sendFlagDecisions) } + func testDecodeSuccessWithMissingHoldouts() { + var data: [String: Any] = ProjectTests.sampleData + data["holdouts"] = nil + + let model: Project = try! OTUtils.model(from: data) + XCTAssertNotNil(model) + XCTAssertEqual(model.holdouts, []) + + } + } // MARK: - Encode