From ebe1188e8cb8103052c17323e5ec050e713679e0 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Fri, 6 Oct 2023 10:35:39 -0700 Subject: [PATCH] feat: local-evaluation (#36) --- AmplitudeExperiment.podspec | 2 +- Experiment.xcodeproj/project.pbxproj | 94 +- .../xcschemes/ExperimentTests.xcscheme | 7 + Package.swift | 2 +- Sources/Experiment/AnyCodable.swift | 138 ++ Sources/Experiment/Backoff.swift | 5 +- Sources/Experiment/EvaluationEngine.swift | 355 +++ Sources/Experiment/EvaluationFlag.swift | 203 ++ Sources/Experiment/Experiment.swift | 4 +- Sources/Experiment/ExperimentClient.swift | 507 ++++- Sources/Experiment/ExperimentConfig.swift | 100 +- Sources/Experiment/ExperimentUser.swift | 25 + Sources/Experiment/Exposure.swift | 12 +- Sources/Experiment/InMemoryStorage.swift | 37 - Sources/Experiment/Murmur3.swift | 113 + Sources/Experiment/Selectable.swift | 54 + Sources/Experiment/SemanticVersion.swift | 82 + Sources/Experiment/Storage.swift | 95 +- Sources/Experiment/TopologicalSort.swift | 59 + Sources/Experiment/UserDefaultsStorage.swift | 61 - Sources/Experiment/Variant.swift | 109 +- .../ConnectorIntegrationTests.swift | 10 +- Tests/ExperimentTests/EnglishWords.swift | 2010 ++++++++++++++++ .../EvaluationIntegrationTests.swift | 531 +++++ .../ExperimentClientTests.swift | 799 ++++++- Tests/ExperimentTests/HashX8632.swift | 2011 +++++++++++++++++ .../ExperimentTests/LoadStoreCacheTests.swift | 116 + Tests/ExperimentTests/Murmur3Tests.swift | 38 + Tests/ExperimentTests/ObjectiveCTest.m | 2 +- Tests/ExperimentTests/SelectableTests.swift | 62 + .../SemanticVersionTests.swift | 151 ++ .../TopologicalSortTests.swift | 304 +++ .../UserDefaultsStorageTests.swift | 60 +- .../UserSessionExposureTrackerTests.swift | 30 +- Tests/ExperimentTests/VariantTests.swift | 84 +- 35 files changed, 7932 insertions(+), 340 deletions(-) create mode 100644 Sources/Experiment/AnyCodable.swift create mode 100644 Sources/Experiment/EvaluationEngine.swift create mode 100644 Sources/Experiment/EvaluationFlag.swift delete mode 100644 Sources/Experiment/InMemoryStorage.swift create mode 100644 Sources/Experiment/Murmur3.swift create mode 100644 Sources/Experiment/Selectable.swift create mode 100644 Sources/Experiment/SemanticVersion.swift create mode 100644 Sources/Experiment/TopologicalSort.swift delete mode 100644 Sources/Experiment/UserDefaultsStorage.swift create mode 100644 Tests/ExperimentTests/EnglishWords.swift create mode 100644 Tests/ExperimentTests/EvaluationIntegrationTests.swift create mode 100644 Tests/ExperimentTests/HashX8632.swift create mode 100644 Tests/ExperimentTests/LoadStoreCacheTests.swift create mode 100644 Tests/ExperimentTests/Murmur3Tests.swift create mode 100644 Tests/ExperimentTests/SelectableTests.swift create mode 100644 Tests/ExperimentTests/SemanticVersionTests.swift create mode 100644 Tests/ExperimentTests/TopologicalSortTests.swift diff --git a/AmplitudeExperiment.podspec b/AmplitudeExperiment.podspec index 3e05a4c..8602352 100644 --- a/AmplitudeExperiment.podspec +++ b/AmplitudeExperiment.podspec @@ -18,7 +18,7 @@ Pod::Spec.new do |spec| spec.osx.deployment_target = '10.10' spec.osx.source_files = 'sources/Experiment/**/*.{h,swift}' - spec.tvos.deployment_target = '9.0' + spec.tvos.deployment_target = '10.0' spec.tvos.source_files = 'sources/Experiment/**/*.{h,swift}' spec.watchos.deployment_target = '3.0' diff --git a/Experiment.xcodeproj/project.pbxproj b/Experiment.xcodeproj/project.pbxproj index 2e76c79..09afa71 100644 --- a/Experiment.xcodeproj/project.pbxproj +++ b/Experiment.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 200F89312AB2522C003AD279 /* EvaluationIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200F89302AB2522C003AD279 /* EvaluationIntegrationTests.swift */; }; 201A78DF2643AB6100663DCB /* ExperimentUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201A78DE2643AB6100663DCB /* ExperimentUserTests.swift */; }; 2047CE312809FCD9002D2B06 /* UserSessionExposureTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2047CE302809FCD9002D2B06 /* UserSessionExposureTrackerTests.swift */; }; 2073275A278E42B0002BBD43 /* SessionAnalyticsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20732759278E42B0002BBD43 /* SessionAnalyticsProvider.swift */; }; @@ -18,12 +19,26 @@ 207CBB9426AB8BD400A0029D /* ExperimentAnalyticsEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207CBB9326AB8BD400A0029D /* ExperimentAnalyticsEvent.swift */; }; 207CBB9826AB8C9800A0029D /* ExposureEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207CBB9726AB8C9800A0029D /* ExposureEvent.swift */; }; 2093E3A026A7690D0036A930 /* ObjectiveCTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 2093E39F26A7690D0036A930 /* ObjectiveCTest.m */; }; + 20A5A6E12AC3583E00047E7F /* LoadStoreCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A5A6E02AC3583E00047E7F /* LoadStoreCacheTests.swift */; }; 20B1BB8E2683CC2A003A960F /* Backoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B1BB8D2683CC2A003A960F /* Backoff.swift */; }; 20B1BF21268BBDA4003A960F /* VariantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B1BF20268BBDA4003A960F /* VariantTests.swift */; }; 20B1BF2B268BFFD7003A960F /* UserDefaultsStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B1BF2A268BFFD7003A960F /* UserDefaultsStorageTests.swift */; }; 20C9FA382787621100A4D530 /* ConnectorExposureTrackingProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C9FA372787621100A4D530 /* ConnectorExposureTrackingProvider.swift */; }; 20C9FA3E2787621B00A4D530 /* ConnectorUserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C9FA3D2787621B00A4D530 /* ConnectorUserProvider.swift */; }; 20C9FA44278791E400A4D530 /* ConnectorIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C9FA43278791E400A4D530 /* ConnectorIntegrationTests.swift */; }; + 20E6CCC92ABB488000F72385 /* AnyCodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20E6CCC82ABB488000F72385 /* AnyCodable.swift */; }; + 20F8C8C42AAFE2A100B5717C /* Murmur3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F8C8C32AAFE2A100B5717C /* Murmur3.swift */; }; + 20F8C8C72AAFE2B300B5717C /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F8C8C62AAFE2B300B5717C /* SemanticVersion.swift */; }; + 20F8C8CA2AAFE2BF00B5717C /* TopologicalSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F8C8C92AAFE2BF00B5717C /* TopologicalSort.swift */; }; + 20F8C8CD2AAFF6CB00B5717C /* EvaluationFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F8C8CC2AAFF6CB00B5717C /* EvaluationFlag.swift */; }; + 20F8C8D02AAFF6DC00B5717C /* Selectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F8C8CF2AAFF6DC00B5717C /* Selectable.swift */; }; + 20F8C8D32AAFF6E900B5717C /* EvaluationEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F8C8D22AAFF6E900B5717C /* EvaluationEngine.swift */; }; + 20F8C8D72AAFF9DA00B5717C /* Murmur3Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F8C8D62AAFF9DA00B5717C /* Murmur3Tests.swift */; }; + 20F8C8D92AB0D33600B5717C /* EnglishWords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F8C8D82AB0D33600B5717C /* EnglishWords.swift */; }; + 20F8C8DB2AB0D36400B5717C /* HashX8632.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F8C8DA2AB0D36400B5717C /* HashX8632.swift */; }; + 20F8C8DD2AB106CC00B5717C /* SemanticVersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F8C8DC2AB106CC00B5717C /* SemanticVersionTests.swift */; }; + 20F8C8DF2AB14B7100B5717C /* SelectableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F8C8DE2AB14B7100B5717C /* SelectableTests.swift */; }; + 20F8C8E12AB218C600B5717C /* TopologicalSortTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F8C8E02AB218C600B5717C /* TopologicalSortTests.swift */; }; 3E0148342921C08D004D259D /* FetchOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E0148332921C08D004D259D /* FetchOptions.swift */; }; E9030DB525B8AFC600BA1BA8 /* Variant.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9030DB425B8AFC600BA1BA8 /* Variant.swift */; }; E914961925796DA800C64B38 /* Experiment.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E914960F25796DA800C64B38 /* Experiment.framework */; }; @@ -31,11 +46,9 @@ E914962025796DA800C64B38 /* Experiment.h in Headers */ = {isa = PBXBuildFile; fileRef = E914961225796DA800C64B38 /* Experiment.h */; settings = {ATTRIBUTES = (Public, ); }; }; E9C5D91B2579718E00867574 /* DefaultUserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C5D9122579718E00867574 /* DefaultUserProvider.swift */; }; E9C5D91C2579718E00867574 /* ExperimentClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C5D9132579718E00867574 /* ExperimentClient.swift */; }; - E9C5D91D2579718E00867574 /* UserDefaultsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C5D9142579718E00867574 /* UserDefaultsStorage.swift */; }; E9C5D91E2579718E00867574 /* ExperimentConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C5D9152579718E00867574 /* ExperimentConfig.swift */; }; E9C5D91F2579718E00867574 /* ExperimentUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C5D9162579718E00867574 /* ExperimentUser.swift */; }; E9C5D9202579718E00867574 /* Experiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C5D9172579718E00867574 /* Experiment.swift */; }; - E9C5D9212579718E00867574 /* InMemoryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C5D9182579718E00867574 /* InMemoryStorage.swift */; }; E9C5D9222579718E00867574 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C5D9192579718E00867574 /* Storage.swift */; }; E9C5D9232579718E00867574 /* ExperimentUserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C5D91A2579718E00867574 /* ExperimentUserProvider.swift */; }; /* End PBXBuildFile section */ @@ -51,6 +64,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 200F89302AB2522C003AD279 /* EvaluationIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationIntegrationTests.swift; sourceTree = ""; }; 201A78DE2643AB6100663DCB /* ExperimentUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentUserTests.swift; sourceTree = ""; }; 2047CE302809FCD9002D2B06 /* UserSessionExposureTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionExposureTrackerTests.swift; sourceTree = ""; }; 20732759278E42B0002BBD43 /* SessionAnalyticsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAnalyticsProvider.swift; sourceTree = ""; }; @@ -63,6 +77,7 @@ 207CBB9726AB8C9800A0029D /* ExposureEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExposureEvent.swift; sourceTree = ""; }; 2093E39E26A7690D0036A930 /* ExperimentTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ExperimentTests-Bridging-Header.h"; sourceTree = ""; }; 2093E39F26A7690D0036A930 /* ObjectiveCTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ObjectiveCTest.m; sourceTree = ""; }; + 20A5A6E02AC3583E00047E7F /* LoadStoreCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadStoreCacheTests.swift; sourceTree = ""; }; 20B1BB8D2683CC2A003A960F /* Backoff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backoff.swift; sourceTree = ""; }; 20B1BF20268BBDA4003A960F /* VariantTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariantTests.swift; sourceTree = ""; }; 20B1BF2A268BFFD7003A960F /* UserDefaultsStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsStorageTests.swift; sourceTree = ""; }; @@ -72,6 +87,19 @@ 20C9FA3D2787621B00A4D530 /* ConnectorUserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectorUserProvider.swift; sourceTree = ""; }; 20C9FA43278791E400A4D530 /* ConnectorIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectorIntegrationTests.swift; sourceTree = ""; }; 20D7EDB62790B62D006DB118 /* AmplitudeCore.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = AmplitudeCore.xcframework; path = Carthage/Build/AmplitudeCore.xcframework; sourceTree = ""; }; + 20E6CCC82ABB488000F72385 /* AnyCodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyCodable.swift; sourceTree = ""; }; + 20F8C8C32AAFE2A100B5717C /* Murmur3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Murmur3.swift; sourceTree = ""; }; + 20F8C8C62AAFE2B300B5717C /* SemanticVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersion.swift; sourceTree = ""; }; + 20F8C8C92AAFE2BF00B5717C /* TopologicalSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopologicalSort.swift; sourceTree = ""; }; + 20F8C8CC2AAFF6CB00B5717C /* EvaluationFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationFlag.swift; sourceTree = ""; }; + 20F8C8CF2AAFF6DC00B5717C /* Selectable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Selectable.swift; sourceTree = ""; }; + 20F8C8D22AAFF6E900B5717C /* EvaluationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationEngine.swift; sourceTree = ""; }; + 20F8C8D62AAFF9DA00B5717C /* Murmur3Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Murmur3Tests.swift; sourceTree = ""; }; + 20F8C8D82AB0D33600B5717C /* EnglishWords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnglishWords.swift; sourceTree = ""; }; + 20F8C8DA2AB0D36400B5717C /* HashX8632.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashX8632.swift; sourceTree = ""; }; + 20F8C8DC2AB106CC00B5717C /* SemanticVersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersionTests.swift; sourceTree = ""; }; + 20F8C8DE2AB14B7100B5717C /* SelectableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableTests.swift; sourceTree = ""; }; + 20F8C8E02AB218C600B5717C /* TopologicalSortTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopologicalSortTests.swift; sourceTree = ""; }; 3E0148332921C08D004D259D /* FetchOptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchOptions.swift; sourceTree = ""; }; E9030DB425B8AFC600BA1BA8 /* Variant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Variant.swift; sourceTree = ""; }; E914960F25796DA800C64B38 /* Experiment.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Experiment.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -83,11 +111,9 @@ E9945CC325797C030066398B /* Package.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; E9C5D9122579718E00867574 /* DefaultUserProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultUserProvider.swift; sourceTree = ""; }; E9C5D9132579718E00867574 /* ExperimentClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExperimentClient.swift; sourceTree = ""; }; - E9C5D9142579718E00867574 /* UserDefaultsStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserDefaultsStorage.swift; sourceTree = ""; }; E9C5D9152579718E00867574 /* ExperimentConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExperimentConfig.swift; sourceTree = ""; }; E9C5D9162579718E00867574 /* ExperimentUser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExperimentUser.swift; sourceTree = ""; }; E9C5D9172579718E00867574 /* Experiment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Experiment.swift; sourceTree = ""; }; - E9C5D9182579718E00867574 /* InMemoryStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InMemoryStorage.swift; sourceTree = ""; }; E9C5D9192579718E00867574 /* Storage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; E9C5D91A2579718E00867574 /* ExperimentUserProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExperimentUserProvider.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -146,13 +172,17 @@ E914961125796DA800C64B38 /* Experiment */ = { isa = PBXGroup; children = ( + 20F8C8C32AAFE2A100B5717C /* Murmur3.swift */, + 20F8C8C62AAFE2B300B5717C /* SemanticVersion.swift */, + 20F8C8C92AAFE2BF00B5717C /* TopologicalSort.swift */, + 20F8C8CC2AAFF6CB00B5717C /* EvaluationFlag.swift */, + 20F8C8CF2AAFF6DC00B5717C /* Selectable.swift */, + 20F8C8D22AAFF6E900B5717C /* EvaluationEngine.swift */, 3E0148332921C08D004D259D /* FetchOptions.swift */, - E9C5D9142579718E00867574 /* UserDefaultsStorage.swift */, - E9C5D9182579718E00867574 /* InMemoryStorage.swift */, E9C5D9192579718E00867574 /* Storage.swift */, - E9C5D9122579718E00867574 /* DefaultUserProvider.swift */, E9C5D91A2579718E00867574 /* ExperimentUserProvider.swift */, E9C5D9172579718E00867574 /* Experiment.swift */, + E9C5D9122579718E00867574 /* DefaultUserProvider.swift */, E9C5D9132579718E00867574 /* ExperimentClient.swift */, E9C5D9152579718E00867574 /* ExperimentConfig.swift */, E9C5D9162579718E00867574 /* ExperimentUser.swift */, @@ -169,6 +199,7 @@ 207C96E527B71262008EE143 /* ExposureTrackingProvider.swift */, 207C96EB27B71770008EE143 /* Exposure.swift */, 207C96EF27B719F2008EE143 /* UserSessionExposureTracker.swift */, + 20E6CCC82ABB488000F72385 /* AnyCodable.swift */, ); path = Experiment; sourceTree = ""; @@ -176,6 +207,13 @@ E914961C25796DA800C64B38 /* ExperimentTests */ = { isa = PBXGroup; children = ( + 20F8C8D62AAFF9DA00B5717C /* Murmur3Tests.swift */, + 20F8C8D82AB0D33600B5717C /* EnglishWords.swift */, + 20F8C8DA2AB0D36400B5717C /* HashX8632.swift */, + 20F8C8DC2AB106CC00B5717C /* SemanticVersionTests.swift */, + 20F8C8DE2AB14B7100B5717C /* SelectableTests.swift */, + 20F8C8E02AB218C600B5717C /* TopologicalSortTests.swift */, + 200F89302AB2522C003AD279 /* EvaluationIntegrationTests.swift */, E914961F25796DA800C64B38 /* Info.plist */, E914961D25796DA800C64B38 /* ExperimentClientTests.swift */, 201A78DE2643AB6100663DCB /* ExperimentUserTests.swift */, @@ -185,6 +223,7 @@ 2093E39E26A7690D0036A930 /* ExperimentTests-Bridging-Header.h */, 20C9FA43278791E400A4D530 /* ConnectorIntegrationTests.swift */, 2047CE302809FCD9002D2B06 /* UserSessionExposureTrackerTests.swift */, + 20A5A6E02AC3583E00047E7F /* LoadStoreCacheTests.swift */, ); path = ExperimentTests; sourceTree = ""; @@ -320,6 +359,7 @@ buildActionMask = 2147483647; files = ( 207C96E627B71262008EE143 /* ExposureTrackingProvider.swift in Sources */, + 20E6CCC92ABB488000F72385 /* AnyCodable.swift in Sources */, 207C96F027B719F2008EE143 /* UserSessionExposureTracker.swift in Sources */, E9C5D9232579718E00867574 /* ExperimentUserProvider.swift in Sources */, E9C5D91F2579718E00867574 /* ExperimentUser.swift in Sources */, @@ -328,18 +368,22 @@ E9030DB525B8AFC600BA1BA8 /* Variant.swift in Sources */, 20C9FA3E2787621B00A4D530 /* ConnectorUserProvider.swift in Sources */, 207C96EC27B71770008EE143 /* Exposure.swift in Sources */, + 20F8C8D02AAFF6DC00B5717C /* Selectable.swift in Sources */, 207CBB9026AB8B9900A0029D /* ExperimentAnalyticsProvider.swift in Sources */, 207CBB9426AB8BD400A0029D /* ExperimentAnalyticsEvent.swift in Sources */, + 20F8C8CA2AAFE2BF00B5717C /* TopologicalSort.swift in Sources */, E9C5D91E2579718E00867574 /* ExperimentConfig.swift in Sources */, 20C9FA382787621100A4D530 /* ConnectorExposureTrackingProvider.swift in Sources */, E9C5D9202579718E00867574 /* Experiment.swift in Sources */, 2073275A278E42B0002BBD43 /* SessionAnalyticsProvider.swift in Sources */, - E9C5D91D2579718E00867574 /* UserDefaultsStorage.swift in Sources */, + 20F8C8CD2AAFF6CB00B5717C /* EvaluationFlag.swift in Sources */, + 20F8C8C42AAFE2A100B5717C /* Murmur3.swift in Sources */, + 20F8C8C72AAFE2B300B5717C /* SemanticVersion.swift in Sources */, 207CBB9826AB8C9800A0029D /* ExposureEvent.swift in Sources */, E9C5D9222579718E00867574 /* Storage.swift in Sources */, - E9C5D9212579718E00867574 /* InMemoryStorage.swift in Sources */, 20B1BB8E2683CC2A003A960F /* Backoff.swift in Sources */, E9C5D91B2579718E00867574 /* DefaultUserProvider.swift in Sources */, + 20F8C8D32AAFF6E900B5717C /* EvaluationEngine.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -347,13 +391,21 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 20F8C8DD2AB106CC00B5717C /* SemanticVersionTests.swift in Sources */, + 20F8C8DF2AB14B7100B5717C /* SelectableTests.swift in Sources */, 20B1BF2B268BFFD7003A960F /* UserDefaultsStorageTests.swift in Sources */, 20C9FA44278791E400A4D530 /* ConnectorIntegrationTests.swift in Sources */, + 200F89312AB2522C003AD279 /* EvaluationIntegrationTests.swift in Sources */, 201A78DF2643AB6100663DCB /* ExperimentUserTests.swift in Sources */, + 20F8C8E12AB218C600B5717C /* TopologicalSortTests.swift in Sources */, + 20F8C8D92AB0D33600B5717C /* EnglishWords.swift in Sources */, 2093E3A026A7690D0036A930 /* ObjectiveCTest.m in Sources */, E914961E25796DA800C64B38 /* ExperimentClientTests.swift in Sources */, 2047CE312809FCD9002D2B06 /* UserSessionExposureTrackerTests.swift in Sources */, 20B1BF21268BBDA4003A960F /* VariantTests.swift in Sources */, + 20F8C8DB2AB0D36400B5717C /* HashX8632.swift in Sources */, + 20F8C8D72AAFF9DA00B5717C /* Murmur3Tests.swift in Sources */, + 20A5A6E12AC3583E00047E7F /* LoadStoreCacheTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -403,7 +455,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -421,7 +473,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 10.0; - MACOSX_DEPLOYMENT_TARGET = 10.10; + MACOSX_DEPLOYMENT_TARGET = 10.13; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -429,6 +481,7 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2,6"; + TVOS_DEPLOYMENT_TARGET = 10.0; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; @@ -481,13 +534,14 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 10.0; - MACOSX_DEPLOYMENT_TARGET = 10.10; + MACOSX_DEPLOYMENT_TARGET = 10.13; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2,6"; + TVOS_DEPLOYMENT_TARGET = 10.0; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -514,7 +568,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.10; + MACOSX_DEPLOYMENT_TARGET = 10.13; PRODUCT_BUNDLE_IDENTIFIER = com.amplitude.Experiment; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; @@ -522,7 +576,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,6"; - TVOS_DEPLOYMENT_TARGET = 9.0; + TVOS_DEPLOYMENT_TARGET = 10.0; WATCHOS_DEPLOYMENT_TARGET = 3.0; }; name = Debug; @@ -547,14 +601,14 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.10; + MACOSX_DEPLOYMENT_TARGET = 10.13; PRODUCT_BUNDLE_IDENTIFIER = com.amplitude.Experiment; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos watchos watchsimulator appletvos appletvsimulator macosx"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,6"; - TVOS_DEPLOYMENT_TARGET = 9.0; + TVOS_DEPLOYMENT_TARGET = 10.0; WATCHOS_DEPLOYMENT_TARGET = 3.0; }; name = Release; @@ -573,7 +627,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.10; + MACOSX_DEPLOYMENT_TARGET = 10.13; PRODUCT_BUNDLE_IDENTIFIER = com.amplitude.ExperimentTests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos macosx appletvos appletvsimulator"; @@ -581,7 +635,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,6"; - TVOS_DEPLOYMENT_TARGET = 9.0; + TVOS_DEPLOYMENT_TARGET = 10.0; }; name = Debug; }; @@ -599,14 +653,14 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.10; + MACOSX_DEPLOYMENT_TARGET = 10.13; PRODUCT_BUNDLE_IDENTIFIER = com.amplitude.ExperimentTests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphonesimulator iphoneos macosx appletvos appletvsimulator"; SWIFT_OBJC_BRIDGING_HEADER = "Tests/ExperimentTests/ExperimentTests-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,6"; - TVOS_DEPLOYMENT_TARGET = 9.0; + TVOS_DEPLOYMENT_TARGET = 10.0; }; name = Release; }; diff --git a/Experiment.xcodeproj/xcshareddata/xcschemes/ExperimentTests.xcscheme b/Experiment.xcodeproj/xcshareddata/xcschemes/ExperimentTests.xcscheme index 5284df5..05bcfa3 100644 --- a/Experiment.xcodeproj/xcshareddata/xcschemes/ExperimentTests.xcscheme +++ b/Experiment.xcodeproj/xcshareddata/xcschemes/ExperimentTests.xcscheme @@ -50,6 +50,13 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> + + + + (_ value: T?) { + if value is NSNull { + self.value = nil + } else { + self.value = value + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self.init(NSNull()) + } else if let bool = try? container.decode(Bool.self) { + self.init(bool) + } else if let int = try? container.decode(Int.self) { + self.init(int) + } else if let uint = try? container.decode(UInt.self) { + self.init(uint) + } else if let double = try? container.decode(Double.self) { + self.init(double) + } else if let string = try? container.decode(String.self) { + self.init(string) + } else if let array = try? container.decode([AnyDecodable].self) { + self.init(array.map { $0.value }) + } else if let dictionary = try? container.decode([String: AnyDecodable].self) { + self.init(dictionary.mapValues { $0.value }) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyDecodable value cannot be decoded") + } + } +} + +internal struct AnyEncodable: Encodable { + + let value: Any + + init(_ value: T?) { + self.value = value ?? () + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch value { + case is NSNull: + try container.encodeNil() + case is Void: + try container.encodeNil() + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let int8 as Int8: + try container.encode(int8) + case let int16 as Int16: + try container.encode(int16) + case let int32 as Int32: + try container.encode(int32) + case let int64 as Int64: + try container.encode(int64) + case let uint as UInt: + try container.encode(uint) + case let uint8 as UInt8: + try container.encode(uint8) + case let uint16 as UInt16: + try container.encode(uint16) + case let uint32 as UInt32: + try container.encode(uint32) + case let uint64 as UInt64: + try container.encode(uint64) + case let float as Float: + try container.encode(float) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let number as NSNumber: + try encode(nsnumber: number, into: &container) + case let date as Date: + try container.encode(date) + case let url as URL: + try container.encode(url) + case let array as [Any?]: + try container.encode(array.map { AnyEncodable($0) }) + case let dictionary as [String: Any?]: + try container.encode(dictionary.mapValues { AnyEncodable($0) }) + case let encodable as Encodable: + try encodable.encode(to: encoder) + default: + let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyEncodable value cannot be encoded") + throw EncodingError.invalidValue(value, context) + } + } + + private func encode(nsnumber: NSNumber, into container: inout SingleValueEncodingContainer) throws { + switch Character(Unicode.Scalar(UInt8(nsnumber.objCType.pointee))) { + case "B": + try container.encode(nsnumber.boolValue) + case "c": + try container.encode(nsnumber.int8Value) + case "s": + try container.encode(nsnumber.int16Value) + case "i", "l": + try container.encode(nsnumber.int32Value) + case "q": + try container.encode(nsnumber.int64Value) + case "C": + try container.encode(nsnumber.uint8Value) + case "S": + try container.encode(nsnumber.uint16Value) + case "I", "L": + try container.encode(nsnumber.uint32Value) + case "Q": + try container.encode(nsnumber.uint64Value) + case "f": + try container.encode(nsnumber.floatValue) + case "d": + try container.encode(nsnumber.doubleValue) + default: + let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "NSNumber cannot be encoded because its type is not handled") + throw EncodingError.invalidValue(nsnumber, context) + } + } +} diff --git a/Sources/Experiment/Backoff.swift b/Sources/Experiment/Backoff.swift index d2896f7..b42ecfe 100644 --- a/Sources/Experiment/Backoff.swift +++ b/Sources/Experiment/Backoff.swift @@ -17,18 +17,19 @@ internal class Backoff { // Dispatch private let lock = DispatchSemaphore(value: 1) - private let fetchQueue = DispatchQueue(label: "com.amplitude.experiment.fetch.backoff", qos: .default) + private let fetchQueue: DispatchQueue // State private var started: Bool = false private var cancelled: Bool = false private var fetchTask: URLSessionTask? = nil - init(attempts: Int, min: Int, max: Int, scalar: Float) { + init(attempts: Int, min: Int, max: Int, scalar: Float, queue: DispatchQueue = DispatchQueue(label: "com.amplitude.experiment.backoff", qos: .default)) { self.attempts = attempts self.min = min self.max = max self.scalar = scalar + self.fetchQueue = queue } func start( diff --git a/Sources/Experiment/EvaluationEngine.swift b/Sources/Experiment/EvaluationEngine.swift new file mode 100644 index 0000000..895774e --- /dev/null +++ b/Sources/Experiment/EvaluationEngine.swift @@ -0,0 +1,355 @@ +// +// EvaluationEngine.swift +// Experiment +// +// Created by Brian Giori on 9/11/23. +// + +import Foundation + +internal class EvaluationEngine { + + struct EvaluationTarget : Selectable { + let context: [String: Any?] + var result: [String: EvaluationVariant] + + func select(selector: String) -> Any? { + switch selector { + case "context": return context + case "result": return result + default: return nil + } + } + } + + func evaluate(context: [String: Any?], flags: [EvaluationFlag]) -> [String: EvaluationVariant] { + var results: [String: EvaluationVariant] = [:] + var target = EvaluationTarget(context: context, result: results) + for flag in flags { + if let variant = evaluateFlag(target: target, flag: flag) { + results[flag.key] = variant + target.result = results + } + } + return results + } + + private func evaluateFlag(target: EvaluationTarget, flag: EvaluationFlag) -> EvaluationVariant? { + var result: EvaluationVariant? = nil + for segment in flag.segments { + if let segmentResult = evaluateSegment(target: target, flag: flag, segment: segment) { + // Merge all metadata into the result + let metadata = mergeMetadata(flag.metadata, segment.metadata, segmentResult.metadata) + result = EvaluationVariant(key: segmentResult.key, value: segmentResult.value, payload: segmentResult.payload, metadata: metadata) + break + } + } + return result + } + + private func evaluateSegment(target: EvaluationTarget, flag: EvaluationFlag, segment: EvaluationSegment) -> EvaluationVariant? { + guard let segmentConditions = segment.conditions else { + // Null conditions always match + if let variantKey = bucket(target: target, segment: segment) { + return flag.variants[variantKey] + } else { + return nil + } + } + // Outer logic is "or" (||) + for conditions in segmentConditions { + var match = true + // Inner list logic is "and" (&&) + for condition in conditions { + match = matchCondition(target: target, condition: condition) + if !match { + break + } + } + if match { + if let variantKey = bucket(target: target, segment: segment) { + return flag.variants[variantKey] + } else { + return nil + } + } + } + return nil + } + + private func matchCondition(target: EvaluationTarget, condition: EvaluationCondition) -> Bool { + let propValue = target.select(selector: condition.selector) + // We need special matching for null properties and set type prop values + // and operators. All other values are matched as strings, since the + // filter values are always strings. + if propValue == nil { + return matchNull(op: condition.op, filterValues: condition.values) + } else if (isSetOperator(op: condition.op)) { + guard let propValueStringList = coerceStringList(value: propValue) else { + return false + } + return matchSet(propValues: propValueStringList, op: condition.op, filterValues: condition.values) + } else { + guard let propValueString = coerceString(value: propValue) else { + return false + } + return matchString(propValue: propValueString, op: condition.op, filterValues: condition.values) + } + } + + private func getHash(key: String) -> Int64 { + let data = key.data(using: .utf8) ?? Data() + let hash = data.murmurHash32x86(seed: 0) + return Int64(hash) & 0xffffffff + } + + private func bucket(target: EvaluationTarget, segment: EvaluationSegment) -> String? { + // TODO: Implement + guard let segmentBucket = segment.bucket else { + // A null bucket means the segment is fully rolled out. Select the default variant. + return segment.variant + } + // Select the bucketing value. + let bucketingValue = coerceString(value: target.select(selector: segmentBucket.selector)) + // A null or empty bucketing value cannot be bucketed. Select the default variant. + guard let bucketingValue = bucketingValue else { + return segment.variant + } + if bucketingValue.isEmpty { + return segment.variant + } + // Salt and hash the value, and compute the allocation and distribution values. + let keyToHash = "\(segmentBucket.salt)/\(bucketingValue)" + let hash = getHash(key: keyToHash) + let allocationValue = hash % 100 + let distributionValue = hash / 100 + for allocation in segmentBucket.allocations { + let allocationStart = Int64(allocation.range[0]) + let allocationEnd = Int64(allocation.range[1]) + if (allocationStart..) -> Bool { + let containsNone = containsNone(filterValues: filterValues) + switch op { + case EvaluationOperator.IS, EvaluationOperator.CONTAINS, EvaluationOperator.LESS_THAN, + EvaluationOperator.LESS_THAN_EQUALS, EvaluationOperator.GREATER_THAN, + EvaluationOperator.GREATER_THAN_EQUALS, EvaluationOperator.VERSION_LESS_THAN, + EvaluationOperator.VERSION_LESS_THAN_EQUALS, EvaluationOperator.VERSION_GREATER_THAN, + EvaluationOperator.VERSION_GREATER_THAN_EQUALS, EvaluationOperator.SET_IS, + EvaluationOperator.SET_CONTAINS, EvaluationOperator.SET_CONTAINS_ANY: return containsNone + case EvaluationOperator.IS_NOT, EvaluationOperator.DOES_NOT_CONTAIN, + EvaluationOperator.SET_DOES_NOT_CONTAIN, EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY: return !containsNone + case EvaluationOperator.REGEX_MATCH: return false + case EvaluationOperator.REGEX_DOES_NOT_MATCH, EvaluationOperator.SET_IS_NOT: return true + default: return false + } + } + + private func matchSet(propValues: Set, op: String, filterValues: Set) -> Bool { + switch op { + case EvaluationOperator.SET_IS: return propValues == filterValues + case EvaluationOperator.SET_IS_NOT: return propValues != filterValues + case EvaluationOperator.SET_CONTAINS: return matchesSetContainsAll(propValues: propValues, filterValues: filterValues) + case EvaluationOperator.SET_DOES_NOT_CONTAIN: return !matchesSetContainsAll(propValues: propValues, filterValues: filterValues) + case EvaluationOperator.SET_CONTAINS_ANY: return matchesSetContainsAny(propValues: propValues, filterValues: filterValues) + case EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY: return !matchesSetContainsAny(propValues: propValues, filterValues: filterValues) + default: return false + } + } + + private func matchString(propValue: String, op: String, filterValues: Set) -> Bool { + switch op { + case EvaluationOperator.IS: return matchesIs(propValue: propValue, filterValues: filterValues) + case EvaluationOperator.IS_NOT: return !matchesIs(propValue: propValue, filterValues: filterValues) + case EvaluationOperator.CONTAINS: return matchesContains(propValue: propValue, filterValues: filterValues) + case EvaluationOperator.DOES_NOT_CONTAIN: return !matchesContains(propValue: propValue, filterValues: filterValues) + case EvaluationOperator.LESS_THAN, EvaluationOperator.LESS_THAN_EQUALS, EvaluationOperator.GREATER_THAN, EvaluationOperator.GREATER_THAN_EQUALS: + return matchesComparable(propValue: propValue, op: op, filterValues: filterValues) { value in + return self.parseDouble(value: value) + } + case EvaluationOperator.VERSION_LESS_THAN, EvaluationOperator.VERSION_LESS_THAN_EQUALS, EvaluationOperator.VERSION_GREATER_THAN, EvaluationOperator.VERSION_GREATER_THAN_EQUALS: + return matchesComparable(propValue: propValue, op: op, filterValues: filterValues) { value in + return SemanticVersion.parse(version: value) + } + case EvaluationOperator.REGEX_MATCH: return matchesRegex(propValue: propValue, filterValues: filterValues) + case EvaluationOperator.REGEX_DOES_NOT_MATCH: return !matchesRegex(propValue: propValue, filterValues: filterValues) + default: return false + } + } + + private func matchesIs(propValue: String, filterValues: Set) -> Bool { + if containsBooleans(filterValues: filterValues) { + let lower = propValue.lowercased() + if lower == "true" || lower == "false" { + return filterValues.contains { $0.lowercased() == lower } + } + } + return filterValues.contains(propValue) + } + + private func matchesContains(propValue: String, filterValues: Set) -> Bool { + for filterValue in filterValues { + if propValue.lowercased().contains(filterValue.lowercased()) { + return true + } + } + return false + } + + private func matchesComparable(propValue: String, op: String, filterValues: Set, transformer: @escaping (String) -> T?) -> Bool { + let propValueTransformed = transformer(propValue) + let filterValuesTransformed = filterValues.map(transformer).filter { $0 != nil } as! [T] + if propValueTransformed == nil || filterValuesTransformed.isEmpty { + // If the prop value or none of the filter values transform, fall + // back on string comparison. + return filterValues.contains { filterValue in + matchesComparable(propValue: propValue, op: op, filterValue: filterValue) + } + } else { + return filterValuesTransformed.contains { filterValueTransformed in + matchesComparable(propValue: propValueTransformed!, op: op, filterValue: filterValueTransformed) + } + } + } + + private func matchesComparable(propValue: T, op: String, filterValue: T) -> Bool { + switch op { + case EvaluationOperator.LESS_THAN, EvaluationOperator.VERSION_LESS_THAN: return propValue < filterValue + case EvaluationOperator.LESS_THAN_EQUALS, EvaluationOperator.VERSION_LESS_THAN_EQUALS: return propValue <= filterValue + case EvaluationOperator.GREATER_THAN, EvaluationOperator.VERSION_GREATER_THAN: return propValue > filterValue + case EvaluationOperator.GREATER_THAN_EQUALS, EvaluationOperator.VERSION_GREATER_THAN_EQUALS: return propValue >= filterValue + default: return false + } + } + + private func matchesRegex(propValue: String, filterValues: Set) -> Bool { + return filterValues.contains { filterValue in + propValue.range(of: filterValue, options: .regularExpression) != nil + } + } + + private func matchesSetContainsAll(propValues: Set, filterValues: Set) -> Bool { + if propValues.count < filterValues.count { + return false + } + for filterValue in filterValues { + if !matchesIs(propValue: filterValue, filterValues: propValues) { + return false + } + } + return true + } + + private func matchesSetContainsAny(propValues: Set, filterValues: Set) -> Bool { + for filterValue in filterValues { + if matchesIs(propValue: filterValue, filterValues: propValues) { + return true + } + } + return false + } + + private func parseDouble(value: String) -> Double? { + return Double.init(value) + } + + private func coerceString(value: Any?) -> String? { + guard let value = value else { + return nil + } + if let stringValue = value as? String { + return stringValue + } else if let jsonData = try? JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed) { + return String(data: jsonData, encoding: .utf8) + } else { + return nil + } + } + + private func coerceStringList(value: Any?) -> Set? { + guard let value = value else { + return nil + } + // Convert sequences to a set of strings + if let sequence = value as? NSArray { + return sequenceToSet(sequence: sequence) + } + if let sequence = value as? Array { + return sequenceToSet(sequence: sequence) + } + // Parse the string value as a json array and convert to a set of strings + // or return nil if the string could not be parsed as a json array. + guard let dataValue = coerceString(value: value)?.data(using: .utf8) else { + return nil + } + if let nsArray = try? JSONSerialization.jsonObject(with: dataValue) as? NSArray { + var result = Set() + for element in nsArray { + if let stringElement = coerceString(value: element) { + result.insert(stringElement) + } + } + return result + } + + return nil + } + + private func sequenceToSet(sequence: any Sequence) -> Set? { + var result = Set() + for element in sequence { + if let stringElement = coerceString(value: element) { + result.insert(stringElement) + } + } + return result + } + + private func containsNone(filterValues: Set) -> Bool { + return filterValues.contains("(none)") + } + + private func containsBooleans(filterValues: Set) -> Bool { + return filterValues.contains { filterValue in + let lower = filterValue.lowercased() + return lower == "true" || lower == "false" + } + } + + private func isSetOperator(op: String) -> Bool { + switch op { + case EvaluationOperator.SET_IS: return true + case EvaluationOperator.SET_IS_NOT: return true + case EvaluationOperator.SET_CONTAINS: return true + case EvaluationOperator.SET_DOES_NOT_CONTAIN: return true + case EvaluationOperator.SET_CONTAINS_ANY: return true + case EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY: return true + default: return false + } + } + + private func mergeMetadata(_ m1: [String: Any?]?, _ m2: [String: Any?]?, _ m3: [String: Any?]?) -> [String: Any?]? { + var mergedMetadata = m1 ?? [:] + if let m2 = m2 { + mergedMetadata = mergedMetadata.merging(m2, uniquingKeysWith: { (_, other) in other }) + } + if let m3 = m3 { + mergedMetadata = mergedMetadata.merging(m3, uniquingKeysWith: { (_, other) in other }) + } + if mergedMetadata.count == 0 { + return nil + } + return mergedMetadata + } +} diff --git a/Sources/Experiment/EvaluationFlag.swift b/Sources/Experiment/EvaluationFlag.swift new file mode 100644 index 0000000..57e1c4a --- /dev/null +++ b/Sources/Experiment/EvaluationFlag.swift @@ -0,0 +1,203 @@ +// +// EvaluationFlag.swift +// Experiment +// +// Created by Brian Giori on 9/11/23. +// + +import Foundation + +internal struct EvaluationFlag: Codable { + let key: String + let variants: [String: EvaluationVariant] + let segments: [EvaluationSegment] + let dependencies: [String]? + let metadata: [String: Any?]? +} + +internal struct EvaluationSegment: Codable { + let bucket: EvaluationBucket? + let conditions: [[EvaluationCondition]]? + let variant: String? + let metadata: [String: Any?]? +} + +internal struct EvaluationBucket: Codable { + let selector: [String] + let salt: String + let allocations: [EvaluationAllocation] +} + +internal struct EvaluationCondition: Codable { + let selector: [String] + let op: String + let values: Set +} + +internal struct EvaluationAllocation: Codable { + let range: [Int] + let distributions: [EvaluationDistribution] +} + +internal struct EvaluationDistribution: Codable { + let variant: String + let range: [Int] +} + +internal struct EvaluationVariant: Codable, Selectable { + let key: String? + let value: Any? + let payload: Any? + let metadata: [String: Any?]? +} + +internal class EvaluationOperator { + static let IS = "is" + static let IS_NOT = "is not" + static let CONTAINS = "contains" + static let DOES_NOT_CONTAIN = "does not contain" + static let LESS_THAN = "less" + static let LESS_THAN_EQUALS = "less or equal" + static let GREATER_THAN = "greater" + static let GREATER_THAN_EQUALS = "greater or equal" + static let VERSION_LESS_THAN = "version less" + static let VERSION_LESS_THAN_EQUALS = "version less or equal" + static let VERSION_GREATER_THAN = "version greater" + static let VERSION_GREATER_THAN_EQUALS = "version greater or equal" + static let SET_IS = "set is" + static let SET_IS_NOT = "set is not" + static let SET_CONTAINS = "set contains" + static let SET_DOES_NOT_CONTAIN = "set does not contain" + static let SET_CONTAINS_ANY = "set contains any" + static let SET_DOES_NOT_CONTAIN_ANY = "set does not contain any" + static let REGEX_MATCH = "regex match" + static let REGEX_DOES_NOT_MATCH = "regex does not match" +} + +// Selectable Extensions + +internal extension EvaluationVariant { + + func select(selector: String) -> Any? { + switch selector { + case "key": return key + case "value": return value + case "payload": return payload + case "metadata": return metadata + default: return nil + } + } +} + +// Codable Extensions + +internal extension EvaluationFlag { + + enum CodingKeys: CodingKey { + case key + case variants + case segments + case dependencies + case metadata + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.key = try container.decode(String.self, forKey: .key) + self.variants = try container.decode([String: EvaluationVariant].self, forKey: .variants) + self.segments = try container.decode([EvaluationSegment].self, forKey: .segments) + self.dependencies = try? container.decode([String].self, forKey: .dependencies) + let metadata = try? container.decode([String: AnyDecodable].self, forKey: .metadata) + self.metadata = metadata?.mapValues { anyDecodable in anyDecodable.value } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(key, forKey: .key) + try container.encode(variants, forKey: .variants) + try container.encode(segments, forKey: .segments) + try? container.encodeIfPresent(dependencies, forKey: .dependencies) + if let metadata = metadata { + try? container.encodeIfPresent(AnyEncodable(metadata), forKey: .metadata) + } + } +} + +internal extension EvaluationSegment { + + enum CodingKeys: CodingKey { + case bucket + case conditions + case variant + case metadata + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.bucket = try? container.decode(EvaluationBucket.self, forKey: .bucket) + self.conditions = try? container.decode([[EvaluationCondition]].self, forKey: .conditions) + self.variant = try? container.decode(String.self, forKey: .variant) + let metadata = try? container.decode([String: AnyDecodable].self, forKey: .metadata) + self.metadata = metadata?.mapValues { anyDecodable in anyDecodable.value } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try? container.encodeIfPresent(bucket, forKey: .bucket) + try? container.encodeIfPresent(conditions, forKey: .conditions) + try? container.encodeIfPresent(variant, forKey: .variant) + if let metadata = metadata { + try? container.encodeIfPresent(AnyEncodable(metadata), forKey: .metadata) + } + } +} + +internal extension EvaluationVariant { + + enum CodingKeys: CodingKey { + case key + case value + case payload + case metadata + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.key = try? container.decode(String.self, forKey: .key) + self.value = try? container.decode(AnyDecodable.self, forKey: .value).value + self.payload = try? container.decode(AnyDecodable.self, forKey: .payload).value + let metadata = try? container.decode([String: AnyDecodable].self, forKey: .metadata) + self.metadata = metadata?.mapValues { anyDecodable in anyDecodable.value } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try? container.encodeIfPresent(key, forKey: .key) + if let value = value { + try? container.encodeIfPresent(AnyEncodable(value), forKey: .value) + } + if let payload = payload { + try? container.encodeIfPresent(AnyEncodable(payload), forKey: .payload) + } + if let metadata = metadata { + try? container.encodeIfPresent(AnyEncodable(metadata), forKey: .metadata) + } + } +} + +// Utility Extensions + +internal extension EvaluationFlag { + func isLocalEvaluationMode() -> Bool { + if let evaluationMode = self.metadata?["evaluationMode"] as? String, evaluationMode == "local" { + return true + } + return false + } + func isRemoteEvaluationMode() -> Bool { + if let evaluationMode = self.metadata?["evaluationMode"] as? String, evaluationMode == "remote" { + return true + } + return false + } +} diff --git a/Sources/Experiment/Experiment.swift b/Sources/Experiment/Experiment.swift index db29fff..1a479f3 100644 --- a/Sources/Experiment/Experiment.swift +++ b/Sources/Experiment/Experiment.swift @@ -22,7 +22,7 @@ import AnalyticsConnector if (instance != nil) { return instance! } - let storage = UserDefaultsStorage(instanceName: instanceName, apiKey: apiKey) + let storage = UserDefaultsStorage() let newInstance: ExperimentClient = DefaultExperimentClient( apiKey: apiKey, config: config, @@ -52,7 +52,7 @@ import AnalyticsConnector if config.exposureTrackingProvider == nil { configBuilder.exposureTrackingProvider(ConnectorExposureTrackingProvider(eventBridge: connector.eventBridge)) } - let storage = UserDefaultsStorage(instanceName: instanceName, apiKey: apiKey) + let storage = UserDefaultsStorage() let newInstance: ExperimentClient = DefaultExperimentClient( apiKey: apiKey, config: configBuilder.build(), diff --git a/Sources/Experiment/ExperimentClient.swift b/Sources/Experiment/ExperimentClient.swift index 56689ce..befdc3e 100644 --- a/Sources/Experiment/ExperimentClient.swift +++ b/Sources/Experiment/ExperimentClient.swift @@ -8,6 +8,7 @@ import Foundation @objc public protocol ExperimentClient { + @objc func start(_ user: ExperimentUser?, completion: ((Error?) -> Void)?) -> Void @objc func fetch(user: ExperimentUser?, completion: ((ExperimentClient, Error?) -> Void)?) @objc func fetch(user: ExperimentUser?, options: FetchOptions?, completion: ((ExperimentClient, Error?) -> Void)?) @objc func variant(_ key: String) -> Variant @@ -30,12 +31,26 @@ private let fetchBackoffMinMillis = 500 private let fetchBackoffMaxMillis = 10000 private let fetchBackoffScalar: Float = 1.5 +private let euServerUrl = "https://api.lab.eu.amplitude.com"; +private let euFlagsServerUrl = "https://flag.lab.eu.amplitude.com"; + internal class DefaultExperimentClient : NSObject, ExperimentClient { private let apiKey: String - private let storage: Storage - private let storageLock = DispatchSemaphore(value: 1) - private let config: ExperimentConfig + + internal let variants: LoadStoreCache + private let variantsStorageQueue = DispatchQueue(label: "com.amplitude.experiment.VariantsStorageQueue", attributes: .concurrent) + + internal let flags: LoadStoreCache + private let flagsStorageQueue = DispatchQueue(label: "com.amplitude.experiment.VariantsStorageQueue", attributes: .concurrent) + + internal let config: ExperimentConfig + private let engine = EvaluationEngine() + + private var isRunning = false + private let runningLock = DispatchSemaphore(value: 1) + private var poller: DispatchSourceTimer? = nil + private var pollerQueue = DispatchQueue(label: "com.amplitude.experiment.PollerQueue", qos: .background) private var user: ExperimentUser? = nil private var userProvider: ExperimentUserProvider? = DefaultUserProvider() @@ -47,10 +62,16 @@ internal class DefaultExperimentClient : NSObject, ExperimentClient { private let backoffLock = DispatchSemaphore(value: 1) private let fetchQueue = DispatchQueue(label: "com.amplitude.experiment.FetchQueue") + private let flagsQueue = DispatchQueue(label: "com.amplitude.experiment.FlagsQueue") + private let startQueue = DispatchQueue(label: "com.amplitude.experiment.StartQueue") internal init(apiKey: String, config: ExperimentConfig, storage: Storage) { self.apiKey = apiKey - self.config = config + let configBuilder = config.copyToBuilder() + if config.serverUrl == ExperimentConfig.Defaults.serverUrl && config.flagsServerUrl == ExperimentConfig.Defaults.flagsServerUrl && config.serverZone == .EU { + configBuilder.serverUrl(euServerUrl).flagsServerUrl(euFlagsServerUrl) + } + self.config = configBuilder.build() if config.userProvider != nil { self.userProvider = config.userProvider } @@ -65,8 +86,92 @@ internal class DefaultExperimentClient : NSObject, ExperimentClient { } else { self.userSessionExposureTracker = nil } - self.storage = storage - self.storage.load() + self.variants = getVariantStorage(apiKey: self.apiKey, instanceName: self.config.instanceName, storage: storage) + self.variants.load() + self.flags = getFlagStorage(apiKey: self.apiKey, instanceName: self.config.instanceName, storage: storage) + self.flags.load() + } + + public func start(_ user: ExperimentUser? = nil, completion: ((Error?) -> Void)? = nil) -> Void { + runningLock.wait() + if isRunning { + runningLock.signal() + return + } + isRunning = true + if self.config.pollOnStart { + let timer = DispatchSource.makeTimerSource(queue: pollerQueue) + timer.schedule(deadline: .now() + .seconds(60), repeating: .seconds(60)) + timer.setEventHandler { self.flagsInternal() } + timer.activate() + self.poller = timer + } + runningLock.signal() + setUser(user) + // Determine to fetch remove evaluation variants on start, either using the configured setting, or dynamically by checking the flag cache. + var remoteFlags = config.fetchOnStart?.boolValue ?? flagsStorageQueue.sync { self.flags.getAll() }.contains { (_, flag: EvaluationFlag) in + flag.isRemoteEvaluationMode() + } + startQueue.async { + var error: Error? = nil + if (remoteFlags) { + // We already have remote flags in our flag cache, so we know we need to + // evaluate remotely even before we've updated our flags. + let startGroup = DispatchGroup() + startGroup.enter() + startGroup.enter() + self.flagsInternal { e in + if let e = e { + error = e + } + startGroup.leave() + } + self.fetch(user: user) { _, e in + if let e = e { + error = e + } + startGroup.leave() + } + startGroup.wait() + } else { + // We don't know if remote evaluation is required, await the flag promise, + // and recheck for remote flags. + let flagsGroup = DispatchGroup() + flagsGroup.enter() + self.flagsInternal { e in + if let e = e { + error = e + } + flagsGroup.leave() + } + flagsGroup.wait() + remoteFlags = self.config.fetchOnStart?.boolValue ?? self.flagsStorageQueue.sync { self.flags.getAll() }.contains { (_, flag: EvaluationFlag) in + flag.isRemoteEvaluationMode() + } + if (remoteFlags) { + let fetchGroup = DispatchGroup() + fetchGroup.enter() + self.fetch(user: user) { _, e in + if let e = e { + error = e + } + fetchGroup.leave() + } + fetchGroup.wait() + } + } + completion?(error) + } + } + + public func stop() { + self.runningLock.wait() + if isRunning { + isRunning = false + poller?.cancel() + self.poller = nil + } + self.runningLock.signal() } public func fetch(user: ExperimentUser?, completion: ((ExperimentClient, Error?) -> Void)? = nil) -> Void { @@ -104,28 +209,35 @@ internal class DefaultExperimentClient : NSObject, ExperimentClient { } public func variant(_ key: String, fallback: Variant?) -> Variant { - let variantAndSource = resolveVariantAndSource(key: key, fallback: fallback) - let variant = variantAndSource.variant; - let source = variantAndSource.source; + let variantAndSource = variantAndSource(key: key, fallback: fallback) if (config.automaticExposureTracking) { - exposureInternal(key: key, variant: variant, source: source) + exposureInternal(key: key, variantAndSource: variantAndSource) } - return variant + return variantAndSource.variant } public func all() -> [String: Variant] { - return sourceVariants().merging(secondaryVariants()) { (source, _) in source } + var localVariants = evaluate() + for flagKey in localVariants.keys { + if let flag = flagsStorageQueue.sync(execute: { flags.get(key: flagKey) }), !flag.isLocalEvaluationMode() { + localVariants.removeValue(forKey: flagKey) + } + } + let remoteAndSecondaryVariants = sourceVariants().merging(secondaryVariants()) { (source, _) in source } + return localVariants.merging(remoteAndSecondaryVariants) { (local, _) in local } } // Clear all variants in the cache and storage. public func clear() { - self.storage.clear(); - self.storage.save(); + variantsStorageQueue.sync(flags: .barrier) { + self.variants.clear(); + self.variants.store(); + } } public func exposure(key: String) { - let variantAndSource = resolveVariantAndSource(key: key, fallback: nil) - exposureInternal(key: key, variant: variantAndSource.variant, source: variantAndSource.source) + let variantAndSource = variantAndSource(key: key, fallback: nil) + exposureInternal(key: key, variantAndSource: variantAndSource) } public func getUser() -> ExperimentUser? { @@ -146,47 +258,148 @@ internal class DefaultExperimentClient : NSObject, ExperimentClient { self.userProvider = userProvider return self } - - private func resolveVariantAndSource(key: String, fallback: Variant?) -> VariantAndSource { - if (config.source == Source.InitialVariants) { - // for source = InitialVariants, fallback order goes: - // 1. InitialFlags - // 2. Local Storage - // 3. Function fallback - // 4. Config fallback - - let sourceVariant = sourceVariants()[key]; - if let variant = sourceVariant { - return VariantAndSource(variant: variant, source: VariantSource.InitialVariants) - } - let secondaryVariant = secondaryVariants()[key] - if let variant = secondaryVariant { - return VariantAndSource(variant: variant, source: VariantSource.SecondaryLocalStorage) - } - if let variant = fallback { - return VariantAndSource(variant: variant, source: VariantSource.FallbackInline) - } - return VariantAndSource(variant: config.fallbackVariant, source: VariantSource.FallbackConfig) - } else { - // for source = LocalStorage, fallback order goes: - // 1. Local Storage - // 2. Function fallback - // 3. InitialFlags - // 4. Config fallback - - let sourceVariant = sourceVariants()[key]; - if let variant = sourceVariant { - return VariantAndSource(variant: variant, source: VariantSource.LocalStorage) - } - if let variant = fallback { - return VariantAndSource(variant: variant, source: VariantSource.FallbackInline) - } - let secondaryVariant = secondaryVariants()[key] - if let variant = secondaryVariant { - return VariantAndSource(variant: variant, source: VariantSource.SecondaryInitialVariants) + + private func evaluate(flagKeys: [String] = []) -> [String: Variant] { + var keys: [String]? = nil + if !flagKeys.isEmpty { + keys = flagKeys + } + let user = mergeUserWithProvider() + do { + let storageFlags = flagsStorageQueue.sync { self.flags.getAll() } + let flags = try topologicalSort(flags: storageFlags, flagKeys: keys) + let evaluationVariants = engine.evaluate(context: user.toEvaluationContext(), flags: flags) + return evaluationVariants.mapValues { evaluationVariant in + evaluationVariant.toVariant() } - return VariantAndSource(variant: config.fallbackVariant, source: VariantSource.FallbackConfig) + } catch { + print("[Experiment] encountered evaluation error: \(error)") + return [:] + } + } + + /** + * For Source.LocalStorage, fallback order goes: + * + * 1. Local Storage + * 2. Inline function fallback + * 3. InitialFlags + * 4. Config fallback + * + * If there is a default variant and no fallback, return the default variant. + */ + private func localStorageVariantAndSource(key: String, fallback: Variant?) -> VariantAndSource { + var defaultVariantAndSource: VariantAndSource = VariantAndSource() + // Local storage + let localStorageVariant = variantsStorageQueue.sync { variants.get(key: key) } + let isLocalStorageDefault = localStorageVariant?.isDefaultVariant() ?? false + if let localStorageVariant = localStorageVariant, !isLocalStorageDefault { + return VariantAndSource(variant: localStorageVariant, source: .LocalStorage, hasDefaultVariant: false) + } else if (isLocalStorageDefault) { + defaultVariantAndSource = VariantAndSource(variant: localStorageVariant ?? Variant(), source: .LocalStorage, hasDefaultVariant: true) + } + // Inline fallback + if let fallback = fallback { + return VariantAndSource(variant: fallback, source: .FallbackInline, hasDefaultVariant: defaultVariantAndSource.hasDefaultVariant) + } + // Initial variants + if let initialVariant = config.initialVariants[key] { + return VariantAndSource(variant: initialVariant, source: .SecondaryInitialVariants, hasDefaultVariant: defaultVariantAndSource.hasDefaultVariant) + } + // Configured fallback, or default variant + if !config.fallbackVariant.isEmpty() { + return VariantAndSource(variant: config.fallbackVariant, source: .FallbackConfig, hasDefaultVariant: defaultVariantAndSource.hasDefaultVariant) + } + return defaultVariantAndSource + } + + /** + * For Source.InitialVariants, fallback order goes: + * + * 1. Initial variants + * 2. Local storage + * 3. Inline function fallback + * 4. Config fallback + * + * If there is a default variant and no fallback, return the default variant. + */ + private func initialVariantsVariantAndSource(key: String, fallback: Variant?) -> VariantAndSource { + var defaultVariantAndSource: VariantAndSource = VariantAndSource() + // Initial variants + if let initialVariant = config.initialVariants[key] { + return VariantAndSource(variant: initialVariant, source: .InitialVariants, hasDefaultVariant: defaultVariantAndSource.hasDefaultVariant) + } + // Local storage + let localStorageVariant = variantsStorageQueue.sync { variants.get(key: key) } + let isLocalStorageDefault = localStorageVariant?.isDefaultVariant() ?? false + if let localStorageVariant = localStorageVariant, !isLocalStorageDefault { + return VariantAndSource(variant: localStorageVariant, source: .LocalStorage, hasDefaultVariant: false) + } else if (isLocalStorageDefault) { + defaultVariantAndSource = VariantAndSource(variant: localStorageVariant ?? Variant(), source: .LocalStorage, hasDefaultVariant: true) + } + // Inline fallback + if let fallback = fallback { + return VariantAndSource(variant: fallback, source: .FallbackInline, hasDefaultVariant: defaultVariantAndSource.hasDefaultVariant) } + // Configured fallback, or default variant + if !config.fallbackVariant.isEmpty() { + return VariantAndSource(variant: config.fallbackVariant, source: .FallbackConfig, hasDefaultVariant: defaultVariantAndSource.hasDefaultVariant) + } + return defaultVariantAndSource + } + + /** + * This function assumes the flag exists and is local evaluation mode. For + * local evaluation, fallback order goes: + * + * 1. Local evaluation + * 2. Inline function fallback + * 3. Initial variants + * 4. Config fallback + * + * If there is a default variant and no fallback, return the default variant. + */ + private func localEvalautionVariantAndSource(key: String, flag: EvaluationFlag, fallback: Variant?) -> VariantAndSource { + var defaultVariantAndSource: VariantAndSource = VariantAndSource() + // Local evaluation + let variant = self.evaluate(flagKeys: [flag.key])[key] + let source = VariantSource.LocalEvaluation + let isLocalEvaluationDefault = variant?.isDefaultVariant() ?? false + if let variant = variant, !isLocalEvaluationDefault { + return VariantAndSource(variant: variant, source: source, hasDefaultVariant: false) + } else if isLocalEvaluationDefault { + defaultVariantAndSource = VariantAndSource(variant: variant ?? Variant(), source: source, hasDefaultVariant: true) + } + // Inline fallback + if let fallback = fallback { + return VariantAndSource(variant: fallback, source: .FallbackInline, hasDefaultVariant: defaultVariantAndSource.hasDefaultVariant) + } + // Initial variants + if let initialVariant = config.initialVariants[key] { + return VariantAndSource(variant: initialVariant, source: .SecondaryInitialVariants, hasDefaultVariant: defaultVariantAndSource.hasDefaultVariant) + } + // Configured fallback, or default variant + if !config.fallbackVariant.isEmpty() { + return VariantAndSource(variant: config.fallbackVariant, source: .FallbackConfig, hasDefaultVariant: defaultVariantAndSource.hasDefaultVariant) + } + return defaultVariantAndSource + } + + private func variantAndSource(key: String, fallback: Variant?) -> VariantAndSource { + var variantAndSource: VariantAndSource = VariantAndSource() + switch config.source { + case .LocalStorage: + variantAndSource = localStorageVariantAndSource(key: key, fallback: fallback) + case .InitialVariants: + variantAndSource = initialVariantsVariantAndSource(key: key, fallback: fallback) + } + guard let flag = flagsStorageQueue.sync(execute: { flags.get(key: key) }) else { + return variantAndSource + } + if flag.isLocalEvaluationMode() || (variantAndSource.variant.isEmpty()) { + variantAndSource = localEvalautionVariantAndSource(key: key, flag: flag, fallback: fallback) + } + return variantAndSource } public func fetchInternal( @@ -214,9 +427,72 @@ internal class DefaultExperimentClient : NSObject, ExperimentClient { } } } + + private func flagsInternal(completion: ((Error?) -> Void)? = nil) { + flagsQueue.async { + self.debug("Updating flag configurations") + return self.doFlags(timeoutMillis: self.config.fetchTimeoutMillis) { result in + switch result { + case .success(let flags): + self.debug("Got \(flags.count) flag configurations") + self.flagsStorageQueue.sync(flags: .barrier) { + self.flags.clear() + self.flags.putAll(values: flags) + self.flags.store() + } + completion?(nil) + case .failure(let error): + print("[Expeirment] get flags failed: \(error)") + completion?(error) + } + } + } + } + + // Must be run on flagsQueue + internal func doFlags( + timeoutMillis: Int, + completion: @escaping ((Result<[String: EvaluationFlag], Error>) -> Void) + ) { + let url = URL(string: "\(config.flagsServerUrl)/sdk/v2/flags")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Api-Key \(apiKey)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = Double(timeoutMillis) / 1000.0 + // Do fetch request + URLSession.shared.dataTask(with: request) { (data, response, error) in + if let error = error { + completion(Result.failure(error)) + return + } + guard let httpResponse = response as? HTTPURLResponse else { + completion(Result.failure(ExperimentError("Response is nil"))) + return + } + guard httpResponse.statusCode == 200 else { + completion(Result.failure(ExperimentError("Error Response: status=\(httpResponse.statusCode)"))) + return + } + guard let data = data else { + completion(Result.failure(ExperimentError("Flag response data is nil"))) + return + } + do { + let flags = try JSONDecoder().decode([EvaluationFlag].self, from: data) + var result: [String: EvaluationFlag] = [:] + for flag in flags { + result[flag.key] = flag + } + completion(Result.success(result)) + } catch { + print("[Experiment] Failed to parse flag data: \(error)") + completion(Result.failure(error)) + } + }.resume() + } // Must be run on fetchQueue - public func doFetch( + internal func doFetch( user: ExperimentUser, timeoutMillis: Int, options: FetchOptions?, @@ -237,7 +513,7 @@ internal class DefaultExperimentClient : NSObject, ExperimentClient { return nil } let userB64EncodedUrl = base64EncodeData(requestData) - let url = URL(string: "\(self.config.serverUrl)/sdk/vardata")! + let url = URL(string: "\(self.config.serverUrl)/sdk/v2/vardata?v=0")! var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Api-Key \(self.apiKey)", forHTTPHeaderField: "Authorization") @@ -267,6 +543,10 @@ internal class DefaultExperimentClient : NSObject, ExperimentClient { completion(Result.failure(ExperimentError("Error Response: status=\(httpResponse.statusCode)"))) return } + guard let data = data else { + completion(Result.failure(ExperimentError("Response data is nil"))) + return + } do { let variants = try self.parseResponseData(data) let end = CFAbsoluteTimeGetCurrent() @@ -344,43 +624,31 @@ internal class DefaultExperimentClient : NSObject, ExperimentClient { guard let data = data else { throw ExperimentError("Response data is nil") } - let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) - guard let keys = jsonObject as? [String: [String: Any]] else { - throw ExperimentError("Failed to cast response json to [String: [String: Any]]") - } - var variants = [String: Variant]() - for (key, value) in keys { - if let variant = Variant(json: value) { - variants[key] = variant - } - } - return variants + return try JSONDecoder().decode([String: Variant].self, from: data) } private func storeVariants(_ variants: [String: Variant], _ options: FetchOptions?) { - storageLock.wait() - defer { storageLock.signal() } - if (options?.flagKeys == nil) { - storage.clear() - } - var failedKeys: [String] = options?.flagKeys ?? [] - for (key, variant) in variants { - failedKeys.removeAll { $0 == key } - self.storage.put(key: key, value: variant) - } - for (key) in failedKeys { - self.storage.remove(key: key) + variantsStorageQueue.sync(flags: .barrier) { + if (options?.flagKeys == nil) { + self.variants.clear() + } + var failedKeys: [String] = options?.flagKeys ?? [] + for (key, variant) in variants { + failedKeys.removeAll { $0 == key } + self.variants.put(key: key, value: variant) + } + for (key) in failedKeys { + self.variants.remove(key: key) + } + self.variants.store() + self.debug("Stored variants: \(variants)") } - storage.save() - self.debug("Stored variants: \(variants)") } private func sourceVariants() -> [String: Variant] { switch config.source { case .LocalStorage: - storageLock.wait() - defer { storageLock.signal() } - return storage.getAll() + return variantsStorageQueue.sync { variants.getAll() } case .InitialVariants: return config.initialVariants } @@ -391,36 +659,65 @@ internal class DefaultExperimentClient : NSObject, ExperimentClient { case .LocalStorage: return config.initialVariants case .InitialVariants: - storageLock.wait() - defer { storageLock.signal() } - return storage.getAll() + return variantsStorageQueue.sync { variants.getAll() } + } + } + + private func exposureInternal(key: String, variantAndSource: VariantAndSource) { + legacyExposureInternal(key: key, variantAndSource: variantAndSource) + guard let userSessionExposureTracker = self.userSessionExposureTracker else { + return + } + // Do not track exposure for fallback variants that are not associated with + // a default variant. + let fallback = variantAndSource.source?.isFallback() ?? true + let hasDefaultVariant = variantAndSource.hasDefaultVariant + if fallback && !hasDefaultVariant { + return + } + var exposureVariant: String? = nil + if !fallback && !variantAndSource.variant.isDefaultVariant() { + exposureVariant = variantAndSource.variant.key ?? variantAndSource.variant.value } + userSessionExposureTracker.track(exposure: Exposure(flagKey: key, variant: exposureVariant, experimentKey: variantAndSource.variant.expKey, metadata: variantAndSource.variant.metadata)) } - private func exposureInternal(key: String, variant: Variant, source: VariantSource) { + private func legacyExposureInternal(key: String, variantAndSource: VariantAndSource) { + guard let analyticsProvider = analyticsProvider else { + return + } + let variant = variantAndSource.variant + let source = variantAndSource.source let exposedUser = mergeUserWithProvider() - let event = ExposureEvent(user: exposedUser, key: key, variant: variant, source: source.rawValue) + let event = ExposureEvent(user: exposedUser, key: key, variant: variantAndSource.variant, source: variantAndSource.source?.rawValue ?? "unknown") // Track the exposure event if an analytics provider is set - if (source.isFallback() || variant.value == nil) { - self.userSessionExposureTracker?.track(exposure: Exposure(flagKey: key, variant: nil, experimentKey: variant.expKey), user: exposedUser) - self.analyticsProvider?.unsetUserProperty(event) + if (source?.isFallback() ?? true || variant.value == nil) { + analyticsProvider.unsetUserProperty(event) } else if (variant.value != nil) { - self.userSessionExposureTracker?.track(exposure: Exposure(flagKey: key, variant: variant.value, experimentKey: variant.expKey), user: exposedUser) - self.analyticsProvider?.setUserProperty(event) - self.analyticsProvider?.track(event) + analyticsProvider.setUserProperty(event) + analyticsProvider.track(event) } } private func debug(_ msg: String) { if self.config.debug { - print("[Experiment] \(msg)") + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + print("\(formatter.string(from: Date())) [Experiment] \(msg)") } } } private struct VariantAndSource { - public private(set) var variant: Variant - public private(set) var source: VariantSource + var variant: Variant + var source: VariantSource? + var hasDefaultVariant: Bool + + init(variant: Variant = Variant(), source: VariantSource? = nil, hasDefaultVariant: Bool = false) { + self.variant = variant + self.source = source + self.hasDefaultVariant = hasDefaultVariant + } } private enum VariantSource : String { @@ -430,6 +727,7 @@ private enum VariantSource : String { case SecondaryInitialVariants = "secondary-initial" case FallbackInline = "fallback-inline" case FallbackConfig = "fallback-config" + case LocalEvaluation = "local-evaluation" func isFallback() -> Bool { switch self { @@ -440,3 +738,14 @@ private enum VariantSource : String { } } } + +internal extension EvaluationVariant { + func toVariant() -> Variant { + var metadata: [String: Any]? = nil + if let m = self.metadata { + metadata = m as [String: Any] + } + let experimentKey = self.metadata?["experimentKey"] as? String ?? nil + return Variant(self.value as? String, payload: self.payload, expKey: experimentKey, key: self.key, metadata: metadata) + } +} diff --git a/Sources/Experiment/ExperimentConfig.swift b/Sources/Experiment/ExperimentConfig.swift index 3e3f3ec..d8d4e66 100644 --- a/Sources/Experiment/ExperimentConfig.swift +++ b/Sources/Experiment/ExperimentConfig.swift @@ -12,6 +12,11 @@ import Foundation case InitialVariants = 1 } +@objc public enum ServerZone: Int { + case US = 0 + case EU = 1 +} + @objc public class ExperimentConfig : NSObject { @objc public let debug: Bool @@ -20,9 +25,13 @@ import Foundation @objc public let initialVariants: [String: Variant] @objc public let source: Source @objc public let serverUrl: String + @objc public let flagsServerUrl: String + @objc public let serverZone: ServerZone @objc public let fetchTimeoutMillis: Int @objc public let retryFetchOnFailure: Bool @objc public let automaticExposureTracking: Bool + @objc public let fetchOnStart: NSNumber? // objc cant do nil boolean values, use nsnumber + @objc public let pollOnStart: Bool @objc public let automaticFetchOnAmplitudeIdentityChange: Bool @objc public let userProvider: ExperimentUserProvider? @available(*, deprecated, message: "Use exposureTrackingProvider instead.") @@ -36,9 +45,13 @@ import Foundation self.initialVariants = ExperimentConfig.Defaults.initialVariants self.source = ExperimentConfig.Defaults.source self.serverUrl = ExperimentConfig.Defaults.serverUrl + self.flagsServerUrl = ExperimentConfig.Defaults.flagsServerUrl + self.serverZone = ExperimentConfig.Defaults.serverZone self.fetchTimeoutMillis = ExperimentConfig.Defaults.fetchTimeoutMillis self.retryFetchOnFailure = ExperimentConfig.Defaults.retryFetchOnFailure self.automaticExposureTracking = ExperimentConfig.Defaults.automaticExposureTracking + self.fetchOnStart = ExperimentConfig.Defaults.fetchOnStart + self.pollOnStart = ExperimentConfig.Defaults.pollOnStart self.automaticFetchOnAmplitudeIdentityChange = ExperimentConfig.Defaults.automaticFetchOnAmplitudeIdentityChange self.userProvider = ExperimentConfig.Defaults.userProvider self.analyticsProvider = ExperimentConfig.Defaults.analyticsProvider @@ -52,9 +65,13 @@ import Foundation self.initialVariants = builder.initialVariants self.source = builder.source self.serverUrl = builder.serverUrl + self.flagsServerUrl = builder.flagsServerUrl + self.serverZone = builder.serverZone self.fetchTimeoutMillis = builder.fetchTimeoutMillis self.retryFetchOnFailure = builder.retryFetchOnFailure self.automaticExposureTracking = builder.automaticExposureTracking + self.fetchOnStart = builder.fetchOnStart + self.pollOnStart = builder.pollOnStart self.automaticFetchOnAmplitudeIdentityChange = builder.automaticFetchOnAmplitudeIdentityChange self.userProvider = builder.userProvider self.analyticsProvider = builder.analyticsProvider @@ -68,9 +85,13 @@ import Foundation self.initialVariants = builder.initialVariants self.source = builder.source self.serverUrl = builder.serverUrl + self.flagsServerUrl = builder.flagsServerUrl + self.serverZone = builder.serverZone self.fetchTimeoutMillis = builder.fetchTimeoutMillis self.retryFetchOnFailure = builder.retryFetchOnFailure self.automaticExposureTracking = builder.automaticExposureTracking + self.fetchOnStart = builder.fetchOnStart + self.pollOnStart = builder.pollOnStart self.automaticFetchOnAmplitudeIdentityChange = builder.automaticFetchOnAmplitudeIdentityChange self.userProvider = builder.userProvider self.analyticsProvider = builder.analyticsProvider @@ -84,9 +105,13 @@ import Foundation static let initialVariants: [String: Variant] = [:] static let source: Source = Source.LocalStorage static let serverUrl: String = "https://api.lab.amplitude.com" + static let flagsServerUrl: String = "https://flag.lab.amplitude.com" + static let serverZone: ServerZone = .US static let fetchTimeoutMillis: Int = 10000 static let retryFetchOnFailure: Bool = true static let automaticExposureTracking: Bool = true + static let fetchOnStart: NSNumber? = nil + static let pollOnStart: Bool = true static let automaticFetchOnAmplitudeIdentityChange: Bool = false static let userProvider: ExperimentUserProvider? = nil static let analyticsProvider: ExperimentAnalyticsProvider? = nil @@ -102,9 +127,13 @@ import Foundation internal var initialVariants: [String: Variant] = ExperimentConfig.Defaults.initialVariants internal var source: Source = ExperimentConfig.Defaults.source internal var serverUrl: String = ExperimentConfig.Defaults.serverUrl + internal var flagsServerUrl: String = ExperimentConfig.Defaults.flagsServerUrl + internal var serverZone: ServerZone = ExperimentConfig.Defaults.serverZone internal var fetchTimeoutMillis: Int = ExperimentConfig.Defaults.fetchTimeoutMillis internal var retryFetchOnFailure: Bool = ExperimentConfig.Defaults.retryFetchOnFailure internal var automaticExposureTracking: Bool = ExperimentConfig.Defaults.automaticExposureTracking + internal var fetchOnStart: NSNumber? = ExperimentConfig.Defaults.fetchOnStart + internal var pollOnStart: Bool = true internal var automaticFetchOnAmplitudeIdentityChange: Bool = ExperimentConfig.Defaults.automaticFetchOnAmplitudeIdentityChange internal var userProvider: ExperimentUserProvider? = ExperimentConfig.Defaults.userProvider internal var analyticsProvider: ExperimentAnalyticsProvider? = ExperimentConfig.Defaults.analyticsProvider @@ -150,6 +179,18 @@ import Foundation return self } + @discardableResult + public func flagsServerUrl(_ flagsServerUrl: String) -> Builder { + self.flagsServerUrl = flagsServerUrl + return self + } + + @discardableResult + public func serverZone(_ serverZone: ServerZone) -> Builder { + self.serverZone = serverZone + return self + } + @discardableResult public func fetchTimeoutMillis(_ fetchTimeoutMillis: Int) -> Builder { self.fetchTimeoutMillis = fetchTimeoutMillis @@ -168,6 +209,23 @@ import Foundation return self } + + @discardableResult + public func fetchOnStart(_ fetchOnStart: Bool) -> Builder { + if fetchOnStart { + self.fetchOnStart = 1 + } else { + self.fetchOnStart = 0 + } + return self + } + + @discardableResult + public func pollOnStart(_ pollOnStart: Bool) -> Builder { + self.pollOnStart = pollOnStart + return self + } + @discardableResult public func automaticFetchOnAmplitudeIdentityChange(_ automaticFetchOnAmplitudeIdentityChange: Bool) -> Builder { self.automaticFetchOnAmplitudeIdentityChange = automaticFetchOnAmplitudeIdentityChange @@ -205,20 +263,28 @@ import Foundation } internal func copyToBuilder() -> ExperimentConfigBuilder { - return ExperimentConfigBuilder() + let fetchOnStart = self.fetchOnStart?.boolValue + let builder = ExperimentConfigBuilder() .debug(self.debug) .instanceName(self.instanceName) .fallbackVariant(self.fallbackVariant) .initialVariants(self.initialVariants) .source(self.source) .serverUrl(self.serverUrl) + .flagsServerUrl(self.flagsServerUrl) + .serverZone(self.serverZone) .fetchTimeoutMillis(self.fetchTimeoutMillis) .fetchRetryOnFailure(self.retryFetchOnFailure) .automaticExposureTracking(self.automaticExposureTracking) + .pollOnStart(self.pollOnStart) .automaticFetchOnAmplitudeIdentityChange(self.automaticFetchOnAmplitudeIdentityChange) .userProvider(self.userProvider) .analyticsProvider(self.analyticsProvider) .exposureTrackingProvider(self.exposureTrackingProvider) + if let fetchOnStart = fetchOnStart { + builder.fetchOnStart(fetchOnStart) + } + return builder } } @@ -230,9 +296,13 @@ import Foundation internal var initialVariants: [String: Variant] = ExperimentConfig.Defaults.initialVariants internal var source: Source = ExperimentConfig.Defaults.source internal var serverUrl: String = ExperimentConfig.Defaults.serverUrl + internal var flagsServerUrl: String = ExperimentConfig.Defaults.flagsServerUrl + internal var serverZone: ServerZone = ExperimentConfig.Defaults.serverZone internal var fetchTimeoutMillis: Int = ExperimentConfig.Defaults.fetchTimeoutMillis internal var retryFetchOnFailure: Bool = ExperimentConfig.Defaults.retryFetchOnFailure internal var automaticExposureTracking: Bool = ExperimentConfig.Defaults.automaticExposureTracking + internal var fetchOnStart: NSNumber? = ExperimentConfig.Defaults.fetchOnStart + internal var pollOnStart: Bool = true internal var automaticFetchOnAmplitudeIdentityChange: Bool = ExperimentConfig.Defaults.automaticFetchOnAmplitudeIdentityChange internal var userProvider: ExperimentUserProvider? = ExperimentConfig.Defaults.userProvider internal var analyticsProvider: ExperimentAnalyticsProvider? = ExperimentConfig.Defaults.analyticsProvider @@ -274,6 +344,18 @@ import Foundation return self } + @discardableResult + @objc public func flagsServerUrl(_ flagsServerUrl: String) -> ExperimentConfigBuilder { + self.flagsServerUrl = flagsServerUrl + return self + } + + @discardableResult + @objc public func serverZone(_ serverZone: ServerZone) -> ExperimentConfigBuilder { + self.serverZone = serverZone + return self + } + @discardableResult @objc public func fetchTimeoutMillis(_ fetchTimeoutMillis: Int) -> ExperimentConfigBuilder { self.fetchTimeoutMillis = fetchTimeoutMillis @@ -292,6 +374,22 @@ import Foundation return self } + @discardableResult + @objc public func fetchOnStart(_ fetchOnStart: Bool) -> ExperimentConfigBuilder { + if fetchOnStart { + self.fetchOnStart = 1 + } else { + self.fetchOnStart = 0 + } + return self + } + + @discardableResult + @objc public func pollOnStart(_ pollOnStart: Bool) -> ExperimentConfigBuilder { + self.pollOnStart = pollOnStart + return self + } + @discardableResult @objc public func automaticFetchOnAmplitudeIdentityChange(_ automaticFetchOnAmplitudeIdentityChange: Bool) -> ExperimentConfigBuilder { self.automaticFetchOnAmplitudeIdentityChange = automaticFetchOnAmplitudeIdentityChange diff --git a/Sources/Experiment/ExperimentUser.swift b/Sources/Experiment/ExperimentUser.swift index ad32d7a..1cb3beb 100644 --- a/Sources/Experiment/ExperimentUser.swift +++ b/Sources/Experiment/ExperimentUser.swift @@ -580,3 +580,28 @@ private func takeOrMerge(_ this: T?, _ other: T?, _ merger: ((T, T) -> T)? = return this } } + +extension ExperimentUser { + func toEvaluationContext() -> [String: Any?] { + var user = toDictionary() + user.removeValue(forKey: "groups") + user.removeValue(forKey: "group_properties") + var context: [String: Any?] = ["user": user] + // Re-configured group properties to match expected context format. + if let userGroups = groups { + var groups: [String: [String: Any]] = [:] + for (groupType, groupNames) in userGroups { + if let groupName = groupNames.first { + var groupNameMap: [String: Any] = ["group_name": groupName] + // Check for group properties + if let groupProperties = groupProperties?[groupType]?[groupName] ?? nil { + groupNameMap["group_properties"] = groupProperties + } + groups[groupType] = groupNameMap + } + } + context["groups"] = groups + } + return context + } +} diff --git a/Sources/Experiment/Exposure.swift b/Sources/Experiment/Exposure.swift index 94ee976..64ba1a0 100644 --- a/Sources/Experiment/Exposure.swift +++ b/Sources/Experiment/Exposure.swift @@ -53,11 +53,21 @@ import Foundation * experiments associated with the same flag. */ @objc public let experimentKey: String? + /** + * (Optional) Flag, segment, and variant metadata produced as a result of + * evaluation for the user. Used for system purposes. + */ + @objc public let metadata: [String: Any]? - internal init(flagKey: String, variant: String?, experimentKey: String?) { + internal init(flagKey: String, variant: String?, experimentKey: String?, metadata: [String: Any?]?) { self.flagKey = flagKey self.variant = variant self.experimentKey = experimentKey + if let m = metadata { + self.metadata = m as [String: Any] + } else { + self.metadata = nil + } } override public func isEqual(_ object: Any?) -> Bool { diff --git a/Sources/Experiment/InMemoryStorage.swift b/Sources/Experiment/InMemoryStorage.swift deleted file mode 100644 index cec3646..0000000 --- a/Sources/Experiment/InMemoryStorage.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// InMemoryStorage.swift -// Experiment -// -// Copyright © 2020 Amplitude. All rights reserved. -// - -import Foundation - -internal class InMemoryStorage: Storage { - - var map: [String:Variant] = [:] - - func put(key: String, value: Variant) { - map[key] = value - } - - func get(key: String) -> Variant? { - return map[key] - } - - func clear() { - map = [:] - } - - func remove(key: String) { - map.removeValue(forKey: key) - } - - func getAll() -> [String:Variant] { - let copy = map - return copy - } - - func load() {} - func save() {} -} diff --git a/Sources/Experiment/Murmur3.swift b/Sources/Experiment/Murmur3.swift new file mode 100644 index 0000000..369c5eb --- /dev/null +++ b/Sources/Experiment/Murmur3.swift @@ -0,0 +1,113 @@ +// +// Murmur3.swift +// Experiment +// +// Created by Brian Giori on 9/11/23. +// + +import Foundation + +private let C1_32: UInt32 = UInt32(bitPattern: -0x3361d2af) +private let C2_32: UInt32 = 0x1b873593 +private let R1_32: UInt32 = 15 +private let R2_32: UInt32 = 13 +private let M_32: UInt32 = 5 +private let N_32: UInt32 = UInt32(bitPattern: -0x19ab949c) + +internal extension String { + func murmurHash32x86(seed: Int) -> Int? { + self.data(using: .utf8)?.murmurHash32x86(seed: seed) + } +} + +internal extension Data { + + func murmurHash32x86(seed: Int) -> Int { + let length = self.count + var hash = UInt32(seed) + let nBlocks = length >> 2 + + // body + for i in 0.. UInt32 { + var kResult = k + var hashResult = hash + kResult &*= C1_32 + kResult = kResult.rotateLeft(n: R1_32) + kResult &*= C2_32 + hashResult ^= kResult + hashResult = hashResult.rotateLeft(n: R2_32) + hashResult &*= M_32 + return hashResult &+ N_32; +} + +private func fmix32(hash: UInt32) -> UInt32 { + var hashResult = hash + hashResult ^= hashResult >> 16 + hashResult &*= UInt32(bitPattern: -0x7a143595) + hashResult ^= hashResult >> 13 + hashResult &*= UInt32(bitPattern:-0x3d4d51cb) + hashResult ^= hashResult >> 16 + return hashResult +} + + +private extension UInt32 { + + func rotateLeft(n: UInt32, width: UInt32 = 32) -> UInt32 { + var un: UInt32 = n + if n > width { + un = un % width + } + let mask: UInt32 = (0xffffffff << (width &- un)) + let r = (self & mask) >> (width &- un) + return (self << un) | r + } +} + +private extension Data { + func readIntLe(index: Int) -> UInt32 { + return UInt32(self[index]) | UInt32(self[index + 1]) << 8 | UInt32(self[index + 2]) << 16 | UInt32(self[index + 3]) << 24 + } +} diff --git a/Sources/Experiment/Selectable.swift b/Sources/Experiment/Selectable.swift new file mode 100644 index 0000000..7dcd2e6 --- /dev/null +++ b/Sources/Experiment/Selectable.swift @@ -0,0 +1,54 @@ +// +// Selectable.swift +// Experiment +// +// Created by Brian Giori on 9/11/23. +// + +import Foundation + +internal protocol Selectable { + func select(selector: String) -> Any? +} + +extension NSDictionary: Selectable { + func select(selector: String) -> Any? { + return self[selector] + } +} + +extension Dictionary: Selectable where Key == String { + func select(selector: String) -> Any? { + return (self as NSDictionary).select(selector: selector) + } +} + +internal extension Selectable { + func select(selector: [String?]?) -> Any? { + guard let selector = selector else { + return nil + } + guard !selector.isEmpty else { + return nil + } + var selectable: Selectable = self + for i in 0.. SemanticVersion? { + guard let version = version else { + return nil + } + guard let regex = try? NSRegularExpression(pattern: VERSION_PATTERN) else { + return nil + } + let matches = regex.matches(in: version, range: NSRange(0.. Bool { + if lhs.major < rhs.major { + return true + } else if lhs.major > rhs.major { + return false + } else if lhs.minor < rhs.minor { + return true + } else if lhs.minor > rhs.minor { + return false + } else if lhs.patch < rhs.patch { + return true + } else if lhs.patch > rhs.patch { + return false + } else if lhs.preRelease != nil && rhs.preRelease == nil { + return true + } else if lhs.preRelease == nil && rhs.preRelease != nil { + return false + } else if let lhsPreRelease = lhs.preRelease, let rhsPreRelease = rhs.preRelease { + return lhsPreRelease < rhsPreRelease + } else { + return false + } + } +} + +private extension Int { + init?(string: String?) { + guard let string = string else { + return nil + } + self.init(string) + } +} diff --git a/Sources/Experiment/Storage.swift b/Sources/Experiment/Storage.swift index 559d299..c1187b8 100644 --- a/Sources/Experiment/Storage.swift +++ b/Sources/Experiment/Storage.swift @@ -7,12 +7,93 @@ import Foundation +internal func getVariantStorage(apiKey: String, instanceName: String, storage: Storage) -> LoadStoreCache { + let namespace = "com.amplituide.experiment.variants.\(instanceName).\(apiKey.suffix(6))" + return LoadStoreCache(namespace: namespace, storage: storage) +} + +internal func getFlagStorage(apiKey: String, instanceName: String, storage: Storage) -> LoadStoreCache { + let namespace = "com.amplituide.experiment.flags.\(instanceName).\(apiKey.suffix(6))" + return LoadStoreCache(namespace: namespace, storage: storage) +} + internal protocol Storage { - func put(key: String, value: Variant) - func get(key: String) -> Variant? - func clear() - func remove(key: String) - func getAll() -> [String:Variant] - func load() - func save() + func get(key: String) -> Data? + func put(key: String, value: Data) + func delete(key: String) +} + +internal class UserDefaultsStorage: Storage { + private let userDefaults = UserDefaults.standard + + func get(key: String) -> Data? { + return userDefaults.value(forKey: key) as? Data + } + + func put(key: String, value: Data) { + userDefaults.set(value, forKey: key) + } + + func delete(key: String) { + userDefaults.removeObject(forKey: key) + } +} + +internal class LoadStoreCache { + + private var cache: [String: Value] = [:] + private let namespace: String + private let storage: Storage + + init(namespace: String, storage: Storage) { + self.namespace = namespace + self.storage = storage + } + + func get(key: String) -> Value? { + return cache[key] + } + + func getAll() -> [String: Value] { + return cache + } + + func put(key: String, value: Value) { + cache[key] = value + } + + func putAll(values: [String: Value]) { + for (k, v) in values { + cache[k] = v + } + } + + func remove(key: String) { + cache.removeValue(forKey: key) + } + + func clear() { + self.cache = [:] + } + + func load() { + do { + if let data = storage.get(key: namespace) { + self.cache = try JSONDecoder().decode([String: Value].self, from: data) + } else { + self.cache = [:] + } + } catch { + print("[Experiment] load failed: \(error)") + } + } + + func store() { + do { + let data = try JSONEncoder().encode(cache) + storage.put(key: namespace, value: data) + } catch { + print("[Experiment] save failed: \(error)") + } + } } diff --git a/Sources/Experiment/TopologicalSort.swift b/Sources/Experiment/TopologicalSort.swift new file mode 100644 index 0000000..cceaffa --- /dev/null +++ b/Sources/Experiment/TopologicalSort.swift @@ -0,0 +1,59 @@ +// +// TopologicalSort.swift +// Experiment +// +// Created by Brian Giori on 9/11/23. +// + +import Foundation + +internal func topologicalSort(flags: [String: EvaluationFlag], flagKeys: [String]? = nil, sorted: Bool = false) throws -> [EvaluationFlag] { + var available: [String: EvaluationFlag] = flags + var result: [EvaluationFlag] = [] + // For testing, we want an consistent iteration order. + let startingKeys: [String] + if sorted { + startingKeys = flagKeys ?? Array(flags.keys.sorted()) + } else { + startingKeys = flagKeys ?? Array(flags.keys) + } + for flagKey in startingKeys { + if let traversal = try parentTraversal(flagKey: flagKey, available: &available) { + result.append(contentsOf: traversal) + } + } + return result +} + +private func parentTraversal(flagKey: String, available: inout [String: EvaluationFlag], path: [String] = []) throws -> [EvaluationFlag]? { + var path = path + guard let flag = available[flagKey] else { + return nil + } + guard let flagDependencies = flag.dependencies, flagDependencies.count != 0 else { + available.removeValue(forKey: flag.key) + return [flag] + } + path.append(flag.key) + var result: [EvaluationFlag] = [] + for parentKey in flagDependencies { + if path.contains(parentKey) { + throw CycleError("Detected a cycle between flags \(path)", path: path) + } + if let traversal = try parentTraversal(flagKey: parentKey, available: &available, path: path) { + result.append(contentsOf: traversal) + } + } + result.append(flag) + available.removeValue(forKey: flag.key) + return result +} + +internal struct CycleError: Error { + let path: [String] + let message: String + init(_ msg: String, path: [String]) { + self.message = msg + self.path = path + } +} diff --git a/Sources/Experiment/UserDefaultsStorage.swift b/Sources/Experiment/UserDefaultsStorage.swift deleted file mode 100644 index 1621f42..0000000 --- a/Sources/Experiment/UserDefaultsStorage.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// UserDefaultsStorage.swift -// Experiment -// -// Copyright © 2020 Amplitude. All rights reserved. -// - -import Foundation - -internal class UserDefaultsStorage: Storage { - let userDefaults = UserDefaults.standard - let key: String - var map: [String:Variant] = [:] - - init(instanceName: String, apiKey: String) { - key = "com.amplituide.experiment.variants.\(instanceName).\(apiKey.suffix(6))" - } - - func put(key: String, value: Variant) { - map[key] = value - } - - func get(key: String) -> Variant? { - return map[key] - } - - func clear() { - map = [:] - } - - func remove(key: String) { - map.removeValue(forKey: key) - } - - func getAll() -> [String:Variant] { - let copy = map - return copy - } - - func load() { - do { - if let data = userDefaults.value(forKey: self.key) as? Data { - let loaded = try JSONDecoder().decode([String:Variant].self, from: data) - for (key, value) in loaded { - map[key] = value - } - } - } catch { - print("[Experiment] load failed: \(error)") - } - } - - func save() { - do { - let data = try JSONEncoder().encode(map) - userDefaults.set(data, forKey: self.key) - } catch { - print("[Experiment] save failed: \(error)") - } - } -} diff --git a/Sources/Experiment/Variant.swift b/Sources/Experiment/Variant.swift index 167e58e..30cec14 100644 --- a/Sources/Experiment/Variant.swift +++ b/Sources/Experiment/Variant.swift @@ -9,55 +9,100 @@ import Foundation @objc public class Variant : NSObject, Codable { + @objc public let key: String? @objc public let value: String? @objc public let payload: Any? @objc public let expKey: String? + @objc public let metadata: [String: Any]? + @objc public init(_ value: String? = nil, payload: Any? = nil) { + self.key = nil + self.value = value + self.payload = payload + self.expKey = nil + self.metadata = nil + } + @objc public init(_ value: String? = nil, payload: Any? = nil, expKey: String? = nil) { + self.key = nil self.value = value self.payload = payload self.expKey = expKey + self.metadata = nil } - - internal init?(json: [String: Any]) { - let key = json["key"] as? String - let value = json["value"] as? String - if (key == nil && value == nil) { - return nil - } - self.value = (value ?? key)! - self.payload = json["payload"] - self.expKey = json["expKey"] as? String + + @objc public init(_ value: String? = nil, payload: Any? = nil, expKey: String? = nil, key: String? = nil, metadata: [String: Any]? = nil) { + self.key = key + self.value = value + self.payload = payload + self.expKey = expKey + self.metadata = metadata } + internal init(key: String? = nil, value: String? = nil, payload: Any? = nil, expKey: String? = nil, metadata: [String:Any]? = nil) { + self.key = key + self.value = value + self.payload = payload + self.expKey = expKey + self.metadata = metadata + } + enum CodingKeys: String, CodingKey { + case key case value case payload case expKey + case metadata } required public init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) - self.value = try values.decode(String.self, forKey: .value) - if let data = try? values.decode(Data.self, forKey: .payload), - let objectPayload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any?] { - self.payload = objectPayload["payload"] ?? nil + self.value = try? values.decode(String.self, forKey: .value) + self.key = (try? values.decode(String.self, forKey: .key)) ?? self.value + // The payload may be encoded multiple ways. The old way + if let payload = try? values.decode(AnyDecodable.self, forKey: .payload) { + self.payload = payload.value + } else if let data = try? values.decode(Data.self, forKey: .payload) { + // The legacy way to encode/decode the payload as a json string where the actual object is the value wrapped inside another json object with a single key, `payload`. + if let objectPayload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any?] { + self.payload = objectPayload["payload"] ?? nil + } else { + self.payload = nil + } } else { self.payload = nil } - self.expKey = try? values.decode(String.self, forKey: .expKey) + // Experiment key should always exist in both the explicit field and the metadata. + let expKey = try? values.decode(String.self, forKey: .expKey) + let metadataAny = try? values.decode([String: AnyDecodable].self, forKey: .metadata) + var metadata = metadataAny?.filter { element in element.value.value != nil }.mapValues { anyDecodable in anyDecodable.value! } + let metadataExpKey = metadata?["experimentKey"] as? String + if let expKey = expKey, metadataExpKey == nil { + if metadata == nil { + metadata = ["experimentKey": expKey] + } else if metadata?["experimentKey"] != nil { + metadata?["experimentKey"] = expKey + } + self.expKey = expKey + } else if let metadataExpKey = metadataExpKey, expKey == nil { + self.expKey = metadataExpKey + } else { + self.expKey = nil + } + self.metadata = metadata } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(value, forKey: .value) - let objectPayload = ["payload": self.payload] - if let data = try? JSONSerialization.data(withJSONObject: objectPayload, options: []) { - try container.encode(data, forKey: .payload) - } else { - try container.encodeNil(forKey: .payload) + try? container.encodeIfPresent(key, forKey: .key) + try? container.encodeIfPresent(value, forKey: .value) + if let payload = payload { + try? container.encodeIfPresent(AnyEncodable(payload), forKey: .payload) + } + try? container.encodeIfPresent(expKey, forKey: .expKey) + if let metadata = metadata { + try? container.encodeIfPresent(AnyEncodable(metadata), forKey: .metadata) } - try container.encode(expKey, forKey: .expKey) } @objc public override func isEqual(_ object: Any?) -> Bool { @@ -85,10 +130,26 @@ import Foundation } @objc override public var description: String { - return "Variant{value=\(value ?? "nil"), payload=\(payload ?? "nil"), expKey=\(expKey ?? "nil")}" + return "Variant{key=\(key ?? "nil"), value=\(value ?? "nil"), payload=\(payload ?? "nil"), expKey=\(expKey ?? "nil"), metadata=\(metadata?.description ?? "nil")}" } @objc override public var debugDescription: String { - return "Variant{value=\(value?.debugDescription ?? "nil"), payload=\(payload.debugDescription), expKey=\(expKey ?? "nil")}" + return "Variant{key=\(key?.debugDescription ?? "nil"), value=\(value?.debugDescription ?? "nil"), payload=\(payload.debugDescription), expKey=\(expKey ?? "nil"), metadata=\(metadata?.debugDescription ?? "nil")}" + } +} + +// Utility extensions + +internal extension Variant { + + func isDefaultVariant() -> Bool { + if let isDefault = metadata?["default"] as? Bool { + return isDefault + } + return false + } + + func isEmpty() -> Bool { + return key == nil && value == nil && payload == nil && expKey == nil && metadata == nil } } diff --git a/Tests/ExperimentTests/ConnectorIntegrationTests.swift b/Tests/ExperimentTests/ConnectorIntegrationTests.swift index 5125df9..a98021b 100644 --- a/Tests/ExperimentTests/ConnectorIntegrationTests.swift +++ b/Tests/ExperimentTests/ConnectorIntegrationTests.swift @@ -112,7 +112,7 @@ class ConnectorIntegrationTests : XCTestCase { // Track event with variant - let exposureEvent1 = Exposure(flagKey: "test-key-1", variant: "test", experimentKey: nil) + let exposureEvent1 = Exposure(flagKey: "test-key-1", variant: "test", experimentKey: nil, metadata: nil) let expectedTrack1 = AnalyticsEvent(eventType: "$exposure", eventProperties: NSDictionary(dictionary: ["flag_key": "test-key-1", "variant": "test"]), userProperties: nil) connectorExposureTrackingProvider.track(exposure: exposureEvent1) @@ -127,7 +127,7 @@ class ConnectorIntegrationTests : XCTestCase { // Track new flag key event with same variant - let exposureEvent2 = Exposure(flagKey: "test-key-2", variant: "test", experimentKey: nil) + let exposureEvent2 = Exposure(flagKey: "test-key-2", variant: "test", experimentKey: nil, metadata: nil) let expectedTrack2 = AnalyticsEvent(eventType: "$exposure", eventProperties: NSDictionary(dictionary: ["flag_key": "test-key-2", "variant": "test"]), userProperties: nil) connectorExposureTrackingProvider.track(exposure: exposureEvent2) @@ -147,7 +147,7 @@ class ConnectorIntegrationTests : XCTestCase { // Track event with variant - let exposureEvent1 = Exposure(flagKey: "test-key", variant: "test", experimentKey: nil) + let exposureEvent1 = Exposure(flagKey: "test-key", variant: "test", experimentKey: nil, metadata: nil) let expectedTrack1 = AnalyticsEvent(eventType: "$exposure", eventProperties: NSDictionary(dictionary: ["flag_key": "test-key", "variant": "test"]), userProperties: nil) connectorExposureTrackingProvider.track(exposure: exposureEvent1) @@ -162,7 +162,7 @@ class ConnectorIntegrationTests : XCTestCase { // Track new flag key event with same variant - let exposureEvent2 = Exposure(flagKey: "test-key", variant: "test2", experimentKey: nil) + let exposureEvent2 = Exposure(flagKey: "test-key", variant: "test2", experimentKey: nil, metadata: nil) let expectedTrack2 = AnalyticsEvent(eventType: "$exposure", eventProperties: NSDictionary(dictionary: ["flag_key": "test-key", "variant": "test2"]), userProperties: nil) connectorExposureTrackingProvider.track(exposure: exposureEvent2) @@ -177,7 +177,7 @@ class ConnectorIntegrationTests : XCTestCase { // Track event with no variant - let exposureEvent3 = Exposure(flagKey: "test-key", variant: nil, experimentKey: nil) + let exposureEvent3 = Exposure(flagKey: "test-key", variant: nil, experimentKey: nil, metadata: nil) let expectedTrack3 = AnalyticsEvent(eventType: "$exposure", eventProperties: NSDictionary(dictionary: ["flag_key": "test-key"]), userProperties: nil) connectorExposureTrackingProvider.track(exposure: exposureEvent3) diff --git a/Tests/ExperimentTests/EnglishWords.swift b/Tests/ExperimentTests/EnglishWords.swift new file mode 100644 index 0000000..fe2c4e1 --- /dev/null +++ b/Tests/ExperimentTests/EnglishWords.swift @@ -0,0 +1,2010 @@ +// +// EnglishWords.swift +// ExperimentTests +// +// Created by Brian Giori on 9/12/23. +// + +import Foundation + +let ENGLISH_WORDS: String = """ +a +a-horizon +a-ok +aardvark +aardwolf +ab +aba +abaca +abacist +aback +abactinal +abacus +abaddon +abaft +abalienate +abalienation +abalone +abampere +abandon +abandoned +abandonment +abarticulation +abase +abased +abasement +abash +abashed +abashment +abasia +abasic +abate +abatement +abating +abatis +abatjour +abattis +abattoir +abaxial +abba +abbacy +abbatial +abbatical +abbatis +abbe +abbess +abbey +abbot +abbreviate +abbreviated +abbreviation +abbreviature +abc +abcoulomb +abdal +abderite +abdicable +abdicant +abdicate +abdication +abdicator +abditory +abditos +abdomen +abdominal +abdominocentesis +abdominoscope +abdominoscopy +abdominous +abdominousness +abdominovesical +abduce +abducent +abduct +abduction +abductive +abductor +abeam +abecedarian +abecedarius +abecedary +abed +abel +abelia +abelmoschus +abelmosk +abends +aber +aberdeen +aberdevine +aberrance +aberrant +aberration +abest +abet +abetalipoproteinemia +abetment +abettor +abeunt +abeyance +abeyant +abfarad +abhenry +abhor +abhorrence +abhorrent +abhorrer +abibis +abidance +abide +abiding +abidjan +abience +abient +abies +abigail +abiit +abilities +ability +abiogenesis +abiogenetic +abiogenist +abiotrophy +abito +abject +abjection +abjectly +abjectness +abjunction +abjuration +abjurationabjurement +abjure +abkari +ablactation +ablated +ablation +ablative +ablaut +ablaze +ablaze(p) +able +ablebodied +ablegate +ableism +ableness +ablepharia +ablepsia +ablepsy +abloom +ablude +ablution +ablutionary +abnaki +abnegation +abnegator +abnormal +abnormality +abnormalize +abnormally +abnormis +abnormity +abnormous +aboard +abocclusion +abode +abodement +aboding +abohm +aboideau +abois +aboiteau +abolengo +abolish +abolishable +abolishment +abolition +abolitionary +abolitionism +abolitionist +abolitionize +abomasal +abomasum +abominable +abominate +abomination +abominator +aborad +aboral +abord +aboriginal +aborigine +aborigines +aborning +abort +aborticide +abortifacient +abortion +abortionist +abortive +abortively +abortus +abound +abounding +about +about(p) +about-face +abouts +above +above-mentioned +aboveboard +aboveground +abovementioned +abovesaid +abovestairs +abra +abracadabra +abrachia +abrade +abraded +abrader +abraham +abramis +abranchiate +abrasion +abrasive +abreast +abrege +abreption +abridge +abridged +abridger +abridgment +abroach +abroad +abrocoma +abrocome +abrogate +abrogated +abrogation +abronia +abrupt +abruption +abruptly +abruptness +abruzzi +abscess +abscessed +abscind +abscision +abscissa +abscission +abscond +absconder +abscondment +absence +absens +absent +absentee +absenteeism +absently +absentminded +absentmindedness +absento +absents +absinth +absinthe +absolute +absolutely +absoluteness +absolution +absolutism +absolutist +absolve +absolved +absolver +absolvitory +absolvitur +absonant +absonous +absorb +absorbable +absorbate +absorbed +absorbefacient +absorbency +absorbent +absorber +absorbing +absorption +absorptivity +absquatulate +abstain +abstainer +abstemious +abstemiously +abstemiousness +abstention +absterge +abstergent +abstersion +abstersive +abstinence +abstinent +abstract +abstracted +abstractedly +abstractedness +abstraction +abstractionism +abstractionist +abstractive +abstractly +abstractness +abstractor +abstruse +abstrusely +absurd +absurdity +absurdly +absurdness +absurdum +abudefduf +abulia +abulic +abuna +abundance +abundant +abundanti +abundantly +abuse +abused +abuser +abusive +abusively +abut +abutilon +abutment +abuttal +abutter +abutting +abuzz +abvolt +abwatt +aby +abysm +abysmal +abyss +abyssal +abyssinian +ac +acacia +academia +academic +academical +academically +academician +academicianship +academist +academy +acadia +acadian +acalypha +acanthaceae +acanthisitta +acanthocephala +acanthocephalan +acanthocereus +acanthocybium +acanthocyte +acanthocytosis +acanthoid +acantholysis +acanthoma +acanthophis +acanthopterygii +acanthoscelides +acanthosis +acanthotic +acanthuridae +acanthurus +acanthus +acapnic +acapulco +acardia +acariasis +acariatre +acaricide +acarid +acaridae +acarina +acarine +acaritre +acarophobia +acarpelous +acarpous +acarus +acatalectic +acataphasia +acathexia +acathexis +acaudate +acaulescent +accedas +accede +accelerando +accelerate +accelerated +accelerating +acceleration +accelerative +accelerator +accelerometer +accension +accent +accented +accentor +accents +accentual +accentuate +accentuation +accept +accepta +acceptability +acceptable +acceptably +acceptance +acceptation +accepted +accepting +acception +acceptive +acceptor +access +accessible +accession +accessional +accessorial +accessory +acciaccatura +accidence +accident +accident-prone +accidental +accidentally +accidentalness +accidents +accipere +accipient +accipiter +accipitres +accipitridae +accipitriformes +accipitrine +acclaim +acclamate +acclamation +acclimatization +acclimatize +acclivitous +acclivity +acclivous +accloy +accolade +accommodate +accommodating +accommodation +accommodational +accommodative +accomodation +accompanied +accompaniment +accompanist +accompany +accompanying +accompli +accomplice +accomplish +accomplishable +accomplished +accomplishment +accomplishments +accompts +accord +accordance +accordant +accordian +according +accordingly +accordion +accordionist +accost +accouchement +accoucheur +accoucheuse +account +accountability +accountable +accountable(p) +accountableness +accountancy +accountant +accountantship +accounter +accounting +accounts +accouple +accouplement +accousente +accouter +accoutered +accouterment +accouterments +accoy +accra +accredit +accreditation +accredited +accretion +accretionary +accretive +accrimination +accroach +accrue +accrued +accrust +accubation +accueil +accultural +acculturation +acculturational +accumbent +accumulate +accumulated +accumulation +accumulative +accuracy +accurate +accurately +accurse +accursed +accusable +accusation +accusative +accusatorial +accusatory +accuse +accused +accuser +accusing +accusingly +accustom +accustomary +accustomed +ace +acebutolol +aceite +aceldama +acentric +acephalia +acephalous +acequia +acequiador +acequiamadre +acer +aceraceae +acerate +aceration +acerb +acerbate +acerbic +acerbity +acerola +acervate +acervatim +acervation +acervulus +acervus +acescent +acetabular +acetabulum +acetal +acetaldehyde +acetamide +acetaminophen +acetanilide +acetate +acetic +acetone +acetonic +acetophenetidin +acetose +acetous +acetyl +acetylcholine +acetylene +acetylenic +acetylic +achaean +achar +acharn +acharne +acharnement +achates +ache +achene +achenial +acheron +acheronian +acherontia +acherontis +acheta +achievability +achievable +achieve +achievement +achiever +achillea +achillean +achilles +achimenes +aching +achira +achivi +achlamydeous +achlorhydria +achlorhydric +achoerodus +acholia +achomawi +achondrite +achondritic +achondroplasia +achondroplastic +achras +achromatic +achromatin +achromatinic +achromatism +achromatize +achromatous +achromia +achromic +achylia +acicula +acicular +aciculate +acid +acid-fast +acid-forming +acid-loving +acidemia +acidic +acidification +acidify +acidimetric +acidimetry +acidity +acidophil +acidophilic +acidosis +acidotic +acidulate +acidulated +acidulous +acierta +aciform +acinaform +acinar +aciniform +acinonyx +acinos +acinus +acipenser +acipenseridae +ackee +acknowledge +acknowledgeable +acknowledged +acknowledgement +acknowledgment +acme +acne +acned +acneiform +acnidosporidia +acocanthera +acold +acology +acolothyst +acolyte +acolyth +acomia +aconcagua +aconite +aconitum +acoraceae +acorea +acorn +acorus +acousma +acoustic +acoustically +acoustician +acoustics +acquaint +acquaintance +acquainted +acquainted(p) +acquaintenace +acquaintend +acquainting +acquest +acquiesce +acquiescence +acquiescent +acquirable +acquire +acquired +acquirement +acquirements +acquirer +acquiring +acquirit +acquisition +acquisitions +acquisitive +acquisitiveness +acquit +acquitment +acquittal +acquittance +acquitted +acrasiomycetes +acre +acre-foot +acreage +acres +acrid +acrididae +acridity +acridotheres +acrilan +acrimonious +acrimony +acris +acritical +acritude +acroama +acroamatic +acroamatical +acroamatics +acroanesthesia +acroatic +acrobat +acrobates +acrobatic +acrobatics +acrocarp +acrocarpous +acrocarpus +acrocentric +acrocephalus +acroclinium +acrocomia +acrocyanosis +acrodont +acrogen +acrogenic +acromatic +acromegalic +acromegaly +acromicria +acromion +acromphalus +acromyotonia +acronym +acronymic +acropetal +acrophobia +acrophobic +acropolis +acropora +acrosome +acrospire +across +across-the-board +acrostic +acrostichum +acrylic +act +acta +actable +actaea +acted +acti +actias +actifed +actin +actinal +acting +acting(a) +actinia +actiniaria +actinic +actinidia +actinidiaceae +actiniopteris +actinism +actinium +actinoid +actinolite +actinomeris +actinometer +actinometric +actinometry +actinomorphic +actinomyces +actinomycetacaea +actinomycetal +actinomycetales +actinomycete +actinomycin +actinomycosis +actinomycotic +actinomyxidia +actinomyxidian +actinopod +actinopoda +action +actionable +actions +actitis +actium +activated +activating(a) +activation +activator +active +actively +activeness +activism +activist +activity +actomyosin +actor +actress +acts +actu +actual +actuality +actualized +actually +actuarial +actuary +actuateact +actuated +actuator +actum +actus +acu +acuate +acuity +aculea +aculeate +aculeated +aculeus +acumen +acuminate +acuminated +acumination +acun +acupressure +acupuncture +acute +acutely +acuteness +acyclic +acyclovir +ad +ad-lib +adactylia +adactylism +adactylous +adad +adaga +adage +adagio +adalia +adam +adamance +adamant +adamantean +adamantine +adamantly +adams +adansonia +adapa +adapid +adapt +adaptability +adaptable +adaptation +adaptational +adapted +adapter +adaption +adaptive +adar +adaxial +add +addable +addax +adde +added +addend +addendum +adder +addere +addesse +addict +addicted +addiction +addictive +adding +addison +additament +addition +additional +additionally +additive +additum +addle +addle-head +addlebrained +addled +addlehead +addlepated +address +addressable +addressed +addressee +addresses +adduce +adducent +adducing +adduction +adductive +adductor +ade +adeem +adel +adelaide +adelges +adelgid +adelgidae +adelie +adelig +adelomorphous +ademption +ademptum +aden +adenanthera +adenine +adenitis +adenium +adenocarcinoma +adenocarcinomatous +adenography +adenoid +adenoidal +adenoidectomy +adenoma +adenomegaly +adenopathy +adenosine +adenota +adenovirus +adeo +adeology +adept +adeptness +adequacy +adequate +adequately +adespotic +adhere +adherence +adherent +adhering +adhesion +adhesive +adhesiveness +adhibenda +adhibit +adhibition +adhortation +adiabatic +adiantaceae +adiantum +adiaphanous +adiathermancy +adience +adient +adieu +adige +adipocere +adipose +adiposity +adirondacks +adit +aditi +aditya +adj +adjacency +adjacent +adjection +adjectitious +adjectival +adjectivally +adjective +adjectively +adjectives +adjoin +adjoining +adjourn +adjournment +adjuc +adjudge +adjudicate +adjudication +adjudicative +adjudicator +adjunct +adjunctive +adjuration +adjuratory +adjure +adjust +adjustable +adjusted +adjuster +adjustive +adjustment +adjutage +adjutant +adjuvant +adjuvat +adlumia +admass +admeasurement +administer +administrable +administration +administrative +administratively +administrator +administrators +admirability +admirable +admirably +admiral +admiralty +admirari +admiration +admire +admired +admirer +admiring +admiringly +admissable +admissibility +admissible +admission +admissive +admit +admittable +admittance +admitted +admitted(a) +admitting +admixture +admlration +admonish +admonished +admonisher +admonition +admonitive +admonitory +adnate +adnexa +adnexal +adnoun +ado +adobe +adobo +adolescence +adolescent +adonic +adonis +adonize +adopt +adoptable +adopted +adoption +adoptive +adorability +adorable +adorably +adoration +adore +adored +adorer +adoring +adoringly +adorn +adorned +adorned(p) +adornment +adown +adrenal +adrenalectomy +adrenaline +adrenarche +adrenergic +adrenocortical +adrenocorticotropic +adrenosterone +adrenotrophin +adriatic +adrift +adrift(p) +adroit +adroitly +adroitness +adrolepsy +adscititious +adscript +adscriptus +adsorbable +adsorbate +adsorbed +adsorbent +adsorption +adulation +adulator +adulatory +adullam +adult +adulterant +adulterate +adulterated +adulterating +adulteration +adulterer +adulteress +adulterine +adulterous +adulterously +adultery +adulthood +adultism +adultness +adultress +adumbrate +adumbration +adumbrative +aduncated +aduncity +aduncous +adust +adustion +adv +advance +advance(a) +advanced +advanced(a) +advancement +advances +advancing +advantage +advantaged +advantageous +advection +advective +advene +advent +adventism +adventist +adventitial +adventitious +adventive +adventure +adventurer +adventures +adventuress +adventurism +adventuristic +adventurous +adventurousness +adverb +adverbial +adverbially +adverbs +adversaria +adversary +adversarys +adversative +adverse +adversely +adversis +adversity +adversitys +adversum +advert +advertence +advertency +advertent +advertise +advertised +advertisement +advertiser +advertising +advice +advisability +advisable +advise +advised +advisedly +advisee +advisemement +adviser +advisory +advocacy +advocate +advocation +advoutress +advoutry +advowson +adynamia +adynamic +adynamy +adytum +adz +adze +adzooks +aecial +aeciospore +aecium +aedes +aedile +aedipus +aeequa +aegean +aegiceras +aegilops +aegina +aegis +aegospotami +aegri +aegypiidae +aegypius +aegyptopithecus +aeneas +aeneid +aeneus +aeolian +aeolic +aeolis +aeolotropic +aeolus +aeon +aeonium +aepyceros +aepyornidae +aepyorniformes +aequa +aequam +aequat +aequis +aequo +aerated +aeration +aerator +aere +aerial +aerialist +aerially +aerides +aerie +aeriferous +aerifiction +aeriform +aerobacter +aerobe +aerobic +aerobics +aerobiosis +aerobiotic +aerodontalgia +aerodrome +aerodynamic +aerodynamics +aerography +aerolite +aerological +aerology +aerolytic +aeromancy +aeromechanic +aeromechanics +aeromedical +aeromedicine +aerometer +aerometry +aeronatics +aeronaut +aeronautic +aeronautical +aeronautics +aerophagia +aerophilatelic +aerophilately +aeroplane +aeroplanist +aeroscope +aeroscopy +aerosol +aerosolized +aerospace +aerosphere +aerostat +aerostatic +aerostatics +aerostation +aertex +aes +aeschylean +aeschylus +aeschynanthus +aesculapian +aesculapius +aesculus +aesir +aesop +aestas +aesthetic +aesthetically +aesthetics +aestival +aetas +aeterna +aeternum +aether +aethionema +aethusa +aetiology +aetobatus +aevi +afar +afeard +afeard(p) +afebrile +affability +affable +affably +affair +affaire +affaires +affairs +affect +affectation +affected +affected(p) +affectedly +affectedness +affectibility +affecting +affectingly +affection +affectional +affectionate +affectionateness +affectioned +affections +affector +affects +affenpinscher +afferent +affettuoso +affiance +affianced +affiche +afficher +affidation +affidavit +affiliated +affiliation +affinal +affined +affinity +affirm +affirmable +affirmance +affirmation +affirmative +affirmatively +affirmativeness +affix +affixal +affixation +affixed +afflation +afflatus +afflict +afflicted +afflicting +affliction +afflictions +afflictive +affluence +affluent +afflux +affluxion +afford +afforestation +affraid +affranchise +affranchisement +affray +affrayment +affricate +affrication +affriction +affright +affrightment +affront +affronted +affronterai +affuse +affusion +afghan +afghani +afghanistan +afibrinogenemia +afield +afire +aflak +aflame(p) +aflare +afloat +afloat(p) +aflutter +afoot +afoot(p) +afore +aforehand +aforementioned +aforenamed +aforesaid +aforesaid(a) +aforethought +aforethought(ip) +afoul +afoul(ip) +afraid +afraid(p) +aframomum +afreet +afresh +afric +africa +african +african-american +africander +afrikaans +afrikaner +afro +afro-asian +afro-wig +afroasiatic +afrocarpus +afropavo +aft +aft(a) +after +after(a) +after-hours +after-school(a) +after-shave +afterage +afterbirth +afterburden +afterburner +aftercare +afterclap +aftercome +aftercourse +aftercrop +afterdamp +afterdeck +afterdinner +aftereffect +aftergame +afterglow +aftergrowth +afterimage +afterlife +aftermath +aftermost +afternoon +afternoon(a) +afterpains +afterpart +afterpiece +aftershaft +aftershafted +aftershock +aftertaste +afterthought +afterwards +afterworld +aga +agacerie +again +agains +against +agalactia +agalloch +agallochium +agama +agamemnon +agamic +agamid +agamidae +agamist +agammaglobulinemia +agapanthus +agape +agape(p) +agapemone +agapornis +agar +agaric +agaricaceae +agaricales +agaricus +agas +agastache +agate +agateware +agathis +agavaceae +agave +agaze +agdistis +age +age-old +aged +aged(a) +agedness +ageism +agelaius +ageless +agelessness +agelong +agency +agenda +agendum +agenesis +agent +agential +agentive +agents +agentship +agerasia +ageratina +ageratum +ages +agglomerate +agglomeration +agglutinate +agglutination +agglutinative +agglutinin +agglutinogen +aggrandize +aggrandizement +aggravable +aggravate +aggravated +aggravating +aggravatingly +aggravation +aggregate +aggregation +aggression +aggressive +aggressively +aggressiveness +aggressor +aggrieve +aggrieved +aggro +aggroup +aghan +aghast +aghast(p) +agianst +agile +agilely +agility +agincourt +aging +agio +agiotage +agir +agis +agitate +agitated +agitation +agitative +agitator +agitur +agjus +agkistrodon +aglaomorpha +aglaonema +agleam +aglet +aglitter(p) +aglow +aglow(p) +agnate +agnatha +agnation +agni +agnition +agnize +agnomen +agnosia +agnostic +agnosticism +agnus +ago +agog +agoing +agonadal +agonal +agonidae +agonies +agonism +agonist +agonistic +agonize +agonized +agonizing +agonizingly +agonus +agony +agora +agoraphobia +agoraphobic +agostadero +agouti +agranulocytic +agranulocytosis +agrapha +agraphia +agraphic +agrarian +agree +agreeable +agreeableness +agreeably +agreed +agreeing +agreeing(a) +agreement +agrescit +agrestic +agribusiness +agricultor +agricultural +agriculture +agriculturist +agrimonia +agriocharis +agrippa +agrobacterium +agrobiologic +agrobiology +agrologic +agrology +agromania +agronomic +agronomist +agronomy +agropyron +agrostemma +agrostis +aground +aground(p) +agrypnia +agrypnotic +agua +aguardiente +ague +aguets +agueweed +aguish +agural +agurial +ah +aha +ahab +ahariolation +ahead +ahead(p) +ahorse +ahorse(p) +ahriman +ahuehuete +ahura +aid +aidance +aide +aide-memoire +aidedecamp +aidetoi +aiding +aidless +aids +aigrette +aiguille +aigulet +aikido +ail +ailanthus +aile +aileron +ailing +ailment +ailurophobia +ailuropoda +ailuropodidae +ailurus +aim +aimer +aimless +aimlessly +aimlessness +aingenium +aioli +air +air(a) +air-conditioned +air-conditioner +air-cooled +air-intake +air-to-air +air-to-surface +airborne +airbrake +airbrush +airbubble +airbuilt +airbus +aircraft +aircraftsman +aircrew +aircrewman +airdock +aire +aired +airedale +airfield +airflow +airfoil +airframe +airheaded +airhole +airiness +airing +airless +airlift +airline +airliner +airlock +airmail +airman +airmanship +airpipe +airplane +airport +airs +airship +airsick +airspace +airspeed +airstream +airstrip +airtight +airwind +airworthiness +airworthy +airy +aise +aisle +ait +aitch +aitchbone +aiunt +aix +aizoaceae +ajaia +ajar +ajar(p) +ajax +ajee +ajuga +ajutage +akan +akaryocyte +akee +akeridae +akimbo +akimbo(ip) +akin +akin(p) +akinesis +akkadian +akron +akwa'ala +al +ala +alabama +alabaman +alabaster +alack +alacran +alacritous +alacrity +alacritywant +aladdin +alalia +alameda +alamo +alanine +alar +alarm +alarmed +alarming +alarmingly +alarmism +alarmist +alarum +alas +alaska +alaskan +alate +alated +alauda +alaudidae +alaw +alb +alba +albacore +albania +albanian +albany +albata +albatrellus +albatross +albedo +albeit +alberca +albert +alberta +albetur +albification +albinal +albinism +albino +albite +albitic +albizzia +albuca +albuginaceae +albugo +albula +albulidae +album +albumen +albumin +albuminous +albuminuria +albuminuric +albuquerque +albuterol +alca +alcaeus +alcaic +alcaid +alcalde +alcazar +alcea +alcedinidae +alcedo +alcelaphus +alces +alchemic +alchemist +alchemistic +alchemy +alcidae +alcohol +alcoholic +alcoholism +alcoran +alcove +alcyonacea +alcyonaria +aldebaran +aldehyde +aldehydic +alder +alderfly +alderman +aldermanic +aldol +aldose +aldosterone +aldosteronism +aldrovanda +ale +alea +aleatory +alectis +alecto +alectoria +alectoris +alectoromancy +alectryomancy +alectura +alee +alehouse +alembic +alentours +aleph +aleph-null +alepisaurus +aleppo +alert +alertly +alertness +alerts +aletris +aleurites +aleuromancy +aleurone +aleuronic +aleut +alewife +alex +alexander +alexandria +alexandrian +alexandrine +alexandrite +alexic +alexipharmic +alexiteric +aleyrodes +aleyrodidae +alfalfa +alfardaws +alfilaria +alfresco +alga +algal +algarroba +algebra +algebraic +algebraically +algebraist +algebraize +algeria +algerian +algeripithecus +alget +algid +algiers +algin +algoid +algol +algolagnic +algology +algometer +algometric +algometry +""" diff --git a/Tests/ExperimentTests/EvaluationIntegrationTests.swift b/Tests/ExperimentTests/EvaluationIntegrationTests.swift new file mode 100644 index 0000000..60482eb --- /dev/null +++ b/Tests/ExperimentTests/EvaluationIntegrationTests.swift @@ -0,0 +1,531 @@ +// +// EvaluationIntegrationTests.swift +// ExperimentTests +// +// Created by Brian Giori on 9/13/23. +// + +import XCTest +import Foundation +@testable import Experiment + +private let DEPLOYMENT_KEY = "server-NgJxxvg8OGwwBsWVXqyxQbdiflbhvugy" + +private var flags: [EvaluationFlag] = [] + +class EvaluationIntegrationTests: XCTestCase { + + let engine = EvaluationEngine() + + override class func setUp() { + flags = try! doFlags() + } + + // Basic Tests + + func testOff() { + let user = userContext(userId: "user_id", deviceId: "device_id") + let result = engine.evaluate(context: user, flags: flags)["test-off"] + XCTAssertEqual("off", result?.key) + } + + func testOn() { + let user = userContext(userId: "user_id", deviceId: "device_id") + let result = engine.evaluate(context: user, flags: flags)["test-on"] + XCTAssertEqual("on", result?.key) + } + + // Opinionated Segment Tests + + func testIndividualInclusionsMatch() { + // Match User ID + var user = userContext(userId: "user_id") + var result = engine.evaluate(context: user, flags: flags)["test-individual-inclusions"] + XCTAssertEqual("on", result?.key) + XCTAssertEqual("individual-inclusions", result?.metadata?["segmentName"] as! String) + // Match Device ID + user = userContext(deviceId: "device_id") + result = engine.evaluate(context: user, flags: flags)["test-individual-inclusions"] + XCTAssertEqual("on", result?.key) + XCTAssertEqual("individual-inclusions", result?.metadata?["segmentName"] as! String) + // Doesn't Match User ID + user = userContext(userId: "not_user_id") + result = engine.evaluate(context: user, flags: flags)["test-individual-inclusions"] + XCTAssertEqual("off", result?.key) + // Doesn't Match Device ID + user = userContext(deviceId: "not_device_id") + result = engine.evaluate(context: user, flags: flags)["test-individual-inclusions"] + XCTAssertEqual("off", result?.key) + } + + func testFlagDependenciesOn() { + let user = userContext(userId: "user_id", deviceId: "device_id") + let result = engine.evaluate(context: user, flags: flags)["test-flag-dependencies-on"] + XCTAssertEqual("on", result?.key) + } + + func testFlagDependenciesOff() { + let user = userContext(userId: "user_id", deviceId: "device_id") + let result = engine.evaluate(context: user, flags: flags)["test-flag-dependencies-off"] + XCTAssertEqual("off", result?.key) + XCTAssertEqual("flag-dependencies", result?.metadata?["segmentName"] as! String) + } + + func testStickyBucketing() { + // On + var user = userContext(userId: "user_id", deviceId: "device_id", userProperties: ["[Experiment] test-sticky-bucketing": "on"]) + var result = engine.evaluate(context: user, flags: flags)["test-sticky-bucketing"] + XCTAssertEqual("on", result?.key) + XCTAssertEqual("sticky-bucketing", result?.metadata?["segmentName"] as! String) + // Off + user = userContext(userId: "user_id", deviceId: "device_id", userProperties: ["[Experiment] test-sticky-bucketing": "off"]) + result = engine.evaluate(context: user, flags: flags)["test-sticky-bucketing"] + XCTAssertEqual("off", result?.key) + XCTAssertEqual("All Other Users", result?.metadata?["segmentName"] as! String) + // Non-variant + user = userContext(userId: "user_id", deviceId: "device_id", userProperties: ["[Experiment] test-sticky-bucketing": "not-a-variant"]) + result = engine.evaluate(context: user, flags: flags)["test-sticky-bucketing"] + XCTAssertEqual("off", result?.key) + } + + func testExperiment() { + let user = userContext(userId: "user_id", deviceId: "device_id") + let result = engine.evaluate(context: user, flags: flags)["test-experiment"] + XCTAssertEqual("on", result?.key) + XCTAssertEqual("exp-1", result?.metadata?["experimentKey"] as! String) + } + + func testFlag() { + let user = userContext(userId: "user_id", deviceId: "device_id") + let result = engine.evaluate(context: user, flags: flags)["test-flag"] + XCTAssertEqual("on", result?.key) + XCTAssertNil(result?.metadata?["experimentKey"] ?? nil) + } + + // Conditional Logic Tests + + func testMultipleConditionsAndValues() { + // All match, on + var user = userContext(userProperties: [ + "key-1": "value-1", + "key-2": "value-2", + "key-3": "value-3", + ]) + var result = engine.evaluate(context: user, flags: flags)["test-multiple-conditions-and-values"] + XCTAssertEqual("on", result?.key) + // Some match, off + user = userContext(userProperties: [ + "key-1": "value-1", + "key-2": "value-2", + ]) + result = engine.evaluate(context: user, flags: flags)["test-multiple-conditions-and-values"] + XCTAssertEqual("off", result?.key) + } + + // Condition Property Targeting Tests + + func testAmplitudePropertyTargeting() { + let user = userContext(userId: "user_id") + let result = engine.evaluate(context: user, flags: flags)["test-amplitude-property-targeting"] + XCTAssertEqual("on", result?.key) + } + + func testCohortTargeting() { + // User in cohort + var user = userContext(cohortIds: ["u0qtvwla", "12345678"]) + var result = engine.evaluate(context: user, flags: flags)["test-cohort-targeting"] + XCTAssertEqual("on", result?.key) + // User not in cohort + user = userContext(cohortIds: ["12345678", "87654321"]) + result = engine.evaluate(context: user, flags: flags)["test-cohort-targeting"] + XCTAssertEqual("off", result?.key) + } + + func testGroupNameTargeting() { + let user = groupContext(groupType: "org name", groupName: "amplitude") + let result = engine.evaluate(context: user, flags: flags)["test-group-name-targeting"] + XCTAssertEqual("on", result?.key) + } + + func testGroupPropertyTargeting() { + let user = groupContext(groupType: "org name", groupName: "amplitude", groupProperties: ["org plan": "enterprise2"]) + let result = engine.evaluate(context: user, flags: flags)["test-group-property-targeting"] + XCTAssertEqual("on", result?.key) + } + + // Bucketing Tests + + func testAmplitudeIdBucketing() { + let user = userContext(amplitudeId: "1234567890") + let result = engine.evaluate(context: user, flags: flags)["test-amplitude-id-bucketing"] + XCTAssertEqual("on", result?.key) + } + + func testUserIdBucketing() { + let user = userContext(userId: "user_id") + let result = engine.evaluate(context: user, flags: flags)["test-user-id-bucketing"] + XCTAssertEqual("on", result?.key) + } + + func testDeviceIdBucketing() { + let user = userContext(deviceId: "device_id") + let result = engine.evaluate(context: user, flags: flags)["test-device-id-bucketing"] + XCTAssertEqual("on", result?.key) + } + + func testCustomUserPropertyBucketing() { + let user = userContext(userProperties: ["key": "value"]) + let result = engine.evaluate(context: user, flags: flags)["test-custom-user-property-bucketing"] + XCTAssertEqual("on", result?.key) + } + + func testGroupNameBucketing() { + let user = groupContext(groupType: "org name", groupName: "amplitude") + let result = engine.evaluate(context: user, flags: flags)["test-group-name-bucketing"] + XCTAssertEqual("on", result?.key) + } + + func testGroupPropertyBucketing() { + let user = groupContext(groupType: "org name", groupName: "amplitude", groupProperties: ["org plan": "enterprise2"]) + let result = engine.evaluate(context: user, flags: flags)["test-group-property-bucketing"] + XCTAssertEqual("on", result?.key) + } + + // Bucketing Allocation Tests + + func test1PercentAllocation() { + var on = 0 + for i in 0..<10000 { + let user = userContext(deviceId: "\(i+1)") + let result = engine.evaluate(context: user, flags: flags.filter { $0.key == "test-1-percent-allocation" })["test-1-percent-allocation"] + if result?.key == "on" { + on += 1 + } else if result?.key != "off" { + XCTFail("Unexpected variant \(result?.key ?? "nil")") + return + } + } + XCTAssertEqual(107, on) + } + + func test50PercentAllocation() { + var on = 0 + for i in 0..<10000 { + let user = userContext(deviceId: "\(i+1)") + let result = engine.evaluate(context: user, flags: flags.filter { $0.key == "test-50-percent-allocation" })["test-50-percent-allocation"] + if result?.key == "on" { + on += 1 + } else if result?.key != "off" { + XCTFail("Unexpected variant \(result?.key ?? "nil")") + return + } + } + XCTAssertEqual(5009, on) + } + + func test99PercentAllocation() { + var on = 0 + for i in 0..<10000 { + let user = userContext(deviceId: "\(i+1)") + let result = engine.evaluate(context: user, flags: flags.filter { $0.key == "test-99-percent-allocation" })["test-99-percent-allocation"] + if result?.key == "on" { + on += 1 + } else if result?.key != "off" { + XCTFail("Unexpected variant \(result?.key ?? "nil")") + return + } + } + XCTAssertEqual(9900, on) + } + + // Bucketing DistributionTests + + func test1PercentDistribution() { + var control = 0 + var treatment = 0 + for i in 0..<10000 { + let user = userContext(deviceId: "\(i+1)") + let result = engine.evaluate(context: user, flags: flags.filter { $0.key == "test-1-percent-distribution" })["test-1-percent-distribution"] + switch result?.key { + case "control": control += 1 + case "treatment": treatment += 1 + default: + XCTFail("Unexpected variant \(result?.key ?? "nil")") + return + } + } + XCTAssertEqual(106, control) + XCTAssertEqual(9894, treatment) + } + + func test50PercentDistribution() { + var control = 0 + var treatment = 0 + for i in 0..<10000 { + let user = userContext(deviceId: "\(i+1)") + let result = engine.evaluate(context: user, flags: flags.filter { $0.key == "test-50-percent-distribution" })["test-50-percent-distribution"] + switch result?.key { + case "control": control += 1 + case "treatment": treatment += 1 + default: + XCTFail("Unexpected variant \(result?.key ?? "nil")") + return } + } + XCTAssertEqual(4990, control) + XCTAssertEqual(5010, treatment) + } + + func test99PercentDistribution() { + var control = 0 + var treatment = 0 + for i in 0..<10000 { + let user = userContext(deviceId: "\(i+1)") + let result = engine.evaluate(context: user, flags: flags.filter { $0.key == "test-99-percent-distribution" })["test-99-percent-distribution"] + switch result?.key { + case "control": control += 1 + case "treatment": treatment += 1 + default: + XCTFail("Unexpected variant \(result?.key ?? "nil")") + return } + } + XCTAssertEqual(9909, control) + XCTAssertEqual(91, treatment) + } + + func testMultipleDistributions() { + var a = 0 + var b = 0 + var c = 0 + var d = 0 + for i in 0..<10000 { + let user = userContext(deviceId: "\(i+1)") + let result = engine.evaluate(context: user, flags: flags.filter { $0.key == "test-multiple-distributions" })["test-multiple-distributions"] + switch result?.key { + case "a": a += 1 + case "b": b += 1 + case "c": c += 1 + case "d": d += 1 + default: + XCTFail("Unexpected variant \(result?.key ?? "nil")") + return } + } + XCTAssertEqual(2444, a) + XCTAssertEqual(2634, b) + XCTAssertEqual(2447, c) + XCTAssertEqual(2475, d) + + } + + // Operator Tests + + func testIs() { + let user = userContext(userProperties: ["key": "value"]) + let result = engine.evaluate(context: user, flags: flags)["test-is"] + XCTAssertEqual("on", result?.key) + } + + func testIsNot() { + let user = userContext(userProperties: ["key": "value"]) + let result = engine.evaluate(context: user, flags: flags)["test-is"] + XCTAssertEqual("on", result?.key) + } + + func testContains() { + let user = userContext(userProperties: ["key": "value"]) + let result = engine.evaluate(context: user, flags: flags)["test-contains"] + XCTAssertEqual("on", result?.key) + } + + func testDoesNotContain() { + let user = userContext(userProperties: ["key": "value"]) + let result = engine.evaluate(context: user, flags: flags)["test-does-not-contain"] + XCTAssertEqual("on", result?.key) + } + + func testLess() { + let user = userContext(userProperties: ["key": "-1"]) + let result = engine.evaluate(context: user, flags: flags)["test-less"] + XCTAssertEqual("on", result?.key) + } + + func testLessOrEqual() { + let user = userContext(userProperties: ["key": "0"]) + let result = engine.evaluate(context: user, flags: flags)["test-less-or-equal"] + XCTAssertEqual("on", result?.key) + } + + func testGreater() { + let user = userContext(userProperties: ["key": "1"]) + let result = engine.evaluate(context: user, flags: flags)["test-greater"] + XCTAssertEqual("on", result?.key) + } + + func testGreaterOrEqual() { + let user = userContext(userProperties: ["key": "0"]) + let result = engine.evaluate(context: user, flags: flags)["test-greater-or-equal"] + XCTAssertEqual("on", result?.key) + } + + func testVersionLess() { + let user = ["user": ["version": "1.9.0"]] + let result = engine.evaluate(context: user, flags: flags)["test-version-less"] + XCTAssertEqual("on", result?.key) + } + + func testVersionLessOrEqual() { + let user = ["user": ["version": "1.10.0"]] + let result = engine.evaluate(context: user, flags: flags)["test-version-less-or-equal"] + XCTAssertEqual("on", result?.key) + } + + func testVersionGreater() { + let user = ["user": ["version": "1.10.0"]] + let result = engine.evaluate(context: user, flags: flags)["test-version-greater"] + XCTAssertEqual("on", result?.key) + } + + func testVersionGreaterOrEqual() { + let user = ["user": ["version": "1.9.0"]] + let result = engine.evaluate(context: user, flags: flags)["test-version-greater-or-equal"] + XCTAssertEqual("on", result?.key) + } + + func testSetIs() { + let user = userContext(userProperties: ["key": ["1", "2", "3"]]) + let result = engine.evaluate(context: user, flags: flags)["test-set-is"] + XCTAssertEqual("on", result?.key) + } + + func testSetIsNot() { + let user = userContext(userProperties: ["key": ["1", "2"]]) + let result = engine.evaluate(context: user, flags: flags)["test-set-is-not"] + XCTAssertEqual("on", result?.key) + } + + func testSetContains() { + let user = userContext(userProperties: ["key": ["1", "2", "3", "4"]]) + let result = engine.evaluate(context: user, flags: flags)["test-set-contains"] + XCTAssertEqual("on", result?.key) + } + + func testSetDoesNotContain() { + let user = userContext(userProperties: ["key": ["1", "2", "4"]]) + let result = engine.evaluate(context: user, flags: flags)["test-set-does-not-contain"] + XCTAssertEqual("on", result?.key) + } + + func testSetContainsAny() { + let user = userContext(cohortIds: ["u0qtvwla", "12345678"]) + let result = engine.evaluate(context: user, flags: flags)["test-set-contains-any"] + XCTAssertEqual("on", result?.key) + } + + func testSetDoesNotContainAny() { + let user = userContext(cohortIds: ["12345678", "87654321"]) + let result = engine.evaluate(context: user, flags: flags)["test-set-does-not-contain-any"] + XCTAssertEqual("on", result?.key) + } + + func testGlobMatch() { + let user = userContext(userProperties: ["key": "/path/1/2/3/end"]) + let result = engine.evaluate(context: user, flags: flags)["test-glob-match"] + XCTAssertEqual("on", result?.key) + } + + func testGlobDoesNotMatch() { + let user = userContext(userProperties: ["key": "/path/1/2/3"]) + let result = engine.evaluate(context: user, flags: flags)["test-glob-does-not-match"] + XCTAssertEqual("on", result?.key) + } + + // Test specific functionality + + func testIsWithBooleans() { + var user = userContext(userProperties: ["true": "TRUE", "false": "FALSE"]) + var result = engine.evaluate(context: user, flags: flags)["test-is-with-booleans"] + XCTAssertEqual("on", result?.key) + user = userContext(userProperties: ["true": "True", "false": "False"]) + result = engine.evaluate(context: user, flags: flags)["test-is-with-booleans"] + XCTAssertEqual("on", result?.key) + user = userContext(userProperties: ["true": "true", "false": "false"]) + result = engine.evaluate(context: user, flags: flags)["test-is-with-booleans"] + XCTAssertEqual("on", result?.key) + } +} + +// Object utils + +private func userContext(userId: String? = nil, deviceId: String? = nil, amplitudeId: String? = nil, userProperties: [String: Any?]? = nil, cohortIds: [String]? = nil) -> [String: Any?] { + var user: [String: Any?] = [:] + if let userId = userId { user["user_id"] = userId } + if let deviceId = deviceId { user["device_id"] = deviceId } + if let amplitudeId = amplitudeId { user["amplitude_id"] = amplitudeId } + if let userProperties = userProperties { user["user_properties"] = userProperties } + if let cohortIds = cohortIds { user["cohort_ids"] = cohortIds } + return ["user": user] +} + +private func groupContext(groupType: String, groupName: String, groupProperties: [String: Any?]? = nil) -> [String: Any?] { + return [ + "groups": [ + groupType: [ + "group_name": groupName, + "group_properties": groupProperties, + ] as [String: Any?] + ] as [String: Any?] + ] as [String: Any?] +} + +// Network utils + +private func doFlags() throws -> [EvaluationFlag] { + var result: Result<[EvaluationFlag], Error>? = nil + let s = DispatchSemaphore(value: 0) + try doFlagsAsync { r in + result = r + s.signal() + } + _ = s.wait(timeout: .now() + .seconds(20)) + switch result { + case .success(let flags): + return flags + case .failure(let error): + throw error + default: + throw ExperimentError("flags response timeout") + } +} + +private func doFlagsAsync(_ completion: @escaping (Result<[EvaluationFlag], Error>) -> Void) throws { + let url = URL(string: "https://flag.lab.amplitude.com/sdk/v2/flags?eval_mode=remote")! + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Api-Key \(DEPLOYMENT_KEY)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 20.0 + // Do fetch request + URLSession.shared.dataTask(with: request) { (data, response, error) in + if let error = error { + completion(Result.failure(error)) + return + } + guard let httpResponse = response as? HTTPURLResponse else { + completion(Result.failure(ExperimentError("Response is nil"))) + return + } + guard httpResponse.statusCode == 200 else { + completion(Result.failure(ExperimentError("Error Response: status=\(httpResponse.statusCode)"))) + return + } + guard let data = data else { + completion(Result.failure(ExperimentError("Flag response data is nil"))) + return + } + do { + let flags = try JSONDecoder().decode([EvaluationFlag].self, from: data) + completion(Result.success(flags)) + } catch { + print("[Experiment] Failed to parse flag data: \(error)") + completion(Result.failure(error)) + } + }.resume() +} diff --git a/Tests/ExperimentTests/ExperimentClientTests.swift b/Tests/ExperimentTests/ExperimentClientTests.swift index 9007fea..d44f2bc 100644 --- a/Tests/ExperimentTests/ExperimentClientTests.swift +++ b/Tests/ExperimentTests/ExperimentClientTests.swift @@ -9,11 +9,13 @@ import XCTest @testable import Experiment let API_KEY = "client-DvWljIjiiuqLbyjqdvBaLFfEBrAvGuA3" +let SERVER_API_KEY = "server-qz35UwzJ5akieoAdIgzM4m9MIiOLXLoz"; + let KEY = "sdk-ci-test" let INITIAL_KEY = "initial-key" let testUser = ExperimentUserBuilder().userId("test_user").build() -let serverVariant = Variant("on", payload: "payload") +let serverVariant = Variant("on", payload: "payload", key: "on") let fallbackVariant = Variant("fallback", payload: "payload") let initialVariant = Variant("initial") let initialVariants: [String: Variant] = [ @@ -104,8 +106,8 @@ class ExperimentClientTests: XCTestCase { var variant = client.variant("asdf", fallback: firstFallback) XCTAssertEqual(firstFallback, variant) - variant = client.variant("asdf") - XCTAssertEqual(fallbackVariant, variant) + variant = client.variant(INITIAL_KEY) + XCTAssertEqual(initialVariant, variant) variant = client.variant("asdf") XCTAssertEqual(fallbackVariant, variant) @@ -169,7 +171,6 @@ class ExperimentClientTests: XCTestCase { func testClearFlagConfig() { let storage = InMemoryStorage() - storage.put(key: "sdk-ci-test", value: serverVariant) let client = DefaultExperimentClient( apiKey: API_KEY, config: ExperimentConfigBuilder() @@ -177,6 +178,7 @@ class ExperimentClientTests: XCTestCase { .build(), storage: storage ) + client.variants.put(key: "sdk-ci-test", value: serverVariant) let variant = client.variant("sdk-ci-test") XCTAssertNotNil(variant) @@ -301,7 +303,6 @@ class ExperimentClientTests: XCTestCase { XCTFail() }) let storage = InMemoryStorage() - storage.put(key: KEY, value: serverVariant) let client = DefaultExperimentClient( apiKey: API_KEY, config: ExperimentConfigBuilder() @@ -309,6 +310,7 @@ class ExperimentClientTests: XCTestCase { .build(), storage: storage ) + client.variants.put(key: KEY, value: serverVariant) _ = client.variant(KEY) XCTAssertTrue(analyticsProvider.didExposureGetTracked) } @@ -420,9 +422,733 @@ class ExperimentClientTests: XCTestCase { storage: InMemoryStorage() ) _ = client.variant("flagKey") - XCTAssertEqual(exposureTrackingProvider.lastExposure, Exposure(flagKey: "flagKey", variant: "value", experimentKey: "expKey")) + XCTAssertEqual(exposureTrackingProvider.lastExposure, Exposure(flagKey: "flagKey", variant: "value", experimentKey: "expKey", metadata: nil)) XCTAssertEqual(exposureTrackingProvider.trackCount, 1) } + + // Local Evaluation Tests + + func testStartLoadsFlagsIntoStorage() { + let storage = InMemoryStorage() + let config = ExperimentConfigBuilder() + .fetchOnStart(true) + .build() + let client = DefaultExperimentClient( + apiKey: SERVER_API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .deviceId("test_device") + .build() + client.startBlocking(user: user) + let flagKey = client.flags.get(key: "sdk-ci-test-local")?.key + XCTAssertEqual("sdk-ci-test-local", flagKey) + } + + func testVariantAfterStartReturnsExpectedLocallyEvaluatedVariant() { + let storage = InMemoryStorage() + let config = ExperimentConfigBuilder() + .fetchOnStart(true) + .build() + let client = DefaultExperimentClient( + apiKey: SERVER_API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .deviceId("test_device") + .build() + client.startBlocking(user: user) + var variant = client.variant("sdk-ci-test-local") + XCTAssertEqual("on", variant.key) + XCTAssertEqual("on", variant.value) + client.setUser(nil) + variant = client.variant("sdk-ci-test-local") + XCTAssertEqual("off", variant.key) + XCTAssertEqual(nil, variant.value) + } + + func testRemoteEvaluationVariantPreferredOverLocalEvaluationVariant() { + let storage = InMemoryStorage() + let config = ExperimentConfigBuilder() + .fetchOnStart(false) + .build() + let client = DefaultExperimentClient( + apiKey: SERVER_API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .userId("test_user") + .deviceId("test_device") + .build() + client.startBlocking(user: user) + var variant = client.variant("sdk-ci-test") + XCTAssertEqual("off", variant.key) + XCTAssertEqual(nil, variant.value) + client.fetchBlocking(user: user) + variant = client.variant("sdk-ci-test") + XCTAssertEqual(Variant(key: "on", value: "on", payload: "payload"), variant) + } + + // Server Zone Tests + + func testNoConfigUsesDefaults() { + let storage = InMemoryStorage() + let config = ExperimentConfigBuilder() + .build() + let client = DefaultExperimentClient( + apiKey: SERVER_API_KEY, + config: config, + storage: storage + ) + XCTAssertEqual("https://api.lab.amplitude.com", client.config.serverUrl) + XCTAssertEqual("https://flag.lab.amplitude.com", client.config.flagsServerUrl) + } + + func testUsServerZoneConfigUsesDefaults() { + let storage = InMemoryStorage() + let config = ExperimentConfigBuilder() + .serverZone(.US) + .build() + let client = DefaultExperimentClient( + apiKey: SERVER_API_KEY, + config: config, + storage: storage + ) + XCTAssertEqual("https://api.lab.amplitude.com", client.config.serverUrl) + XCTAssertEqual("https://flag.lab.amplitude.com", client.config.flagsServerUrl) + } + + func testUsServerZoneWithExplicitConfigUsesExplicitConfig() { + let storage = InMemoryStorage() + let config = ExperimentConfigBuilder() + .serverZone(.US) + .serverUrl("https://experiment.company.com") + .flagsServerUrl("https://flags.company.com") + .build() + let client = DefaultExperimentClient( + apiKey: SERVER_API_KEY, + config: config, + storage: storage + ) + XCTAssertEqual("https://experiment.company.com", client.config.serverUrl) + XCTAssertEqual("https://flags.company.com", client.config.flagsServerUrl) + } + + func testEuServerZoneUsesEuDefaults() { + let storage = InMemoryStorage() + let config = ExperimentConfigBuilder() + .serverZone(.EU) + .build() + let client = DefaultExperimentClient( + apiKey: SERVER_API_KEY, + config: config, + storage: storage + ) + XCTAssertEqual("https://api.lab.eu.amplitude.com", client.config.serverUrl) + XCTAssertEqual("https://flag.lab.eu.amplitude.com", client.config.flagsServerUrl) + } + + func testEuServerZoneWithExplicitConfigUsesExplicitConfig() { + let storage = InMemoryStorage() + let config = ExperimentConfigBuilder() + .serverZone(.EU) + .serverUrl("https://experiment.company.com") + .flagsServerUrl("https://flags.company.com") + .build() + let client = DefaultExperimentClient( + apiKey: SERVER_API_KEY, + config: config, + storage: storage + ) + XCTAssertEqual("https://experiment.company.com", client.config.serverUrl) + XCTAssertEqual("https://flags.company.com", client.config.flagsServerUrl) + } + + // Fallback Tests + + // Local Storage Source + + func testLocalStorage_AccessedFromLocalStoragePrimary() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.LocalStorage) + .fetchOnStart(true) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .userId("test_user") + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test") + XCTAssertEqual("on", variant.key) + XCTAssertEqual("on", variant.value) + XCTAssertEqual("payload", variant.payload as! String) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual("on", exposureTrackingProvider.lastExposure?.variant) + + } + + func testLocalStorage_AccessedFromInlineFallback() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.LocalStorage) + .fetchOnStart(true) + .initialVariants(["sdk-ci-test": Variant(key: "initial", value: "initial")]) + .fallbackVariant(Variant(key: "fallback", value: "fallback")) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test", fallback: Variant(value: "inline")) + XCTAssertEqual(Variant("inline"), variant) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual(nil, exposureTrackingProvider.lastExposure?.variant) + } + + func testLocalStorage_AccessedFromInitialVariants_NoExplicitFallback() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.LocalStorage) + .fetchOnStart(true) + .initialVariants(["sdk-ci-test": Variant(key: "initial", value: "initial")]) + .fallbackVariant(Variant(key: "fallback", value: "fallback")) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test") + XCTAssertEqual(Variant(key: "initial", value: "initial"), variant) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual(nil, exposureTrackingProvider.lastExposure?.variant) + } + + func testLocalStorage_AccessedFromConfiguredFallback_NoInitialVariantsOrExplicitFallback() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.LocalStorage) + .fetchOnStart(true) + .initialVariants(["sdk-ci-test-not-selected": Variant(key: "initial", value: "initial")]) + .fallbackVariant(Variant(key: "fallback", value: "fallback")) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test") + XCTAssertEqual(Variant(key: "fallback", value: "fallback"), variant) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual(nil, exposureTrackingProvider.lastExposure?.variant) + } + + func testLocalStorage_DefaultVariantReturned_NoOtherFallback() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.LocalStorage) + .fetchOnStart(true) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test") + XCTAssertEqual("off", variant.key) + XCTAssertEqual(nil, variant.value) + XCTAssertEqual(true, variant.metadata?["default"] as! Bool) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual(nil, exposureTrackingProvider.lastExposure?.variant) + } + + // Initial Variants Source + + func testInitialVariants_AccessedFromInitialVariantsPrimary() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.InitialVariants) + .fetchOnStart(true) + .initialVariants(["sdk-ci-test": Variant(key: "initial", value: "initial")]) + .fallbackVariant(Variant(key: "fallback", value: "fallback")) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test") + XCTAssertEqual(Variant(key: "initial", value: "initial"), variant) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual("initial", exposureTrackingProvider.lastExposure?.variant) + } + + func testInitialVariants_AccessedFromLocalStorageSecondary() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.InitialVariants) + .fetchOnStart(true) + .initialVariants(["sdk-ci-test-not-selected": Variant(key: "initial", value: "initial")]) + .fallbackVariant(Variant(key: "fallback", value: "fallback")) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .userId("test_user") + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test") + XCTAssertEqual(Variant(key: "on", value: "on", payload: "payload"), variant) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual("on", exposureTrackingProvider.lastExposure?.variant) + } + + func testInitialVariants_AccessedFromInlineFallback() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.InitialVariants) + .fetchOnStart(true) + .initialVariants(["sdk-ci-test-not-selected": Variant(key: "initial", value: "initial")]) + .fallbackVariant(Variant(key: "fallback", value: "fallback")) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test", fallback: Variant(value: "inline")) + XCTAssertEqual(Variant("inline"), variant) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual(nil, exposureTrackingProvider.lastExposure?.variant) + } + + func testInitialVariants_AccessedFromConfiguredFallback_NoInitialVariantsOrExplicitFallback() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.InitialVariants) + .fetchOnStart(true) + .initialVariants(["sdk-ci-test-not-selected": Variant(key: "initial", value: "initial")]) + .fallbackVariant(Variant(key: "fallback", value: "fallback")) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test") + XCTAssertEqual(Variant(key: "fallback", value: "fallback"), variant) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual(nil, exposureTrackingProvider.lastExposure?.variant) + } + + func testInitialVariants_DefaultVariantReturned_NoOtherFallback() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.InitialVariants) + .fetchOnStart(true) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test") + XCTAssertEqual("off", variant.key) + XCTAssertEqual(nil, variant.value) + XCTAssertEqual(true, variant.metadata?["default"] as! Bool) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual(nil, exposureTrackingProvider.lastExposure?.variant) + } + + // Local Evaluation Source + + func testLocalEvaluation_ReturnsLocallyEvaluatedVariant() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.LocalStorage) + .fetchOnStart(true) + .initialVariants(["sdk-ci-test-local": Variant(key: "initial", value: "initial")]) + .fallbackVariant(Variant(key: "fallback", value: "fallback")) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .deviceId("0123456789") + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test-local") + XCTAssertEqual("on", variant.key) + XCTAssertEqual("on", variant.value) + XCTAssertEqual("local", variant.metadata?["evaluationMode"] as! String) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test-local", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual("on", exposureTrackingProvider.lastExposure?.variant) + } + + func testLocalEvaluation_LocallyEvaluatedDefaultVariant_WithInlineFallback() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.LocalStorage) + .fetchOnStart(true) + .initialVariants(["sdk-ci-test-local": Variant(key: "initial", value: "initial")]) + .fallbackVariant(Variant(key: "fallback", value: "fallback")) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test-local", fallback: Variant("inline")) + XCTAssertEqual(Variant("inline"), variant) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test-local", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual(nil, exposureTrackingProvider.lastExposure?.variant) + } + + func testLocalEvaluation_LocallyEvaluatedDefaultVariant_WithInitialVariants() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.LocalStorage) + .fetchOnStart(true) + .initialVariants(["sdk-ci-test-local": Variant(key: "initial", value: "initial")]) + .fallbackVariant(Variant(key: "fallback", value: "fallback")) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test-local") + XCTAssertEqual(Variant(key: "initial", value: "initial"), variant) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test-local", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual(nil, exposureTrackingProvider.lastExposure?.variant) + } + + func testLocalEvaluation_LocallyEvaluatedDefaultVariant_WithConfiguredFallback() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.LocalStorage) + .fetchOnStart(true) + .initialVariants(["sdk-ci-test-local-not-selected": Variant(key: "initial", value: "initial")]) + .fallbackVariant(Variant(key: "fallback", value: "fallback")) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test-local") + XCTAssertEqual(Variant(key: "fallback", value: "fallback"), variant) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test-local", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual(nil, exposureTrackingProvider.lastExposure?.variant) + } + + func testLocalEvaluation_DefaultVariantReturned_NoOtherFallback() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.LocalStorage) + .fetchOnStart(true) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test-local") + XCTAssertEqual("off", variant.key) + XCTAssertEqual(nil, variant.value) + XCTAssertEqual(1, exposureTrackingProvider.trackCount) + XCTAssertEqual("sdk-ci-test-local", exposureTrackingProvider.lastExposure?.flagKey) + XCTAssertEqual(nil, exposureTrackingProvider.lastExposure?.variant) + } + + // All + + func testAll_ReturnsLocalEvaluationVariant_OverRemoteOrInitialVariants_LocalStorageSource() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.LocalStorage) + .fetchOnStart(true) + .initialVariants([ + "sdk-ci-test": Variant(key: "initial", value: "initial"), + "sdk-ci-test-local": Variant(key: "initial", value: "initial") + ]) + .fallbackVariant(Variant(key: "fallback", value: "fallback")) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .userId("test_user") + .deviceId("0123456789") + .build() + client.startBlocking(user: user) + let allVariants = client.all() + let localVariant = allVariants["sdk-ci-test-local"] + XCTAssertEqual("on", localVariant?.key) + XCTAssertEqual("on", localVariant?.value) + XCTAssertEqual("local", localVariant?.metadata?["evaluationMode"] as! String) + let remoteVariant = allVariants["sdk-ci-test"] + XCTAssertEqual("on", remoteVariant?.key) + XCTAssertEqual("on", remoteVariant?.value) + } + + func testAll_ReturnsLocalEvaluationVariant_OverRemoteOrInitialVariants_InitialVariantsSource() { + let storage = InMemoryStorage() + let exposureTrackingProvider = TestExposureTrackingProvider() + let config = ExperimentConfigBuilder() + .exposureTrackingProvider(exposureTrackingProvider) + .source(.InitialVariants) + .fetchOnStart(true) + .initialVariants([ + "sdk-ci-test": Variant(key: "initial", value: "initial"), + "sdk-ci-test-local": Variant(key: "initial", value: "initial") + ]) + .fallbackVariant(Variant(key: "fallback", value: "fallback")) + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .userId("test_user") + .deviceId("0123456789") + .build() + client.startBlocking(user: user) + let allVariants = client.all() + let localVariant = allVariants["sdk-ci-test-local"] + XCTAssertEqual("on", localVariant?.key) + XCTAssertEqual("on", localVariant?.value) + XCTAssertEqual("local", localVariant?.metadata?["evaluationMode"] as! String) + let remoteVariant = allVariants["sdk-ci-test"] + XCTAssertEqual("initial", remoteVariant?.key) + XCTAssertEqual("initial", remoteVariant?.value) + } + + // Start Tests + + private class MockClient: DefaultExperimentClient { + + var fetchCalls = 0 + var mockFetch: (() -> Result<[String: Variant], Error>)? = nil + var flagCalls = 0 + var mockFlags: (() -> Result<[String: EvaluationFlag], Error>)? = nil + + override func doFetch( + user: ExperimentUser, + timeoutMillis: Int, + options: FetchOptions?, + completion: @escaping ((Result<[String: Variant], Error>) -> Void) + ) -> URLSessionTask? { + fetchCalls += 1 + if let mockFetch = mockFetch { + completion(mockFetch()) + return nil + } else { + return super.doFetch(user: user, timeoutMillis: timeoutMillis, options: options, completion: completion) + } + } + + override func doFlags( + timeoutMillis: Int, + completion: @escaping ((Result<[String: EvaluationFlag], Error>) -> Void) + ) { + flagCalls += 1 + if let mockFlags = mockFlags { + completion(mockFlags()) + } else { + super.doFlags(timeoutMillis: timeoutMillis, completion: completion) + } + } + } + + func testStart_WithLocalAndRemoteEvaluation_CallsFetch() { + let storage = InMemoryStorage() + let config = ExperimentConfigBuilder() + .build() + let client = DefaultExperimentClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + let user = ExperimentUserBuilder() + .userId("test_user") + .build() + client.startBlocking(user: user) + let variant = client.variant("sdk-ci-test") + // If we get on for the variant, fetch must be called. + XCTAssertEqual("on", variant.key) + XCTAssertEqual("on", variant.value) + } + + func testStart_WithLocalEvaluationOnly_DoesNotCallFetch() { + let storage = InMemoryStorage() + let config = ExperimentConfigBuilder() + .build() + let client = MockClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + client.mockFlags = { + return .success([:]) + } + client.mockFetch = { + return .success([:]) + } + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + XCTAssertEqual(0, client.fetchCalls) + client.fetchBlocking(user: user) + XCTAssertEqual(1, client.fetchCalls) + } + + func testStart_WithLocalEvalautionOnly_FetchOnStartEnabled_CallsFetch() { + let storage = InMemoryStorage() + let config = ExperimentConfigBuilder() + .fetchOnStart(true) + .build() + let client = MockClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + client.mockFlags = { + return .success([:]) + } + client.mockFetch = { + return .success([:]) + } + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + XCTAssertEqual(1, client.fetchCalls) + client.fetchBlocking(user: user) + XCTAssertEqual(2, client.fetchCalls) + } + + func testStart_WithLocalEvalautionOnly_FetchOnStartDisabled_DoesNotCallFetch() { + let storage = InMemoryStorage() + let config = ExperimentConfigBuilder() + .fetchOnStart(false) + .build() + let client = MockClient( + apiKey: API_KEY, + config: config, + storage: storage + ) + client.mockFlags = { + return .success([:]) + } + client.mockFetch = { + return .success([:]) + } + let user = ExperimentUserBuilder() + .build() + client.startBlocking(user: user) + XCTAssertEqual(0, client.fetchCalls) + client.fetchBlocking(user: user) + XCTAssertEqual(1, client.fetchCalls) + } } class TestAnalyticsProvider : ExperimentAnalyticsProvider { @@ -460,3 +1186,64 @@ class TestUserProvider : ExperimentUserProvider { .build() } } + +class InMemoryStorage: Storage { + private var store: [String: Data] = [:] + func get(key: String) -> Data? { + return store[key] + } + + func put(key: String, value: Data) { + store[key] = value + } + + func delete(key: String) { + store.removeValue(forKey: key) + } +} + +extension DefaultExperimentClient { + func startBlocking(user: ExperimentUser) { + let s = DispatchSemaphore(value: 0) + start(user) { error in + if let error = error { + XCTFail(error.localizedDescription) + } + s.signal() + } + switch s.wait(timeout: .now() + .seconds(20)) { + case .timedOut: XCTFail("start request timed out") + case .success: return + } + } + func startBlockingThrows(user: ExperimentUser) throws { + let s = DispatchSemaphore(value: 0) + var err: Error? + start(user) { error in + if let error = error { + err = error + } + s.signal() + } + switch s.wait(timeout: .now() + .seconds(20)) { + case .timedOut: XCTFail("start request timed out") + case .success: + if let error = err { + throw error + } + } + } + func fetchBlocking(user: ExperimentUser) { + let s = DispatchSemaphore(value: 0) + fetch(user: user) { _, error in + if let error = error { + XCTFail(error.localizedDescription) + } + s.signal() + } + switch s.wait(timeout: .now() + .seconds(20)) { + case .timedOut: XCTFail("start request timed out") + case .success: return + } + } +} diff --git a/Tests/ExperimentTests/HashX8632.swift b/Tests/ExperimentTests/HashX8632.swift new file mode 100644 index 0000000..c42263a --- /dev/null +++ b/Tests/ExperimentTests/HashX8632.swift @@ -0,0 +1,2011 @@ +// +// HashX8632.swift +// ExperimentTests +// +// Created by Brian Giori on 9/12/23. +// + +import Foundation + + +let MURMUR3_X86_32: String = """ +479943832 +1667709009 +821871466 +3407736416 +3096332942 +3551383265 +3482684574 +2565507831 +3854066542 +2587107967 +2808392652 +3446716934 +3502963659 +1987448620 +24285452 +2738203305 +4232420174 +3862742673 +1866007835 +2260804683 +3625386851 +1454628382 +3350035316 +3809838383 +2426510977 +1193232102 +2866675877 +51648458 +3306774988 +1682563376 +2965126635 +941752146 +2429220625 +276371974 +2583938337 +59050478 +2858339597 +125225229 +988317359 +1113280942 +3715449071 +3444742227 +2456188981 +1442787282 +2173228754 +4045160086 +2928355258 +3381815694 +3322818116 +4182951506 +1805745589 +2101748366 +3792465379 +2370038907 +4272752794 +3326761741 +3504238505 +1406242349 +711813597 +1026919206 +1162801996 +3568847465 +1793047661 +1636371546 +3932247933 +1526356669 +1225471874 +3540638123 +2635550809 +4097763765 +3112919846 +3669823156 +449299197 +3482740039 +2214560286 +3573650952 +965724450 +22241655 +3782283617 +488558219 +3722744581 +308050588 +1787755456 +3635016399 +2928539924 +3674664861 +3789510022 +208914522 +3357179249 +1136002305 +1223882462 +1489673113 +2616149414 +4150564834 +92689916 +4292195407 +1887956199 +2573581345 +119505784 +3381225555 +2127168897 +1221576289 +1799495839 +3184250659 +4252821245 +926076625 +1165334098 +2738430625 +1213272418 +696817588 +3828657519 +432552516 +1185187836 +1469601454 +3976428667 +2221717097 +273053635 +3312990606 +260262762 +485774389 +2161756208 +2574222548 +3971468958 +3966418662 +1402090407 +3207319850 +3510111757 +1691226417 +210010751 +3167713523 +808128695 +1467135643 +2098791062 +2824528353 +265455727 +3817768194 +2297434297 +516437602 +3429077782 +1665722383 +1725163699 +3834356229 +2308223238 +3469654560 +2591905186 +2120020250 +4084571501 +1170031954 +2890400796 +716489704 +2789972441 +3641589098 +1407711709 +3034710729 +1643798472 +443810740 +3510908902 +266230063 +3253401496 +1154946843 +2230277207 +3441337261 +3770208956 +1517420149 +4073671813 +2617112832 +3754202074 +1642266108 +3896133307 +2674121734 +3805252303 +2868534446 +2272665555 +2615253321 +355387517 +1391279937 +4183562935 +1444477415 +457354149 +1368757089 +1185932692 +3956234285 +815078461 +2290455632 +3866070370 +1989844323 +3553465460 +119501113 +2701783073 +1892610665 +559640549 +2616771970 +4005750476 +3371912046 +362801503 +1324398509 +1249204340 +3548655625 +1145187998 +2118727525 +1094336927 +823799195 +980604398 +1580418341 +2026811535 +154690488 +566787852 +810057128 +1707651442 +3507190449 +4237630584 +2551055318 +3515381994 +1269743426 +1812848471 +80052581 +2035725641 +3049436605 +2195212065 +1318653802 +2245741237 +1363246427 +2406124216 +825666336 +1651808082 +1916646796 +413795722 +3851246225 +3198226486 +1221879996 +1076061994 +1592858141 +2575708577 +4185606507 +3964661531 +1077896895 +274068358 +642506002 +976601532 +2616288143 +3692520296 +66175194 +1458620196 +2769318157 +3652412113 +437404922 +1245829761 +621804297 +2055065820 +3137033121 +683454299 +2990393269 +2077612862 +326672952 +700606360 +3267867075 +2436744631 +732620276 +4253335689 +3488564445 +612606850 +827327274 +3043504842 +114477531 +2356740879 +4043028977 +1452936829 +2875196560 +2072170996 +7376391 +1023612149 +2713539077 +3011952297 +2810713799 +840415664 +3684492239 +323846150 +540782310 +854433253 +3646233015 +3970433237 +1489430183 +1359641427 +3297178217 +2739708822 +2930674002 +3676755256 +1693129065 +1186344186 +3614822403 +3536297717 +1693651274 +2912984517 +2427144686 +39601588 +447811838 +896743038 +3649085850 +3544697351 +1727638948 +1459232538 +3981007615 +3343398595 +45740861 +1409220498 +1554557133 +385608624 +2711897640 +2556851370 +31784056 +3996459150 +2236236113 +3268559443 +1676133402 +4170716858 +2961341127 +3330387646 +2032866238 +2323779213 +997562267 +1163856300 +2401130160 +3534043939 +1526460816 +497953481 +434404863 +2598954397 +1880049848 +42687069 +2115084235 +1023314251 +653116723 +1575679949 +2207026213 +658496145 +188454540 +74773676 +4079917818 +2742180667 +1718403919 +3467778015 +1667851559 +3249898252 +2894254320 +1453970587 +1871381956 +2191200100 +1923995608 +1585402617 +238905625 +1186636312 +1441092839 +3976298986 +1910009683 +2013960270 +4141896354 +2598852332 +2565770833 +1152200988 +376123365 +2748082454 +2806447596 +1237627630 +2456073301 +18297902 +962423124 +311925022 +3494690406 +232125398 +3768891634 +425335007 +2250940149 +1286522375 +3727250457 +327768174 +1131433808 +776675303 +806297962 +2320481583 +887634214 +224927059 +1071636533 +897138920 +2761180897 +1877608917 +211714391 +1320958441 +3305302506 +1199978114 +189712539 +2338772098 +4110073093 +3543471284 +2478815746 +715330183 +3887433828 +1030680667 +3826956930 +1021197200 +1969755147 +746431212 +3223225061 +60100726 +2766813346 +3083524672 +1979824401 +2530020481 +4013529374 +3510419866 +562322081 +3480148065 +3545814150 +1475429168 +2487392694 +829033654 +2684010907 +3587737279 +2832379853 +818226767 +999053687 +3400064511 +3069783881 +2162543950 +2248528731 +949788089 +4163943386 +787990728 +3962567055 +2779550968 +3558066911 +2422164805 +1943573869 +1749987761 +4144035084 +2993565407 +1935448595 +3159612048 +3685501735 +540903897 +2908439485 +1914454572 +1123490337 +3742157185 +3509001304 +895732841 +2368302610 +2455105807 +1399869929 +3139440125 +1293366076 +3021024712 +1144107802 +2214280696 +2007906238 +1972503713 +3497166630 +2934945802 +150226060 +302290846 +3507177779 +1061057668 +533727180 +4125876014 +2683883754 +2057779400 +3337988993 +2527348240 +704756384 +2525168361 +651613445 +3039464093 +2899175484 +2515835901 +2774434160 +3993741290 +1442643311 +1663358189 +1781180986 +3347369800 +3284181095 +1076109161 +3568507044 +748054083 +1548759950 +3220435225 +4236337430 +3794294861 +1219800664 +1651064367 +1363905096 +3078546027 +323289380 +2010248514 +66889553 +860671828 +3064373862 +1380934452 +1175812279 +2009044307 +3958328789 +2154567647 +591437262 +1641738086 +2288299256 +501046143 +1727265065 +1279697279 +945611776 +530292284 +1698572276 +635126711 +2904959450 +880690436 +2347156371 +3918407210 +946722844 +3218210008 +1399737189 +923691871 +1319128627 +3245692737 +3737964057 +41826165 +2027162914 +486261897 +2786253279 +2947271480 +4275611960 +2233827989 +46627886 +1691985495 +4181650768 +2533017019 +3161383587 +2261811614 +3565610549 +3386521896 +59132769 +161670924 +231621965 +4045288910 +1667179964 +449187926 +2344112010 +3467753403 +2305101156 +2296694200 +703532106 +1142394369 +1183945048 +2021559822 +4166415422 +954072873 +3847391202 +288502854 +1950400383 +3614755555 +3336012377 +3776800563 +3222044657 +1376387809 +3932743002 +36146641 +1752496937 +2601521776 +2164493091 +1399432738 +3934836427 +3513047743 +2652905190 +814640578 +2971907118 +329978951 +317251984 +2105896398 +3290391579 +869559945 +1662959374 +2411343309 +2183131922 +2999665611 +604211450 +440203927 +295572879 +2657198930 +437239755 +1164192264 +470866387 +1755976844 +1546694178 +576928681 +2761292588 +3645714043 +3507624825 +395737289 +3267650905 +78838860 +1626342422 +2679402531 +1406771551 +3266226658 +4214937957 +1675437638 +2216120497 +3689082907 +3186372108 +2511915765 +34467525 +572511405 +4157005326 +95605688 +2890105817 +748779133 +1097015041 +789156156 +1653603334 +2247234555 +601241297 +3275544899 +814209449 +2570592879 +1774098114 +485685284 +1409129655 +2537644521 +3870539782 +847187015 +592498244 +2558396831 +3521503035 +614692122 +2771658112 +3577134905 +2494141426 +81162297 +850121388 +717096011 +2205488185 +3730476312 +1974619798 +3277992597 +406033996 +3119412164 +2705821557 +1651380651 +2636665424 +1953697251 +3069299329 +2792992905 +1561856108 +1993447863 +4005826653 +2134018641 +3914961247 +2728296686 +808327910 +2226535580 +3241534487 +2442681 +391603768 +2770601636 +3626765907 +1818824840 +392719218 +1367150609 +1141794622 +2701729042 +1579716514 +1860843867 +1410241613 +527241781 +4213727616 +3703459097 +2178820185 +1954003526 +843783550 +4113951527 +1245752470 +3207549252 +1404850546 +2635490741 +1266204803 +1857112478 +4292414932 +2186307714 +2434532279 +3935724308 +2748740696 +3505568062 +3988374441 +484081045 +4005141205 +2387494402 +3608836999 +4262213474 +3106826283 +445927826 +4071803420 +309867577 +3500758064 +4086941368 +914164102 +2668420338 +4214615934 +3118852435 +3576373248 +660900972 +547390543 +407033641 +1706244219 +3312283754 +1992079545 +921597021 +558320745 +4044558623 +1341997166 +3961356485 +3181451102 +3245996717 +2365376705 +2999917225 +2593376121 +2190092023 +2965216209 +2858388372 +1460519790 +3625826717 +2108322133 +1996335868 +2715922682 +917481412 +1825463384 +3005827365 +399616526 +3003198331 +2887157916 +1174558717 +3583769212 +1337811651 +599654970 +888127439 +1769385198 +1925858107 +3982557209 +1119883036 +2714415471 +611639245 +429216215 +2544646678 +2962331678 +4141379649 +985486527 +3184988813 +4010129267 +4214937903 +4150689113 +1028470775 +673712797 +675999988 +3410367343 +2422171229 +855415704 +1223295504 +406655830 +3691263914 +290810063 +891334188 +3788267641 +1513520347 +3400181987 +3478557428 +4014487758 +2802387961 +4008154345 +2929855991 +3526840663 +1230502518 +3464132038 +3920111497 +2919289023 +1172935864 +2900249378 +694797723 +2313309466 +4111021356 +2136416877 +2297524679 +2134884023 +1306711673 +2284132675 +1743220491 +568139715 +667478595 +35168067 +2673978838 +3891576919 +1528721841 +3188075470 +471272420 +2303387459 +1477779029 +2744768577 +1336378048 +1516722101 +222546532 +2722583132 +2774666788 +3926184745 +2020318314 +3590544633 +3282502994 +2323276517 +184250139 +1241772204 +2331745656 +3205795611 +3545623655 +587550778 +1407676934 +2985409624 +3746202431 +351739541 +2723800019 +438558775 +4185591131 +3595960631 +979814554 +3866011097 +2625949275 +2440240515 +1098356411 +3387319666 +1633713248 +2967407103 +1680699613 +59554886 +55943917 +100484899 +4157431707 +1747851344 +1537875403 +994888934 +2369930284 +1687539712 +4065105946 +889552074 +2837076628 +2728509243 +2160045407 +3454231334 +2561282408 +1763619278 +3053791223 +3868618734 +4123753028 +3810266261 +3163015507 +3497096272 +3414643063 +1932823504 +2923980959 +3020589255 +3263462340 +1879262667 +1534768220 +3511781839 +789785567 +4015462982 +476944116 +2229113231 +3248714722 +2847014415 +4109600768 +142301427 +3419338280 +1241981257 +186371752 +3542744997 +2262133025 +2789463806 +307379095 +33445834 +425913123 +2918335140 +1572219061 +277483306 +1509941403 +4129723340 +833257604 +538618864 +355461387 +395930754 +2493847794 +2515787584 +3938674219 +580964118 +2775418465 +4259565641 +2112179098 +670029902 +4017498764 +610159687 +1209888758 +3561676319 +1923604773 +1332050469 +3606455966 +1638118900 +2654529851 +850904257 +3614221178 +1784516892 +1815976611 +2928419899 +2862390384 +1486753005 +2556583923 +3514695902 +767761394 +1360706234 +3935506207 +2249128585 +2870327202 +2395120415 +2635356720 +2162689222 +4171841634 +2654110505 +1637499906 +1427943040 +1137510903 +2142606313 +3703386790 +854945889 +2379145327 +2201184606 +153547460 +2856700316 +3977951268 +1342141581 +2786874304 +63019704 +2865446926 +4024771971 +1886050815 +221390908 +3354946726 +1140178746 +1359337938 +3218842266 +2284383695 +3153006133 +415745063 +3410515842 +3642472434 +1691128412 +1054634102 +3429592236 +2485206145 +707307215 +2452249591 +3273292331 +645439352 +4071259309 +3342723182 +3342715908 +1284344423 +4064608701 +3987531496 +4090725199 +1801949766 +494452007 +436563778 +2255276886 +506399143 +3009612576 +192519482 +3195083287 +3942653198 +1070423984 +386566998 +1539410374 +2048669885 +1863184759 +2284272453 +2291357642 +3452504893 +2621152825 +2454668710 +1817744667 +1229920387 +2826567630 +279493146 +697278789 +304606287 +3676687625 +4143629353 +4280417445 +2353235155 +1424653354 +1643501461 +341070130 +3441010229 +943904705 +39125610 +3717991064 +764826915 +997268883 +92397327 +1500599803 +2763891793 +2236582775 +2585842595 +550297128 +1664834417 +3511579147 +4181711328 +3408955670 +3980832180 +790711359 +1841524075 +818563632 +1729224205 +1696800603 +3315539059 +63074257 +2894691303 +3886896197 +1639795350 +3279628827 +1815748138 +3332036295 +6327415 +1537237437 +4075416501 +3823305462 +1697015875 +3073765873 +268124703 +1508317646 +875693095 +3607877509 +3040642197 +795701643 +3006712200 +234879086 +4073562065 +3726454173 +2752425176 +1407371816 +1317740699 +2829963686 +957838546 +2081254442 +1999775920 +3698637610 +638993530 +58967796 +197513608 +1179182019 +2349668417 +1600701465 +3437964051 +3434164759 +3912892399 +2040079264 +587722307 +1675152278 +4145603677 +487004433 +2553814587 +3363315843 +326858577 +3519712511 +333992124 +1549819933 +3738961258 +1218932097 +2866062040 +126077633 +3356013417 +3929310981 +1983801482 +975271763 +2929375963 +3270372260 +158407809 +3516263996 +644671075 +722951279 +1851902713 +1729762370 +2327401085 +2095734572 +2745730413 +1680140910 +1155355083 +253978657 +1349532159 +3424366335 +2776829984 +3615345609 +2682918852 +3614724695 +4150087782 +761495465 +3173708930 +225908670 +2742411792 +3998584912 +1797616672 +3672050669 +2184908410 +419323385 +737179967 +3288491367 +3000789351 +2080942491 +2426882682 +2864630608 +3007863510 +1038201506 +1177581695 +802540715 +340214720 +4030216418 +84650719 +3722626089 +3458502957 +3413434416 +4225621861 +1816025963 +4002422034 +435200476 +3744837657 +2212562978 +3783848151 +2213607735 +2756425550 +3015211131 +1601337402 +3394767977 +766406004 +1273713750 +2134955252 +3606732757 +1171862239 +125994686 +2903294250 +3561332848 +1813177696 +596487801 +3433059340 +960225991 +1453114220 +1151920528 +355999712 +1815934530 +3882211934 +1430273284 +3594720355 +3001119229 +2856922809 +3876128321 +2601009847 +210218186 +2610150056 +1788418876 +2162426743 +3024773148 +773224368 +2382669098 +3180799825 +15944152 +3851502974 +1495612368 +3396463362 +3691235621 +2358018526 +378618584 +1129308550 +3281570940 +3718493827 +3331985955 +1217264672 +2402032531 +3671329047 +2184596905 +3264792477 +2335424803 +3668097440 +1009432720 +2601476522 +3765270557 +1039149214 +1707426599 +3314457281 +2640196865 +1366367044 +1802265828 +4251728133 +674383950 +2571949473 +2187646062 +3762426629 +2649532698 +268661045 +236112017 +1941226725 +4236759496 +128747019 +338194262 +158970807 +937283909 +2108738670 +203588356 +3772802675 +360540841 +2856698424 +1425689232 +2176664612 +377847636 +3372592368 +894609819 +1458677693 +2738668183 +3670423959 +4098338025 +3232472539 +2035720620 +3521099876 +606978502 +2811189443 +4211087557 +890044869 +2021976803 +3886867809 +481285305 +806615354 +1625877641 +1099718055 +1372238067 +3213299505 +252376525 +2546857989 +2100410966 +1342382724 +4231306714 +1848799132 +3553469267 +126734683 +634198562 +1581303695 +3993513098 +1286156135 +668643917 +4067859599 +934928944 +1780975974 +3124648693 +1079636000 +2728852827 +1712792386 +3973717505 +3730541706 +1008622869 +374215003 +4076699729 +3782065301 +1277680220 +3644582223 +3230384581 +4048329101 +3972530603 +1622651190 +1780122149 +1944730914 +3072661266 +842813796 +3119053457 +566527530 +2260649084 +755521347 +2959631877 +1924364446 +3955166907 +2415944365 +1677072589 +665468144 +1188398722 +2522488958 +3552825967 +1524289960 +192430158 +1584913193 +1627176702 +2638370594 +210907863 +4087932353 +2366334891 +3761589603 +1069118981 +949839095 +87114663 +1559735965 +3586780570 +4030358951 +3277975437 +1015573746 +3470038195 +2785670912 +2603922433 +1210947993 +1106862462 +929942164 +568439440 +3688432423 +1868460156 +3340533010 +2816898687 +1527607140 +562649736 +4168938871 +1520941134 +3398401661 +811755035 +681817209 +708779615 +1406782199 +846994942 +1294513990 +1485919443 +1227349508 +1921181604 +1113119277 +2901205146 +3561488788 +1235874401 +3563624094 +1225137659 +969981390 +4192400661 +489022838 +3807783589 +1884583315 +614102067 +2264070180 +428942557 +679805882 +3441550586 +4173809585 +3171644380 +1480825731 +517848241 +3105634246 +2753417821 +2346133695 +824377742 +1079509557 +4068838714 +2110392896 +1144129374 +4252557789 +677996070 +541231505 +284261936 +3056228629 +3231332803 +1847810301 +3710688827 +2483826767 +3414140333 +2131677643 +2603079466 +2499686130 +554707642 +473131486 +673631503 +1605096723 +28256843 +2895662654 +4180761669 +2920687099 +2733091807 +2170543968 +2793464479 +1899054144 +3289616378 +1804298063 +355464343 +1900616923 +2045906425 +4064075790 +1025215720 +1313403757 +46741124 +3966725132 +600007132 +2584269667 +2996629167 +26808252 +2738683753 +3246888369 +1909686873 +3597229537 +2985911807 +2483340995 +945767497 +1217341064 +3881133795 +3911348945 +3415900629 +721668741 +3159840084 +2429316508 +1544943051 +1372872204 +308167994 +1575461492 +3915125143 +3161878400 +513043714 +2781808939 +4242240462 +2779538599 +409022199 +3079444933 +7181778 +3847373973 +2203279073 +183517683 +3600304109 +1555821764 +341763631 +532312498 +472624802 +3651808920 +3335899740 +2060936333 +1580548766 +1493129880 +369955197 +3645011883 +515387584 +2505922842 +239186087 +1489160813 +2224110516 +1439646696 +3488896105 +2107308081 +2738925067 +3910177423 +541285089 +846127926 +1304863982 +3076541409 +3316518439 +813149638 +2056294945 +2553011997 +2680708909 +1929801903 +1647656964 +2660623807 +3246111254 +3367317527 +1817046968 +2207202227 +2085598467 +2785243057 +1527692304 +1937633278 +2256643744 +4206640071 +1951874480 +3522497273 +191090235 +1744568533 +929420982 +2543298498 +3608150214 +2183049692 +4262986775 +3029529525 +1217983543 +4039538404 +1798133678 +467170149 +1225435672 +1516038687 +2179810027 +440472117 +620748452 +4150289537 +2753699450 +1148611940 +3982022074 +3403224026 +1210105084 +2406481806 +1662914097 +1686746214 +990347982 +875010577 +3561388222 +4053700483 +3725565174 +1523304993 +2929777979 +2380320202 +158443510 +4127763946 +298310148 +1170708875 +2838935908 +730294034 +726163556 +1279994819 +1969058109 +1824644497 +34245999 +3531729984 +4147203355 +1588468981 +1040270329 +273816480 +1241534256 +2903884658 +2419354760 +1592350681 +554237864 +148356387 +2750729997 +984858271 +391956606 +1961325167 +1924893885 +2524574211 +2911725517 +4147790882 +6007871 +3577520231 +529789211 +310679183 +1534876059 +3172840515 +1359826983 +1084548478 +1435338039 +3093653778 +2962571233 +1696160738 +4187396440 +4195141931 +3675622148 +1200160064 +3734161938 +1394760307 +2766952622 +1621753974 +3829848041 +492998765 +866657171 +3366215792 +3515460627 +3519395627 +1757409447 +3425057988 +3372287342 +2196377313 +1662872149 +2586354745 +1641599349 +703701314 +3522518384 +2879428994 +1481970611 +2901064997 +1990078781 +3923791481 +1669561050 +1800708128 +4118021958 +2961152044 +2480579794 +1251125753 +3914664591 +1288677346 +1782997871 +1088718170 +3861128759 +560073703 +3749549526 +2813200110 +4057688340 +1653419509 +2738885937 +1072603126 +2372054889 +532421742 +4012825124 +3568864527 +2379551214 +2329039951 +3515380036 +3772623189 +1167779284 +3827115574 +3450782787 +3727098958 +2446103738 +888223339 +818312784 +1054260550 +3212741400 +1241077316 +2726877516 +1610829842 +3231517301 +2553846088 +2122642582 +3273752080 +3706085302 +561325561 +2661189587 +1862924332 +3966062866 +3724281329 +3131534479 +1374961168 +2709850648 +3794568567 +4094193258 +1117760822 +2255524067 +2142625777 +3235413565 +1585159601 +4192698229 +1703936686 +3847675573 +781200030 +3694319770 +2278214339 +3416056679 +2576275294 +1399746260 +4144663947 +2526554209 +1453205161 +1283243237 +2793221816 +468818834 +1883702388 +4046169532 +478491756 +3517653914 +2880043241 +3106697039 +3574137365 +1331541081 +2592823873 +2766733359 +2082174256 +4121072913 +1329291532 +326859590 +3538145657 +1901401112 +2748468818 +3481925272 +2310955076 +2260966644 +4007544712 +2133163598 +3131743632 +773072117 +2716164713 +2423286762 +3045868481 +2784750273 +4064018178 +3849292388 +3779311888 +54866040 +3188091291 +541443232 +983453024 +1191086045 +1564766768 +3450656317 +2635443018 +2381165572 +3101730823 +3891348887 +670249684 +2782004831 +3178682831 +2283008334 +622079136 +1687813969 +23914480 +1301707473 +3649043072 +1801898607 +2664738120 +722003399 +2032834454 +1941892145 +3566499739 +1448307534 +3833378326 +3013802132 +1079884306 +4121291762 +4015506573 +766278917 +1924471194 +950741092 +2182241048 +4022867267 +3039794966 +3803660 +1136813864 +3794624705 +2149473705 +3797028109 +3527363504 +597193319 +4150165033 +212656849 +2724927155 +1479196699 +2639151227 +1917224994 +932648234 +137715978 +1065322702 +3790868789 +1322394387 +1410231372 +551805591 +584474655 +345337368 +2053314044 +1345960108 +2976710117 +1223845776 +2648892988 +1672251520 +4164656011 +3659470034 +1517319498 +1430815721 +170079433 +3994576009 +4257055185 +3802527467 +2406434495 +1370723618 +4141695210 +2787082280 +2795946050 +4039467595 +2986728267 +1512055632 +3878356470 +2740489985 +2725996043 +1906150062 +3133152306 +1910728929 +2966699050 +2291373271 +2130219933 +1259913030 +1442730330 +3266959683 +1263621027 +2857138324 +1777613223 +2579031555 +2638907916 +218770162 +1713901295 +2064398102 +3561393089 +2027770387 +4092020889 +2485987707 +1325175561 +828434530 +3226326012 +2600004709 +3903505396 +1957504069 +1097426595 +80136267 +2580174835 +1510657164 +1404811129 +4167086127 +3105578570 +3284967354 +473364371 +888950103 +2200755908 +3700976274 +3268168088 +3909960036 +1160266256 +8493419 +1573434170 +2529587403 +144521645 +2180369265 +1745395468 +4181988875 +860509949 +3073653849 +3068709047 +3552941337 +4066652781 +251576741 +3728212326 +3936056760 +2927413143 +2803614169 +2831827639 +4194635233 +3985850263 +1143662519 +3793731367 +3504933439 +4030732468 +1679901271 +2627247017 +221321083 +4230669443 +412062344 +1378498242 +1405063105 +810053778 +165060875 +3141103272 +4035584215 +3837409555 +3477600288 +1857053058 +1412747569 +3924680040 +2517143636 +3893580387 +314489287 +575612169 +2301423839 +672721763 +379926221 +761647549 +717586402 +430164438 +2585102748 +4197928827 +3450588957 +135630448 +2552486054 +1352955135 +3505901640 +2239863013 +985319674 +1888221102 +146990110 +3587682505 +1387772658 +100528453 +1708549230 +714145621 +2686773158 +1034251943 +1542908012 +2971108531 +3175675256 +3155812979 +760745085 +3681634288 +3783077696 +4217161467 +2273014888 +2451974032 +264680448 +53851393 +3424479224 +2739451217 +1253261111 +344934658 +1804926748 +3807665795 +1151326556 +1439068681 +2685670378 +2322855556 +131047634 +3402654236 +3320531027 +3004896517 +1118091581 +2741680934 +396533897 +491219183 +1927402425 +1057939946 +380697219 +2046587612 +1569670525 +360356901 +2391290331 +412315979 +1220654423 +3765059160 +2701169740 +1107272523 +158622376 +1520381348 +835982829 +4023089343 +2290597368 +1577291706 +1189569986 +2493558805 +2142702960 +2197415758 +3948847365 +264929504 +667630022 +2933655471 +2766580877 +1168943299 +133611945 +2486401355 +4224587256 +2367702403 +2412800226 +1646873382 +3747956491 +2744973553 +3988038049 +3595012406 +1017451466 +2649703220 +3740838078 +3105159101 +2804436143 +3127843949 +2652285974 +996252447 +4002643122 +2962869400 +2938000538 +4098624499 +1043561429 +1994115777 +659054836 +1134136174 +838515121 +3286292150 +1925025503 +1984754452 +998454022 +3385093023 +2123911291 +4090478485 +2819407212 +2085409569 +2571076318 +2571160046 +2074098790 +553659926 +2284050650 +2932204823 +3170383113 +3427056117 +3089175458 +3531552472 +1536747422 +2528019347 +973549920 +1665933403 +3676633449 +756429907 +4099937919 +4290541561 +126302130 +3928415825 +1462292920 +3801263493 +2310690578 +191970844 +3128366957 +""" diff --git a/Tests/ExperimentTests/LoadStoreCacheTests.swift b/Tests/ExperimentTests/LoadStoreCacheTests.swift new file mode 100644 index 0000000..d53e9cc --- /dev/null +++ b/Tests/ExperimentTests/LoadStoreCacheTests.swift @@ -0,0 +1,116 @@ +// +// LoadStoreCacheTests.swift +// ExperimentTests +// +// Created by Brian Giori on 9/26/23. +// + +import Foundation +import XCTest +@testable import Experiment + +class LoadStoreCacheTests: XCTestCase { + + func testCacheMethods() { + let storage = InMemoryStorage() + let cache = LoadStoreCache(namespace: "test", storage: storage) + // Put / Get + cache.put(key: "flag-key-1", value: Variant(key: "on", value: "on")) + let variant = cache.get(key: "flag-key-1") + XCTAssertEqual(Variant(key: "on", value: "on"), variant) + // Put All / Get All + cache.putAll(values: [ + "flag-key-2": Variant(key: "on", value: "on"), + "flag-key-3": Variant(key: "on", value: "on") + ]) + var variants = cache.getAll() + XCTAssertEqual([ + "flag-key-1": Variant(key: "on", value: "on"), + "flag-key-2": Variant(key: "on", value: "on"), + "flag-key-3": Variant(key: "on", value: "on") + ], variants) + // Delete + cache.remove(key: "flag-key-3") + variants = cache.getAll() + XCTAssertEqual([ + "flag-key-1": Variant(key: "on", value: "on"), + "flag-key-2": Variant(key: "on", value: "on") + ], variants) + // Clear + cache.clear() + variants = cache.getAll() + XCTAssertEqual([:], variants) + } + + func testLoad() { + let namespace = "test" + let storage = InMemoryStorage() + let cache = LoadStoreCache(namespace: namespace, storage: storage) + let testData = """ + {"flag-key-1":{"key":"on","value":"on"}} + """.data(using: .utf8)! + storage.put(key: namespace, value: testData) + var variant = cache.get(key: "flag-key-1") + XCTAssertEqual(nil, variant) + cache.load() + variant = cache.get(key: "flag-key-1") + XCTAssertEqual(Variant(key: "on", value: "on"), variant) + } + + func testLoadOverwritesCache() { + let namespace = "test" + let storage = InMemoryStorage() + let cache = LoadStoreCache(namespace: namespace, storage: storage) + let testData = """ + {"flag-key-1":{"key":"off","value":"off"}} + """.data(using: .utf8)! + storage.put(key: namespace, value: testData) + cache.put(key: "flag-key-1", value: Variant(key: "on", value: "on")) + var variant = cache.get(key: "flag-key-1") + XCTAssertEqual(Variant(key: "on", value: "on"), variant) + cache.load() + variant = cache.get(key: "flag-key-1") + XCTAssertEqual(Variant(key: "off", value: "off"), variant) + storage.delete(key: namespace) + cache.load() + variant = cache.get(key: "flag-key-1") + XCTAssertEqual(nil, variant) + } + + func testStore() { + let namespace = "test" + let storage = InMemoryStorage() + let cache = LoadStoreCache(namespace: namespace, storage: storage) + let testData = """ + {"flag-key-1":{"key":"on","value":"on"}} + """.data(using: .utf8)! + cache.put(key: "flag-key-1", value: Variant(key: "on", value: "on")) + cache.store() + let storageData = storage.get(key: namespace) + XCTAssertEqual(testData, storageData) + } + + func testStoreOverwritesStorage() { + let namespace = "test" + let storage = InMemoryStorage() + let cache = LoadStoreCache(namespace: namespace, storage: storage) + let initialData = """ + {"flag-key-1":{"key":"on","value":"on"}} + """.data(using: .utf8)! + storage.put(key: namespace, value: initialData) + cache.put(key: "flag-key-1", value: Variant(key: "off", value: "off")) + cache.store() + var storageData = storage.get(key: namespace) + var expectedData = """ + {"flag-key-1":{"key":"off","value":"off"}} + """.data(using: .utf8)! + XCTAssertEqual(expectedData, storageData) + cache.clear() + cache.store() + storageData = storage.get(key: namespace) + expectedData = """ + {} + """.data(using: .utf8)! + XCTAssertEqual(expectedData, storageData) + } +} diff --git a/Tests/ExperimentTests/Murmur3Tests.swift b/Tests/ExperimentTests/Murmur3Tests.swift new file mode 100644 index 0000000..dfcf906 --- /dev/null +++ b/Tests/ExperimentTests/Murmur3Tests.swift @@ -0,0 +1,38 @@ +// +// Murmur3Tests.swift +// ExperimentTests +// +// Created by Brian Giori on 9/11/23. +// + +import XCTest +import Foundation +@testable import Experiment + +let MURMUR_SEED = 0x7f3a21ea + +class Murmur3Tests: XCTestCase { + + func testMurmur3HashSimple() { + let input = "brian" + let result = input.murmurHash32x86(seed: MURMUR_SEED) + XCTAssertEqual(result, 3948467465) + } + + func testMurmur3EnglishWords() { + let inputs = ENGLISH_WORDS.split(separator: "\n") + let outputs = MURMUR3_X86_32.split(separator: "\n") + for i in 0..) + XCTAssertEqual([1, 2, 3], object.select(selector: ["object", "array"]) as! Array) + } +} diff --git a/Tests/ExperimentTests/SemanticVersionTests.swift b/Tests/ExperimentTests/SemanticVersionTests.swift new file mode 100644 index 0000000..24e0d56 --- /dev/null +++ b/Tests/ExperimentTests/SemanticVersionTests.swift @@ -0,0 +1,151 @@ +// +// SemanticVersionTests.swift +// ExperimentTests +// +// Created by Brian Giori on 9/12/23. +// + +import XCTest +import Foundation +@testable import Experiment + +class SemanticVersionTests : XCTestCase { + + func testInvalidVersions() { + // just major + assertInvalidVersion("10") + // trailing dots + assertInvalidVersion("10.") + assertInvalidVersion("10..") + assertInvalidVersion("10.2.") + assertInvalidVersion("10.2.33.") + // trailing dots on prerelease tags are not handled because prerelease tags are considered + // strings anyway for comparison which should be fine - e.g. "10.2.33-alpha1.2." + + // dots in the middle + assertInvalidVersion("10..2.33") + assertInvalidVersion("102...33") + + // invalid characters + assertInvalidVersion("a.2.3") + assertInvalidVersion("23!") + assertInvalidVersion("23.#5") + assertInvalidVersion("") + assertInvalidVersion(nil) + + // more numbers + assertInvalidVersion("2.3.4.567") + assertInvalidVersion("2.3.4.5.6.7") + + // prerelease if provided should always have major, minor, patch + assertInvalidVersion("10.2.alpha") + assertInvalidVersion("10.alpha") + assertInvalidVersion("alpha-1.2.3") + + // prerelease should be separated by a hyphen after patch + assertInvalidVersion("10.2.3alpha") + assertInvalidVersion("10.2.3alpha-1.2.3") + + // negative numbers + assertInvalidVersion("-10.1") + assertInvalidVersion("10.-1") + } + + func testValidVersions() { + assertValidVersion("100.2") + assertValidVersion("0.102.39") + assertValidVersion("0.0.0") + + // versions with leading 0s would be converted to int + assertValidVersion("01.02") + assertValidVersion("001.001100.000900") + + // prerelease tags + assertValidVersion("10.20.30-alpha") + assertValidVersion("10.20.30-1.x.y") + assertValidVersion("10.20.30-aslkjd") + assertValidVersion("10.20.30-b894") + assertValidVersion("10.20.30-b8c9") + } + + func testVersionComparison() { + // EQUALS case + assertVersionComparison("66.12.23", EvaluationOperator.IS, "66.12.23") + // patch if not specified equals 0 + assertVersionComparison("5.6", EvaluationOperator.IS, "5.6.0") + // leading 0s are not stored when parsed + assertVersionComparison("06.007.0008", EvaluationOperator.IS, "6.7.8") + // with pre release + assertVersionComparison("1.23.4-b-1.x.y", EvaluationOperator.IS, "1.23.4-b-1.x.y") + + // DOES NOT EQUAL case + assertVersionComparison("1.23.4-alpha-1.2", EvaluationOperator.IS_NOT, "1.23.4-alpha-1") + // trailing 0s aren't stripped + assertVersionComparison("1.2.300", EvaluationOperator.IS_NOT, "1.2.3") + assertVersionComparison("1.20.3", EvaluationOperator.IS_NOT, "1.2.3") + + // LESS THAN case + // patch of .1 makes it greater + assertVersionComparison("50.2", EvaluationOperator.VERSION_LESS_THAN, "50.2.1") + // minor 9 > minor 20 + assertVersionComparison("20.9", EvaluationOperator.VERSION_LESS_THAN, "20.20") + // same version with pre release should be lesser + assertVersionComparison("20.9.4-alpha1", EvaluationOperator.VERSION_LESS_THAN, "20.9.4") + // compare prerelease as strings + assertVersionComparison("20.9.4-a-1.2.3", EvaluationOperator.VERSION_LESS_THAN, "20.9.4-a-1.3") + // since prerelease is compared as strings a1.23 < a1.5 because 2 < 5 + assertVersionComparison("20.9.4-a1.23", EvaluationOperator.VERSION_LESS_THAN, "20.9.4-a1.5") + + // GREATER THAN case + assertVersionComparison("12.30.2", EvaluationOperator.VERSION_GREATER_THAN, "12.4.1") + // 100 > 1 + assertVersionComparison("7.100", EvaluationOperator.VERSION_GREATER_THAN, "7.1") + // 10 > 9 + assertVersionComparison("7.10", EvaluationOperator.VERSION_GREATER_THAN, "7.9") + // converts to 7.10.20 > 7.9.1 + assertVersionComparison("07.010.0020", EvaluationOperator.VERSION_GREATER_THAN, "7.009.1") + // patch comparison comes first + assertVersionComparison("20.5.6-b1.2.x", EvaluationOperator.VERSION_GREATER_THAN, "20.5.5") + } +} + +func assertInvalidVersion(_ versionString: String?) { + guard SemanticVersion.parse(version: versionString) != nil else { + // expect null + return + } + XCTFail("Should have failed creating a semantic version for \(versionString ?? "nil")") +} + +func assertValidVersion(_ versionString: String) { + if SemanticVersion.parse(version: versionString) == nil { + XCTFail("Expected a non null semantic version for \(versionString)") + } +} + +func assertVersionComparison(_ v1: String, _ op: String, _ v2: String) { + guard let sv1 = SemanticVersion.parse(version: v1) else { + XCTFail("parsing should succeed: \(v1)") + return + } + guard let sv2 = SemanticVersion.parse(version: v2) else { + XCTFail("parsing should succeed: \(v2)") + return + } + switch (op) { + case EvaluationOperator.IS: + XCTAssertTrue(sv1 == sv2) + break + case EvaluationOperator.IS_NOT: + XCTAssertTrue(sv1 != sv2) + break + case EvaluationOperator.VERSION_LESS_THAN: + XCTAssertTrue(sv1 < sv2) + break + case EvaluationOperator.VERSION_GREATER_THAN: + XCTAssertTrue(sv1 > sv2) + break + default: + XCTFail("unexpected op \(op)") + } +} diff --git a/Tests/ExperimentTests/TopologicalSortTests.swift b/Tests/ExperimentTests/TopologicalSortTests.swift new file mode 100644 index 0000000..7a61ba3 --- /dev/null +++ b/Tests/ExperimentTests/TopologicalSortTests.swift @@ -0,0 +1,304 @@ +// +// TopologicalSortTests.swift +// ExperimentTests +// +// Created by Brian Giori on 9/13/23. +// + +import XCTest +import Foundation +@testable import Experiment + +class TopologicalSortTests: XCTestCase { + + func testEmpty() throws { + let flagConfigs: [EvaluationFlag] = [] + var result = try topologicalSort(flagConfigs) + XCTAssertTrue(result.count == 0) + result = try topologicalSort(flagConfigs, keys: [1]) + XCTAssertTrue(result.count == 0) + } + + func testSingleFlagNoDependencies() throws { + let dependencies: [Int] = [] + let flagConfigs = [flag(1, dependencies)] + // No flag keys + var result = try topologicalSort(flagConfigs) + XCTAssertEqual([flag(1, dependencies)], result) + // With flag keys + result = try topologicalSort(flagConfigs, keys: [1]) + XCTAssertEqual([flag(1, dependencies)], result) + // With flag keys, no match + result = try topologicalSort(flagConfigs, keys: [999]) + XCTAssertEqual([], result) + } + + func testSingleFlagWithDependencies() throws { + let dependencies: [Int] = [2] + let flagConfigs = [flag(1, dependencies)] + // No flag keys + var result = try topologicalSort(flagConfigs) + XCTAssertEqual([flag(1, dependencies)], result) + // With flag keys + result = try topologicalSort(flagConfigs, keys: [1]) + XCTAssertEqual([flag(1, dependencies)], result) + // With flag keys, no match + result = try topologicalSort(flagConfigs, keys: [999]) + XCTAssertEqual([], result) + } + + func testMultipleFlagsNoDependencies() throws { + let dependencies: [Int] = [] + let flagConfigs = [ + flag(1, dependencies), + flag(2, dependencies) + ] + // No flag keys + var result = try topologicalSort(flagConfigs) + XCTAssertEqual([ + flag(1, dependencies), + flag(2, dependencies) + ], result) + // With flag keys + result = try topologicalSort(flagConfigs, keys: [1, 2]) + XCTAssertEqual([ + flag(1, dependencies), + flag(2, dependencies) + ], result) + // With flag keys, no match + result = try topologicalSort(flagConfigs, keys: [99, 999]) + XCTAssertEqual([], result) + } + + func testMultipleFlagsWithDependencies() throws { + let flagConfigs = [ + flag(1, [2]), + flag(2, [3]), + flag(3, []), + ] + // No flag keys + var result = try topologicalSort(flagConfigs) + XCTAssertEqual([ + flag(3, []), + flag(2, [3]), + flag(1, [2]), + ], result) + // With flag keys + result = try topologicalSort(flagConfigs, keys: [1, 2]) + XCTAssertEqual([ + flag(3, []), + flag(2, [3]), + flag(1, [2]), + ], result) + // With flag keys, no match + result = try topologicalSort(flagConfigs, keys: [99, 999]) + XCTAssertEqual([], result) + } + + func testSingleFlagCycle() throws { + let flagConfigs = [flag(1, [1])] + // No flag keys + do { + _ = try topologicalSort(flagConfigs) + XCTFail("Expected cylce") + } catch is CycleError { + // Success + } + // With flag keys + do { + _ = try topologicalSort(flagConfigs, keys: [1]) + XCTFail("Expected cylce") + } catch is CycleError { + // Success + } + // With flag keys, not match + _ = try topologicalSort(flagConfigs, keys: [999]) + } + + func testTwoFlagCycle() throws { + let flagConfigs = [ + flag(1, [2]), + flag(2, [1]) + ] + // No flag keys + do { + _ = try topologicalSort(flagConfigs) + XCTFail("Expected cylce") + } catch is CycleError { + // Success + } + // With flag keys + do { + _ = try topologicalSort(flagConfigs, keys: [2]) + XCTFail("Expected cylce") + } catch is CycleError { + // Success + } + // With flag keys, not match + _ = try topologicalSort(flagConfigs, keys: [999]) + } + + func testMultipleFlagsComplexCylcle() throws { + let flagConfigs = [ + flag(3, [1, 2]), + flag(1, []), + flag(4, [21, 3]), + flag(2, []), + flag(5, [3]), + flag(6, []), + flag(7, []), + flag(8, [9]), + flag(9, []), + flag(20, [4]), + flag(21, [20]), + ] + do { + // Force iteration order + let keys = flagConfigs.map { flag in Int(flag.key)! } + _ = try topologicalSort(flagConfigs, keys: keys) + XCTFail("Expected cylce") + } catch is CycleError { + // Success + } + } + + func testTopologicalSortComplexNoCycle_startWithLeaf() throws { + let flags = [ + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(3, [6, 5]), + flag(4, [8, 7]), + flag(5, [10, 7]), + flag(7, [8]), + flag(6, [7, 4]), + flag(8, []), + flag(9, [10, 7, 5]), + flag(10, [7]), + flag(20, []), + flag(21, [20]), + flag(30, []), + ] + // Force iteration order + let keys = flags.map { flag in Int(flag.key)! } + let result = try topologicalSort(flags, keys: keys) + XCTAssertEqual([ + flag(8, []), + flag(7, [8]), + flag(4, [8, 7]), + flag(6, [7, 4]), + flag(10, [7]), + flag(5, [10, 7]), + flag(3, [6, 5]), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(9, [10, 7, 5]), + flag(20, []), + flag(21, [20]), + flag(30, []), + ], result) + } + + func testTopologicalSortComplexNoCycle_startWithMiddle() throws { + let flags = [ + flag(6, [7, 4]), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(3, [6, 5]), + flag(4, [8, 7]), + flag(5, [10, 7]), + flag(7, [8]), + flag(8, []), + flag(9, [10, 7, 5]), + flag(10, [7]), + flag(20, []), + flag(21, [20]), + flag(30, []), + ] + // Force iteration order + let keys = flags.map { flag in Int(flag.key)! } + let result = try topologicalSort(flags, keys: keys) + XCTAssertEqual([ + flag(8, []), + flag(7, [8]), + flag(4, [8, 7]), + flag(6, [7, 4]), + flag(10, [7]), + flag(5, [10, 7]), + flag(3, [6, 5]), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(9, [10, 7, 5]), + flag(20, []), + flag(21, [20]), + flag(30, []), + ], result) + } + + func testTopologicalSortComplexNoCycle_startWithRoot() throws { + let flags = [ + flag(8, []), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(3, [6, 5]), + flag(4, [8, 7]), + flag(5, [10, 7]), + flag(7, [8]), + flag(6, [7, 4]), + flag(9, [10, 7, 5]), + flag(10, [7]), + flag(20, []), + flag(21, [20]), + flag(30, []), + ] + // Force iteration order + let keys = flags.map { flag in Int(flag.key)! } + let result = try topologicalSort(flags, keys: keys) + XCTAssertEqual([ + flag(8, []), + flag(7, [8]), + flag(4, [8, 7]), + flag(6, [7, 4]), + flag(10, [7]), + flag(5, [10, 7]), + flag(3, [6, 5]), + flag(1, [6, 3]), + flag(2, [8, 5, 3, 1]), + flag(9, [10, 7, 5]), + flag(20, []), + flag(21, [20]), + flag(30, []), + ], result) + } +} + +// Utils + +extension EvaluationFlag : Equatable { + public static func == (lhs: EvaluationFlag, rhs: EvaluationFlag) -> Bool { + return lhs.key == rhs.key + } +} + +func flag(_ key: Int, _ dependencies: [Int]) -> EvaluationFlag { + return EvaluationFlag( + key: String(key), + variants: [:], + segments: [], + dependencies: dependencies.map { i in + String(i) + }, + metadata: nil + ) +} + +func topologicalSort(_ flags: [EvaluationFlag], keys: [Int]? = nil) throws -> [EvaluationFlag] { + var flagMap: [String: EvaluationFlag] = [:] + for flag in flags { + flagMap[flag.key] = flag + } + if let flagKeys = keys { + return try topologicalSort(flags: flagMap, flagKeys: flagKeys.map { i in String(i) }, sorted: true) + } else { + return try topologicalSort(flags: flagMap, sorted: true) + } +} diff --git a/Tests/ExperimentTests/UserDefaultsStorageTests.swift b/Tests/ExperimentTests/UserDefaultsStorageTests.swift index 8cf7c00..2d62907 100644 --- a/Tests/ExperimentTests/UserDefaultsStorageTests.swift +++ b/Tests/ExperimentTests/UserDefaultsStorageTests.swift @@ -13,57 +13,25 @@ let apiKey = "123456" let userDefaults = UserDefaults.standard let userDefaultsKey = "com.amplituide.experiment.variants.\(instance).\(apiKey)" +let storage = UserDefaultsStorage() + class UserDefaultsStorageTests: XCTestCase { - let storage = UserDefaultsStorage(instanceName: instance, apiKey: apiKey) - let variants: [String: Variant] = [ - "variant1": Variant("1", payload: 1), - "variant2": Variant("2", payload: "2"), - "variant3": Variant("3", payload: nil), - "variant4": Variant("4", payload: [4, 4, 4]), - "variant5": Variant("5", payload: ["k":"v"]), - "variant6": Variant("6", payload: true), - "variant7": Variant("7", payload: 6.9) - ] - func testLoadAndGetAll() { - preload(variants) - storage.load() - let storageVariants = storage.getAll() - XCTAssertEqual(variants, storageVariants) - } - - func testPutSaveAndGetAll() { - for (key, variant) in variants { - storage.put(key: key, value: variant) - } - storage.save() - let storageVariants = storage.getAll() - XCTAssertEqual(variants, storageVariants) - storage.load() - let storageVariants2 = storage.getAll() - XCTAssertEqual(variants, storageVariants2) - XCTAssertEqual(storageVariants, storageVariants2) - } - - func testLoadNilStorage() { - userDefaults.set(nil, forKey: userDefaultsKey) - storage.load() - print(storage.getAll()) + override class func tearDown() { + storage.delete(key: userDefaultsKey) } - func testClear() { - preload(variants) - storage.load() - storage.clear() - let empty = storage.getAll() - XCTAssertTrue(empty.isEmpty) - storage.load() - let storageVariants = storage.getAll() - XCTAssertEqual(variants, storageVariants) + override func setUp() { + storage.delete(key: userDefaultsKey) } - func preload(_ variants: [String: Variant]) { - let data = try! JSONEncoder().encode(variants) - userDefaults.set(data, forKey: userDefaultsKey) + func testAllMethods() { + let data = "data".data(using: .utf8)! + storage.put(key: userDefaultsKey, value: data) + var value = storage.get(key: userDefaultsKey) + XCTAssertEqual(data, value) + storage.delete(key: userDefaultsKey) + value = storage.get(key: userDefaultsKey) + XCTAssertEqual(nil, value) } } diff --git a/Tests/ExperimentTests/UserSessionExposureTrackerTests.swift b/Tests/ExperimentTests/UserSessionExposureTrackerTests.swift index 872154e..8705728 100644 --- a/Tests/ExperimentTests/UserSessionExposureTrackerTests.swift +++ b/Tests/ExperimentTests/UserSessionExposureTrackerTests.swift @@ -26,14 +26,14 @@ class UserSessionExposureTrackerTests : XCTestCase { let provider = TestExposureTrackingProvider() let tracker = UserSessionExposureTracker(exposureTrackingProvider: provider) - let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil) + let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure) } XCTAssertEqual(exposure, provider.lastExposure) XCTAssertEqual(1, provider.trackCount) - let exposure2 = Exposure(flagKey: "flag2", variant: "variant", experimentKey: nil) + let exposure2 = Exposure(flagKey: "flag2", variant: "variant", experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure2) } @@ -45,11 +45,11 @@ class UserSessionExposureTrackerTests : XCTestCase { let provider = TestExposureTrackingProvider() let tracker = UserSessionExposureTracker(exposureTrackingProvider: provider) - let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil) + let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure) } - let exposure2 = Exposure(flagKey: "flag", variant: nil, experimentKey: nil) + let exposure2 = Exposure(flagKey: "flag", variant: nil, experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure2) } @@ -62,11 +62,11 @@ class UserSessionExposureTrackerTests : XCTestCase { let provider = TestExposureTrackingProvider() let tracker = UserSessionExposureTracker(exposureTrackingProvider: provider) - let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil) + let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure) } - let exposure2 = Exposure(flagKey: "flag", variant: nil, experimentKey: nil) + let exposure2 = Exposure(flagKey: "flag", variant: nil, experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure2) } @@ -79,7 +79,7 @@ class UserSessionExposureTrackerTests : XCTestCase { let provider = TestExposureTrackingProvider() let tracker = UserSessionExposureTracker(exposureTrackingProvider: provider) - let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil) + let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure) } @@ -95,7 +95,7 @@ class UserSessionExposureTrackerTests : XCTestCase { let provider = TestExposureTrackingProvider() let tracker = UserSessionExposureTracker(exposureTrackingProvider: provider) - let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil) + let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure) } @@ -111,7 +111,7 @@ class UserSessionExposureTrackerTests : XCTestCase { let provider = TestExposureTrackingProvider() let tracker = UserSessionExposureTracker(exposureTrackingProvider: provider) - let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil) + let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure, user: ExperimentUserBuilder().userId("uid").build()) } @@ -127,7 +127,7 @@ class UserSessionExposureTrackerTests : XCTestCase { let provider = TestExposureTrackingProvider() let tracker = UserSessionExposureTracker(exposureTrackingProvider: provider) - let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil) + let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure, user: ExperimentUserBuilder().deviceId("did").build()) } @@ -143,7 +143,7 @@ class UserSessionExposureTrackerTests : XCTestCase { let provider = TestExposureTrackingProvider() let tracker = UserSessionExposureTracker(exposureTrackingProvider: provider) - let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil) + let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure, user: ExperimentUserBuilder().userId("uid").build()) } @@ -159,7 +159,7 @@ class UserSessionExposureTrackerTests : XCTestCase { let provider = TestExposureTrackingProvider() let tracker = UserSessionExposureTracker(exposureTrackingProvider: provider) - let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil) + let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure, user: ExperimentUserBuilder().deviceId("did").build()) } @@ -175,7 +175,7 @@ class UserSessionExposureTrackerTests : XCTestCase { let provider = TestExposureTrackingProvider() let tracker = UserSessionExposureTracker(exposureTrackingProvider: provider) - let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil) + let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure) } @@ -191,7 +191,7 @@ class UserSessionExposureTrackerTests : XCTestCase { let provider = TestExposureTrackingProvider() let tracker = UserSessionExposureTracker(exposureTrackingProvider: provider) - let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil) + let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure, user: ExperimentUserBuilder().userId("uid").deviceId("did").build()) } @@ -207,7 +207,7 @@ class UserSessionExposureTrackerTests : XCTestCase { let provider = TestExposureTrackingProvider() let tracker = UserSessionExposureTracker(exposureTrackingProvider: provider) - let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil) + let exposure = Exposure(flagKey: "flag", variant: "variant", experimentKey: nil, metadata: nil) for _ in 0...10 { tracker.track(exposure: exposure, user: ExperimentUserBuilder().userId("uid").deviceId("did").build()) } diff --git a/Tests/ExperimentTests/VariantTests.swift b/Tests/ExperimentTests/VariantTests.swift index 3e463c9..3736da0 100644 --- a/Tests/ExperimentTests/VariantTests.swift +++ b/Tests/ExperimentTests/VariantTests.swift @@ -23,12 +23,12 @@ let payloadArrayJson = """ let payloadArray = try! JSONSerialization.jsonObject(with: payloadArrayJson.data(using: .utf8)!, options: []) let variantNullPayload = Variant("testNull", payload: nil) -let variantStringPayload = Variant("testString", payload: "test") -let variantIntPayload = Variant("testInt", payload: 69) -let variantDoublePayload = Variant("testDouble", payload: 6.9) -let variantBoolPayload = Variant("testBool", payload: true) -let variantObjectPayload = Variant("testObject", payload: payloadObject) -let variantArrayPayload = Variant("testObject", payload: payloadArray) +let variantStringPayload = Variant("testString", payload: "test", key: "testString") +let variantIntPayload = Variant("testInt", payload: 69, key: "testInt") +let variantDoublePayload = Variant("testDouble", payload: 6.9, key: "testDouble") +let variantBoolPayload = Variant("testBool", payload: true, key: "testBool") +let variantObjectPayload = Variant("testObject", payload: payloadObject, key: "testObject") +let variantArrayPayload = Variant("testArray", payload: payloadArray, key: "testArray") class VariantTests: XCTestCase { @@ -116,18 +116,80 @@ class VariantTests: XCTestCase { let decoded = try! decoder.decode(Variant.self, from: encoded) print("decoded: \(decoded)") XCTAssertEqual(decoded, original) - let originalPayload = original.payload as! [Any] - let decodedPayload = decoded.payload as! [Any] - XCTAssertEqual(decodedPayload.debugDescription, originalPayload.debugDescription) + let originalPayload = try! JSONEncoder().encode(AnyEncodable(original.payload as! [Any?])) + let decodedPayload = try! JSONEncoder().encode(AnyEncodable(decoded.payload as! [Any?])) + XCTAssertEqual(decodedPayload.description, originalPayload.description) } func testVariantExpeirmentKey() { - let variantMap = ["value":"value","expKey":"expKey"] - let variantFromMap = Variant(json: variantMap) + let variantMap = """ + {"value":"value","expKey":"expKey"} + """.data(using: .utf8)! + let variantFromMap = try! decoder.decode(Variant.self, from: variantMap) let variant = Variant("value", payload: nil, expKey: "expKey") XCTAssertEqual(variant, variantFromMap) let encoded = try! encoder.encode(variant) let decoded = try! decoder.decode(Variant.self, from: encoded) XCTAssertEqual(decoded, variant) } + + // V1 -> V2 variant encoding transformation tests + + func testV1VariantTransformation() { + let rawVariant = """ + {"value":"on"} + """.data(using: .utf8)! + let variant = try! JSONDecoder().decode(Variant.self, from: rawVariant) + XCTAssertEqual(Variant(key: "on", value: "on"), variant) + } + + func testV1VariantTransformationWithNewPayload() { + let rawVariant = """ + {"value":"on", "payload":{"k":"v"}} + """.data(using: .utf8)! + let variant = try! JSONDecoder().decode(Variant.self, from: rawVariant) + XCTAssertEqual(Variant(key: "on", value: "on", payload: ["k":"v"]), variant) + } + + func testV1VariantTransformationWithOldPayload() { + let rawVariant = """ + {"value":"on", "payload":"{\\"payload\\":{\\"k\\":\\"v\\"}}"} + """.data(using: .utf8)! + let variant = try! JSONDecoder().decode(Variant.self, from: rawVariant) + XCTAssertEqual(Variant(key: "on", value: "on", payload: ["k":"v"]), variant) + } + + func testV1VariantTransformationWithPayloadAndExperimentKey() { + let rawVariant = """ + {"value":"on", "payload":{"k":"v"}, "expKey":"exp-1"} + """.data(using: .utf8)! + let variant = try! JSONDecoder().decode(Variant.self, from: rawVariant) + XCTAssertEqual(Variant(key: "on", value: "on", payload: ["k":"v"], expKey: "exp-1"), variant) + XCTAssertEqual("exp-1", variant.metadata?["experimentKey"] as! String) + } + + // Test V2 encoding and decoding + + func testV2VariantTransformation() { + let rawVariant = """ + {"key":"treatment", "value":"on"} + """.data(using: .utf8)! + let variant = try! JSONDecoder().decode(Variant.self, from: rawVariant) + XCTAssertEqual(Variant(key: "treatment", value: "on"), variant) + } + + func testV2VariantTransformationWithExperimentKeyMetadata() { + let rawVariant = """ + {"key":"treatment", "value":"on", "metadata":{"experimentKey":"exp-1"}} + """.data(using: .utf8)! + let variant = try! JSONDecoder().decode(Variant.self, from: rawVariant) + XCTAssertEqual(Variant(key: "treatment", value: "on", expKey: "exp-1", metadata: ["experimentKey":"exp-1"]), variant) + } + func testV2VariantTransformationWithExperimentKeyExplicit() { + let rawVariant = """ + {"key":"treatment", "value":"on", "expKey":"exp-1"} + """.data(using: .utf8)! + let variant = try! JSONDecoder().decode(Variant.self, from: rawVariant) + XCTAssertEqual(Variant(key: "treatment", value: "on", expKey: "exp-1", metadata: ["experimentKey":"exp-1"]), variant) + } }