diff --git a/mParticle-Rokt.xcodeproj/project.pbxproj b/mParticle-Rokt.xcodeproj/project.pbxproj index 5c090f7..5beb0a8 100644 --- a/mParticle-Rokt.xcodeproj/project.pbxproj +++ b/mParticle-Rokt.xcodeproj/project.pbxproj @@ -8,14 +8,15 @@ /* Begin PBXBuildFile section */ 2502325C2D7A7BF3004794A2 /* Rokt-Widget in Frameworks */ = {isa = PBXBuildFile; productRef = 2502325B2D7A7BF3004794A2 /* Rokt-Widget */; }; + 7E084C862E0C4B3A0098059B /* MPRoktLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E084C842E0C4B340098059B /* MPRoktLayout.swift */; }; + 7E084C8A2E12C4D30098059B /* mParticle_Rokt_SwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E084C892E12C4D30098059B /* mParticle_Rokt_SwiftTests.swift */; }; 7E15B20B2D9AE82600C1FF3E /* Rokt-Widget in Frameworks */ = {isa = PBXBuildFile; productRef = 7E15B20A2D9AE82600C1FF3E /* Rokt-Widget */; }; 7EDDAAB02E05A88E00D089CF /* mParticle-Apple-SDK in Frameworks */ = {isa = PBXBuildFile; productRef = 7EDDAAAF2E05A88E00D089CF /* mParticle-Apple-SDK */; }; 7EDDAAB22E05A89B00D089CF /* mParticle-Apple-SDK in Frameworks */ = {isa = PBXBuildFile; productRef = 7EDDAAB12E05A89B00D089CF /* mParticle-Apple-SDK */; }; 7EE7F13E2DA95BEE006C5440 /* OCMock in Frameworks */ = {isa = PBXBuildFile; productRef = 7EE7F13D2DA95BEE006C5440 /* OCMock */; }; B34CE55A2E04356F00712DE1 /* MPRoktEventMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D778512E02845700D887A4 /* MPRoktEventMapper.swift */; }; - B3D778532E02845700D887A4 /* MPRoktEventMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3D778512E02845700D887A4 /* MPRoktEventMapper.swift */; }; DBB01A601DC1478A00A7B188 /* mParticle_Rokt.h in Headers */ = {isa = PBXBuildFile; fileRef = DBB01A5E1DC1478A00A7B188 /* mParticle_Rokt.h */; settings = {ATTRIBUTES = (Public, ); }; }; - DBB01A681DC1480700A7B188 /* MPKitRokt.h in Headers */ = {isa = PBXBuildFile; fileRef = DBB01A661DC1480700A7B188 /* MPKitRokt.h */; }; + DBB01A681DC1480700A7B188 /* MPKitRokt.h in Headers */ = {isa = PBXBuildFile; fileRef = DBB01A661DC1480700A7B188 /* MPKitRokt.h */; settings = {ATTRIBUTES = (Public, ); }; }; DBB01A691DC1480700A7B188 /* MPKitRokt.m in Sources */ = {isa = PBXBuildFile; fileRef = DBB01A671DC1480700A7B188 /* MPKitRokt.m */; }; FF0BB63E217A84E800B0556C /* mParticle_RoktTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FF0BB63D217A84E800B0556C /* mParticle_RoktTests.m */; }; FF0BB640217A84E800B0556C /* mParticle_Rokt.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBB01A5B1DC1478A00A7B188 /* mParticle_Rokt.framework */; }; @@ -32,6 +33,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 7E084C842E0C4B340098059B /* MPRoktLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPRoktLayout.swift; sourceTree = ""; }; + 7E084C882E12C4D30098059B /* mParticle_RoktTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "mParticle_RoktTests-Bridging-Header.h"; sourceTree = ""; }; + 7E084C892E12C4D30098059B /* mParticle_Rokt_SwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mParticle_Rokt_SwiftTests.swift; sourceTree = ""; }; B3D778512E02845700D887A4 /* MPRoktEventMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPRoktEventMapper.swift; sourceTree = ""; }; DBB01A5B1DC1478A00A7B188 /* mParticle_Rokt.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = mParticle_Rokt.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DBB01A5E1DC1478A00A7B188 /* mParticle_Rokt.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = mParticle_Rokt.h; sourceTree = ""; }; @@ -89,6 +93,7 @@ DBB01A5D1DC1478A00A7B188 /* mParticle-Rokt */ = { isa = PBXGroup; children = ( + 7E084C842E0C4B340098059B /* MPRoktLayout.swift */, B3D778512E02845700D887A4 /* MPRoktEventMapper.swift */, DBB01A661DC1480700A7B188 /* MPKitRokt.h */, DBB01A671DC1480700A7B188 /* MPKitRokt.m */, @@ -102,7 +107,9 @@ isa = PBXGroup; children = ( FF0BB63D217A84E800B0556C /* mParticle_RoktTests.m */, + 7E084C892E12C4D30098059B /* mParticle_Rokt_SwiftTests.swift */, FF0BB63F217A84E800B0556C /* Info.plist */, + 7E084C882E12C4D30098059B /* mParticle_RoktTests-Bridging-Header.h */, ); path = mParticle_RoktTests; sourceTree = ""; @@ -237,6 +244,7 @@ buildActionMask = 2147483647; files = ( B34CE55A2E04356F00712DE1 /* MPRoktEventMapper.swift in Sources */, + 7E084C862E0C4B3A0098059B /* MPRoktLayout.swift in Sources */, DBB01A691DC1480700A7B188 /* MPKitRokt.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -245,8 +253,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B3D778532E02845700D887A4 /* MPRoktEventMapper.swift in Sources */, FF0BB63E217A84E800B0556C /* mParticle_RoktTests.m in Sources */, + 7E084C8A2E12C4D30098059B /* mParticle_Rokt_SwiftTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -449,6 +457,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.mparticle.mParticle-RoktTests"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "mParticle_RoktTests/mParticle_RoktTests-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -489,6 +498,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.mparticle.mParticle-RoktTests"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "mParticle_RoktTests/mParticle_RoktTests-Bridging-Header.h"; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/mParticle-Rokt/MPKitRokt.h b/mParticle-Rokt/MPKitRokt.h index d86b17e..1e96c8e 100644 --- a/mParticle-Rokt/MPKitRokt.h +++ b/mParticle-Rokt/MPKitRokt.h @@ -16,4 +16,6 @@ @property (nonatomic, strong, nullable) NSDictionary *launchOptions; @property (nonatomic, unsafe_unretained, readonly) BOOL started; ++ (NSDictionary * _Nonnull)prepareAttributes:(NSDictionary * _Nonnull)attributes filteredUser:(FilteredMParticleUser * _Nullable)filteredUser performMapping:(BOOL)performMapping; + @end diff --git a/mParticle-Rokt/MPKitRokt.m b/mParticle-Rokt/MPKitRokt.m index d35cb0d..99af656 100644 --- a/mParticle-Rokt/MPKitRokt.m +++ b/mParticle-Rokt/MPKitRokt.m @@ -6,6 +6,8 @@ NSString * const kMPRemoteConfigUserAttributeFilter = @"ua"; NSString * const MPKitRoktErrorDomain = @"com.mparticle.kits.rokt"; NSString * const MPKitRoktErrorMessageKey = @"mParticle-Rokt Error"; +NSString * const kMPPlacementAttributesMapping = @"placementAttributesMapping"; +static __weak MPKitRokt *roktKit = nil; @interface MPKitRokt () @@ -42,6 +44,7 @@ - (MPKitExecStatus *)didFinishLaunchingWithConfiguration:(NSDictionary *)configu } _configuration = configuration; + roktKit = self; NSString *sdkVersion = [MParticle sharedInstance].version; // https://go.mparticle.com/work/SQDSDKS-7379 @@ -93,23 +96,7 @@ - (MPKitExecStatus *)executeWithIdentifier:(NSString * _Nullable)identifier config:(MPRoktConfig * _Nullable)mpRoktConfig callbacks:(MPRoktEventCallback * _Nullable)callbacks filteredUser:(FilteredMParticleUser * _Nonnull)filteredUser { - NSDictionary *mpAttributes = [filteredUser.userAttributes transformValuesToString]; - NSMutableDictionary *finalAtt = [[NSMutableDictionary alloc] init]; - [finalAtt addEntriesFromDictionary:mpAttributes]; - - // Add MPID to the attributes being passed to the Rokt SDK - if (filteredUser.userId.stringValue != nil) { - [finalAtt addEntriesFromDictionary:@{@"mpid": filteredUser.userId.stringValue}]; - } - - // Add all known user identities to the attributes being passed to the Rokt SDK - [self addIdentityAttributes:finalAtt filteredUser:filteredUser]; - - // The core SDK does not set sandbox on the user, but we must pass it to Rokt if provided - NSString *sandboxKey = @"sandbox"; - if (attributes[sandboxKey] != nil) { - [finalAtt addEntriesFromDictionary:@{sandboxKey: attributes[sandboxKey]}]; - } + NSDictionary *finalAtt = [MPKitRokt prepareAttributes:attributes filteredUser:filteredUser performMapping:NO]; //Convert MPRoktConfig to RoktConfig RoktConfig *roktConfig = [MPKitRokt convertMPRoktConfig:mpRoktConfig]; @@ -171,7 +158,128 @@ - (RoktFrameworkType)mapMPWrapperSdkToRoktFrameworkType:(MPWrapperSdk)wrapperSdk return safePlacements; } -- (void)addIdentityAttributes:(NSMutableDictionary * _Nullable)attributes filteredUser:(FilteredMParticleUser * _Nonnull)filteredUser { ++ (NSDictionary *)confirmSandboxAttribute:(NSDictionary * _Nullable)attributes { + NSMutableDictionary *finalAttributes = attributes.mutableCopy; + NSString *sandboxKey = @"sandbox"; + + // Determine the value of the sandbox attribute based off the current environment + NSString *sandboxValue = ([[MParticle sharedInstance] environment] == MPEnvironmentDevelopment) ? @"true" : @"false"; + + if (finalAttributes != nil) { + // Only set sandbox if it`s not set by the client + if (![finalAttributes.allKeys containsObject:sandboxKey]) { + finalAttributes[sandboxKey] = sandboxValue; + } + } else { + finalAttributes = [[NSMutableDictionary alloc] initWithDictionary:@{sandboxKey: sandboxValue}]; + } + + return finalAttributes; +} + ++ (NSDictionary * _Nonnull)prepareAttributes:(NSDictionary * _Nonnull)attributes filteredUser:(FilteredMParticleUser * _Nullable)filteredUser performMapping:(BOOL)performMapping { + if (filteredUser == nil && roktKit != nil) { + filteredUser = [[[MPKitAPI alloc] init] getCurrentUserWithKit:roktKit]; + } + NSDictionary *mpAttributes = [filteredUser.userAttributes transformValuesToString]; + if (performMapping) { + mpAttributes = [self mapAttributes:attributes filteredUser:filteredUser]; + } + + NSMutableDictionary *finalAtt = [[NSMutableDictionary alloc] init]; + [finalAtt addEntriesFromDictionary:mpAttributes]; + + // Add MPID to the attributes being passed to the Rokt SDK + if (filteredUser.userId.stringValue != nil) { + [finalAtt addEntriesFromDictionary:@{@"mpid": filteredUser.userId.stringValue}]; + } + + // Add all known user identities to the attributes being passed to the Rokt SDK + [self addIdentityAttributes:finalAtt filteredUser:filteredUser]; + + // The core SDK does not set sandbox on the user, but we must pass it to Rokt if provided + NSString *sandboxKey = @"sandbox"; + if (attributes[sandboxKey] != nil) { + [finalAtt addEntriesFromDictionary:@{sandboxKey: attributes[sandboxKey]}]; + } + + return [self confirmSandboxAttribute:finalAtt]; +} + ++ (NSDictionary *)mapAttributes:(NSDictionary * _Nullable)attributes filteredUser:(FilteredMParticleUser * _Nonnull)filteredUser { + NSArray *> *attributeMap = nil; + + // Get the kit configuration + NSArray *kitConfigs = [MParticle sharedInstance].kitContainer_PRIVATE.originalConfig.copy; + NSDictionary *roktKitConfig; + for (NSDictionary *kitConfig in kitConfigs) { + if (kitConfig[@"id"] != nil && [kitConfig[@"id"] integerValue] == 181) { + roktKitConfig = kitConfig; + } + } + + // Return nil if no Rokt Kit configuration found + if (!roktKitConfig) { + return attributes; + } + + // Get the placement attributes map + NSString *strAttributeMap; + NSData *dataAttributeMap; + // Rokt Kit is available though there may not be an attribute map + attributeMap = @[]; + if (roktKitConfig[kMPPlacementAttributesMapping] != [NSNull null]) { + strAttributeMap = [roktKitConfig[kMPPlacementAttributesMapping] stringByRemovingPercentEncoding]; + dataAttributeMap = [strAttributeMap dataUsingEncoding:NSUTF8StringEncoding]; + } + + if (dataAttributeMap != nil) { + // Convert it to an array of dictionaries + NSError *error = nil; + + @try { + attributeMap = [NSJSONSerialization JSONObjectWithData:dataAttributeMap options:kNilOptions error:&error]; + } @catch (NSException *exception) { + } + + if (attributeMap && !error) { + NSLog(@"%@", attributeMap); + } else { + NSLog(@"%@", error); + } + } + + if (attributeMap) { + NSMutableDictionary *mappedAttributes = attributes.mutableCopy; + for (NSDictionary *map in attributeMap) { + NSString *mapFrom = map[@"map"]; + NSString *mapTo = map[@"value"]; + if (mappedAttributes[mapFrom]) { + NSString * value = mappedAttributes[mapFrom]; + [mappedAttributes removeObjectForKey:mapFrom]; + mappedAttributes[mapTo] = value; + } + } + for (NSString *key in mappedAttributes) { + if (![key isEqual:@"sandbox"]) { + [[MParticle sharedInstance].identity.currentUser setUserAttribute:key value:mappedAttributes[key]]; + } + } + + // Add userAttributes to the attributes sent to Rokt + for (NSString *uaKey in filteredUser.userAttributes) { + if (![mappedAttributes.allKeys containsObject:uaKey]) { + mappedAttributes[uaKey] = filteredUser.userAttributes[uaKey]; + } + } + + return [mappedAttributes transformValuesToString]; + } else { + return attributes; + } +} + ++ (void)addIdentityAttributes:(NSMutableDictionary * _Nullable)attributes filteredUser:(FilteredMParticleUser * _Nonnull)filteredUser { NSMutableDictionary *identityAttributes = [[NSMutableDictionary alloc] init]; for (NSNumber *identityNumberKey in filteredUser.userIdentities) { NSString *identityStringKey = [MPKitRokt stringForIdentityType:identityNumberKey.unsignedIntegerValue]; diff --git a/mParticle-Rokt/MPRoktLayout.swift b/mParticle-Rokt/MPRoktLayout.swift new file mode 100644 index 0000000..132244f --- /dev/null +++ b/mParticle-Rokt/MPRoktLayout.swift @@ -0,0 +1,45 @@ +// +// MPRoktLayout.swift +// mParticle-Rokt +// +// Copyright 2025 Rokt Pte Ltd +// +// Licensed under the Rokt Software Development Kit (SDK) Terms of Use +// Version 2.0 (the "License"); +// +// You may not use this file except in compliance with the License. +// +// You may obtain a copy of the License at https://rokt.com/sdk-license-2-0/ + +import SwiftUI +import Rokt_Widget +import mParticle_Apple_SDK + +@available(iOS 15, *) +public struct MPRoktLayout: View { + private var roktLayout: RoktLayout + + public init( + sdkTriggered: Binding, + viewName: String? = nil, + locationName: String = "", + attributes: [String: String], + config: RoktConfig? = nil, + onEvent: ((RoktEvent) -> Void)? = nil + ) { + let preparedAttributes = MPKitRokt.prepareAttributes(attributes, filteredUser: Optional.none, performMapping: true) + + self.roktLayout = RoktLayout.init( + sdkTriggered: sdkTriggered, + viewName: viewName, + locationName: locationName, + attributes: preparedAttributes, + config: config, + onEvent: onEvent + ) + } + + public var body: some View { + return self.roktLayout.body + } +} diff --git a/mParticle-Rokt/mParticle_Rokt.h b/mParticle-Rokt/mParticle_Rokt.h index a1fc808..4ed815c 100644 --- a/mParticle-Rokt/mParticle_Rokt.h +++ b/mParticle-Rokt/mParticle_Rokt.h @@ -1,4 +1,5 @@ #import +#import "MPKitRokt.h" FOUNDATION_EXPORT double mParticle_RoktVersionNumber; FOUNDATION_EXPORT const unsigned char mParticle_RoktVersionString[]; diff --git a/mParticle_RoktTests/mParticle_RoktTests-Bridging-Header.h b/mParticle_RoktTests/mParticle_RoktTests-Bridging-Header.h new file mode 100644 index 0000000..fe2f4ba --- /dev/null +++ b/mParticle_RoktTests/mParticle_RoktTests-Bridging-Header.h @@ -0,0 +1,8 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// +#import +#import +#import +#import "MPKitRokt.h" +#import diff --git a/mParticle_RoktTests/mParticle_RoktTests.m b/mParticle_RoktTests/mParticle_RoktTests.m index b65683d..f0645ab 100644 --- a/mParticle_RoktTests/mParticle_RoktTests.m +++ b/mParticle_RoktTests/mParticle_RoktTests.m @@ -21,9 +21,7 @@ - (MPKitExecStatus *)purchaseFinalized:(NSString *)placementId - (NSDictionary * _Nullable) confirmEmbeddedViews:(NSDictionary * _Nullable)embeddedViews; -- (NSDictionary *) filteredUserAttributes:(NSDictionary * _Nonnull)attributes kitConfiguration:(MPKitConfiguration *)kitConfiguration; - -- (void)addIdentityAttributes:(NSMutableDictionary * _Nullable)attributes filteredUser:(FilteredMParticleUser * _Nonnull)filteredUser; ++ (void)addIdentityAttributes:(NSMutableDictionary * _Nullable)attributes filteredUser:(FilteredMParticleUser * _Nonnull)filteredUser; + (RoktConfig *)convertMPRoktConfig:(MPRoktConfig *)mpRoktConfig; @@ -132,7 +130,45 @@ - (void)testExecuteWithIdentifier { MPRoktEmbeddedView *view = [[MPRoktEmbeddedView alloc] init]; NSString *identifier = @"TestView"; NSDictionary *embeddedViews = @{@"placement1": view}; - NSDictionary *attributes = @{@"attr1": @"value1", @"sandbox": @"true"}; + NSDictionary *attributes = @{@"attr1": @"value1", @"sandbox": @"false"}; + FilteredMParticleUser *user = [[FilteredMParticleUser alloc] init]; + + // Expected attributes in final call + NSDictionary *expectedAttributes = @{ + @"sandbox": @"false" + }; + + // Expect Rokt execute call with correct parameters + OCMExpect([mockRoktSDK executeWithViewName:identifier + attributes:expectedAttributes + placements:OCMOCK_ANY + config:nil + onLoad:nil + onUnLoad:nil + onShouldShowLoadingIndicator:nil + onShouldHideLoadingIndicator:nil + onEmbeddedSizeChange:nil]); + + MPKitExecStatus *status = [self.kitInstance executeWithIdentifier:identifier + attributes:attributes + embeddedViews:embeddedViews + config:nil + callbacks:nil + filteredUser:user]; + + // Verify + XCTAssertNotNil(status); + XCTAssertEqual(status.returnCode, MPKitReturnCodeSuccess); + OCMVerifyAll(mockRoktSDK); +} + +- (void)testExecuteSandboxDetection { + id mockRoktSDK = OCMClassMock([Rokt class]); + + MPRoktEmbeddedView *view = [[MPRoktEmbeddedView alloc] init]; + NSString *identifier = @"TestView"; + NSDictionary *embeddedViews = @{@"placement1": view}; + NSDictionary *attributes = @{@"attr1": @"value1"}; FilteredMParticleUser *user = [[FilteredMParticleUser alloc] init]; // Expected attributes in final call @@ -196,8 +232,7 @@ - (void)testAddIdentityAttributes { id mockfilteredUser = OCMPartialMock(filteredUser); [[[mockfilteredUser stub] andReturn:testIdentities] userIdentities]; - MPKitRokt *kit = [[MPKitRokt alloc] init]; - [kit addIdentityAttributes:passedAttributes filteredUser:filteredUser]; + [MPKitRokt addIdentityAttributes:passedAttributes filteredUser:filteredUser]; XCTAssertEqualObjects(passedAttributes[@"customerid"], @"testCustomerID"); XCTAssertEqualObjects(passedAttributes[@"email"], @"testEmail@gmail.com"); @@ -259,8 +294,7 @@ - (void)testAddIdentityAttributesWithExistingAttributes { id mockfilteredUser = OCMPartialMock(filteredUser); [[[mockfilteredUser stub] andReturn:testIdentities] userIdentities]; - MPKitRokt *kit = [[MPKitRokt alloc] init]; - [kit addIdentityAttributes:passedAttributes filteredUser:filteredUser]; + [MPKitRokt addIdentityAttributes:passedAttributes filteredUser:filteredUser]; XCTAssertEqualObjects(passedAttributes[@"foo"], @"bar"); XCTAssertEqualObjects(passedAttributes[@"customerid"], @"testCustomerID"); diff --git a/mParticle_RoktTests/mParticle_Rokt_SwiftTests.swift b/mParticle_RoktTests/mParticle_Rokt_SwiftTests.swift new file mode 100644 index 0000000..67971e3 --- /dev/null +++ b/mParticle_RoktTests/mParticle_Rokt_SwiftTests.swift @@ -0,0 +1,329 @@ +// +// mParticle_Rokt_SwiftTests.swift +// mParticle_RoktTests +// +// Created by Brandon Stalnaker on 6/30/25. +// Copyright © 2025 mParticle. All rights reserved. +// + +import Testing +import SwiftUI +@testable import mParticle_Rokt +import Rokt_Widget + +struct mParticle_Rokt_SwiftTests { + + // MARK: - Initialization Tests + + @MainActor @available(iOS 15, *) + @Test func testMPRoktLayoutBasicInitialization() { + // Given + let sdkTriggered = Binding.constant(false) + let locationName = "test_location" + let attributes: [String: String] = ["key1": "value1", "key2": "value2"] + + // When + let layout = MPRoktLayout( + sdkTriggered: sdkTriggered, + locationName: locationName, + attributes: attributes + ) + + // Then + #expect(layout.body != nil, "Layout body should not be nil") + } + + @MainActor @available(iOS 15, *) + @Test func testMPRoktLayoutInitializationWithAllParameters() { + // Given + let sdkTriggered = Binding.constant(true) + let viewName = "test_view" + let locationName = "test_location" + let attributes: [String: String] = ["user_id": "12345", "sandbox": "true"] + let config = RoktConfig.Builder() + .colorMode(.light) + .build() + let onEvent: (RoktEvent) -> Void = { event in + } + + // When + let layout = MPRoktLayout( + sdkTriggered: sdkTriggered, + viewName: viewName, + locationName: locationName, + attributes: attributes, + config: config, + onEvent: onEvent + ) + + // Then + #expect(layout.body != nil, "Layout body should not be nil") + } + + @MainActor @available(iOS 15, *) + @Test func testMPRoktLayoutInitializationWithEmptyAttributes() { + // Given + let sdkTriggered = Binding.constant(false) + let locationName = "empty_attributes_test" + let attributes: [String: String] = [:] + + // When + let layout = MPRoktLayout( + sdkTriggered: sdkTriggered, + locationName: locationName, + attributes: attributes + ) + + // Then + #expect(layout.body != nil, "Layout should handle empty attributes") + } + + @MainActor @available(iOS 15, *) + @Test func testMPRoktLayoutInitializationWithSandboxAttribute() { + // Given + let sdkTriggered = Binding.constant(false) + let locationName = "sandbox_test" + let attributes: [String: String] = ["sandbox": "true", "user_id": "test_user"] + + // When + let layout = MPRoktLayout( + sdkTriggered: sdkTriggered, + locationName: locationName, + attributes: attributes + ) + + // Then + #expect(layout.body != nil, "Layout should handle sandbox attribute") + } + + // MARK: - Attribute Preparation Tests + + @available(iOS 15, *) + @Test func testAttributePreparationCalled() { + // Given + let attributes: [String: String] = ["original_key": "original_value"] + + // When + let preparedAttributes = MPKitRokt.prepareAttributes( + attributes, + filteredUser: nil, + performMapping: true + ) + + // Then + #expect(preparedAttributes != nil, "Prepared attributes should not be nil") + #expect(preparedAttributes.count >= attributes.count, "Prepared attributes should contain at least the original attributes") + } + + @available(iOS 15, *) + @Test func testAttributePreparationWithoutMapping() { + // Given + let attributes: [String: String] = ["test_key": "test_value"] + + // When + let preparedAttributes = MPKitRokt.prepareAttributes( + attributes, + filteredUser: nil, + performMapping: false + ) + + // Then + #expect(preparedAttributes != nil, "Prepared attributes should not be nil") + #expect(preparedAttributes["sandbox"] != nil, "Sandbox attribute should be added automatically") + } + + @available(iOS 15, *) + @Test func testAttributePreparationPreservesSandbox() { + // Given + let attributes: [String: String] = ["sandbox": "true", "custom_attr": "value"] + + // When + let preparedAttributes = MPKitRokt.prepareAttributes( + attributes, + filteredUser: nil, + performMapping: false + ) + + // Then + #expect(preparedAttributes["sandbox"] == "true", "Sandbox attribute should be preserved") + #expect(preparedAttributes["custom_attr"] == nil, "Custom attributes should be preserved") + } + + @available(iOS 15, *) + @Test func testAttributePreparationPerformMapping() { + // Given + let attributes: [String: String] = ["sandbox": "true", "custom_attr": "value"] + + // When + let preparedAttributes = MPKitRokt.prepareAttributes( + attributes, + filteredUser: nil, + performMapping: true + ) + + // Then + #expect(preparedAttributes["sandbox"] == "true", "Sandbox attribute should be preserved") + #expect(preparedAttributes["custom_attr"] != nil, "Custom attributes should be preserved") + } + + // MARK: - View Functionality Tests + + @MainActor @available(iOS 15, *) + @Test func testMPRoktLayoutIsSwiftUIView() { + // Given + let sdkTriggered = Binding.constant(false) + let attributes: [String: String] = ["test": "value"] + + // When + let layout = MPRoktLayout( + sdkTriggered: sdkTriggered, + locationName: "test", + attributes: attributes + ) + + // Then + #expect(layout is any View, "MPRoktLayout should conform to SwiftUI View protocol") + } + + @MainActor @available(iOS 15, *) + @Test func testMPRoktLayoutBodyProperty() { + // Given + let sdkTriggered = Binding.constant(false) + let attributes: [String: String] = ["test": "value"] + + // When + let layout = MPRoktLayout( + sdkTriggered: sdkTriggered, + locationName: "test", + attributes: attributes + ) + + // Then + let body = layout.body + #expect(body != nil, "Layout body should be accessible") + } + + // MARK: - Parameter Validation Tests + + @MainActor @available(iOS 15, *) + @Test func testMPRoktLayoutWithLongLocationName() { + // Given + let sdkTriggered = Binding.constant(false) + let longLocationName = String(repeating: "a", count: 1000) + let attributes: [String: String] = ["test": "value"] + + // When + let layout = MPRoktLayout( + sdkTriggered: sdkTriggered, + locationName: longLocationName, + attributes: attributes + ) + + // Then + #expect(layout.body != nil, "Layout should handle long location names") + } + + @MainActor @available(iOS 15, *) + @Test func testMPRoktLayoutWithSpecialCharacters() { + // Given + let sdkTriggered = Binding.constant(false) + let locationName = "test_location_with_特殊字符_🎉" + let attributes: [String: String] = [ + "unicode_key_🌟": "unicode_value_🎯", + "special_chars": "!@#$%^&*()_+-=[]{}|;':\",./<>?" + ] + + // When + let layout = MPRoktLayout( + sdkTriggered: sdkTriggered, + locationName: locationName, + attributes: attributes + ) + + // Then + #expect(layout.body != nil, "Layout should handle special characters and unicode") + } + + // MARK: - State Management Tests + + @MainActor @available(iOS 15, *) + @Test func testMPRoktLayoutSDKTriggeredStateChange() { + // Given + let sdkTriggered = Binding.constant(false) + let attributes: [String: String] = ["test": "value"] + + // When + let layout = MPRoktLayout( + sdkTriggered: sdkTriggered, + locationName: "state_test", + attributes: attributes + ) + + // Then + #expect(layout.body != nil, "Layout should be created with initial state") + + // When state changes + sdkTriggered.wrappedValue = true + + // Then + #expect(layout.body != nil, "Layout should handle state changes") + } + + // MARK: - Integration Tests + + @MainActor @available(iOS 15, *) + @Test func testMPRoktLayoutAttributeProcessingIntegration() { + // Given + let sdkTriggered = Binding.constant(false) + let attributes: [String: String] = [ + "user_id": "12345", + "email": "test@example.com", + "custom_attribute": "custom_value" + ] + + // When + let layout = MPRoktLayout( + sdkTriggered: sdkTriggered, + viewName: "integration_test", + locationName: "test_location", + attributes: attributes + ) + + // Then + #expect(layout.body != nil, "Layout should properly integrate attribute processing") + } + + @MainActor @available(iOS 15, *) + @Test func testMPRoktLayoutWithComplexConfiguration() { + // Given + let sdkTriggered = Binding.constant(true) + let viewName = "complex_config_test" + let locationName = "complex_location" + let attributes: [String: String] = [ + "user_type": "premium", + "campaign_id": "summer_2025", + "ab_test_group": "variant_a", + "sandbox": "false" + ] + let config = RoktConfig.Builder() + .colorMode(.light) + .build() + var eventsReceived: [RoktEvent] = [] + let onEvent: (RoktEvent) -> Void = { event in + eventsReceived.append(event) + } + + // When + let layout = MPRoktLayout( + sdkTriggered: sdkTriggered, + viewName: viewName, + locationName: locationName, + attributes: attributes, + config: config, + onEvent: onEvent + ) + + // Then + #expect(layout.body != nil, "Layout should handle complex configurations") + } +}