Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions Sources/Data Model/Holdout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ struct Holdout: Codable, ExperimentCore {
var audienceConditions: ConditionHolder?
var includedFlags: [String]
var excludedFlags: [String]

var experiments: [String]

enum CodingKeys: String, CodingKey {
case id, key, status, variations, trafficAllocation, audienceIds, audienceConditions, includedFlags, excludedFlags
case id, key, status, variations, trafficAllocation, audienceIds, audienceConditions, includedFlags, excludedFlags, experiments
}

var variationsMap: [String: OptimizelyVariation] = [:]
Expand All @@ -54,9 +55,10 @@ struct Holdout: Codable, ExperimentCore {
trafficAllocation = try container.decode([TrafficAllocation].self, forKey: .trafficAllocation)
audienceIds = try container.decode([String].self, forKey: .audienceIds)
audienceConditions = try container.decodeIfPresent(ConditionHolder.self, forKey: .audienceConditions)

includedFlags = try container.decodeIfPresent([String].self, forKey: .includedFlags) ?? []
excludedFlags = try container.decodeIfPresent([String].self, forKey: .excludedFlags) ?? []
experiments = try container.decodeIfPresent([String].self, forKey: .experiments) ?? []
}
}

Expand All @@ -70,12 +72,17 @@ extension Holdout: Equatable {
lhs.audienceIds == rhs.audienceIds &&
lhs.audienceConditions == rhs.audienceConditions &&
lhs.includedFlags == rhs.includedFlags &&
lhs.excludedFlags == rhs.excludedFlags
lhs.excludedFlags == rhs.excludedFlags &&
lhs.experiments == rhs.experiments
}
}

