From fe740749d011dee4895a633a1d8f21630e15cfbc Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Fri, 12 Jun 2026 13:17:10 -0700 Subject: [PATCH 1/5] [AI-FSSDK] [FSSDK-12760] add localHoldouts to datafile for backward compatibility --- Sources/Data Model/Holdout.swift | 4 + Sources/Data Model/HoldoutConfig.swift | 148 ++++++++++++---- Sources/Data Model/Project.swift | 14 +- Sources/Data Model/ProjectConfig.swift | 11 +- .../HoldoutConfigTests.swift | 159 ++++++++++++++++++ .../ProjectConfigTests.swift | 14 +- .../ProjectTests.swift | 32 +++- 7 files changed, 338 insertions(+), 44 deletions(-) diff --git a/Sources/Data Model/Holdout.swift b/Sources/Data Model/Holdout.swift index 62b701a7..b6da58cb 100644 --- a/Sources/Data Model/Holdout.swift +++ b/Sources/Data Model/Holdout.swift @@ -76,6 +76,10 @@ extension Holdout { return status == .running } + /// True when this holdout applies to every flag (global scope). + /// Scope is set by the datafile section (`holdouts` vs `localHoldouts`); + /// HoldoutConfig strips `includedRules` from `holdouts`-section entries so + /// this property stays consistent with section membership. var isGlobal: Bool { return includedRules == nil } diff --git a/Sources/Data Model/HoldoutConfig.swift b/Sources/Data Model/HoldoutConfig.swift index b97764c6..36dc2e30 100644 --- a/Sources/Data Model/HoldoutConfig.swift +++ b/Sources/Data Model/HoldoutConfig.swift @@ -1,5 +1,5 @@ // -// Copyright 2025, Optimizely, Inc. and contributors +// Copyright 2025-2026, 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. @@ -16,59 +16,143 @@ import Foundation +/// Holds parsed holdout entries and exposes per-rule and global lookups. +/// +/// FSSDK-12760: Two top-level datafile sections drive holdout scoping: +/// - `holdouts` -> ALL entries are global (applied to every flag). +/// Any `includedRules` field on these entries is +/// STRIPPED at parse time; section membership alone +/// determines scope. +/// - `localHoldouts` -> ALL entries are local (rule-scoped via +/// `includedRules`). Entries with no/empty +/// `includedRules` are invalid; the SDK logs an error +/// and excludes them (no fallback to global application). +/// +/// Backward compatibility: datafiles that emit only the `holdouts` section +/// continue to work — every entry is treated as global, matching pre- +/// localHoldouts behavior. The legacy `allHoldouts` setter is also preserved: +/// it partitions a flat list by `includedRules == nil` (global) vs non-nil +/// (local) and then applies the same section semantics as if the datafile +/// had been split, so existing test fixtures and code paths continue to work. struct HoldoutConfig { + private let logger = OPTLoggerFactory.getLogger() + + /// Combined flat view of all holdouts after applying section semantics. + /// Setting this property partitions entries the legacy way (nil + /// `includedRules` -> global section, non-nil -> local section) and + /// rebuilds all maps. Reads always reflect post-parsing state: + /// - global-section entries have `includedRules` stripped + /// - invalid local entries (missing/empty `includedRules`) are excluded var allHoldouts: [Holdout] { - didSet { - updateHoldoutMapping() - } + get { return _allHoldouts } + set { applyFlatList(newValue) } } + + private var _allHoldouts: [Holdout] = [] + private(set) var global: [Holdout] = [] private(set) var holdoutIdMap: [String: Holdout] = [:] private(set) var ruleHoldoutsMap: [String: [Holdout]] = [:] - - init(allholdouts: [Holdout] = []) { - self.allHoldouts = allholdouts - updateHoldoutMapping() + + // MARK: - Init + + init() {} + + /// Backward-compatible init that accepts a single flat list and partitions it + /// the legacy way: entries with `includedRules == nil` go to the global + /// section, entries with a non-nil list go to the local section. + init(allholdouts: [Holdout]) { + applyFlatList(allholdouts) + } + + /// Section-aware init. Mirrors the datafile layout: callers pass the + /// `holdouts` section and the `localHoldouts` section separately. + init(globalHoldouts: [Holdout], localHoldouts: [Holdout]) { + applySections(globalHoldouts: globalHoldouts, localHoldouts: localHoldouts) } - - /// Updates internal mappings of holdouts including the id map, global list, and per-rule maps. - mutating func updateHoldoutMapping() { - holdoutIdMap = { - var map = [String: Holdout]() - allHoldouts.forEach { map[$0.id] = $0 } - return map - }() - - global = [] - ruleHoldoutsMap = [:] - - for holdout in allHoldouts { - if holdout.isGlobal { - // includedRules == nil → global holdout - global.append(holdout) + + // MARK: - Section application + + /// Legacy path — partitions a flat list by `includedRules` and rebuilds maps. + private mutating func applyFlatList(_ holdouts: [Holdout]) { + var globals: [Holdout] = [] + var locals: [Holdout] = [] + for h in holdouts { + if h.includedRules == nil { + globals.append(h) } else { - // includedRules == [ruleId, ...] → local holdout - for ruleId in holdout.includedRules! { - ruleHoldoutsMap[ruleId, default: []].append(holdout) - } + locals.append(h) } } + applySections(globalHoldouts: globals, localHoldouts: locals) } - + + /// Section-based path — applies the canonical FSSDK-12760 semantics: + /// - Global section entries: `includedRules` is STRIPPED (set to nil). + /// Section membership alone determines global scope; any stray + /// `includedRules` field must not narrow the entry's application. + /// - Local section entries: must carry a non-nil, non-empty + /// `includedRules` list. Invalid entries are logged and excluded + /// from every map; they do NOT fall back to global application. + private mutating func applySections(globalHoldouts: [Holdout], localHoldouts: [Holdout]) { + var newAll: [Holdout] = [] + var newIdMap: [String: Holdout] = [:] + var newGlobal: [Holdout] = [] + var newRuleMap: [String: [Holdout]] = [:] + + // Global section: strip `includedRules` so the entity is unambiguously + // global (isGlobal == true), even if the datafile (or caller) included + // the field by mistake. + for var holdout in globalHoldouts { + holdout.includedRules = nil + newAll.append(holdout) + newIdMap[holdout.id] = holdout + newGlobal.append(holdout) + } + + // Local section: every entry must carry a non-empty `includedRules` + // list. Invalid entries are logged and excluded. + for holdout in localHoldouts { + guard let rules = holdout.includedRules, !rules.isEmpty else { + logger.e( + "Local holdout '\(holdout.key)' (id: \(holdout.id)) is missing or has empty 'includedRules'; skipping." + ) + continue + } + newAll.append(holdout) + newIdMap[holdout.id] = holdout + for ruleId in rules { + newRuleMap[ruleId, default: []].append(holdout) + } + } + + self._allHoldouts = newAll + self.holdoutIdMap = newIdMap + self.global = newGlobal + self.ruleHoldoutsMap = newRuleMap + } + + // MARK: - Lookups + /// Returns local holdouts targeting a specific rule. + /// Local holdouts come from the `localHoldouts` datafile section and are + /// scoped per-rule via their `includedRules` field. A rule ID not present + /// in any holdout's `includedRules` returns an empty list — silently skipped. /// - Parameter ruleId: The rule identifier. /// - Returns: An array of `Holdout` objects targeting the given rule. func getHoldoutsForRule(ruleId: String) -> [Holdout] { return ruleHoldoutsMap[ruleId] ?? [] } - /// Returns all global holdouts. + /// Returns all global holdouts (parsed from the top-level `holdouts` section). + /// Section membership in `holdouts` is the sole signal for global scope — + /// any `includedRules` field on these entries is ignored at parse time. /// - Returns: An array of global `Holdout` objects. func getGlobalHoldouts() -> [Holdout] { return global } - - /// Get a Holdout object for an Id. + + /// Get a Holdout object for an Id. Works for both global and local entries. func getHoldout(id: String) -> Holdout? { return holdoutIdMap[id] } diff --git a/Sources/Data Model/Project.swift b/Sources/Data Model/Project.swift index 95d58248..508512ef 100644 --- a/Sources/Data Model/Project.swift +++ b/Sources/Data Model/Project.swift @@ -53,7 +53,11 @@ struct Project: Codable, Equatable { var sdkKey: String? var environmentKey: String? // Holdouts + // `holdouts` carries ONLY global holdouts. `localHoldouts` (new top-level + // section, FSSDK-12760) carries ONLY rule-scoped local holdouts. Section + // membership is the sole signal for scope; older SDKs ignore the new key. var holdouts: [Holdout] + var localHoldouts: [Holdout] // Region var region: Region? let logger = OPTLoggerFactory.getLogger() @@ -65,7 +69,7 @@ struct Project: Codable, Equatable { // V3 case anonymizeIP // V4 - case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey, holdouts, region + case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey, holdouts, localHoldouts, region } init(from decoder: Decoder) throws { @@ -96,6 +100,9 @@ struct Project: Codable, Equatable { 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) ?? [] + // Local holdouts (FSSDK-12760) - new top-level section; defaults to empty when absent + // so old datafiles continue to parse unchanged. + localHoldouts = try container.decodeIfPresent([Holdout].self, forKey: .localHoldouts) ?? [] // Region - defaults to US if not present region = try container.decodeIfPresent(Region.self, forKey: .region) } @@ -103,12 +110,13 @@ struct Project: Codable, Equatable { // 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 && lhs.holdouts == rhs.holdouts && + lhs.localHoldouts == rhs.localHoldouts && 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 && lhs.integrations == rhs.integrations && lhs.typedAudiences == rhs.typedAudiences && - lhs.featureFlags == rhs.featureFlags && lhs.botFiltering == rhs.botFiltering && - lhs.sendFlagDecisions == rhs.sendFlagDecisions && lhs.sdkKey == rhs.sdkKey && + lhs.featureFlags == rhs.featureFlags && lhs.botFiltering == rhs.botFiltering && + lhs.sendFlagDecisions == rhs.sendFlagDecisions && lhs.sdkKey == rhs.sdkKey && lhs.environmentKey == rhs.environmentKey && lhs.region == rhs.region } } diff --git a/Sources/Data Model/ProjectConfig.swift b/Sources/Data Model/ProjectConfig.swift index 83c59375..8c875849 100644 --- a/Sources/Data Model/ProjectConfig.swift +++ b/Sources/Data Model/ProjectConfig.swift @@ -81,7 +81,16 @@ class ProjectConfig { // variation into any experiment with type == .featureRollout injectFeatureRolloutVariations() - holdoutConfig.allHoldouts = project.holdouts + // Holdout sections are partitioned by the datafile (FSSDK-12760): + // - `holdouts` -> global holdouts (applied to every flag) + // - `localHoldouts` -> rule-scoped local holdouts + // HoldoutConfig enforces the partition: `includedRules` on global-section + // entries is stripped, and local-section entries without `includedRules` + // are logged and excluded. + holdoutConfig = HoldoutConfig( + globalHoldouts: project.holdouts, + localHoldouts: project.localHoldouts + ) self.experimentKeyMap = { var map = [String: Experiment]() diff --git a/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift index acc9f804..9ca5523d 100644 --- a/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift +++ b/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift @@ -165,4 +165,163 @@ class HoldoutConfigTests: XCTestCase { XCTAssertEqual(config.global.count, 1) XCTAssertEqual(config.ruleHoldoutsMap["rule1"]?.count, 1) } + + // MARK: - FSSDK-12760: localHoldouts section semantics + + /// Section-aware init: entries in the global section are classified as + /// global regardless of any `includedRules` field on them. + func testSectionAwareInit_globalSectionEntriesAreGlobal() { + var globalWithStrayRules: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + globalWithStrayRules.id = "g_stray" + globalWithStrayRules.includedRules = ["rule_should_be_ignored"] + + let config = HoldoutConfig( + globalHoldouts: [globalWithStrayRules], + localHoldouts: [] + ) + + // Must be classified as global + XCTAssertEqual(config.global.count, 1) + XCTAssertEqual(config.global.first?.id, "g_stray") + // `includedRules` must be stripped — section membership is the sole signal + XCTAssertNil(config.global.first?.includedRules) + XCTAssertTrue(config.global.first!.isGlobal) + // The stray rule must NOT be registered in the rule map + XCTAssertTrue(config.getHoldoutsForRule(ruleId: "rule_should_be_ignored").isEmpty) + // Entity is still retrievable by id + XCTAssertNotNil(config.getHoldout(id: "g_stray")) + } + + /// Section-aware init: entries in the local section register under each + /// rule in their `includedRules` list and never appear in `global`. + func testSectionAwareInit_localSectionEntriesAreLocal() { + var local: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + local.id = "l1" + local.includedRules = ["rule_x", "rule_y"] + + let config = HoldoutConfig( + globalHoldouts: [], + localHoldouts: [local] + ) + + XCTAssertTrue(config.global.isEmpty) + XCTAssertEqual(config.getHoldoutsForRule(ruleId: "rule_x"), [local]) + XCTAssertEqual(config.getHoldoutsForRule(ruleId: "rule_y"), [local]) + XCTAssertTrue(config.getHoldoutsForRule(ruleId: "rule_z").isEmpty) + } + + /// Local-section entries with `includedRules == nil` are invalid per spec. + /// They must be excluded from every map and must NOT fall back to global. + func testLocalSection_missingIncludedRules_isExcluded() { + var invalid: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + invalid.id = "h_invalid" + invalid.includedRules = nil // missing — invalid for local section + + let config = HoldoutConfig( + globalHoldouts: [], + localHoldouts: [invalid] + ) + + // Not applied as global + XCTAssertTrue(config.global.isEmpty) + // Not applied as local for any rule + XCTAssertTrue(config.getHoldoutsForRule(ruleId: "any_rule").isEmpty) + // Not retrievable by id either + XCTAssertNil(config.getHoldout(id: "h_invalid")) + } + + /// Local-section entries with an empty `includedRules` list are invalid per + /// spec (they target no rules). They must be excluded entirely. + func testLocalSection_emptyIncludedRules_isExcluded() { + var invalid: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + invalid.id = "h_empty" + invalid.includedRules = [] + + let config = HoldoutConfig( + globalHoldouts: [], + localHoldouts: [invalid] + ) + + XCTAssertTrue(config.global.isEmpty) + XCTAssertTrue(config.getHoldoutsForRule(ruleId: "any_rule").isEmpty) + XCTAssertNil(config.getHoldout(id: "h_empty")) + } + + /// Both sections present: entries never cross over. Global stays global, + /// local stays local, even when both sections share a rule id space. + func testBothSections_partitionEnforced() { + var g1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + g1.id = "g1" + var g2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + g2.id = "g2" + + var l1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + l1.id = "l1" + l1.includedRules = ["rule_a"] + var l2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + l2.id = "l2" + l2.includedRules = ["rule_b"] + + let config = HoldoutConfig( + globalHoldouts: [g1, g2], + localHoldouts: [l1, l2] + ) + + // Global section + let globalIds = Set(config.getGlobalHoldouts().map { $0.id }) + XCTAssertEqual(globalIds, Set(["g1", "g2"])) + + // Local section — each rule resolves to its own holdout, never to a global one + XCTAssertEqual(config.getHoldoutsForRule(ruleId: "rule_a").map { $0.id }, ["l1"]) + XCTAssertEqual(config.getHoldoutsForRule(ruleId: "rule_b").map { $0.id }, ["l2"]) + + // Both sections are retrievable by id + XCTAssertNotNil(config.getHoldout(id: "g1")) + XCTAssertNotNil(config.getHoldout(id: "l1")) + } + + /// Backward compatibility: when the datafile has no `localHoldouts` section + /// (passed as an empty list), every entry in the `holdouts` section is + /// treated as global — exactly matching pre-FSSDK-12760 behavior. + func testBackwardCompat_noLocalHoldoutsSection() { + var g1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + g1.id = "g1" + + let config = HoldoutConfig( + globalHoldouts: [g1], + localHoldouts: [] + ) + + XCTAssertEqual(config.getGlobalHoldouts().count, 1) + XCTAssertEqual(config.getGlobalHoldouts().first?.id, "g1") + XCTAssertTrue(config.ruleHoldoutsMap.isEmpty) + } + + /// Mixed-validity local section: valid entries are kept, invalid entries + /// are excluded without affecting the valid ones. + func testLocalSection_invalidEntriesDoNotAffectValidOnes() { + var valid: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + valid.id = "valid" + valid.includedRules = ["rule_x"] + + var invalidNil: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) + invalidNil.id = "invalid_nil" + invalidNil.includedRules = nil + + var invalidEmpty: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) + invalidEmpty.id = "invalid_empty" + invalidEmpty.includedRules = [] + + let config = HoldoutConfig( + globalHoldouts: [], + localHoldouts: [valid, invalidNil, invalidEmpty] + ) + + // Only the valid local holdout is registered + XCTAssertEqual(config.getHoldoutsForRule(ruleId: "rule_x").map { $0.id }, ["valid"]) + XCTAssertNotNil(config.getHoldout(id: "valid")) + XCTAssertNil(config.getHoldout(id: "invalid_nil")) + XCTAssertNil(config.getHoldout(id: "invalid_empty")) + XCTAssertTrue(config.global.isEmpty) + } } diff --git a/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift b/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift index 9389e70a..978825c1 100644 --- a/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift +++ b/Tests/OptimizelyTests-DataModel/ProjectConfigTests.swift @@ -98,10 +98,12 @@ class ProjectConfigTests: XCTestCase { var holdout2 = HoldoutTests.sampleData var holdout3 = HoldoutTests.sampleData var holdout4 = HoldoutTests.sampleData - - holdout0["id"] = "3000" // Global holdout (includedRules == nil) - holdout1["id"] = "3001" // Global holdout (includedRules == nil) - holdout2["id"] = "3002" // Global holdout (includedRules == nil) + + // FSSDK-12760: scope is determined by datafile section, not by `includedRules`. + // Entries in `holdouts` are global; entries in `localHoldouts` are local. + holdout0["id"] = "3000" // Global holdout (in `holdouts` section) + holdout1["id"] = "3001" // Global holdout (in `holdouts` section) + holdout2["id"] = "3002" // Global holdout (in `holdouts` section) holdout3["id"] = "3003" // Local holdout targeting rules in feature 2000 and 2002 holdout4["id"] = "3004" // Local holdout targeting rules NOT in feature 2001 @@ -135,7 +137,9 @@ class ProjectConfigTests: XCTestCase { var projectData = ProjectTests.sampleData projectData["experiments"] = [exp0, exp1, exp2, exp3, exp4] projectData["featureFlags"] = [feature0, feature1, feature2, feature3] - projectData["holdouts"] = [holdout0, holdout1, holdout2, holdout3, holdout4] + // FSSDK-12760: globals go into `holdouts`, locals into `localHoldouts`. + projectData["holdouts"] = [holdout0, holdout1, holdout2] + projectData["localHoldouts"] = [holdout3, holdout4] // check experimentFeatureMap extracted properly diff --git a/Tests/OptimizelyTests-DataModel/ProjectTests.swift b/Tests/OptimizelyTests-DataModel/ProjectTests.swift index e0d10fb9..4a01e82a 100644 --- a/Tests/OptimizelyTests-DataModel/ProjectTests.swift +++ b/Tests/OptimizelyTests-DataModel/ProjectTests.swift @@ -215,13 +215,39 @@ extension ProjectTests { 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: - FSSDK-12760: top-level `localHoldouts` section + + /// Old datafiles without a `localHoldouts` key must continue to parse; + /// `localHoldouts` defaults to an empty array. + func testDecodeSuccessWithMissingLocalHoldouts() { + var data: [String: Any] = ProjectTests.sampleData + data["localHoldouts"] = nil + + let model: Project = try! OTUtils.model(from: data) + XCTAssertEqual(model.localHoldouts, []) + } + + /// New datafiles emit a `localHoldouts` array alongside `holdouts`. + /// Both sections must be decoded into their respective fields. + func testDecodeSuccessWithLocalHoldoutsSection() { + var data: [String: Any] = ProjectTests.sampleData + data["localHoldouts"] = [HoldoutTests.sampleDataWithIncludedRules] + + let model: Project = try! OTUtils.model(from: data) + XCTAssertEqual(model.localHoldouts.count, 1) + XCTAssertEqual(model.localHoldouts.first?.id, "55555") + XCTAssertEqual(model.localHoldouts.first?.includedRules, ["4444", "5555"]) + // Global section must not be affected + XCTAssertEqual(model.holdouts, [try! OTUtils.model(from: HoldoutTests.sampleData)]) + } + } // MARK: - Encode From 1abb59a52b541c4e88c254e72b57e7a8910fc4da Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Mon, 15 Jun 2026 15:25:30 -0700 Subject: [PATCH 2/5] [FSSDK-12760] Empty commit to re-trigger CI From 227b39ec3ab35dfb9bf6f7c4b22c5924099b3f19 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Tue, 16 Jun 2026 18:37:45 +0600 Subject: [PATCH 3/5] [FSSDK-12760] Clean up boilerplate comments and remove JIRA refs from HoldoutConfig - Trim verbose doc comments, keep only meaningful ones - Replace JIRA ticket references with generic descriptions - Remove applyFlatList, use applySections directly - Update test call sites to use section-aware constructors Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/Data Model/HoldoutConfig.swift | 75 +++---------------- Sources/Data Model/Project.swift | 5 +- .../BatchEventBuilderTests_Events.swift | 2 +- .../DecisionListenerTest_Holdouts.swift | 4 +- .../DecisionServiceTests_Holdouts.swift | 21 ------ .../DecisionServiceTests_LocalHoldouts.swift | 2 +- ...zelyUserContextTests_Decide_Holdouts.swift | 8 +- .../HoldoutConfigTests.swift | 14 ++-- 8 files changed, 25 insertions(+), 106 deletions(-) diff --git a/Sources/Data Model/HoldoutConfig.swift b/Sources/Data Model/HoldoutConfig.swift index 36dc2e30..b5b83951 100644 --- a/Sources/Data Model/HoldoutConfig.swift +++ b/Sources/Data Model/HoldoutConfig.swift @@ -18,34 +18,16 @@ import Foundation /// Holds parsed holdout entries and exposes per-rule and global lookups. /// -/// FSSDK-12760: Two top-level datafile sections drive holdout scoping: -/// - `holdouts` -> ALL entries are global (applied to every flag). -/// Any `includedRules` field on these entries is -/// STRIPPED at parse time; section membership alone -/// determines scope. -/// - `localHoldouts` -> ALL entries are local (rule-scoped via -/// `includedRules`). Entries with no/empty -/// `includedRules` are invalid; the SDK logs an error -/// and excludes them (no fallback to global application). -/// -/// Backward compatibility: datafiles that emit only the `holdouts` section -/// continue to work — every entry is treated as global, matching pre- -/// localHoldouts behavior. The legacy `allHoldouts` setter is also preserved: -/// it partitions a flat list by `includedRules == nil` (global) vs non-nil -/// (local) and then applies the same section semantics as if the datafile -/// had been split, so existing test fixtures and code paths continue to work. +/// Two top-level datafile sections drive holdout scoping: +/// - `holdouts` → global (applied to every flag); `includedRules` is stripped at parse time. +/// - `localHoldouts` → rule-scoped via `includedRules`; entries without rules are excluded. struct HoldoutConfig { private let logger = OPTLoggerFactory.getLogger() - /// Combined flat view of all holdouts after applying section semantics. - /// Setting this property partitions entries the legacy way (nil - /// `includedRules` -> global section, non-nil -> local section) and - /// rebuilds all maps. Reads always reflect post-parsing state: - /// - global-section entries have `includedRules` stripped - /// - invalid local entries (missing/empty `includedRules`) are excluded + /// Combined flat view of all holdouts. Setting this treats all entries as global. var allHoldouts: [Holdout] { get { return _allHoldouts } - set { applyFlatList(newValue) } + set { applySections(globalHoldouts: newValue, localHoldouts: []) } } private var _allHoldouts: [Holdout] = [] @@ -58,51 +40,23 @@ struct HoldoutConfig { init() {} - /// Backward-compatible init that accepts a single flat list and partitions it - /// the legacy way: entries with `includedRules == nil` go to the global - /// section, entries with a non-nil list go to the local section. init(allholdouts: [Holdout]) { - applyFlatList(allholdouts) + applySections(globalHoldouts: allholdouts, localHoldouts: []) } - /// Section-aware init. Mirrors the datafile layout: callers pass the - /// `holdouts` section and the `localHoldouts` section separately. init(globalHoldouts: [Holdout], localHoldouts: [Holdout]) { applySections(globalHoldouts: globalHoldouts, localHoldouts: localHoldouts) } // MARK: - Section application - /// Legacy path — partitions a flat list by `includedRules` and rebuilds maps. - private mutating func applyFlatList(_ holdouts: [Holdout]) { - var globals: [Holdout] = [] - var locals: [Holdout] = [] - for h in holdouts { - if h.includedRules == nil { - globals.append(h) - } else { - locals.append(h) - } - } - applySections(globalHoldouts: globals, localHoldouts: locals) - } - - /// Section-based path — applies the canonical FSSDK-12760 semantics: - /// - Global section entries: `includedRules` is STRIPPED (set to nil). - /// Section membership alone determines global scope; any stray - /// `includedRules` field must not narrow the entry's application. - /// - Local section entries: must carry a non-nil, non-empty - /// `includedRules` list. Invalid entries are logged and excluded - /// from every map; they do NOT fall back to global application. private mutating func applySections(globalHoldouts: [Holdout], localHoldouts: [Holdout]) { var newAll: [Holdout] = [] var newIdMap: [String: Holdout] = [:] var newGlobal: [Holdout] = [] var newRuleMap: [String: [Holdout]] = [:] - // Global section: strip `includedRules` so the entity is unambiguously - // global (isGlobal == true), even if the datafile (or caller) included - // the field by mistake. + // Strip `includedRules` so global entries are unambiguously global. for var holdout in globalHoldouts { holdout.includedRules = nil newAll.append(holdout) @@ -110,8 +64,7 @@ struct HoldoutConfig { newGlobal.append(holdout) } - // Local section: every entry must carry a non-empty `includedRules` - // list. Invalid entries are logged and excluded. + // Local entries must carry a non-empty `includedRules`; invalid ones are excluded. for holdout in localHoldouts { guard let rules = holdout.includedRules, !rules.isEmpty else { logger.e( @@ -135,24 +88,16 @@ struct HoldoutConfig { // MARK: - Lookups /// Returns local holdouts targeting a specific rule. - /// Local holdouts come from the `localHoldouts` datafile section and are - /// scoped per-rule via their `includedRules` field. A rule ID not present - /// in any holdout's `includedRules` returns an empty list — silently skipped. - /// - Parameter ruleId: The rule identifier. - /// - Returns: An array of `Holdout` objects targeting the given rule. func getHoldoutsForRule(ruleId: String) -> [Holdout] { return ruleHoldoutsMap[ruleId] ?? [] } - /// Returns all global holdouts (parsed from the top-level `holdouts` section). - /// Section membership in `holdouts` is the sole signal for global scope — - /// any `includedRules` field on these entries is ignored at parse time. - /// - Returns: An array of global `Holdout` objects. + /// Returns all global holdouts. func getGlobalHoldouts() -> [Holdout] { return global } - /// Get a Holdout object for an Id. Works for both global and local entries. + /// 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 508512ef..8c00728f 100644 --- a/Sources/Data Model/Project.swift +++ b/Sources/Data Model/Project.swift @@ -52,10 +52,7 @@ struct Project: Codable, Equatable { var sendFlagDecisions: Bool? var sdkKey: String? var environmentKey: String? - // Holdouts - // `holdouts` carries ONLY global holdouts. `localHoldouts` (new top-level - // section, FSSDK-12760) carries ONLY rule-scoped local holdouts. Section - // membership is the sole signal for scope; older SDKs ignore the new key. + // Holdouts: `holdouts` = global, `localHoldouts` = rule-scoped. var holdouts: [Holdout] var localHoldouts: [Holdout] // Region diff --git a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift index fdf1e871..7bc2c969 100644 --- a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift +++ b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift @@ -578,7 +578,7 @@ extension BatchEventBuilderTests_Events { var holdout: Holdout = try! OTUtils.model(from: sampleHoldout) holdout.includedRules = [] // Empty array = local holdout targeting no rules (excludes feature_1) optimizely.config?.project.holdouts = [holdout] - optimizely.config?.holdoutConfig.allHoldouts = [holdout] + optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let exp = expectation(description: "Wait for event to dispatch") diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift index 1f9bc14d..d8d3eab8 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift @@ -146,7 +146,7 @@ class DecisionListenerTests_Holdouts: BaseHoldoutTests { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [] // Empty array = local holdout targeting no rules (excludes feature_1) optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let exp = expectation(description: "x") let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) @@ -176,7 +176,7 @@ class DecisionListenerTests_Holdouts: BaseHoldoutTests { holdout_2.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] optimizely.config!.project.holdouts = [holdout, holdout_2] - optimizely.config!.holdoutConfig.allHoldouts = [holdout, holdout_2] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout, holdout_2]) let exp = expectation(description: "x") let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift index 62907151..68490670 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift @@ -499,27 +499,6 @@ extension DecisionServiceTests_Holdouts { XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") } - func testGetVariationForFeatureExperiment_HoldoutExcludedFlag() { - // Modify holdout to exclude the feature flag - var modifiedHoldoutData = sampleHoldout - modifiedHoldoutData["includedRules"] = [] // Empty array = local holdout targeting no rules (excludes flag_id_1234) - let excludedHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout - self.config.project.holdouts = [excludedHoldout] - self.config.holdoutConfig.allHoldouts = [excludedHoldout] - - let decision = mockDecisionService.getVariationForFeature( - config: config, - featureFlag: featureFlag, - user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) - ).result - - // Should skip holdout and bucket into experiment - XCTAssertNotNil(decision, "Decision should not be nil") - XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") - XCTAssertEqual(decision?.variation?.key, kVariationKeyD, "Should return experiment variation") - XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should Westhill") - } - func testGetVariationForFeatureExperiment_MultipleHoldoutsWithOrdering() { // Setup multiple holdouts: global, included, excluded let tfAllocationRange = 1500 diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift index f652b766..f1d21308 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift @@ -301,7 +301,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] // Only targets experiment in feature_1 config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer let mockBucketer = MockBucketer(mockBucketValue: 2500) diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift index b4993be6..e4972d3d 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift @@ -238,7 +238,7 @@ class OptimizelyUserContextTests_Decide_Holdouts: BaseHoldoutTests { // Include all rules in feature_1: experiment + delivery rules holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService @@ -325,7 +325,7 @@ extension OptimizelyUserContextTests_Decide_Holdouts { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = ["10420810910"] // Experiment rule in feature_2 optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService @@ -372,7 +372,7 @@ extension OptimizelyUserContextTests_Decide_Holdouts { // Local holdout targeting all feature_1 rules (experiment + delivery rules), excludes feature_2 holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService @@ -426,7 +426,7 @@ extension OptimizelyUserContextTests_Decide_Holdouts { includedHoldout.includedRules = ["10420810910"] // Experiment rule in feature_2 optimizely.config!.project.holdouts = [gHoldout, includedHoldout] - optimizely.config!.holdoutConfig.allHoldouts = [gHoldout, includedHoldout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [gHoldout], localHoldouts: [includedHoldout]) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 1000)) optimizely.decisionService = mockDecisionService diff --git a/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift index 9ca5523d..22a92d29 100644 --- a/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift +++ b/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift @@ -30,8 +30,7 @@ class HoldoutConfigTests: XCTestCase { let localHoldout1: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) let localHoldout2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithDifferentRules) - let allHoldouts = [globalHoldout, localHoldout1, localHoldout2] - let holdoutConfig = HoldoutConfig(allholdouts: allHoldouts) + let holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [localHoldout1, localHoldout2]) // Verify holdoutIdMap XCTAssertEqual(holdoutConfig.holdoutIdMap["11111"]?.includedRules, nil) @@ -56,8 +55,7 @@ class HoldoutConfigTests: XCTestCase { var holdout2: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithDifferentRules) holdout2.id = "22222" - let allHoldouts = [holdout0, holdout1, holdout2] - let holdoutConfig = HoldoutConfig(allholdouts: allHoldouts) + let holdoutConfig = HoldoutConfig(globalHoldouts: [holdout0], localHoldouts: [holdout1, holdout2]) XCTAssertEqual(holdoutConfig.getHoldout(id: "00000"), holdout0) XCTAssertEqual(holdoutConfig.getHoldout(id: "11111"), holdout1) @@ -74,7 +72,7 @@ class HoldoutConfigTests: XCTestCase { var local: Holdout = try! OTUtils.model(from: HoldoutTests.sampleDataWithIncludedRules) local.id = "l1" - let config = HoldoutConfig(allholdouts: [local, global1, global2]) + let config = HoldoutConfig(globalHoldouts: [global1, global2], localHoldouts: [local]) let result = config.getGlobalHoldouts() XCTAssertEqual(result.count, 2) @@ -95,7 +93,7 @@ class HoldoutConfigTests: XCTestCase { var global: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) global.id = "g1" - let config = HoldoutConfig(allholdouts: [local1, local2, global]) + let config = HoldoutConfig(globalHoldouts: [global], localHoldouts: [local1, local2]) // Rule1 should only have local1 XCTAssertEqual(config.getHoldoutsForRule(ruleId: "rule1"), [local1]) @@ -137,7 +135,7 @@ class HoldoutConfigTests: XCTestCase { local3.id = "l3" local3.includedRules = ["shared_rule", "other_rule"] - let config = HoldoutConfig(allholdouts: [local1, local2, local3]) + let config = HoldoutConfig(globalHoldouts: [], localHoldouts: [local1, local2, local3]) let sharedRuleHoldouts = config.getHoldoutsForRule(ruleId: "shared_rule") XCTAssertEqual(sharedRuleHoldouts.count, 3) @@ -159,7 +157,7 @@ class HoldoutConfigTests: XCTestCase { local.id = "l1" local.includedRules = ["rule1"] - config.allHoldouts = [global, local] + config = HoldoutConfig(globalHoldouts: [global], localHoldouts: [local]) // Verify maps were updated XCTAssertEqual(config.global.count, 1) From 4fd3f89b6f636789106312967cfd036cfd800f6b Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Tue, 16 Jun 2026 18:50:44 +0600 Subject: [PATCH 4/5] [FSSDK-12760] Remove allHoldouts from HoldoutConfig - Remove allHoldouts property, _allHoldouts backing store, and init(allholdouts:) - No production code used allHoldouts; only test convenience - Update all test sites to use HoldoutConfig(globalHoldouts:localHoldouts:) Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/Data Model/HoldoutConfig.swift | 16 ---- .../BatchEventBuilderTests_Events.swift | 18 ++--- .../DecisionListenerTest_Holdouts.swift | 4 +- .../DecisionServiceTests_Holdouts.swift | 78 +++++++++---------- .../DecisionServiceTests_LocalHoldouts.swift | 26 +++---- .../FeatureGateTests_LocalHoldouts.swift | 10 +-- ...zelyUserContextTests_Decide_Holdouts.swift | 74 +++++++++--------- ...xtTests_Decide_With_Holdouts_Reasons.swift | 24 +++--- .../HoldoutConfigTests.swift | 5 +- 9 files changed, 119 insertions(+), 136 deletions(-) diff --git a/Sources/Data Model/HoldoutConfig.swift b/Sources/Data Model/HoldoutConfig.swift index b5b83951..8ee5d92a 100644 --- a/Sources/Data Model/HoldoutConfig.swift +++ b/Sources/Data Model/HoldoutConfig.swift @@ -24,14 +24,6 @@ import Foundation struct HoldoutConfig { private let logger = OPTLoggerFactory.getLogger() - /// Combined flat view of all holdouts. Setting this treats all entries as global. - var allHoldouts: [Holdout] { - get { return _allHoldouts } - set { applySections(globalHoldouts: newValue, localHoldouts: []) } - } - - private var _allHoldouts: [Holdout] = [] - private(set) var global: [Holdout] = [] private(set) var holdoutIdMap: [String: Holdout] = [:] private(set) var ruleHoldoutsMap: [String: [Holdout]] = [:] @@ -40,10 +32,6 @@ struct HoldoutConfig { init() {} - init(allholdouts: [Holdout]) { - applySections(globalHoldouts: allholdouts, localHoldouts: []) - } - init(globalHoldouts: [Holdout], localHoldouts: [Holdout]) { applySections(globalHoldouts: globalHoldouts, localHoldouts: localHoldouts) } @@ -51,7 +39,6 @@ struct HoldoutConfig { // MARK: - Section application private mutating func applySections(globalHoldouts: [Holdout], localHoldouts: [Holdout]) { - var newAll: [Holdout] = [] var newIdMap: [String: Holdout] = [:] var newGlobal: [Holdout] = [] var newRuleMap: [String: [Holdout]] = [:] @@ -59,7 +46,6 @@ struct HoldoutConfig { // Strip `includedRules` so global entries are unambiguously global. for var holdout in globalHoldouts { holdout.includedRules = nil - newAll.append(holdout) newIdMap[holdout.id] = holdout newGlobal.append(holdout) } @@ -72,14 +58,12 @@ struct HoldoutConfig { ) continue } - newAll.append(holdout) newIdMap[holdout.id] = holdout for ruleId in rules { newRuleMap[ruleId, default: []].append(holdout) } } - self._allHoldouts = newAll self.holdoutIdMap = newIdMap self.global = newGlobal self.ruleHoldoutsMap = newRuleMap diff --git a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift index 7bc2c969..6c244b3b 100644 --- a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift +++ b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift @@ -498,23 +498,23 @@ extension BatchEventBuilderTests_Events { let holdout: Holdout = try! OTUtils.model(from: sampleHoldout) optimizely.config?.project.holdouts = [holdout] - optimizely.config?.holdoutConfig.allHoldouts = [holdout] - + optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let exp = expectation(description: "Wait for event to dispatch") let user = optimizely.createUserContext(userId: userId) _ = user.decide(key: featureKey) - + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { exp.fulfill() } - + let result = XCTWaiter.wait(for: [exp], timeout: 0.2) if result == XCTWaiter.Result.completed { let event = getFirstEventJSON(client: optimizely)! let visitor = (event["visitors"] as! Array>)[0] let snapshot = (visitor["snapshots"] as! Array>)[0] let decision = (snapshot["decisions"] as! Array>)[0] - + let metaData = decision["metadata"] as! Dictionary XCTAssertEqual(metaData["rule_type"] as! String, Constants.DecisionSource.holdout.rawValue) XCTAssertEqual(metaData["rule_key"] as! String, "holdout_key") @@ -524,9 +524,9 @@ extension BatchEventBuilderTests_Events { } else { XCTFail("No event found") } - + } - + func testImpressionEvent_UserInHoldout_IncludedFlags() { let eventDispatcher2 = MockEventDispatcher() var optimizely: OptimizelyClient! = OptimizelyClient(sdkKey: "12345", eventDispatcher: eventDispatcher2) @@ -536,7 +536,7 @@ extension BatchEventBuilderTests_Events { var holdout: Holdout = try! OTUtils.model(from: sampleHoldout) holdout.includedRules = ["10390977673"] // exp_no_audience rule in feature_1 optimizely.config?.project.holdouts = [holdout] - optimizely.config?.holdoutConfig.allHoldouts = [holdout] + optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let exp = expectation(description: "Wait for event to dispatch") @@ -619,7 +619,7 @@ extension BatchEventBuilderTests_Events { holdout.trafficAllocation[0].endOfRange = 0 holdout.includedRules = ["10390977673"] // exp_with_audience rule in feature_1 optimizely.config?.project.holdouts = [holdout] - optimizely.config?.holdoutConfig.allHoldouts = [holdout] + optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let exp = expectation(description: "Wait for event to dispatch") diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift index d8d3eab8..144cf9ca 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift @@ -74,7 +74,7 @@ class DecisionListenerTests_Holdouts: BaseHoldoutTests { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) self.notificationCenter = self.optimizely.notificationCenter! } @@ -123,7 +123,7 @@ class DecisionListenerTests_Holdouts: BaseHoldoutTests { // Include all rules in feature_1: experiment + delivery rules holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let exp = expectation(description: "x") let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift index 68490670..73f95931 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift @@ -208,9 +208,9 @@ class DecisionServiceTests_Holdouts: BaseHoldoutTests { featureFlag = try! OTUtils.model(from: sampleFeatureFlagData) self.config.project.featureFlags = [featureFlag] self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) } - + override func tearDown() { super.tearDown() } @@ -230,36 +230,36 @@ extension DecisionServiceTests_Holdouts { holdout.audienceConditions = try! OTUtils.model(from: ["or", kAudienceIdCountry]) holdout.audienceIds = [kAudienceIdAge] self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(result, "attribute should be matched to audienceConditions") - + // (2) matching false result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryNotMatch)).result XCTAssertFalse(result, "attribute should be matched to audienceConditions") - + // (3) other attribute result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesAgeMatch)).result XCTAssertFalse(result, "no matching attribute provided") } - + func testDoesMeetAudienceConditionsWithAudienceIds() { self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) - + // (1) matching true - + holdout = try! OTUtils.model(from: sampleHoldout) holdout.audienceConditions = nil holdout.audienceIds = [kAudienceIdCountry] self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, @@ -285,28 +285,28 @@ extension DecisionServiceTests_Holdouts { holdout.audienceConditions = try! OTUtils.model(from: []) holdout.audienceIds = [kAudienceIdAge] self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + let result: Bool! = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(result, "empty conditions is true always") } - + func testDoesMeetAudienceConditionsWithAudienceIdsEmpty() { self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) holdout = try! OTUtils.model(from: sampleHoldout) holdout.audienceConditions = nil holdout.audienceIds = [] self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + let result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(result, "empty conditions is true always") } - + func testDoesMeetAudienceConditionsWithCornerCases() { self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) holdout = try! OTUtils.model(from: sampleHoldout) @@ -318,23 +318,23 @@ extension DecisionServiceTests_Holdouts { holdout.audienceConditions = array[0] holdout.audienceIds = [kAudienceIdAge] self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(result) - + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesEmpty)).result XCTAssertFalse(result) - + // (2) invalid string in "audienceConditions" array = try! OTUtils.model(from: ["and"]) holdout.audienceConditions = array[0] self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, @@ -345,8 +345,8 @@ extension DecisionServiceTests_Holdouts { holdout.audienceConditions = nil holdout.audienceIds = [] self.config.project.holdouts = [holdout] - self.config.holdoutConfig.allHoldouts = [holdout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result @@ -407,7 +407,7 @@ extension DecisionServiceTests_Holdouts { modifiedHoldoutData["status"] = "Draft" let inactiveHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout self.config.project.holdouts = [inactiveHoldout] - self.config.holdoutConfig.allHoldouts = [inactiveHoldout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [inactiveHoldout]) let decision = mockDecisionService.getVariationForFeature( config: config, @@ -426,7 +426,7 @@ extension DecisionServiceTests_Holdouts { func testGetVariationForFeatureExperiment_NoHoldouts() { // Remove holdouts self.config.project.holdouts = [] - self.config.holdoutConfig.allHoldouts = [] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: []) let decision = mockDecisionService.getVariationForFeature( config: config, @@ -451,7 +451,7 @@ extension DecisionServiceTests_Holdouts { // Use global holdout since there are no valid experiments let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout self.config.project.holdouts = [globalHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: []) // Use bucket value 400 which is within globalHoldout range (0-500) let mockBucketer = MockBucketer(mockBucketValue: 400) @@ -480,7 +480,7 @@ extension DecisionServiceTests_Holdouts { // Use global holdout since there are no valid experiments let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout self.config.project.holdouts = [globalHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: []) // Use bucket value 400 which is within globalHoldout range (0-500) let mockBucketer = MockBucketer(mockBucketValue: 400) @@ -511,8 +511,8 @@ extension DecisionServiceTests_Holdouts { excludedHoldout.trafficAllocation[0].endOfRange = tfAllocationRange self.config.project.holdouts = [globalHoldout, includedHoldout, excludedHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout, includedHoldout, excludedHoldout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout, excludedHoldout]) + // Mock bucketer to bucket into the first valid holdout (global) let mockBucketer = MockBucketer(mockBucketValue: 1000) // Within all holdout ranges let mockDecisionService = MockDecisionService(bucketer: mockBucketer) @@ -536,7 +536,7 @@ extension DecisionServiceTests_Holdouts { let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout self.config.project.holdouts = [globalHoldout, includedHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout, includedHoldout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout]) // Mock bucketer to fail global holdout bucketing, succeed for included let mockBucketer = MockBucketer(mockBucketValue: 700) // Outside global range, within included range @@ -561,8 +561,8 @@ extension DecisionServiceTests_Holdouts { let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout let excludedHoldout = try! OTUtils.model(from: sampleHoldoutExcluded) as Holdout self.config.project.holdouts = [globalHoldout, includedHoldout, excludedHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout, includedHoldout, excludedHoldout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout, excludedHoldout]) + // Mock bucketer to fail all holdout bucketing let mockBucketer = MockBucketer(mockBucketValue: 1500) // Outside all holdout ranges mockDecisionService = MockDecisionService(bucketer: mockBucketer) @@ -586,14 +586,14 @@ extension DecisionServiceTests_Holdouts { modifiedHoldoutData["trafficAllocation"] = [] let noTrafficHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout self.config.project.holdouts = [noTrafficHoldout] - self.config.holdoutConfig.allHoldouts = [noTrafficHoldout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [noTrafficHoldout], localHoldouts: []) + let decision = mockDecisionService.getVariationForFeature( config: config, featureFlag: featureFlag, user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) ).result - + // Holdout has no traffic allocation, should fall back to experiment XCTAssertNotNil(decision) XCTAssertEqual(decision?.experiment?.id, kExperimentId) @@ -610,8 +610,8 @@ extension DecisionServiceTests_Holdouts { let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout // Requires country: "us" self.config.project.holdouts = [globalHoldout, includedHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout, includedHoldout] - + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout]) + // Mock bucketer to fail included holdout bucketing let mockBucketer = MockBucketer(mockBucketValue: 1500) // Outside included holdout range mockDecisionService = MockDecisionService(bucketer: mockBucketer) @@ -634,7 +634,7 @@ extension DecisionServiceTests_Holdouts { modifiedHoldoutData["variations"] = [] let noVariationsHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout self.config.project.holdouts = [noVariationsHoldout] - self.config.holdoutConfig.allHoldouts = [noVariationsHoldout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [noVariationsHoldout], localHoldouts: []) let decision = mockDecisionService.getVariationForFeature( config: config, @@ -654,7 +654,7 @@ extension DecisionServiceTests_Holdouts { let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout self.config.project.holdouts = [globalHoldout, includedHoldout] - self.config.holdoutConfig.allHoldouts = [globalHoldout, includedHoldout] + self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout]) // First call let decision1 = mockDecisionService.getVariationForFeature( diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift index f1d21308..e1d6cf06 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift @@ -73,7 +73,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = nil // Global holdout config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) // Mock bucketer to ensure user buckets into holdout (50% traffic = endOfRange 5000) let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -96,7 +96,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = nil // Global holdout config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) // Mock bucketer to ensure user MISSES holdout (50% traffic = endOfRange 5000) let mockBucketer = MockBucketer(mockBucketValue: 7000) @@ -120,7 +120,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] // Target the experiment rule config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user buckets into holdout let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -143,7 +143,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user MISSES holdout let mockBucketer = MockBucketer(mockBucketValue: 7000) @@ -166,7 +166,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { holdout.includedRules = [experimentRuleId] holdout.audienceIds = ["13389130056"] // Audience from decide_datafile config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure would bucket IF audience matched let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -191,7 +191,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [deliveryRuleId] // Target delivery rule from rollout config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user buckets into holdout let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -214,7 +214,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [deliveryRuleId] config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user MISSES holdout let mockBucketer = MockBucketer(mockBucketValue: 7000) @@ -249,7 +249,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { holdout2.variations[0].key = "holdout_2_variation" config.project.holdouts = [holdout1, holdout2] - config.holdoutConfig.allHoldouts = [holdout1, holdout2] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout1, holdout2]) // Mock bucketer to ensure user buckets into both let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -277,7 +277,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { holdout2.variations[0].id = "holdout_2_var_id" config.project.holdouts = [holdout1, holdout2] - config.holdoutConfig.allHoldouts = [holdout1, holdout2] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout1, holdout2]) // Mock bucketer let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -339,7 +339,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { localHoldout.variations[0].key = "local_variation" config.project.holdouts = [globalHoldout, localHoldout] - config.holdoutConfig.allHoldouts = [globalHoldout, localHoldout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [localHoldout]) // Mock bucketer to ensure user buckets into both let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -361,7 +361,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure would bucket into holdout let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -387,7 +387,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { // Test that local holdout targeting non-existent rule doesn't break evaluation // Expected: Empty array returned from getHoldoutsForRule(), normal evaluation continues - let config = HoldoutConfig(allholdouts: []) + let config = HoldoutConfig(globalHoldouts: [], localHoldouts: []) let result = config.getHoldoutsForRule(ruleId: "nonexistent_rule") XCTAssertTrue(result.isEmpty, "Non-existent rule should return empty array") @@ -402,7 +402,7 @@ class DecisionServiceTests_LocalHoldouts: BaseHoldoutTests { holdout.status = .draft // Not running holdout.includedRules = [experimentRuleId] config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure would bucket IF holdout was active let mockBucketer = MockBucketer(mockBucketValue: 2500) diff --git a/Tests/OptimizelyTests-Common/FeatureGateTests_LocalHoldouts.swift b/Tests/OptimizelyTests-Common/FeatureGateTests_LocalHoldouts.swift index 6081cef2..c075db1e 100644 --- a/Tests/OptimizelyTests-Common/FeatureGateTests_LocalHoldouts.swift +++ b/Tests/OptimizelyTests-Common/FeatureGateTests_LocalHoldouts.swift @@ -72,7 +72,7 @@ class FeatureGateTests_LocalHoldouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user WOULD bucket into holdout if it were evaluated let mockBucketer = MockBucketer(mockBucketValue: 2500) // Within holdout range (0-5000) @@ -101,7 +101,7 @@ class FeatureGateTests_LocalHoldouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [deliveryRuleId] config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user WOULD bucket into holdout if it were evaluated let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -132,7 +132,7 @@ class FeatureGateTests_LocalHoldouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to bucket user into holdout let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -162,7 +162,7 @@ class FeatureGateTests_LocalHoldouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [deliveryRuleId] config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to bucket user into holdout let mockBucketer = MockBucketer(mockBucketValue: 2500) @@ -191,7 +191,7 @@ class FeatureGateTests_LocalHoldouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = nil // Global holdout config.project.holdouts = [holdout] - config.holdoutConfig.allHoldouts = [holdout] + config.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) // Mock bucketer to bucket user into holdout let mockBucketer = MockBucketer(mockBucketValue: 2500) diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift index e4972d3d..7619319c 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift @@ -65,8 +65,8 @@ class OptimizelyUserContextTests_Decide_Holdouts: BaseHoldoutTests { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) // Call decide with reasons @@ -94,9 +94,9 @@ class OptimizelyUserContextTests_Decide_Holdouts: BaseHoldoutTests { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) let decisions = user.decide(keys: featureKeys) @@ -125,8 +125,8 @@ class OptimizelyUserContextTests_Decide_Holdouts: BaseHoldoutTests { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) let decisions = user.decide(keys: featureKeys) @@ -148,12 +148,12 @@ class OptimizelyUserContextTests_Decide_Holdouts: BaseHoldoutTests { func testDecide_with_holdout_options_excludeVariables() { let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - - + + let featureKey = "feature_1" let user = optimizely.createUserContext(userId: kUserId) @@ -170,13 +170,13 @@ class OptimizelyUserContextTests_Decide_Holdouts: BaseHoldoutTests { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = ["10420810910"] // Experiment rule in feature_2 optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) - + var user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) var decision = user.decide(key: featureKey) @@ -193,8 +193,8 @@ class OptimizelyUserContextTests_Decide_Holdouts: BaseHoldoutTests { try! optimizely.start(datafile: OTUtils.loadJSONDatafile("decide_datafile")!) optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + user = optimizely.createUserContext(userId: kUserId) decision = user.decide(key: featureKey) @@ -210,14 +210,14 @@ class OptimizelyUserContextTests_Decide_Holdouts: BaseHoldoutTests { // Include all rules in feature_1: experiment + delivery rules holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) let user = optimizely.createUserContext(userId: kUserId) - + let decision1 = user.decide(key: featureKey1) XCTAssert(decision1 == OptimizelyDecision(variationKey: "key_holdout_variation", @@ -279,15 +279,15 @@ extension OptimizelyUserContextTests_Decide_Holdouts { let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) let variablesExpected3 = OptimizelyJSON.createEmpty() - + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) let decisions = user.decideAll() @@ -467,11 +467,11 @@ extension OptimizelyUserContextTests_Decide_Holdouts { func testDecideAll_with_holdouts_options_enabledFlagsOnly() { let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) let decisions = user.decideAll(options: [.enabledFlagsOnly]) @@ -484,17 +484,17 @@ extension OptimizelyUserContextTests_Decide_Holdouts { extension OptimizelyUserContextTests_Decide_Holdouts { func testDecide_sendImpression() { let featureKey = "feature_2" - + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let user = optimizely.createUserContext(userId: kUserId) let decision = user.decide(key: featureKey) - + optimizely.eventLock.sync{} XCTAssertEqual(decision.variationKey, "key_holdout_variation") @@ -521,10 +521,10 @@ extension OptimizelyUserContextTests_Decide_Holdouts { func testDecideError_doNotSendImpression() { let featureKey = "invalid" // invalid flag - + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService @@ -542,7 +542,7 @@ extension OptimizelyUserContextTests_Decide_Holdouts { func testDecide_sendImpression_with_disable_tracking() { let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService @@ -560,7 +560,7 @@ extension OptimizelyUserContextTests_Decide_Holdouts { func testDecide_sendImpression_withSendFlagDecisionsOff() { let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift index 6e57e7bd..a28e2eca 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift @@ -61,11 +61,11 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: BaseHoldoutTests let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let user = optimizely.createUserContext(userId: kUserId) // Call decide with reasons let decision = user.decide(key: featureKey, options: [.includeReasons]) @@ -75,7 +75,7 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: BaseHoldoutTests XCTAssertFalse(decision.enabled, "Feature should be disabled in holdout") XCTAssert(decision.reasons.contains(LogMessage.userBucketedIntoVariationInHoldout(kUserId, "key_holdout", "key_holdout_variation").reason)) } - + /// Test when user is bucketed into the included flags holdout for feature_1 func testDecideReasons_userBucketedIntoIncludedHoldout() { let featureKey = "feature_1" @@ -85,7 +85,7 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: BaseHoldoutTests // Include all rules in feature_1: experiment + delivery rules holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService @@ -120,7 +120,7 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: BaseHoldoutTests let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 600)) optimizely.decisionService = mockDecisionService optimizely.config!.project.holdouts = [holdout1, holdout2] - optimizely.config!.holdoutConfig.allHoldouts = [holdout1, holdout2] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout1], localHoldouts: [holdout2]) let user = optimizely.createUserContext(userId: kUserId) // Call decide with reasons @@ -138,7 +138,7 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: BaseHoldoutTests var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.status = .draft optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService @@ -168,9 +168,9 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: BaseHoldoutTests let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) // Call decide with reasons let decision = user.decide(key: featureKey, options: [.includeReasons]) @@ -193,8 +193,8 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: BaseHoldoutTests let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig.allHoldouts = [holdout] - + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) // Call decide with reasons let decision = user.decide(key: featureKey, options: [.includeReasons]) diff --git a/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift index 22a92d29..9afe5871 100644 --- a/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift +++ b/Tests/OptimizelyTests-DataModel/HoldoutConfigTests.swift @@ -18,7 +18,7 @@ import XCTest class HoldoutConfigTests: XCTestCase { func testEmptyHoldouts_shouldHaveEmptyMaps() { - let config = HoldoutConfig(allholdouts: []) + let config = HoldoutConfig(globalHoldouts: [], localHoldouts: []) XCTAssertTrue(config.holdoutIdMap.isEmpty) XCTAssertTrue(config.global.isEmpty) @@ -145,11 +145,10 @@ class HoldoutConfigTests: XCTestCase { } func testUpdateHoldoutMappingTriggeredOnAllHoldoutsChange() { - var config = HoldoutConfig(allholdouts: []) + var config = HoldoutConfig(globalHoldouts: [], localHoldouts: []) XCTAssertTrue(config.global.isEmpty) XCTAssertTrue(config.ruleHoldoutsMap.isEmpty) - // When allHoldouts changes, updateHoldoutMapping() should be called automatically var global: Holdout = try! OTUtils.model(from: HoldoutTests.sampleData) global.id = "g1" From fa20c95a5324b1a231fc08d2903412d4f7b5e0f2 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Tue, 16 Jun 2026 19:43:42 +0600 Subject: [PATCH 5/5] [FSSDK-12760] Code review fixes: log warning, remove dual-write, fix didSet ordering - Add log warning when stripping non-nil includedRules from global holdouts - Remove redundant project.holdouts dual-write in tests - Fix double space typo in DecisionListenerTest_Holdouts - Fix test ordering where project.sendFlagDecisions didSet wiped holdoutConfig Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/Data Model/HoldoutConfig.swift | 4 +- .../BatchEventBuilderTests_Events.swift | 12 ++--- .../DecisionListenerTest_Holdouts.swift | 5 +- .../DecisionServiceTests_Holdouts.swift | 33 +++--------- .../DecisionServiceTests_LocalHoldouts.swift | 13 ----- ...zelyUserContextTests_Decide_Holdouts.swift | 54 +++++++------------ ...xtTests_Decide_With_Holdouts_Reasons.swift | 9 +--- 7 files changed, 35 insertions(+), 95 deletions(-) diff --git a/Sources/Data Model/HoldoutConfig.swift b/Sources/Data Model/HoldoutConfig.swift index 8ee5d92a..e92bc328 100644 --- a/Sources/Data Model/HoldoutConfig.swift +++ b/Sources/Data Model/HoldoutConfig.swift @@ -43,8 +43,10 @@ struct HoldoutConfig { var newGlobal: [Holdout] = [] var newRuleMap: [String: [Holdout]] = [:] - // Strip `includedRules` so global entries are unambiguously global. for var holdout in globalHoldouts { + if holdout.includedRules != nil { + logger.w("Global holdout '\(holdout.key)' (id: \(holdout.id)) has 'includedRules' which will be ignored; global holdouts apply to all flags.") + } holdout.includedRules = nil newIdMap[holdout.id] = holdout newGlobal.append(holdout) diff --git a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift index 6c244b3b..b1c829ab 100644 --- a/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift +++ b/Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift @@ -497,7 +497,6 @@ extension BatchEventBuilderTests_Events { try! optimizely.start(datafile: datafile) let holdout: Holdout = try! OTUtils.model(from: sampleHoldout) - optimizely.config?.project.holdouts = [holdout] optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let exp = expectation(description: "Wait for event to dispatch") @@ -535,15 +534,14 @@ extension BatchEventBuilderTests_Events { var holdout: Holdout = try! OTUtils.model(from: sampleHoldout) holdout.includedRules = ["10390977673"] // exp_no_audience rule in feature_1 - optimizely.config?.project.holdouts = [holdout] optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) - + let exp = expectation(description: "Wait for event to dispatch") - + let user = optimizely.createUserContext(userId: userId) _ = user.decide(key: featureKey) - - + + // Add a delay before evaluating getFirstEventJSON DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { exp.fulfill() // Fulfill the expectation after the delay @@ -577,7 +575,6 @@ extension BatchEventBuilderTests_Events { var holdout: Holdout = try! OTUtils.model(from: sampleHoldout) holdout.includedRules = [] // Empty array = local holdout targeting no rules (excludes feature_1) - optimizely.config?.project.holdouts = [holdout] optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let exp = expectation(description: "Wait for event to dispatch") @@ -618,7 +615,6 @@ extension BatchEventBuilderTests_Events { /// Set traffic allocation to gero holdout.trafficAllocation[0].endOfRange = 0 holdout.includedRules = ["10390977673"] // exp_with_audience rule in feature_1 - optimizely.config?.project.holdouts = [holdout] optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let exp = expectation(description: "Wait for event to dispatch") diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift index effcd30d..0d2f52bc 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift @@ -119,7 +119,6 @@ class DecisionListenerTests_Holdouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout // Include all rules in feature_1: experiment + delivery rules holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let exp = expectation(description: "x") @@ -142,7 +141,6 @@ class DecisionListenerTests_Holdouts: XCTestCase { func testDecisionListenerDecideWithExcludedFlags() { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [] // Empty array = local holdout targeting no rules (excludes feature_1) - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let exp = expectation(description: "x") @@ -172,8 +170,7 @@ class DecisionListenerTests_Holdouts: XCTestCase { // Include all rules in feature_1: experiment + delivery rules holdout_2.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] - optimizely.config!.project.holdouts = [holdout, holdout_2] - optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout, holdout_2]) + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout, holdout_2]) let exp = expectation(description: "x") let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift index 933ada4b..87bf4a98 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift @@ -208,7 +208,6 @@ class DecisionServiceTests_Holdouts: XCTestCase { featureFlag = try! OTUtils.model(from: sampleFeatureFlagData) self.config.project.featureFlags = [featureFlag] - self.config.project.holdouts = [holdout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) } @@ -226,7 +225,6 @@ extension DecisionServiceTests_Holdouts { holdout = try! OTUtils.model(from: sampleHoldout) holdout.audienceConditions = try! OTUtils.model(from: ["or", kAudienceIdCountry]) holdout.audienceIds = [kAudienceIdAge] - self.config.project.holdouts = [holdout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, @@ -255,20 +253,19 @@ extension DecisionServiceTests_Holdouts { holdout = try! OTUtils.model(from: sampleHoldout) holdout.audienceConditions = nil holdout.audienceIds = [kAudienceIdCountry] - self.config.project.holdouts = [holdout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) - + var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(result, "attribute should be matched to audienceConditions") - + // (2) matching false result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryNotMatch)).result XCTAssertFalse(result, "attribute should be matched to audienceConditions") - + // (3) other attribute result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, @@ -281,7 +278,6 @@ extension DecisionServiceTests_Holdouts { holdout = try! OTUtils.model(from: sampleHoldout) holdout.audienceConditions = try! OTUtils.model(from: []) holdout.audienceIds = [kAudienceIdAge] - self.config.project.holdouts = [holdout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let result: Bool! = self.mockDecisionService.doesMeetAudienceConditions(config: config, @@ -295,7 +291,6 @@ extension DecisionServiceTests_Holdouts { holdout = try! OTUtils.model(from: sampleHoldout) holdout.audienceConditions = nil holdout.audienceIds = [] - self.config.project.holdouts = [holdout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, @@ -314,7 +309,6 @@ extension DecisionServiceTests_Holdouts { var array: [ConditionHolder] = try! OTUtils.model(from: [kAudienceIdCountry]) holdout.audienceConditions = array[0] holdout.audienceIds = [kAudienceIdAge] - self.config.project.holdouts = [holdout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, @@ -330,18 +324,16 @@ extension DecisionServiceTests_Holdouts { // (2) invalid string in "audienceConditions" array = try! OTUtils.model(from: ["and"]) holdout.audienceConditions = array[0] - self.config.project.holdouts = [holdout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) - + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, experiment: holdout, user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result XCTAssert(result) - + // (2) invalid string in "audienceConditions" holdout.audienceConditions = nil holdout.audienceIds = [] - self.config.project.holdouts = [holdout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) result = self.mockDecisionService.doesMeetAudienceConditions(config: config, @@ -403,7 +395,6 @@ extension DecisionServiceTests_Holdouts { var modifiedHoldoutData = sampleHoldout modifiedHoldoutData["status"] = "Draft" let inactiveHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout - self.config.project.holdouts = [inactiveHoldout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [inactiveHoldout]) let decision = mockDecisionService.getVariationForFeature( @@ -422,7 +413,6 @@ extension DecisionServiceTests_Holdouts { func testGetVariationForFeatureExperiment_NoHoldouts() { // Remove holdouts - self.config.project.holdouts = [] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: []) let decision = mockDecisionService.getVariationForFeature( @@ -447,7 +437,6 @@ extension DecisionServiceTests_Holdouts { // Use global holdout since there are no valid experiments let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout - self.config.project.holdouts = [globalHoldout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: []) // Use bucket value 400 which is within globalHoldout range (0-500) @@ -476,7 +465,6 @@ extension DecisionServiceTests_Holdouts { // Use global holdout since there are no valid experiments let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout - self.config.project.holdouts = [globalHoldout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: []) // Use bucket value 400 which is within globalHoldout range (0-500) @@ -507,7 +495,6 @@ extension DecisionServiceTests_Holdouts { var excludedHoldout = try! OTUtils.model(from: sampleHoldoutExcluded) as Holdout excludedHoldout.trafficAllocation[0].endOfRange = tfAllocationRange - self.config.project.holdouts = [globalHoldout, includedHoldout, excludedHoldout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout, excludedHoldout]) // Mock bucketer to bucket into the first valid holdout (global) @@ -532,9 +519,8 @@ extension DecisionServiceTests_Holdouts { // Setup multiple holdouts let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout - self.config.project.holdouts = [globalHoldout, includedHoldout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout]) - + // Mock bucketer to fail global holdout bucketing, succeed for included let mockBucketer = MockBucketer(mockBucketValue: 700) // Outside global range, within included range mockDecisionService = MockDecisionService(bucketer: mockBucketer) @@ -557,7 +543,6 @@ extension DecisionServiceTests_Holdouts { let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout let excludedHoldout = try! OTUtils.model(from: sampleHoldoutExcluded) as Holdout - self.config.project.holdouts = [globalHoldout, includedHoldout, excludedHoldout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout, excludedHoldout]) // Mock bucketer to fail all holdout bucketing @@ -582,7 +567,6 @@ extension DecisionServiceTests_Holdouts { var modifiedHoldoutData = sampleHoldoutGlobal modifiedHoldoutData["trafficAllocation"] = [] let noTrafficHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout - self.config.project.holdouts = [noTrafficHoldout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [noTrafficHoldout], localHoldouts: []) let decision = mockDecisionService.getVariationForFeature( @@ -606,7 +590,6 @@ extension DecisionServiceTests_Holdouts { let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout // Requires country: "us" - self.config.project.holdouts = [globalHoldout, includedHoldout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout]) // Mock bucketer to fail included holdout bucketing @@ -630,7 +613,6 @@ extension DecisionServiceTests_Holdouts { var modifiedHoldoutData = sampleHoldoutGlobal modifiedHoldoutData["variations"] = [] let noVariationsHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout - self.config.project.holdouts = [noVariationsHoldout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [noVariationsHoldout], localHoldouts: []) let decision = mockDecisionService.getVariationForFeature( @@ -650,9 +632,8 @@ extension DecisionServiceTests_Holdouts { // Setup multiple holdouts let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout - self.config.project.holdouts = [globalHoldout, includedHoldout] self.config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [includedHoldout]) - + // First call let decision1 = mockDecisionService.getVariationForFeature( config: config, diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift index ff017067..781971fa 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_LocalHoldouts.swift @@ -69,7 +69,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create global holdout (includedRules: nil) var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = nil // Global holdout - config.project.holdouts = [holdout] config.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) // Mock bucketer to ensure user buckets into holdout (50% traffic = endOfRange 5000) @@ -92,7 +91,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create global holdout var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = nil // Global holdout - config.project.holdouts = [holdout] config.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) // Mock bucketer to ensure user MISSES holdout (50% traffic = endOfRange 5000) @@ -116,7 +114,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create local holdout targeting specific experiment rule var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] // Target the experiment rule - config.project.holdouts = [holdout] config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user buckets into holdout @@ -139,7 +136,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create local holdout targeting experiment rule var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] - config.project.holdouts = [holdout] config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user MISSES holdout @@ -162,7 +158,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] holdout.audienceIds = ["13389130056"] // Audience from decide_datafile - config.project.holdouts = [holdout] config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure would bucket IF audience matched @@ -187,7 +182,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create local holdout targeting delivery rule var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [deliveryRuleId] // Target delivery rule from rollout - config.project.holdouts = [holdout] config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user buckets into holdout @@ -210,7 +204,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create local holdout targeting delivery rule var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [deliveryRuleId] - config.project.holdouts = [holdout] config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure user MISSES holdout @@ -245,7 +238,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { holdout2.variations[0].id = "holdout_2_var_id" holdout2.variations[0].key = "holdout_2_variation" - config.project.holdouts = [holdout1, holdout2] config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout1, holdout2]) // Mock bucketer to ensure user buckets into both @@ -273,7 +265,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { holdout2.includedRules = [deliveryRuleId] holdout2.variations[0].id = "holdout_2_var_id" - config.project.holdouts = [holdout1, holdout2] config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout1, holdout2]) // Mock bucketer @@ -297,7 +288,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create local holdout targeting specific rule in feature_1 var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] // Only targets experiment in feature_1 - config.project.holdouts = [holdout] config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer @@ -335,7 +325,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { localHoldout.variations[0].id = "local_var_id" localHoldout.variations[0].key = "local_variation" - config.project.holdouts = [globalHoldout, localHoldout] config.holdoutConfig = HoldoutConfig(globalHoldouts: [globalHoldout], localHoldouts: [localHoldout]) // Mock bucketer to ensure user buckets into both @@ -357,7 +346,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { // Create local holdout var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = [experimentRuleId] - config.project.holdouts = [holdout] config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure would bucket into holdout @@ -398,7 +386,6 @@ class DecisionServiceTests_LocalHoldouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.status = .draft // Not running holdout.includedRules = [experimentRuleId] - config.project.holdouts = [holdout] config.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) // Mock bucketer to ensure would bucket IF holdout was active diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift index e58340eb..0633e9ca 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift @@ -61,7 +61,6 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) @@ -90,7 +89,6 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) @@ -121,7 +119,6 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) @@ -144,7 +141,6 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { func testDecide_with_holdout_options_excludeVariables() { let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) @@ -166,7 +162,6 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = ["10420810910"] // Experiment rule in feature_2 - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) @@ -189,7 +184,6 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { defaultDecideOptions: [.excludeVariables]) try! optimizely.start(datafile: OTUtils.loadJSONDatafile("decide_datafile")!) - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) user = optimizely.createUserContext(userId: kUserId) @@ -206,7 +200,6 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout // Include all rules in feature_1: experiment + delivery rules holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) @@ -234,16 +227,15 @@ class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout // Include all rules in feature_1: experiment + delivery rules holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) - + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) let user = optimizely.createUserContext(userId: kUserId) - + let decisions = user.decide(keys: [featureKey1, featureKey2]) XCTAssert(decisions.count == 2) @@ -275,7 +267,6 @@ extension OptimizelyUserContextTests_Decide_Holdouts { let featureKey3 = "feature_3" let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) @@ -321,21 +312,20 @@ extension OptimizelyUserContextTests_Decide_Holdouts { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.includedRules = ["10420810910"] // Experiment rule in feature_2 - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) - + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) let variablesExpected3 = OptimizelyJSON.createEmpty() - + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) let decisions = user.decideAll() - + XCTAssert(decisions.count == 3) - + XCTAssert(decisions[featureKey1]! == OptimizelyDecision(variationKey: "a", enabled: true, variables: variablesExpected1, @@ -368,7 +358,6 @@ extension OptimizelyUserContextTests_Decide_Holdouts { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout // Local holdout targeting all feature_1 rules (experiment + delivery rules), excludes feature_2 holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) @@ -422,7 +411,6 @@ extension OptimizelyUserContextTests_Decide_Holdouts { /// Local holdout applicable to feature_2 experiment rule includedHoldout.includedRules = ["10420810910"] // Experiment rule in feature_2 - optimizely.config!.project.holdouts = [gHoldout, includedHoldout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [gHoldout], localHoldouts: [includedHoldout]) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 1000)) @@ -463,7 +451,6 @@ extension OptimizelyUserContextTests_Decide_Holdouts { func testDecideAll_with_holdouts_options_enabledFlagsOnly() { let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) @@ -483,7 +470,6 @@ extension OptimizelyUserContextTests_Decide_Holdouts { let featureKey = "feature_2" let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) @@ -493,7 +479,7 @@ extension OptimizelyUserContextTests_Decide_Holdouts { let decision = user.decide(key: featureKey) optimizely.eventLock.sync{} - + XCTAssertEqual(decision.variationKey, "key_holdout_variation") XCTAssertFalse(decision.enabled) XCTAssertFalse(eventDispatcher.events.isEmpty) @@ -520,17 +506,16 @@ extension OptimizelyUserContextTests_Decide_Holdouts { let featureKey = "invalid" // invalid flag let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) - + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let user = optimizely.createUserContext(userId: kUserId) let decision = user.decide(key: featureKey) - + optimizely.eventLock.sync{} - + XCTAssertNil(decision.variationKey) XCTAssertFalse(decision.enabled) XCTAssert(eventDispatcher.events.isEmpty) @@ -538,12 +523,11 @@ extension OptimizelyUserContextTests_Decide_Holdouts { func testDecide_sendImpression_with_disable_tracking() { let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) - + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + let featureKey = "feature_2" let user = optimizely.createUserContext(userId: kUserId) @@ -556,13 +540,13 @@ extension OptimizelyUserContextTests_Decide_Holdouts { func testDecide_sendImpression_withSendFlagDecisionsOff() { let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] - optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) - + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - + optimizely.config?.project.sendFlagDecisions = false + optimizely.config!.project.holdouts = [holdout] + optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let featureKey = "feature_2" diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift index 9f411a50..11aeedf2 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift @@ -56,7 +56,6 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { let featureKey = "feature_1" let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) @@ -80,9 +79,8 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout // Include all rules in feature_1: experiment + delivery rules holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"] - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout]) - + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService @@ -115,7 +113,6 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { // Bucket valud outside global holdout range but inside second holdout range let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 600)) optimizely.decisionService = mockDecisionService - optimizely.config!.project.holdouts = [holdout1, holdout2] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout1], localHoldouts: [holdout2]) let user = optimizely.createUserContext(userId: kUserId) @@ -133,7 +130,6 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout holdout.status = .draft - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) @@ -163,10 +159,8 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) - let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) // Call decide with reasons let decision = user.decide(key: featureKey, options: [.includeReasons]) @@ -188,7 +182,6 @@ class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) optimizely.decisionService = mockDecisionService - optimizely.config!.project.holdouts = [holdout] optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: []) let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch)