From eadb805bd02fb0026f5ad5791e41123b0382bfe8 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Tue, 17 Mar 2026 09:35:10 +0600 Subject: [PATCH] [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) + } +}