From b6a496d8bd222f1a39024ccbb267d98e9f58fca2 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Thu, 11 Dec 2025 19:04:33 +0600 Subject: [PATCH 1/3] Add support nested object into event meta --- example/lib/main.dart | 20 +- ios/Classes/HelperClasses/Constants.swift | 2 + ios/Classes/HelperClasses/Utils.swift | 75 +++--- lib/src/utils/constants.dart | 2 + lib/src/utils/utils.dart | 94 +++++--- test/nested_object_test.dart | 263 ++++++++++++++++++++++ 6 files changed, 393 insertions(+), 63 deletions(-) create mode 100644 test/nested_object_test.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 9b8f1eb..74f6916 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -137,12 +137,22 @@ class _MyAppState extends State { print("log event notification received"); }); - // Track call + // Track call with nested objects response = await userContext.trackEvent("myevent", { - "age": 20, - "doubleValue": 12.12, - "boolValue": false, - "stringValue": "121" + "revenue": 99.99, + "user": { + "id": "user123", + "premium": true, + "tags": ["vip", "loyal"] + }, + "items": [ + {"name": "Product A", "quantity": 2, "price": 49.99}, + {"name": "Product B", "quantity": 1, "price": 50.00} + ], + "metadata": { + "source": "mobile_app", + "platform": "ios" + } }); // To cancel track listener diff --git a/ios/Classes/HelperClasses/Constants.swift b/ios/Classes/HelperClasses/Constants.swift index a29370a..f80735c 100644 --- a/ios/Classes/HelperClasses/Constants.swift +++ b/ios/Classes/HelperClasses/Constants.swift @@ -139,4 +139,6 @@ struct TypeValue { static let int = "int" static let double = "double" static let bool = "bool" + static let map = "map" + static let list = "list" } diff --git a/ios/Classes/HelperClasses/Utils.swift b/ios/Classes/HelperClasses/Utils.swift index 41b39c1..7636b68 100644 --- a/ios/Classes/HelperClasses/Utils.swift +++ b/ios/Classes/HelperClasses/Utils.swift @@ -26,36 +26,59 @@ public class Utils: NSObject { } var typedDictionary = [String: Any]() for (k,v) in args { - if let typedValue = v as? Dictionary, let value = typedValue["value"] as? Any, let type = typedValue["type"] as? String { - switch type { - case TypeValue.string: - if let strValue = value as? String { - typedDictionary[k] = strValue - } - break - case TypeValue.int: - if let intValue = value as? Int { - typedDictionary[k] = NSNumber(value: intValue).intValue - } - break - case TypeValue.double: - if let doubleValue = value as? Double { - typedDictionary[k] = NSNumber(value: doubleValue).doubleValue - } - break - case TypeValue.bool: - if let booleanValue = value as? Bool { - typedDictionary[k] = NSNumber(value: booleanValue).boolValue - } - break - default: - break - } + if let processedValue = processTypedValue(v) { + typedDictionary[k] = processedValue } - continue } return typedDictionary } + + /// Recursively processes typed values from Flutter to native Swift types + private static func processTypedValue(_ value: Any?) -> Any? { + guard let typedValue = value as? Dictionary, + let val = typedValue["value"], + let type = typedValue["type"] as? String else { + return nil + } + + switch type { + case TypeValue.string: + return val as? String + case TypeValue.int: + if let intValue = val as? Int { + return NSNumber(value: intValue).intValue + } + return nil + case TypeValue.double: + if let doubleValue = val as? Double { + return NSNumber(value: doubleValue).doubleValue + } + return nil + case TypeValue.bool: + if let booleanValue = val as? Bool { + return NSNumber(value: booleanValue).boolValue + } + return nil + case TypeValue.map: + guard let nestedMap = val as? Dictionary else { + return nil + } + var result = [String: Any]() + for (k, v) in nestedMap { + if let processedValue = processTypedValue(v) { + result[k] = processedValue + } + } + return result + case TypeValue.list: + guard let nestedArray = val as? [Any?] else { + return nil + } + return nestedArray.compactMap { processTypedValue($0) } + default: + return nil + } + } /// Returns callback required for LogEventListener static func getLogEventCallback(id: Int, sdkKey: String) -> LogEventListener { diff --git a/lib/src/utils/constants.dart b/lib/src/utils/constants.dart index 2bb5421..f5874c1 100644 --- a/lib/src/utils/constants.dart +++ b/lib/src/utils/constants.dart @@ -20,6 +20,8 @@ class Constants { static const String intType = "int"; static const String doubleType = "double"; static const String boolType = "bool"; + static const String mapType = "map"; + static const String listType = "list"; // Supported Method Names static const String initializeMethod = "initialize"; diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index 8b18b13..3d903d3 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -43,40 +43,11 @@ class Utils { // Only keep primitive values Map primitiveMap = {}; for (MapEntry e in map.entries) { - if (e.value is String) { + dynamic processedValue = _processValue(e.value); + if (processedValue != null) { primitiveMap[e.key] = e.value; - typedMap[e.key] = { - Constants.value: e.value, - Constants.type: Constants.stringType - }; - continue; + typedMap[e.key] = processedValue; } - if (e.value is double) { - primitiveMap[e.key] = e.value; - typedMap[e.key] = { - Constants.value: e.value, - Constants.type: Constants.doubleType - }; - continue; - } - if (e.value is int) { - primitiveMap[e.key] = e.value; - typedMap[e.key] = { - Constants.value: e.value, - Constants.type: Constants.intType - }; - continue; - } - if (e.value is bool) { - primitiveMap[e.key] = e.value; - typedMap[e.key] = { - Constants.value: e.value, - Constants.type: Constants.boolType - }; - continue; - } - // ignore: avoid_print - print('Unsupported value type for key: ${e.key}.'); } if (Platform.isIOS) { @@ -85,6 +56,65 @@ class Utils { return primitiveMap; } + /// Recursively processes values to add type information for iOS + static dynamic _processValue(dynamic value) { + if (value is String) { + return { + Constants.value: value, + Constants.type: Constants.stringType + }; + } + if (value is double) { + return { + Constants.value: value, + Constants.type: Constants.doubleType + }; + } + if (value is int) { + return { + Constants.value: value, + Constants.type: Constants.intType + }; + } + if (value is bool) { + return { + Constants.value: value, + Constants.type: Constants.boolType + }; + } + if (value is Map) { + // Handle nested maps + Map nestedMap = {}; + (value as Map).forEach((k, v) { + dynamic processedValue = _processValue(v); + if (processedValue != null) { + nestedMap[k.toString()] = processedValue; + } + }); + return { + Constants.value: nestedMap, + Constants.type: Constants.mapType + }; + } + if (value is List) { + // Handle arrays + List nestedList = []; + for (var item in value) { + dynamic processedValue = _processValue(item); + if (processedValue != null) { + nestedList.add(processedValue); + } + } + return { + Constants.value: nestedList, + Constants.type: Constants.listType + }; + } + // ignore: avoid_print + print('Unsupported value type: ${value.runtimeType}'); + return null; + } + static List convertDecideOptions( Set options) { return options.map((option) => Utils.decideOptions[option]!).toList(); diff --git a/test/nested_object_test.dart b/test/nested_object_test.dart new file mode 100644 index 0000000..af74eee --- /dev/null +++ b/test/nested_object_test.dart @@ -0,0 +1,263 @@ +/// ************************************************************************** +/// Copyright 2022-2024, Optimizely, Inc. and contributors * +/// * +/// Licensed under the Apache License, Version 2.0 (the "License"); * +/// you may not use this file except in compliance with the License. * +/// You may obtain a copy of the License at * +/// * +/// http://www.apache.org/licenses/LICENSE-2.0 * +/// * +/// Unless required by applicable law or agreed to in writing, software * +/// distributed under the License is distributed on an "AS IS" BASIS, * +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * +/// See the License for the specific language governing permissions and * +/// limitations under the License. * +///**************************************************************************/ + +import 'dart:io' show Platform; +import 'package:flutter_test/flutter_test.dart'; +import 'package:optimizely_flutter_sdk/src/utils/utils.dart'; + +void main() { + group('Utils.convertToTypedMap with nested objects', () { + test('should handle nested maps without error', () { + final input = { + 'simple': 'value', + 'user': { + 'id': '123', + 'name': 'John', + 'age': 30, + } + }; + + // Should not throw an error + final result = Utils.convertToTypedMap(input); + + // Result should not be null and should have the keys + expect(result, isNotNull); + expect(result.containsKey('simple'), true); + expect(result.containsKey('user'), true); + + if (!Platform.isIOS) { + // On Android/VM, verify nested structure is preserved + expect(result['simple'], 'value'); + expect(result['user'], isA()); + final userMap = result['user'] as Map; + expect(userMap['id'], '123'); + expect(userMap['name'], 'John'); + expect(userMap['age'], 30); + } + }); + + test('should handle deeply nested maps', () { + final input = { + 'level1': { + 'level2': { + 'level3': { + 'value': 'deep', + } + } + } + }; + + final result = Utils.convertToTypedMap(input); + + expect(result, isNotNull); + expect(result.containsKey('level1'), true); + + if (!Platform.isIOS) { + final level1 = result['level1'] as Map; + final level2 = level1['level2'] as Map; + final level3 = level2['level3'] as Map; + expect(level3['value'], 'deep'); + } + }); + + test('should handle lists of primitives', () { + final input = { + 'tags': ['flutter', 'optimizely', 'sdk'], + 'scores': [1, 2, 3, 4, 5], + }; + + final result = Utils.convertToTypedMap(input); + + expect(result, isNotNull); + expect(result.containsKey('tags'), true); + expect(result.containsKey('scores'), true); + + if (!Platform.isIOS) { + expect(result['tags'], isA()); + expect((result['tags'] as List).length, 3); + expect((result['tags'] as List)[0], 'flutter'); + + expect(result['scores'], isA()); + expect((result['scores'] as List).length, 5); + expect((result['scores'] as List)[0], 1); + } + }); + + test('should handle lists of maps', () { + final input = { + 'users': [ + {'name': 'Alice', 'age': 30}, + {'name': 'Bob', 'age': 25}, + ] + }; + + final result = Utils.convertToTypedMap(input); + + expect(result, isNotNull); + expect(result.containsKey('users'), true); + + if (!Platform.isIOS) { + final users = result['users'] as List; + expect(users.length, 2); + expect((users[0] as Map)['name'], 'Alice'); + expect((users[0] as Map)['age'], 30); + expect((users[1] as Map)['name'], 'Bob'); + expect((users[1] as Map)['age'], 25); + } + }); + + test('should handle complex mixed structures', () { + final input = { + 'event': 'purchase', + 'revenue': 99.99, + 'user': { + 'id': 'user123', + 'premium': true, + 'tags': ['vip', 'loyal'], + }, + 'items': [ + {'name': 'Product A', 'quantity': 2}, + {'name': 'Product B', 'quantity': 1}, + ], + }; + + final result = Utils.convertToTypedMap(input); + + expect(result, isNotNull); + expect(result.containsKey('event'), true); + expect(result.containsKey('revenue'), true); + expect(result.containsKey('user'), true); + expect(result.containsKey('items'), true); + + if (!Platform.isIOS) { + expect(result['event'], 'purchase'); + expect(result['revenue'], 99.99); + + final userMap = result['user'] as Map; + expect(userMap['id'], 'user123'); + expect(userMap['premium'], true); + expect((userMap['tags'] as List)[0], 'vip'); + + final items = result['items'] as List; + expect(items.length, 2); + expect((items[0] as Map)['name'], 'Product A'); + expect((items[0] as Map)['quantity'], 2); + } + }); + + test('should handle empty collections', () { + final input = { + 'emptyMap': {}, + 'emptyList': [], + 'name': 'test', + }; + + final result = Utils.convertToTypedMap(input); + + expect(result, isNotNull); + expect(result.containsKey('emptyMap'), true); + expect(result.containsKey('emptyList'), true); + expect(result.containsKey('name'), true); + + if (!Platform.isIOS) { + expect(result['emptyMap'], isA()); + expect((result['emptyMap'] as Map).isEmpty, true); + expect(result['emptyList'], isA()); + expect((result['emptyList'] as List).isEmpty, true); + expect(result['name'], 'test'); + } + }); + + test('should handle real-world trackEvent example', () { + final input = { + 'event_type': 'checkout', + 'revenue': 199.99, + 'user': { + 'id': 'user_12345', + 'email': 'user@example.com', + 'is_premium': true, + 'account_age_days': 365, + }, + 'cart': { + 'items': [ + { + 'product_id': 'prod_1', + 'name': 'Widget', + 'price': 99.99, + 'quantity': 1, + }, + { + 'product_id': 'prod_2', + 'name': 'Gadget', + 'price': 100.00, + 'quantity': 1, + }, + ], + 'total_items': 2, + }, + 'metadata': { + 'source': 'mobile_app', + 'platform': 'ios', + 'version': '2.1.0', + }, + }; + + // The key test: this should not throw an exception or error + final result = Utils.convertToTypedMap(input); + + // Verify structure was preserved + expect(result, isNotNull); + expect(result.containsKey('event_type'), true); + expect(result.containsKey('user'), true); + expect(result.containsKey('cart'), true); + expect(result.containsKey('metadata'), true); + + if (!Platform.isIOS) { + // Verify the nested structure is intact on Android + final userMap = result['user'] as Map; + expect(userMap['id'], 'user_12345'); + expect(userMap['is_premium'], true); + expect(userMap['account_age_days'], 365); + + final cartMap = result['cart'] as Map; + final items = cartMap['items'] as List; + expect(items.length, 2); + + final firstItem = items[0] as Map; + expect(firstItem['product_id'], 'prod_1'); + expect(firstItem['price'], 99.99); + } + }); + + test('should not throw error on previous unsupported types', () { + // This test ensures we no longer silently drop nested objects + final input = { + 'supported': 'value', + 'nested': { + 'should': 'work', + 'now': true, + } + }; + + // Previously this would print "Unsupported value type for key: nested" + // and drop the nested map. Now it should handle it. + expect(() => Utils.convertToTypedMap(input), returnsNormally); + + final result = Utils.convertToTypedMap(input); + expect(result.containsKey('nested'), true); + }); + }); +} From c6705a306fd16575c08cf728c8f884b0b99641d1 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Thu, 11 Dec 2025 22:51:39 +0600 Subject: [PATCH 2/3] clean up --- example/lib/main.dart | 46 +++++++++++++------------------------------ 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 74f6916..39c905e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -29,16 +29,18 @@ class _MyAppState extends State { OptimizelyDecideOption.includeReasons, OptimizelyDecideOption.excludeVariables }; + final customLogger = CustomLogger(); - var flutterSDK = OptimizelyFlutterSdk("X9mZd2WDywaUL9hZXyh9A", - datafilePeriodicDownloadInterval: 10 * 60, - eventOptions: const EventOptions( - batchSize: 1, timeInterval: 60, maxQueueSize: 10000), - defaultLogLevel: OptimizelyLogLevel.debug, - defaultDecideOptions: defaultOptions, - logger: customLogger, - ); + var flutterSDK = OptimizelyFlutterSdk( + "X9mZd2WDywaUL9hZXyh9A", + datafilePeriodicDownloadInterval: 10 * 60, + eventOptions: const EventOptions( + batchSize: 1, timeInterval: 60, maxQueueSize: 10000), + defaultLogLevel: OptimizelyLogLevel.debug, + defaultDecideOptions: defaultOptions, + logger: customLogger, + ); var response = await flutterSDK.initializeClient(); setState(() { @@ -60,7 +62,7 @@ class _MyAppState extends State { "stringValue": "121" }); - // To add decide listener + // Add decide listener var decideListenerId = await flutterSDK.addDecisionNotificationListener((notification) { print("Parsed decision event ...................."); @@ -73,17 +75,13 @@ class _MyAppState extends State { Set options = { OptimizelyDecideOption.ignoreUserProfileService, }; + // Decide call var decideResponse = await userContext.decide('flag1', options); uiResponse += "\nFirst decide call variationKey: ${decideResponse.decision!.variationKey}"; - // should return following response without forced decision - // flagKey: flag1 - // ruleKey: default-rollout-7371-20896892800 - // variationKey: off - - // Setting forced decision + // Set forced decision await userContext.setForcedDecision( OptimizelyDecisionContext("flag1", "flag1_experiment"), OptimizelyForcedDecision("variation_a")); @@ -93,11 +91,6 @@ class _MyAppState extends State { uiResponse += "\nSecond decide call variationKey: ${decideResponse.decision!.variationKey}"; - // should return following response with forced decision - // flagKey: flag1 - // ruleKey: flag1_experiment - // variationKey: variation_a - // removing forced decision await userContext.removeForcedDecision( OptimizelyDecisionContext("flag1", "flag1_experiment")); @@ -111,14 +104,6 @@ class _MyAppState extends State { uiResponse = uiResponse; }); - // should return original response without forced decision - // flagKey: flag1 - // ruleKey: default-rollout-7371-20896892800 - // variationKey: off - - // To cancel decide listener - // await flutterSDK.removeNotificationListener(decideListenerId); - // To add track listener var trackListenerID = await flutterSDK.addTrackNotificationListener((notification) { @@ -149,10 +134,7 @@ class _MyAppState extends State { {"name": "Product A", "quantity": 2, "price": 49.99}, {"name": "Product B", "quantity": 1, "price": 50.00} ], - "metadata": { - "source": "mobile_app", - "platform": "ios" - } + "metadata": {"source": "mobile_app", "platform": "ios"} }); // To cancel track listener From 5f031ab56cee4731b6a883c38119b7d3b95d3082 Mon Sep 17 00:00:00 2001 From: muzahidul-opti Date: Thu, 11 Dec 2025 23:24:40 +0600 Subject: [PATCH 3/3] Add test cases for iOS --- lib/src/utils/utils.dart | 14 +- test/nested_object_test.dart | 482 +++++++++++++++++++++++++---------- 2 files changed, 365 insertions(+), 131 deletions(-) diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index 3d903d3..e697b80 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -33,7 +33,17 @@ class Utils { OptimizelySegmentOption.resetCache: "resetCache", }; - static Map convertToTypedMap(Map map) { + /// Converts a map to platform-specific typed format + /// + /// On iOS, returns a typed map with type information for proper native conversion. + /// On Android, returns the original primitive map. + /// + /// The [forceIOSFormat] parameter is used for testing purposes only to test + /// iOS format conversion without running on actual iOS platform. + static Map convertToTypedMap( + Map map, { + bool forceIOSFormat = false, + }) { if (map.isEmpty) { return map; } @@ -50,7 +60,7 @@ class Utils { } } - if (Platform.isIOS) { + if (Platform.isIOS || forceIOSFormat) { return typedMap; } return primitiveMap; diff --git a/test/nested_object_test.dart b/test/nested_object_test.dart index af74eee..5ad903b 100644 --- a/test/nested_object_test.dart +++ b/test/nested_object_test.dart @@ -14,13 +14,15 @@ /// limitations under the License. * ///**************************************************************************/ -import 'dart:io' show Platform; import 'package:flutter_test/flutter_test.dart'; import 'package:optimizely_flutter_sdk/src/utils/utils.dart'; +import 'package:optimizely_flutter_sdk/src/utils/constants.dart'; void main() { - group('Utils.convertToTypedMap with nested objects', () { - test('should handle nested maps without error', () { + group('Utils.convertToTypedMap - Android Format (primitiveMap)', () { + // These tests verify Android behavior where original structure is preserved + + test('should preserve nested maps in Android format', () { final input = { 'simple': 'value', 'user': { @@ -30,26 +32,22 @@ void main() { } }; - // Should not throw an error - final result = Utils.convertToTypedMap(input); + final result = Utils.convertToTypedMap(input); // Default is Android format - // Result should not be null and should have the keys expect(result, isNotNull); expect(result.containsKey('simple'), true); expect(result.containsKey('user'), true); - if (!Platform.isIOS) { - // On Android/VM, verify nested structure is preserved - expect(result['simple'], 'value'); - expect(result['user'], isA()); - final userMap = result['user'] as Map; - expect(userMap['id'], '123'); - expect(userMap['name'], 'John'); - expect(userMap['age'], 30); - } + // Android format: original structure preserved + expect(result['simple'], 'value'); + expect(result['user'], isA()); + final userMap = result['user'] as Map; + expect(userMap['id'], '123'); + expect(userMap['name'], 'John'); + expect(userMap['age'], 30); }); - test('should handle deeply nested maps', () { + test('should preserve deeply nested maps in Android format', () { final input = { 'level1': { 'level2': { @@ -62,18 +60,13 @@ void main() { final result = Utils.convertToTypedMap(input); - expect(result, isNotNull); - expect(result.containsKey('level1'), true); - - if (!Platform.isIOS) { - final level1 = result['level1'] as Map; - final level2 = level1['level2'] as Map; - final level3 = level2['level3'] as Map; - expect(level3['value'], 'deep'); - } + final level1 = result['level1'] as Map; + final level2 = level1['level2'] as Map; + final level3 = level2['level3'] as Map; + expect(level3['value'], 'deep'); }); - test('should handle lists of primitives', () { + test('should preserve lists of primitives in Android format', () { final input = { 'tags': ['flutter', 'optimizely', 'sdk'], 'scores': [1, 2, 3, 4, 5], @@ -81,22 +74,16 @@ void main() { final result = Utils.convertToTypedMap(input); - expect(result, isNotNull); - expect(result.containsKey('tags'), true); - expect(result.containsKey('scores'), true); - - if (!Platform.isIOS) { - expect(result['tags'], isA()); - expect((result['tags'] as List).length, 3); - expect((result['tags'] as List)[0], 'flutter'); - - expect(result['scores'], isA()); - expect((result['scores'] as List).length, 5); - expect((result['scores'] as List)[0], 1); - } + expect(result['tags'], isA()); + expect((result['tags'] as List).length, 3); + expect((result['tags'] as List)[0], 'flutter'); + + expect(result['scores'], isA()); + expect((result['scores'] as List).length, 5); + expect((result['scores'] as List)[0], 1); }); - test('should handle lists of maps', () { + test('should preserve lists of maps in Android format', () { final input = { 'users': [ {'name': 'Alice', 'age': 30}, @@ -106,82 +93,233 @@ void main() { final result = Utils.convertToTypedMap(input); - expect(result, isNotNull); - expect(result.containsKey('users'), true); - - if (!Platform.isIOS) { - final users = result['users'] as List; - expect(users.length, 2); - expect((users[0] as Map)['name'], 'Alice'); - expect((users[0] as Map)['age'], 30); - expect((users[1] as Map)['name'], 'Bob'); - expect((users[1] as Map)['age'], 25); - } + final users = result['users'] as List; + expect(users.length, 2); + expect((users[0] as Map)['name'], 'Alice'); + expect((users[0] as Map)['age'], 30); + expect((users[1] as Map)['name'], 'Bob'); + expect((users[1] as Map)['age'], 25); }); - test('should handle complex mixed structures', () { + test('should handle empty collections in Android format', () { final input = { - 'event': 'purchase', - 'revenue': 99.99, - 'user': { - 'id': 'user123', - 'premium': true, - 'tags': ['vip', 'loyal'], - }, - 'items': [ - {'name': 'Product A', 'quantity': 2}, - {'name': 'Product B', 'quantity': 1}, - ], + 'emptyMap': {}, + 'emptyList': [], + 'name': 'test', }; final result = Utils.convertToTypedMap(input); - expect(result, isNotNull); - expect(result.containsKey('event'), true); - expect(result.containsKey('revenue'), true); - expect(result.containsKey('user'), true); - expect(result.containsKey('items'), true); - - if (!Platform.isIOS) { - expect(result['event'], 'purchase'); - expect(result['revenue'], 99.99); - - final userMap = result['user'] as Map; - expect(userMap['id'], 'user123'); - expect(userMap['premium'], true); - expect((userMap['tags'] as List)[0], 'vip'); - - final items = result['items'] as List; - expect(items.length, 2); - expect((items[0] as Map)['name'], 'Product A'); - expect((items[0] as Map)['quantity'], 2); - } + expect(result['emptyMap'], isA()); + expect((result['emptyMap'] as Map).isEmpty, true); + expect(result['emptyList'], isA()); + expect((result['emptyList'] as List).isEmpty, true); + expect(result['name'], 'test'); + }); + }); + + group('Utils.convertToTypedMap - iOS Format (typedMap)', () { + // These tests verify iOS behavior where types are wrapped + + test('should wrap primitive types with type information for iOS', () { + final input = { + 'stringKey': 'value', + 'intKey': 42, + 'doubleKey': 3.14, + 'boolKey': true, + }; + + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); + + // iOS format: values are wrapped with type information + expect(result['stringKey'], isA()); + expect(result['stringKey']['value'], 'value'); + expect(result['stringKey']['type'], Constants.stringType); + + expect(result['intKey']['value'], 42); + expect(result['intKey']['type'], Constants.intType); + + expect(result['doubleKey']['value'], 3.14); + expect(result['doubleKey']['type'], Constants.doubleType); + + expect(result['boolKey']['value'], true); + expect(result['boolKey']['type'], Constants.boolType); + }); + + test('should wrap nested maps with type information for iOS', () { + final input = { + 'user': { + 'id': '123', + 'name': 'John', + 'age': 30, + } + }; + + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); + + // Check outer map type + expect(result['user'], isA()); + expect(result['user']['type'], Constants.mapType); + expect(result['user']['value'], isA()); + + // Check nested values + final nestedMap = result['user']['value'] as Map; + expect(nestedMap['id']['value'], '123'); + expect(nestedMap['id']['type'], Constants.stringType); + expect(nestedMap['name']['value'], 'John'); + expect(nestedMap['name']['type'], Constants.stringType); + expect(nestedMap['age']['value'], 30); + expect(nestedMap['age']['type'], Constants.intType); + }); + + test('should wrap deeply nested maps for iOS', () { + final input = { + 'user': { + 'profile': { + 'preferences': { + 'theme': 'dark', + 'notifications': true, + } + } + } + }; + + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); + + expect(result['user']['type'], Constants.mapType); + final userMap = result['user']['value'] as Map; + + expect(userMap['profile']['type'], Constants.mapType); + final profileMap = userMap['profile']['value'] as Map; + + expect(profileMap['preferences']['type'], Constants.mapType); + final preferencesMap = profileMap['preferences']['value'] as Map; + + expect(preferencesMap['theme']['value'], 'dark'); + expect(preferencesMap['theme']['type'], Constants.stringType); + expect(preferencesMap['notifications']['value'], true); + expect(preferencesMap['notifications']['type'], Constants.boolType); + }); + + test('should wrap lists of primitives for iOS', () { + final input = { + 'tags': ['flutter', 'optimizely', 'sdk'], + 'scores': [1, 2, 3, 4, 5], + }; + + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); + + // Check list is wrapped + expect(result['tags']['type'], Constants.listType); + final tagsList = result['tags']['value'] as List; + expect(tagsList.length, 3); + expect(tagsList[0]['value'], 'flutter'); + expect(tagsList[0]['type'], Constants.stringType); + + expect(result['scores']['type'], Constants.listType); + final scoresList = result['scores']['value'] as List; + expect(scoresList.length, 5); + expect(scoresList[0]['value'], 1); + expect(scoresList[0]['type'], Constants.intType); + }); + + test('should wrap lists of maps for iOS', () { + final input = { + 'users': [ + {'name': 'Alice', 'age': 30}, + {'name': 'Bob', 'age': 25}, + ] + }; + + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); + + expect(result['users']['type'], Constants.listType); + final usersList = result['users']['value'] as List; + expect(usersList.length, 2); + + // Check first user + expect(usersList[0]['type'], Constants.mapType); + final firstUser = usersList[0]['value'] as Map; + expect(firstUser['name']['value'], 'Alice'); + expect(firstUser['name']['type'], Constants.stringType); + expect(firstUser['age']['value'], 30); + expect(firstUser['age']['type'], Constants.intType); + + // Check second user + expect(usersList[1]['type'], Constants.mapType); + final secondUser = usersList[1]['value'] as Map; + expect(secondUser['name']['value'], 'Bob'); + expect(secondUser['age']['value'], 25); }); - test('should handle empty collections', () { + test('should handle empty collections in iOS format', () { final input = { 'emptyMap': {}, 'emptyList': [], 'name': 'test', }; - final result = Utils.convertToTypedMap(input); + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); - expect(result, isNotNull); - expect(result.containsKey('emptyMap'), true); - expect(result.containsKey('emptyList'), true); - expect(result.containsKey('name'), true); - - if (!Platform.isIOS) { - expect(result['emptyMap'], isA()); - expect((result['emptyMap'] as Map).isEmpty, true); - expect(result['emptyList'], isA()); - expect((result['emptyList'] as List).isEmpty, true); - expect(result['name'], 'test'); - } + expect(result['emptyMap']['type'], Constants.mapType); + final emptyMapValue = result['emptyMap']['value'] as Map; + expect(emptyMapValue.isEmpty, true); + + expect(result['emptyList']['type'], Constants.listType); + final emptyListValue = result['emptyList']['value'] as List; + expect(emptyListValue.isEmpty, true); + + expect(result['name']['value'], 'test'); + expect(result['name']['type'], Constants.stringType); + }); + + test('should handle mixed complex structures for iOS', () { + final input = { + 'event': 'purchase', + 'revenue': 99.99, + 'user': { + 'id': 'user123', + 'premium': true, + 'tags': ['vip', 'loyal'], + }, + 'items': [ + {'name': 'Product A', 'quantity': 2}, + {'name': 'Product B', 'quantity': 1}, + ], + }; + + final result = Utils.convertToTypedMap(input, forceIOSFormat: true); + + // Check primitives + expect(result['event']['value'], 'purchase'); + expect(result['event']['type'], Constants.stringType); + expect(result['revenue']['value'], 99.99); + expect(result['revenue']['type'], Constants.doubleType); + + // Check nested map + expect(result['user']['type'], Constants.mapType); + final userMap = result['user']['value'] as Map; + expect(userMap['id']['value'], 'user123'); + expect(userMap['premium']['value'], true); + + // Check nested list in map + expect(userMap['tags']['type'], Constants.listType); + final tagsList = userMap['tags']['value'] as List; + expect(tagsList[0]['value'], 'vip'); + + // Check list of maps + expect(result['items']['type'], Constants.listType); + final itemsList = result['items']['value'] as List; + expect(itemsList.length, 2); + expect(itemsList[0]['type'], Constants.mapType); + final item0 = itemsList[0]['value'] as Map; + expect(item0['name']['value'], 'Product A'); + expect(item0['quantity']['value'], 2); }); + }); - test('should handle real-world trackEvent example', () { + group('Utils.convertToTypedMap - Real World Scenarios', () { + test('should handle real-world trackEvent example in both formats', () { final input = { 'event_type': 'checkout', 'revenue': 199.99, @@ -215,35 +353,41 @@ void main() { }, }; - // The key test: this should not throw an exception or error - final result = Utils.convertToTypedMap(input); - - // Verify structure was preserved - expect(result, isNotNull); - expect(result.containsKey('event_type'), true); - expect(result.containsKey('user'), true); - expect(result.containsKey('cart'), true); - expect(result.containsKey('metadata'), true); - - if (!Platform.isIOS) { - // Verify the nested structure is intact on Android - final userMap = result['user'] as Map; - expect(userMap['id'], 'user_12345'); - expect(userMap['is_premium'], true); - expect(userMap['account_age_days'], 365); - - final cartMap = result['cart'] as Map; - final items = cartMap['items'] as List; - expect(items.length, 2); - - final firstItem = items[0] as Map; - expect(firstItem['product_id'], 'prod_1'); - expect(firstItem['price'], 99.99); - } + // Test Android format + final androidResult = Utils.convertToTypedMap(input); + expect(androidResult, isNotNull); + expect(androidResult.containsKey('event_type'), true); + expect(androidResult['event_type'], 'checkout'); + + final androidUserMap = androidResult['user'] as Map; + expect(androidUserMap['id'], 'user_12345'); + expect(androidUserMap['is_premium'], true); + + final androidCartMap = androidResult['cart'] as Map; + final androidItems = androidCartMap['items'] as List; + expect(androidItems.length, 2); + expect((androidItems[0] as Map)['product_id'], 'prod_1'); + + // Test iOS format + final iosResult = Utils.convertToTypedMap(input, forceIOSFormat: true); + expect(iosResult, isNotNull); + expect(iosResult.containsKey('event_type'), true); + expect(iosResult['event_type']['value'], 'checkout'); + expect(iosResult['event_type']['type'], Constants.stringType); + + final iosUserMap = iosResult['user']['value'] as Map; + expect(iosUserMap['id']['value'], 'user_12345'); + expect(iosUserMap['is_premium']['value'], true); + + final iosCartMap = iosResult['cart']['value'] as Map; + final iosItems = iosCartMap['items']['value'] as List; + expect(iosItems.length, 2); + final iosFirstItem = iosItems[0]['value'] as Map; + expect(iosFirstItem['product_id']['value'], 'prod_1'); }); - test('should not throw error on previous unsupported types', () { - // This test ensures we no longer silently drop nested objects + test('should not throw error on nested objects (regression test)', () { + // This ensures we no longer silently drop nested objects like before final input = { 'supported': 'value', 'nested': { @@ -252,12 +396,92 @@ void main() { } }; - // Previously this would print "Unsupported value type for key: nested" - // and drop the nested map. Now it should handle it. + // Should work in both formats without error expect(() => Utils.convertToTypedMap(input), returnsNormally); + expect(() => Utils.convertToTypedMap(input, forceIOSFormat: true), returnsNormally); - final result = Utils.convertToTypedMap(input); - expect(result.containsKey('nested'), true); + final androidResult = Utils.convertToTypedMap(input); + expect(androidResult.containsKey('nested'), true); + expect((androidResult['nested'] as Map)['should'], 'work'); + + final iosResult = Utils.convertToTypedMap(input, forceIOSFormat: true); + expect(iosResult.containsKey('nested'), true); + expect(iosResult['nested']['type'], Constants.mapType); + }); + + test('should handle list with mixed types in both formats', () { + final input = { + 'mixed': [1, 'two', 3.0, true], + }; + + // Android format + final androidResult = Utils.convertToTypedMap(input); + final androidMixed = androidResult['mixed'] as List; + expect(androidMixed[0], 1); + expect(androidMixed[1], 'two'); + expect(androidMixed[2], 3.0); + expect(androidMixed[3], true); + + // iOS format + final iosResult = Utils.convertToTypedMap(input, forceIOSFormat: true); + final iosMixed = iosResult['mixed']['value'] as List; + expect(iosMixed[0]['value'], 1); + expect(iosMixed[0]['type'], Constants.intType); + expect(iosMixed[1]['value'], 'two'); + expect(iosMixed[1]['type'], Constants.stringType); + expect(iosMixed[2]['value'], 3.0); + expect(iosMixed[2]['type'], Constants.doubleType); + expect(iosMixed[3]['value'], true); + expect(iosMixed[3]['type'], Constants.boolType); + }); + }); + + group('Utils.convertToTypedMap - Edge Cases', () { + test('should handle empty map', () { + final input = {}; + + final androidResult = Utils.convertToTypedMap(input); + expect(androidResult.isEmpty, true); + + final iosResult = Utils.convertToTypedMap(input, forceIOSFormat: true); + expect(iosResult.isEmpty, true); + }); + + test('should handle map with only primitives', () { + final input = { + 'a': 1, + 'b': 'text', + 'c': true, + 'd': 3.14, + }; + + final androidResult = Utils.convertToTypedMap(input); + expect(androidResult['a'], 1); + expect(androidResult['b'], 'text'); + + final iosResult = Utils.convertToTypedMap(input, forceIOSFormat: true); + expect(iosResult['a']['value'], 1); + expect(iosResult['b']['value'], 'text'); + }); + + test('should handle deeply nested arrays', () { + final input = { + 'nested': [ + [1, 2, 3], + [4, 5, 6], + ] + }; + + // Android + final androidResult = Utils.convertToTypedMap(input); + expect(((androidResult['nested'] as List)[0] as List)[0], 1); + + // iOS + final iosResult = Utils.convertToTypedMap(input, forceIOSFormat: true); + final iosOuter = iosResult['nested']['value'] as List; + final iosInner = iosOuter[0]['value'] as List; + expect(iosInner[0]['value'], 1); + expect(iosInner[0]['type'], Constants.intType); }); }); }