extension Holdout {
var isActivated: Bool {
return status == .running
}

var isLocal: Bool {
return !experiments.isEmpty
}
}
35 changes: 29 additions & 6 deletions Sources/Data Model/HoldoutConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ struct HoldoutConfig {
private(set) var flagHoldoutsMap: [String: [Holdout]] = [:]
private(set) var includedHoldouts: [String: [Holdout]] = [:]
private(set) var excludedHoldouts: [String: [Holdout]] = [:]
private(set) var experimentHoldoutsMap: [String: [Holdout]] = [:]

init(allholdouts: [Holdout] = []) {
self.allHoldouts = allholdouts
Expand All @@ -45,12 +46,27 @@ struct HoldoutConfig {
global = []
includedHoldouts = [:]
excludedHoldouts = [:]

experimentHoldoutsMap = [:]

for holdout in allHoldouts {
// Handle experiment-specific holdouts (local holdouts)
if !holdout.experiments.isEmpty {
for experimentId in holdout.experiments {
if var existing = experimentHoldoutsMap[experimentId] {
existing.append(holdout)
experimentHoldoutsMap[experimentId] = existing
} else {
experimentHoldoutsMap[experimentId] = [holdout]
}
}
continue // Skip flag-level logic for experiment-specific holdouts
}

// Handle flag-level holdouts (global holdouts)
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] {
Expand All @@ -60,10 +76,10 @@ struct HoldoutConfig {
includedHoldouts[flagId] = [holdout]
}
}

case (true, false):
global.append(holdout)

holdout.excludedFlags.forEach { flagId in
if var existing = excludedHoldouts[flagId] {
existing.append(holdout)
Expand All @@ -75,7 +91,7 @@ struct HoldoutConfig {
}
}
}

/// 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.
Expand Down Expand Up @@ -109,7 +125,14 @@ struct HoldoutConfig {

return flagHoldoutsMap[id] ?? []
}


/// Returns the holdouts applicable to the given experiment ID.
/// - Parameter experimentId: The experiment identifier.
/// - Returns: An array of `Holdout` objects targeting this experiment.
func getHoldoutsForExperiment(experimentId: String) -> [Holdout] {
return experimentHoldoutsMap[experimentId] ?? []
}

/// Get a Holdout object for an Id.
func getHoldout(id: String) -> Holdout? {
return holdoutIdMap[id]
Expand Down
155 changes: 152 additions & 3 deletions Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,27 @@ class HoldoutConfigTests: XCTestCase {

XCTAssertEqual(holdoutConfig.includedHoldouts["4444"], [holdout1])
XCTAssertEqual(holdoutConfig.excludedHoldouts["8888"], [holdout2])

}


func testExperimentHoldoutsMap() {
var holdout0: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithExperiments)
holdout0.id = "exp_holdout_1"

var holdout1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
holdout1.id = "global_holdout"

let allHoldouts = [holdout0, holdout1]
let holdoutConfig = HoldoutConfig(allholdouts: allHoldouts)

// Verify experimentHoldoutsMap is populated correctly
XCTAssertEqual(holdoutConfig.experimentHoldoutsMap["1681267"], [holdout0])
XCTAssertEqual(holdoutConfig.experimentHoldoutsMap["1681268"], [holdout0])

// Global holdout should not appear in experimentHoldoutsMap
XCTAssertNil(holdoutConfig.experimentHoldoutsMap[holdout1.id])
}

func testGetHoldoutById() {
var holdout0: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
holdout0.id = "00000"
Expand Down Expand Up @@ -151,5 +169,136 @@ class HoldoutConfigTests: XCTestCase {
XCTAssertEqual(config.flagHoldoutsMap.count, 1)
XCTAssertEqual(cache_v, config.flagHoldoutsMap["f1"])
}


func testGetHoldoutsForExperiment_singleHoldout() {
var holdout: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithExperiments)
holdout.id = "holdout_1"

let config = HoldoutConfig(allholdouts: [holdout])

// Verify getHoldoutsForExperiment returns correct holdout for both experiments
XCTAssertEqual(config.getHoldoutsForExperiment(experimentId: "1681267"), [holdout])
XCTAssertEqual(config.getHoldoutsForExperiment(experimentId: "1681268"), [holdout])
}

func testGetHoldoutsForExperiment_multipleHoldouts() {
// Create multiple holdouts targeting same experiment
var holdout1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
holdout1.id = "holdout_1"
holdout1.experiments = ["exp1"]

var holdout2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
holdout2.id = "holdout_2"
holdout2.experiments = ["exp1"]

var holdout3: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
holdout3.id = "holdout_3"
holdout3.experiments = ["exp1"]

let config = HoldoutConfig(allholdouts: [holdout1, holdout2, holdout3])

// Verify all are returned in correct order
let result = config.getHoldoutsForExperiment(experimentId: "exp1")
XCTAssertEqual(result.count, 3)
XCTAssertEqual(result[0].id, "holdout_1")
XCTAssertEqual(result[1].id, "holdout_2")
XCTAssertEqual(result[2].id, "holdout_3")
}

func testGetHoldoutsForExperiment_nonExistentExperiment() {
var holdout: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithExperiments)

let config = HoldoutConfig(allholdouts: [holdout])

// Verify returns empty array for non-existent experiment
XCTAssertEqual(config.getHoldoutsForExperiment(experimentId: "non_existent"), [])
}

func testExperimentMapping_oneHoldoutMultipleExperiments() {
var holdout: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
holdout.id = "holdout_1"
holdout.experiments = ["exp1", "exp2", "exp3"]

let config = HoldoutConfig(allholdouts: [holdout])

// Verify holdout appears in map for all three experiments
XCTAssertEqual(config.getHoldoutsForExperiment(experimentId: "exp1"), [holdout])
XCTAssertEqual(config.getHoldoutsForExperiment(experimentId: "exp2"), [holdout])
XCTAssertEqual(config.getHoldoutsForExperiment(experimentId: "exp3"), [holdout])
}

func testExperimentMapping_multipleHoldoutsOneExperiment() {
var holdout1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
holdout1.id = "holdout_1"
holdout1.experiments = ["exp_shared"]

var holdout2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
holdout2.id = "holdout_2"
holdout2.experiments = ["exp_shared"]

var holdout3: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
holdout3.id = "holdout_3"
holdout3.experiments = ["exp_shared"]

let config = HoldoutConfig(allholdouts: [holdout1, holdout2, holdout3])

// Verify all appear in the array for that experiment
let result = config.getHoldoutsForExperiment(experimentId: "exp_shared")
XCTAssertEqual(result.count, 3)
XCTAssertTrue(result.contains(holdout1))
XCTAssertTrue(result.contains(holdout2))
XCTAssertTrue(result.contains(holdout3))
}

func testUpdateHoldoutMapping_rebuildsExperimentMap() {
var holdout1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
holdout1.id = "holdout_1"
holdout1.experiments = ["exp1"]

var config = HoldoutConfig(allholdouts: [holdout1])

// Verify initial state
XCTAssertEqual(config.getHoldoutsForExperiment(experimentId: "exp1"), [holdout1])
XCTAssertEqual(config.getHoldoutsForExperiment(experimentId: "exp2"), [])

// Modify allHoldouts (triggers updateHoldoutMapping via didSet)
var holdout2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
holdout2.id = "holdout_2"
holdout2.experiments = ["exp2"]

config.allHoldouts = [holdout1, holdout2]

// Verify experimentHoldoutsMap is rebuilt correctly
XCTAssertEqual(config.getHoldoutsForExperiment(experimentId: "exp1"), [holdout1])
XCTAssertEqual(config.getHoldoutsForExperiment(experimentId: "exp2"), [holdout2])
}

func testLocalHoldouts_dontInterfereWithFlagMapping() {
// Create mix of flag-level and experiment-level holdouts
var flagHoldout: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
flagHoldout.id = "flag_holdout"
flagHoldout.includedFlags = ["flag1"]

var expHoldout: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
expHoldout.id = "exp_holdout"
expHoldout.experiments = ["exp1"]

var globalHoldout: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData)
globalHoldout.id = "global_holdout"

var config = HoldoutConfig(allholdouts: [flagHoldout, expHoldout, globalHoldout])

// Verify flag mapping still works correctly
let flagResult = config.getHoldoutForFlag(id: "flag1")
XCTAssertTrue(flagResult.contains(globalHoldout))
XCTAssertTrue(flagResult.contains(flagHoldout))
XCTAssertFalse(flagResult.contains(expHoldout)) // Experiment holdout should not appear in flag mapping

// Verify experiment mapping works independently
let expResult = config.getHoldoutsForExperiment(experimentId: "exp1")
XCTAssertEqual(expResult, [expHoldout])
XCTAssertFalse(expResult.contains(flagHoldout))
XCTAssertFalse(expResult.contains(globalHoldout))
}

}
Loading
Loading