From eadb805bd02fb0026f5ad5791e41123b0382bfe8 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Tue, 17 Mar 2026 09:35:10 +0600 Subject: [PATCH 1/5] [FSSDK-12337] Add Feature Rollout support Add Feature Rollout support to the Swift SDK. Feature Rollouts are a new experiment rule type that combines Targeted Delivery simplicity with A/B test measurement capabilities. - Add optional `type` field (ExperimentType enum) to the Experiment model with valid values: ab, mab, cmab, td, fr - Add config parsing logic to inject the "everyone else" rollout variation into feature rollout experiments (type == .featureRollout) - Add traffic allocation entry (endOfRange=10000) for the injected variation - Add `getEveryoneElseVariation` helper to extract the last rollout rule's first variation - Rebuild experiment lookup maps after injection so decisions use updated data - Add 10 unit tests covering injection, edge cases, and backward compatibility --- Sources/Data Model/Experiment.swift | 23 +- Sources/Data Model/ProjectConfig.swift | 62 +++- .../FeatureRolloutTests.swift | 284 ++++++++++++++++++ 3 files changed, 364 insertions(+), 5 deletions(-) create mode 100644 Tests/OptimizelyTests-DataModel/FeatureRolloutTests.swift diff --git a/Sources/Data Model/Experiment.swift b/Sources/Data Model/Experiment.swift index bfe8418a..fbfbdc5e 100644 --- a/Sources/Data Model/Experiment.swift +++ b/Sources/Data Model/Experiment.swift @@ -17,6 +17,15 @@ import Foundation struct Experiment: Codable, ExperimentCore { + /// Valid experiment type values from the datafile. + enum ExperimentType: String, Codable { + case ab = "ab" + case mab = "mab" + case cmab = "cmab" + case targetedDelivery = "td" + case featureRollout = "fr" + } + enum Status: String, Codable { case running = "Running" case launched = "Launched" @@ -24,7 +33,7 @@ struct Experiment: Codable, ExperimentCore { case notStarted = "Not started" case archived = "Archived" } - + var id: String var key: String var status: Status @@ -36,9 +45,10 @@ struct Experiment: Codable, ExperimentCore { // datafile spec defines this as [String: Any]. Supposed to be [ExperimentKey: VariationKey] var forcedVariations: [String: String] var cmab: Cmab? - + var type: ExperimentType? + enum CodingKeys: String, CodingKey { - case id, key, status, layerId, variations, trafficAllocation, audienceIds, audienceConditions, forcedVariations, cmab + case id, key, status, layerId, variations, trafficAllocation, audienceIds, audienceConditions, forcedVariations, cmab, type } // MARK: - OptimizelyConfig @@ -59,7 +69,8 @@ extension Experiment: Equatable { lhs.audienceIds == rhs.audienceIds && lhs.audienceConditions == rhs.audienceConditions && lhs.forcedVariations == rhs.forcedVariations && - lhs.cmab == rhs.cmab + lhs.cmab == rhs.cmab && + lhs.type == rhs.type } } @@ -74,4 +85,8 @@ extension Experiment { var isCmab: Bool { return cmab != nil } + + var isFeatureRollout: Bool { + return type == .featureRollout + } } diff --git a/Sources/Data Model/ProjectConfig.swift b/Sources/Data Model/ProjectConfig.swift index be2c71d6..b728b4cf 100644 --- a/Sources/Data Model/ProjectConfig.swift +++ b/Sources/Data Model/ProjectConfig.swift @@ -135,7 +135,24 @@ class ProjectConfig { project.rollouts.forEach { map[$0.id] = $0 } return map }() - + + // Feature Rollout injection: for each feature flag, inject the "everyone else" + // variation into any experiment with type == .featureRollout + injectFeatureRolloutVariations() + + // Rebuild experiment maps after injection so lookup maps contain injected variations + self.experimentKeyMap = { + var map = [String: Experiment]() + allExperiments.forEach { map[$0.key] = $0 } + return map + }() + + self.experimentIdMap = { + var map = [String: Experiment]() + allExperiments.forEach { map[$0.id] = $0 } + return map + }() + // all variations for each flag // - datafile does not contain a separate entity for this. // - we collect variations used in each rule (experiment rules and delivery rules) @@ -179,6 +196,49 @@ class ProjectConfig { } +// MARK: - Feature Rollout Injection + +extension ProjectConfig { + /// Injects the "everyone else" variation from a flag's rollout into any + /// experiment with type == .featureRollout. After injection the existing + /// decision logic evaluates feature rollouts without modification. + func injectFeatureRolloutVariations() { + for flag in project.featureFlags { + guard let everyoneElseVariation = getEveryoneElseVariation(for: flag) else { + continue + } + + for experimentId in flag.experimentIds { + guard let index = allExperiments.firstIndex(where: { $0.id == experimentId }) else { + continue + } + + guard allExperiments[index].isFeatureRollout else { + continue + } + + allExperiments[index].variations.append(everyoneElseVariation) + allExperiments[index].trafficAllocation.append( + TrafficAllocation(entityId: everyoneElseVariation.id, endOfRange: 10000) + ) + } + } + } + + /// Returns the first variation of the last experiment (the "everyone else" + /// rule) in the rollout associated with the given feature flag. Returns nil + /// if the rollout cannot be resolved or has no variations. + func getEveryoneElseVariation(for flag: FeatureFlag) -> Variation? { + guard !flag.rolloutId.isEmpty, + let rollout = rolloutIdMap[flag.rolloutId], + let everyoneElseRule = rollout.experiments.last, + let variation = everyoneElseRule.variations.first else { + return nil + } + return variation + } +} + // MARK: - Persistent Data extension ProjectConfig { diff --git a/Tests/OptimizelyTests-DataModel/FeatureRolloutTests.swift b/Tests/OptimizelyTests-DataModel/FeatureRolloutTests.swift new file mode 100644 index 00000000..dd4502a7 --- /dev/null +++ b/Tests/OptimizelyTests-DataModel/FeatureRolloutTests.swift @@ -0,0 +1,284 @@ +// +// Copyright 2024, 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 FeatureRolloutTests: XCTestCase { + + // MARK: - Helpers + + /// Creates an experiment dictionary with the given id, key, and optional type. + private func makeExperiment(id: String, key: String, type: String? = nil, + variations: [[String: Any]]? = nil, + trafficAllocation: [[String: Any]]? = nil) -> [String: Any] { + var data: [String: Any] = [ + "id": id, + "key": key, + "status": "Running", + "layerId": "layer_\(id)", + "variations": variations ?? [["id": "var_\(id)", "key": "var_key_\(id)", "featureEnabled": true, "variables": []]], + "trafficAllocation": trafficAllocation ?? [["entityId": "var_\(id)", "endOfRange": 5000]], + "audienceIds": [], + "forcedVariations": [:] + ] + if let type = type { + data["type"] = type + } + return data + } + + /// Creates a rollout dictionary with the given id and experiments. + private func makeRollout(id: String, experiments: [[String: Any]]) -> [String: Any] { + return ["id": id, "experiments": experiments] + } + + /// Creates a feature flag dictionary. + private func makeFeatureFlag(id: String, key: String, experimentIds: [String], + rolloutId: String) -> [String: Any] { + return [ + "id": id, + "key": key, + "experimentIds": experimentIds, + "rolloutId": rolloutId, + "variables": [] + ] + } + + /// Creates a minimal project dictionary and returns a ProjectConfig. + private func makeProjectConfig(experiments: [[String: Any]], + featureFlags: [[String: Any]], + rollouts: [[String: Any]]) throws -> ProjectConfig { + let projectData: [String: Any] = [ + "version": "4", + "projectId": "test_project", + "experiments": experiments, + "audiences": [], + "groups": [], + "attributes": [], + "accountId": "123456", + "events": [], + "revision": "1", + "anonymizeIP": true, + "rollouts": rollouts, + "featureFlags": featureFlags, + "botFiltering": false, + "sendFlagDecisions": true + ] + let data = try JSONSerialization.data(withJSONObject: projectData) + return try ProjectConfig(datafile: data) + } + + // MARK: - Test 1: Backward compatibility + + func testExperimentWithoutTypeFieldHasNilType() { + // Old datafiles do not have a "type" field on experiments. + let data = makeExperiment(id: "exp_1", key: "exp_key_1") + let model: Experiment = try! OTUtils.model(from: data) + + XCTAssertNil(model.type, "Experiments without a type field should have type == nil") + } + + // MARK: - Test 2: Core injection + + func testFeatureRolloutExperimentGetsEveryoneElseVariationInjected() throws { + // A feature rollout experiment (type="fr") should get the everyone-else + // variation appended, along with a traffic allocation entry at endOfRange 10000. + let frExperiment = makeExperiment(id: "fr_exp", key: "fr_exp_key", type: "fr") + + let everyoneElseVariation: [String: Any] = [ + "id": "ee_var_id", "key": "ee_var_key", "featureEnabled": false, "variables": [] + ] + let everyoneElseRule = makeExperiment(id: "ee_rule", key: "ee_rule_key", + variations: [everyoneElseVariation]) + let rollout = makeRollout(id: "rollout_1", experiments: [everyoneElseRule]) + + let flag = makeFeatureFlag(id: "flag_1", key: "flag_key_1", + experimentIds: ["fr_exp"], rolloutId: "rollout_1") + + let config = try makeProjectConfig(experiments: [frExperiment], + featureFlags: [flag], + rollouts: [rollout]) + + let experiment = config.getExperiment(key: "fr_exp_key")! + + // The original variation + injected everyone-else variation + XCTAssertEqual(experiment.variations.count, 2, + "Feature rollout experiment should have 2 variations after injection") + XCTAssertEqual(experiment.variations.last?.id, "ee_var_id", + "Last variation should be the everyone-else variation") + + // Traffic allocation should include the injected entry + let lastAllocation = experiment.trafficAllocation.last! + XCTAssertEqual(lastAllocation.entityId, "ee_var_id") + XCTAssertEqual(lastAllocation.endOfRange, 10000) + } + + // MARK: - Test 3: Variation maps updated + + func testFlagVariationsMapContainsInjectedVariation() throws { + // The flagVariationsMap (used by decisions) must include the injected variation. + let frExperiment = makeExperiment(id: "fr_exp", key: "fr_exp_key", type: "fr") + + let everyoneElseVariation: [String: Any] = [ + "id": "ee_var_id", "key": "ee_var_key", "featureEnabled": false, "variables": [] + ] + let everyoneElseRule = makeExperiment(id: "ee_rule", key: "ee_rule_key", + variations: [everyoneElseVariation]) + let rollout = makeRollout(id: "rollout_1", experiments: [everyoneElseRule]) + + let flag = makeFeatureFlag(id: "flag_1", key: "flag_key_1", + experimentIds: ["fr_exp"], rolloutId: "rollout_1") + + let config = try makeProjectConfig(experiments: [frExperiment], + featureFlags: [flag], + rollouts: [rollout]) + + let flagVariations = config.flagVariationsMap["flag_key_1"]! + let hasInjectedVariation = flagVariations.contains { $0.id == "ee_var_id" } + XCTAssertTrue(hasInjectedVariation, + "flagVariationsMap must contain the injected everyone-else variation") + + // experimentKeyMap and experimentIdMap should also reflect the injection + let expByKey = config.getExperiment(key: "fr_exp_key")! + let expById = config.getExperiment(id: "fr_exp")! + XCTAssertEqual(expByKey.variations.count, 2) + XCTAssertEqual(expById.variations.count, 2) + } + + // MARK: - Test 4: Non-rollout experiments unchanged + + func testNonFeatureRolloutExperimentsAreNotModified() throws { + // Experiments with type "ab", "mab", "cmab", "td", or nil should not + // be modified by the injection logic. + let abExperiment = makeExperiment(id: "ab_exp", key: "ab_key", type: "ab") + let mabExperiment = makeExperiment(id: "mab_exp", key: "mab_key", type: "mab") + let tdExperiment = makeExperiment(id: "td_exp", key: "td_key", type: "td") + let noTypeExperiment = makeExperiment(id: "no_type_exp", key: "no_type_key") + + let everyoneElseVariation: [String: Any] = [ + "id": "ee_var_id", "key": "ee_var_key", "featureEnabled": false, "variables": [] + ] + let everyoneElseRule = makeExperiment(id: "ee_rule", key: "ee_rule_key", + variations: [everyoneElseVariation]) + let rollout = makeRollout(id: "rollout_1", experiments: [everyoneElseRule]) + + let flag = makeFeatureFlag(id: "flag_1", key: "flag_key_1", + experimentIds: ["ab_exp", "mab_exp", "td_exp", "no_type_exp"], + rolloutId: "rollout_1") + + let config = try makeProjectConfig( + experiments: [abExperiment, mabExperiment, tdExperiment, noTypeExperiment], + featureFlags: [flag], + rollouts: [rollout]) + + // Each experiment should still have exactly 1 variation (no injection) + XCTAssertEqual(config.getExperiment(key: "ab_key")!.variations.count, 1) + XCTAssertEqual(config.getExperiment(key: "mab_key")!.variations.count, 1) + XCTAssertEqual(config.getExperiment(key: "td_key")!.variations.count, 1) + XCTAssertEqual(config.getExperiment(key: "no_type_key")!.variations.count, 1) + } + + // MARK: - Test 5: No rollout edge case + + func testFeatureRolloutWithEmptyRolloutIdDoesNotCrash() throws { + // If the flag has an empty rolloutId, injection should be silently skipped. + let frExperiment = makeExperiment(id: "fr_exp", key: "fr_exp_key", type: "fr") + + let flag = makeFeatureFlag(id: "flag_1", key: "flag_key_1", + experimentIds: ["fr_exp"], rolloutId: "") + + let config = try makeProjectConfig(experiments: [frExperiment], + featureFlags: [flag], + rollouts: []) + + let experiment = config.getExperiment(key: "fr_exp_key")! + XCTAssertEqual(experiment.variations.count, 1, + "Experiment should keep original variations when rollout cannot be resolved") + } + + func testFeatureRolloutWithEmptyRolloutExperimentsDoesNotCrash() throws { + // If the rollout has no experiments, injection should be silently skipped. + let frExperiment = makeExperiment(id: "fr_exp", key: "fr_exp_key", type: "fr") + + let rollout = makeRollout(id: "rollout_1", experiments: []) + + let flag = makeFeatureFlag(id: "flag_1", key: "flag_key_1", + experimentIds: ["fr_exp"], rolloutId: "rollout_1") + + let config = try makeProjectConfig(experiments: [frExperiment], + featureFlags: [flag], + rollouts: [rollout]) + + let experiment = config.getExperiment(key: "fr_exp_key")! + XCTAssertEqual(experiment.variations.count, 1, + "Experiment should keep original variations when rollout has no experiments") + } + + func testFeatureRolloutWithNoVariationsInRolloutRuleDoesNotCrash() throws { + // If the everyone-else rule has no variations, injection should be silently skipped. + let frExperiment = makeExperiment(id: "fr_exp", key: "fr_exp_key", type: "fr") + + let emptyRule = makeExperiment(id: "ee_rule", key: "ee_rule_key", variations: []) + let rollout = makeRollout(id: "rollout_1", experiments: [emptyRule]) + + let flag = makeFeatureFlag(id: "flag_1", key: "flag_key_1", + experimentIds: ["fr_exp"], rolloutId: "rollout_1") + + let config = try makeProjectConfig(experiments: [frExperiment], + featureFlags: [flag], + rollouts: [rollout]) + + let experiment = config.getExperiment(key: "fr_exp_key")! + XCTAssertEqual(experiment.variations.count, 1, + "Experiment should keep original variations when everyone-else rule has no variations") + } + + // MARK: - Test 6: Type field parsed correctly + + func testExperimentTypeFieldIsParsedCorrectly() { + let types: [(String, Experiment.ExperimentType)] = [ + ("ab", .ab), + ("mab", .mab), + ("cmab", .cmab), + ("td", .targetedDelivery), + ("fr", .featureRollout) + ] + + for (rawValue, expectedType) in types { + var data = makeExperiment(id: "exp_\(rawValue)", key: "exp_key_\(rawValue)") + data["type"] = rawValue + let model: Experiment = try! OTUtils.model(from: data) + XCTAssertEqual(model.type, expectedType, + "Experiment type '\(rawValue)' should be parsed as \(expectedType)") + } + } + + func testExperimentIsFeatureRolloutProperty() { + var frData = makeExperiment(id: "fr_1", key: "fr_key_1") + frData["type"] = "fr" + let frModel: Experiment = try! OTUtils.model(from: frData) + XCTAssertTrue(frModel.isFeatureRollout) + + var abData = makeExperiment(id: "ab_1", key: "ab_key_1") + abData["type"] = "ab" + let abModel: Experiment = try! OTUtils.model(from: abData) + XCTAssertFalse(abModel.isFeatureRollout) + + let noTypeData = makeExperiment(id: "none_1", key: "none_key_1") + let noTypeModel: Experiment = try! OTUtils.model(from: noTypeData) + XCTAssertFalse(noTypeModel.isFeatureRollout) + } +} From 3b1ab2592515ab4105018bb37994d5a1dfda5a17 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Mon, 30 Mar 2026 18:50:48 +0600 Subject: [PATCH 2/5] Remove redaundant rebuild lookup table --- Sources/Data Model/ProjectConfig.swift | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/Sources/Data Model/ProjectConfig.swift b/Sources/Data Model/ProjectConfig.swift index b728b4cf..0deae449 100644 --- a/Sources/Data Model/ProjectConfig.swift +++ b/Sources/Data Model/ProjectConfig.swift @@ -71,6 +71,10 @@ class ProjectConfig { self.allExperiments = project.experiments + project.groups.map { $0.experiments }.flatMap { $0 } + // Feature Rollout injection: for each feature flag, inject the "everyone else" + // variation into any experiment with type == .featureRollout + injectFeatureRolloutVariations() + holdoutConfig.allHoldouts = project.holdouts self.experimentKeyMap = { @@ -136,23 +140,6 @@ class ProjectConfig { return map }() - // Feature Rollout injection: for each feature flag, inject the "everyone else" - // variation into any experiment with type == .featureRollout - injectFeatureRolloutVariations() - - // Rebuild experiment maps after injection so lookup maps contain injected variations - self.experimentKeyMap = { - var map = [String: Experiment]() - allExperiments.forEach { map[$0.key] = $0 } - return map - }() - - self.experimentIdMap = { - var map = [String: Experiment]() - allExperiments.forEach { map[$0.id] = $0 } - return map - }() - // all variations for each flag // - datafile does not contain a separate entity for this. // - we collect variations used in each rule (experiment rules and delivery rules) From 0bf7c7bca41aae2be92f8f9317e567029121160d Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Mon, 30 Mar 2026 21:49:22 +0600 Subject: [PATCH 3/5] fix rollout id map lookup logic --- Sources/Data Model/ProjectConfig.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/Data Model/ProjectConfig.swift b/Sources/Data Model/ProjectConfig.swift index 0deae449..e879cc7d 100644 --- a/Sources/Data Model/ProjectConfig.swift +++ b/Sources/Data Model/ProjectConfig.swift @@ -71,6 +71,12 @@ class ProjectConfig { self.allExperiments = project.experiments + project.groups.map { $0.experiments }.flatMap { $0 } + self.rolloutIdMap = { + var map = [String: Rollout]() + project.rollouts.forEach { map[$0.id] = $0 } + return map + }() + // Feature Rollout injection: for each feature flag, inject the "everyone else" // variation into any experiment with type == .featureRollout injectFeatureRolloutVariations() @@ -134,12 +140,6 @@ class ProjectConfig { return project.featureFlags.map { $0.key } }() - self.rolloutIdMap = { - var map = [String: Rollout]() - project.rollouts.forEach { map[$0.id] = $0 } - return map - }() - // all variations for each flag // - datafile does not contain a separate entity for this. // - we collect variations used in each rule (experiment rules and delivery rules) From 9af9675f005bdaca70001e860b43132099806579 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Fri, 3 Apr 2026 21:16:20 +0600 Subject: [PATCH 4/5] chore: trigger CI Co-Authored-By: Claude Opus 4.6 (1M context) From 69d4a96dcf209e1a4aa2c2ba387e6cf1cd5f7cb3 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Fri, 3 Apr 2026 22:14:19 +0600 Subject: [PATCH 5/5] [FSSDK-12337] Handle unknown experiment types gracefully in datafile parsing Unknown experiment type values (e.g., "new_unknown_type") no longer crash datafile parsing. They are silently dropped to nil, aligning with other SDKs for forward compatibility. Co-Authored-By: Claude Opus 4.6 --- OptimizelySwiftSDK.xcodeproj/project.pbxproj | 6 ++++++ Sources/Data Model/Experiment.swift | 16 ++++++++++++++++ .../FeatureRolloutTests.swift | 10 ++++++++++ 3 files changed, 32 insertions(+) diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index 825bb60e..ae6f1c25 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2053,6 +2053,8 @@ 98261A472EDDC35900F7230A /* OptimizelyClientTests_Cmab_Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98261A462EDDC35900F7230A /* OptimizelyClientTests_Cmab_Config.swift */; }; 982C071F2D8C82AE0068B1FF /* HoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */; }; 982C07202D8C82AE0068B1FF /* HoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */; }; + 983F81842F801E7500CDBC8D /* FeatureRolloutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983F81832F801E7500CDBC8D /* FeatureRolloutTests.swift */; }; + 983F81852F801E7500CDBC8D /* FeatureRolloutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983F81832F801E7500CDBC8D /* FeatureRolloutTests.swift */; }; 9841590F2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */; }; 984159102E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */; }; 984159122E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */; }; @@ -2620,6 +2622,7 @@ 98261A172ED89A8500F7230A /* CmabConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabConfig.swift; sourceTree = ""; }; 98261A462EDDC35900F7230A /* OptimizelyClientTests_Cmab_Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_Cmab_Config.swift; sourceTree = ""; }; 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutTests.swift; sourceTree = ""; }; + 983F81832F801E7500CDBC8D /* FeatureRolloutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureRolloutTests.swift; sourceTree = ""; }; 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Async.swift; sourceTree = ""; }; 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_CMAB.swift; sourceTree = ""; }; 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketTests_BucketToEntity.swift; sourceTree = ""; }; @@ -3218,6 +3221,7 @@ 6E75199E22C5211100B2B157 /* FeatureVariableTests.swift */, 6E75199F22C5211100B2B157 /* AttributeTests.swift */, 6E7519A022C5211100B2B157 /* VariableTests.swift */, + 983F81832F801E7500CDBC8D /* FeatureRolloutTests.swift */, 6E7519A122C5211100B2B157 /* FeatureFlagTests.swift */, 6E7519A222C5211100B2B157 /* AudienceTests.swift */, 84640880281320F000CCF97D /* IntegrationTests.swift */, @@ -5340,6 +5344,7 @@ 6E7518CE22C520D400B2B157 /* Audience.swift in Sources */, 6E75189222C520D400B2B157 /* Project.swift in Sources */, 6E7516F822C520D400B2B157 /* OptimizelyError.swift in Sources */, + 983F81842F801E7500CDBC8D /* FeatureRolloutTests.swift in Sources */, 980A40742F112EFF00F25D38 /* RetryStrategy.swift in Sources */, 84B4D75E27E2A7550078CDA4 /* OptimizelySegmentOption.swift in Sources */, 848617E82863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, @@ -5561,6 +5566,7 @@ 6E75193522C520D500B2B157 /* OPTDataStore.swift in Sources */, 6EC6DD4824ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */, 6E75182122C520D400B2B157 /* BatchEventBuilder.swift in Sources */, + 983F81852F801E7500CDBC8D /* FeatureRolloutTests.swift in Sources */, 6E86CEA924FDC847005DAFED /* OptimizelyUserContext+ObjC.swift in Sources */, 6E9B118322C5488100C22D81 /* UserAttributeTests_Evaluate.swift in Sources */, 98F28A1D2E01940500A86546 /* Cmab.swift in Sources */, diff --git a/Sources/Data Model/Experiment.swift b/Sources/Data Model/Experiment.swift index fbfbdc5e..6397e6a1 100644 --- a/Sources/Data Model/Experiment.swift +++ b/Sources/Data Model/Experiment.swift @@ -51,6 +51,22 @@ struct Experiment: Codable, ExperimentCore { case id, key, status, layerId, variations, trafficAllocation, audienceIds, audienceConditions, forcedVariations, cmab, type } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + key = try container.decode(String.self, forKey: .key) + status = try container.decode(Status.self, forKey: .status) + layerId = try container.decode(String.self, forKey: .layerId) + variations = try container.decode([Variation].self, forKey: .variations) + trafficAllocation = try container.decode([TrafficAllocation].self, forKey: .trafficAllocation) + audienceIds = try container.decode([String].self, forKey: .audienceIds) + audienceConditions = try container.decodeIfPresent(ConditionHolder.self, forKey: .audienceConditions) + forcedVariations = try container.decode([String: String].self, forKey: .forcedVariations) + cmab = try container.decodeIfPresent(Cmab.self, forKey: .cmab) + // Gracefully handle unknown experiment types by dropping to nil + type = try? container.decodeIfPresent(ExperimentType.self, forKey: .type) + } + // MARK: - OptimizelyConfig var variationsMap: [String: OptimizelyVariation] = [:] diff --git a/Tests/OptimizelyTests-DataModel/FeatureRolloutTests.swift b/Tests/OptimizelyTests-DataModel/FeatureRolloutTests.swift index dd4502a7..9c987ab8 100644 --- a/Tests/OptimizelyTests-DataModel/FeatureRolloutTests.swift +++ b/Tests/OptimizelyTests-DataModel/FeatureRolloutTests.swift @@ -266,6 +266,16 @@ class FeatureRolloutTests: XCTestCase { } } + func testUnknownExperimentTypeDecodesAsNil() { + var data = makeExperiment(id: "exp_unknown", key: "exp_key_unknown") + data["type"] = "new_unknown_type" + let model: Experiment = try! OTUtils.model(from: data) + + XCTAssertNil(model.type, "Unknown type should be gracefully dropped to nil") + XCTAssertFalse(model.isFeatureRollout, + "Unknown type should not be treated as feature rollout") + } + func testExperimentIsFeatureRolloutProperty() { var frData = makeExperiment(id: "fr_1", key: "fr_key_1") frData["type"] = "fr"