From b0edc97914f00c1ae7e70e24816efba548b6eb3a Mon Sep 17 00:00:00 2001 From: peterstone2017 <12449837+YunchuWang@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:42:59 -0800 Subject: [PATCH] Support complex type deserialization (standalone) --- .../Converters/JsonDataConverter.cs | 177 ++++++++- .../ObjectWithTypeMetadataJsonConverter.cs | 376 ++++++++++++++++++ .../Converters/JsonDataConverterTests.cs | 272 +++++++++++++ 3 files changed, 819 insertions(+), 6 deletions(-) create mode 100644 src/Abstractions/Converters/ObjectWithTypeMetadataJsonConverter.cs create mode 100644 test/Abstractions.Tests/Converters/JsonDataConverterTests.cs diff --git a/src/Abstractions/Converters/JsonDataConverter.cs b/src/Abstractions/Converters/JsonDataConverter.cs index eeca67a35..dc734bb66 100644 --- a/src/Abstractions/Converters/JsonDataConverter.cs +++ b/src/Abstractions/Converters/JsonDataConverter.cs @@ -1,7 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections; +using System.Collections.Generic; using System.Text.Json; +using System.Text.Json.Serialization; namespace Microsoft.DurableTask.Converters; @@ -11,19 +14,37 @@ namespace Microsoft.DurableTask.Converters; public class JsonDataConverter : DataConverter { // WARNING: Changing default serialization options could potentially be breaking for in-flight orchestrations. - static readonly JsonSerializerOptions DefaultOptions = new() - { - IncludeFields = true, - }; + static readonly JsonSerializerOptions DefaultOptions = CreateDefaultOptions(); readonly JsonSerializerOptions? options; /// /// Initializes a new instance of the class. /// - /// The serializer options. + /// The serializer options. If null, default options with type metadata preservation are used. public JsonDataConverter(JsonSerializerOptions? options = null) { + if (options != null) + { + // Ensure the ObjectWithTypeMetadataJsonConverterFactory is present in custom options + // Check if it's already there to avoid duplicates + bool hasConverter = false; + foreach (JsonConverter converter in options.Converters) + { + if (converter is ObjectWithTypeMetadataJsonConverterFactory) + { + hasConverter = true; + break; + } + } + + if (!hasConverter) + { + // Add at the beginning to ensure it handles object types first + options.Converters.Insert(0, new ObjectWithTypeMetadataJsonConverterFactory()); + } + } + this.options = options ?? DefaultOptions; } @@ -32,6 +53,22 @@ public JsonDataConverter(JsonSerializerOptions? options = null) /// public static JsonDataConverter Default { get; } = new JsonDataConverter(); + static JsonSerializerOptions CreateDefaultOptions() + { + JsonSerializerOptions options = new() + { + IncludeFields = true, + }; + + // Add converter factory for preserving type information when deserializing to object type + // and Dictionary values. This must be added early to handle object types + // before default JsonElement conversion. + // See issue #430: https://github.com/microsoft/durabletask-dotnet/issues/430 + options.Converters.Insert(0, new ObjectWithTypeMetadataJsonConverterFactory()); + + return options; + } + /// public override string? Serialize(object? value) { @@ -41,6 +78,134 @@ public JsonDataConverter(JsonSerializerOptions? options = null) /// public override object? Deserialize(string? data, Type targetType) { - return data != null ? JsonSerializer.Deserialize(data, targetType, this.options) : null; + if (data == null) + { + return null; + } + + // Special case: If target type is JsonElement, we should unwrap any type metadata + // and return the actual JSON content, not the wrapped structure + if (targetType == typeof(JsonElement)) + { + using (JsonDocument doc = JsonDocument.Parse(data)) + { + JsonElement root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("$type", out JsonElement typeElement) && + root.TryGetProperty("$value", out JsonElement valueElement)) + { + // Unwrap and return the actual value as JsonElement + return valueElement.Clone(); + } + } + } + + // Check if the JSON is a wrapped object (has $type and $value) + // This handles both array wrappers and object wrappers + using (JsonDocument doc = JsonDocument.Parse(data)) + { + JsonElement root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("$type", out JsonElement typeElement) && + root.TryGetProperty("$value", out JsonElement valueElement)) + { + // This is a wrapped object - check what type it is + string typeName = typeElement.GetString()!; + Type? wrappedType = Type.GetType(typeName, throwOnError: false); + + // If the wrapped type matches the target type exactly, unwrap and deserialize + if (wrappedType != null && wrappedType == targetType) + { + // The wrapped type matches the target type - unwrap and deserialize + string unwrappedJson = valueElement.GetRawText(); + return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options); + } + + // Special case: If wrapped type is an array and target type is also an array, + // try to deserialize the entire array (for cases like int[] -> int[]) + if (wrappedType != null && wrappedType.IsArray && targetType.IsArray) + { + string unwrappedJson = valueElement.GetRawText(); + return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options); + } + + // Special case: If wrapped type is a collection (like List) and target type is an array (like T[]), + // deserialize the $value JSON directly as the target array type + // This works because List and T[] have the same JSON representation: [item1, item2, ...] + if (wrappedType != null && targetType.IsArray && valueElement.ValueKind == JsonValueKind.Array) + { + // Check if wrapped type is a generic collection that implements IEnumerable + if (wrappedType.IsGenericType) + { + Type genericTypeDef = wrappedType.GetGenericTypeDefinition(); + if (genericTypeDef == typeof(List<>) || + genericTypeDef == typeof(IList<>) || + genericTypeDef == typeof(ICollection<>) || + genericTypeDef == typeof(IEnumerable<>)) + { + Type[] genericArgs = wrappedType.GetGenericArguments(); + if (genericArgs.Length == 1) + { + Type elementType = genericArgs[0]; + Type targetElementType = targetType.GetElementType()!; + + // If the element types match, we can deserialize directly + if (elementType == targetElementType) + { + string unwrappedJson = valueElement.GetRawText(); + return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options); + } + } + } + } + } + + // Special case: If wrapped type is an array and target type is NOT an array, + // check if the array contains a single wrapped element that matches the target type + if (wrappedType != null && wrappedType.IsArray && !targetType.IsArray) + { + if (valueElement.ValueKind == JsonValueKind.Array && valueElement.GetArrayLength() > 0) + { + // Get the first element of the array + JsonElement firstElement = valueElement[0]; + string firstElementJson = firstElement.GetRawText(); + + // Check if the first element is also wrapped + using (JsonDocument innerDoc = JsonDocument.Parse(firstElementJson)) + { + JsonElement innerRoot = innerDoc.RootElement; + if (innerRoot.ValueKind == JsonValueKind.Object && + innerRoot.TryGetProperty("$type", out JsonElement innerTypeElement) && + innerRoot.TryGetProperty("$value", out JsonElement innerValueElement)) + { + string innerTypeName = innerTypeElement.GetString()!; + Type? innerWrappedType = Type.GetType(innerTypeName, throwOnError: false); + + if (innerWrappedType != null && innerWrappedType == targetType) + { + // Unwrap the inner object + string unwrappedJson = innerValueElement.GetRawText(); + return JsonSerializer.Deserialize(unwrappedJson, targetType, this.options); + } + } + } + + // First element is not wrapped, try to deserialize it directly + // This handles cases where the array contains a single unwrapped element + try + { + return JsonSerializer.Deserialize(firstElementJson, targetType, this.options); + } + catch + { + // If deserialization fails, fall through to normal deserialization + } + } + } + } + } + + // Not wrapped or type doesn't match - deserialize normally + return JsonSerializer.Deserialize(data, targetType, this.options); } } diff --git a/src/Abstractions/Converters/ObjectWithTypeMetadataJsonConverter.cs b/src/Abstractions/Converters/ObjectWithTypeMetadataJsonConverter.cs new file mode 100644 index 000000000..6d695c1a1 --- /dev/null +++ b/src/Abstractions/Converters/ObjectWithTypeMetadataJsonConverter.cs @@ -0,0 +1,376 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.DurableTask.Converters; + +/// +/// A JSON converter factory that preserves type information for complex objects when deserializing to type +/// and Dictionary<string, object> values. This fixes issue #430 where Dictionary<string, object> values and other +/// object-typed properties were being deserialized as JsonElement instead of their original types. +/// +sealed class ObjectWithTypeMetadataJsonConverterFactory : JsonConverterFactory +{ + /// + public override bool CanConvert(Type typeToConvert) + { + // Handle object type + if (typeToConvert == typeof(object)) + { + return true; + } + + // Handle Dictionary and IDictionary + if (typeToConvert.IsGenericType) + { + Type genericType = typeToConvert.GetGenericTypeDefinition(); + if (genericType == typeof(Dictionary<,>) || genericType == typeof(IDictionary<,>)) + { + Type[] args = typeToConvert.GetGenericArguments(); + if (args.Length == 2 && args[0] == typeof(string) && args[1] == typeof(object)) + { + return true; + } + } + } + + // Also handle if the type implements IDictionary + if (typeof(IDictionary).IsAssignableFrom(typeToConvert) && typeToConvert != typeof(object)) + { + return true; + } + + return false; + } + + /// + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + if (typeToConvert == typeof(object)) + { + return new ObjectJsonConverter(); + } + + if (typeToConvert.IsGenericType) + { + Type genericType = typeToConvert.GetGenericTypeDefinition(); + if (genericType == typeof(Dictionary<,>) || genericType == typeof(IDictionary<,>)) + { + Type[] args = typeToConvert.GetGenericArguments(); + if (args.Length == 2 && args[0] == typeof(string) && args[1] == typeof(object)) + { + return new DictionaryStringObjectJsonConverter(); + } + } + } + + // Handle types that implement IDictionary + if (typeof(IDictionary).IsAssignableFrom(typeToConvert) && typeToConvert != typeof(object)) + { + return new DictionaryStringObjectJsonConverter(); + } + + throw new NotSupportedException($"Type {typeToConvert} is not supported by this converter factory."); + } + + /// + /// Converter for object type that preserves type information. + /// + sealed class ObjectJsonConverter : JsonConverter + { + const string TypePropertyName = "$type"; + const string ValuePropertyName = "$value"; + + /// + public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + // Check if this is a wrapped object with type metadata + if (reader.TokenType == JsonTokenType.StartObject) + { + // Parse the entire object to check for $type property + // This consumes the reader + using (JsonDocument doc = JsonDocument.ParseValue(ref reader)) + { + JsonElement root = doc.RootElement; + + if (root.TryGetProperty(TypePropertyName, out JsonElement typeElement) && + typeElement.ValueKind == JsonValueKind.String) + { + // This is a wrapped object with type metadata + string typeName = typeElement.GetString()!; + Type? targetType = Type.GetType(typeName, throwOnError: false); + + if (targetType != null && root.TryGetProperty(ValuePropertyName, out JsonElement valueElement)) + { + // Deserialize the $value to the specified type + // Use GetRawText() to get the JSON string, then deserialize it + string jsonText = valueElement.GetRawText(); + return JsonSerializer.Deserialize(jsonText, targetType, options); + } + } + + // No type metadata or type not found - return as JsonElement for backward compatibility + return root.Clone(); + } + } + + // For primitives, deserialize normally + return JsonSerializer.Deserialize(ref reader, options); + } + + /// + public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + Type valueType = value.GetType(); + + // For primitives and well-known types, serialize normally without type metadata + if (IsPrimitiveOrWellKnownType(valueType)) + { + JsonSerializer.Serialize(writer, value, valueType, options); + return; + } + + // For complex objects, wrap with type metadata + writer.WriteStartObject(); + writer.WriteString(TypePropertyName, valueType.AssemblyQualifiedName ?? valueType.FullName ?? valueType.Name); + writer.WritePropertyName(ValuePropertyName); + JsonSerializer.Serialize(writer, value, valueType, options); + writer.WriteEndObject(); + } + + /// + /// Determines if a type is a primitive or well-known type that doesn't need type metadata. + /// + static bool IsPrimitiveOrWellKnownType(Type type) + { + // Handle nullable types + Type underlyingType = Nullable.GetUnderlyingType(type) ?? type; + + // Primitives + if (underlyingType.IsPrimitive) + { + return true; + } + + // Well-known value types + if (underlyingType == typeof(string) || + underlyingType == typeof(DateTime) || + underlyingType == typeof(DateTimeOffset) || + underlyingType == typeof(TimeSpan) || + underlyingType == typeof(Guid) || + underlyingType == typeof(decimal) || + underlyingType == typeof(Uri)) + { + return true; + } + + // JsonElement and JsonNode (already JSON types, no need to wrap) + if (underlyingType == typeof(JsonElement)) + { + return true; + } + + // JsonNode is a special type from System.Text.Json that represents JSON values + // It should be serialized/deserialized directly without type metadata wrapping + string? typeName = underlyingType.FullName; + if (typeName != null && (typeName == "System.Text.Json.Nodes.JsonNode" || + typeName == "System.Text.Json.Nodes.JsonObject" || + typeName == "System.Text.Json.Nodes.JsonArray" || + typeName == "System.Text.Json.Nodes.JsonValue")) + { + return true; + } + + // Record types - records are value-like types used for data transfer + // They should be serialized without type metadata wrapping for compatibility + // Check if the type is a record by looking for the compiler-generated EqualityContract property + // Records are sealed classes with EqualityContract property + if (underlyingType.IsClass && !underlyingType.IsAbstract) + { + PropertyInfo? equalityContract = underlyingType.GetProperty("EqualityContract", BindingFlags.NonPublic | BindingFlags.Instance); + if (equalityContract != null && equalityContract.PropertyType == typeof(Type)) + { + // This is likely a record type - don't wrap with type metadata + return true; + } + } + + // Arrays of primitives + if (underlyingType.IsArray) + { + Type elementType = underlyingType.GetElementType()!; + // Don't wrap object[] arrays - they're often used as generic containers + // and the raw JSON format is more useful and compatible + if (elementType == typeof(object)) + { + return true; + } + return IsPrimitiveOrWellKnownType(elementType); + } + + return false; + } + } + + /// + /// Converter for Dictionary<string, object> that preserves type information for values. + /// + sealed class DictionaryStringObjectJsonConverter : JsonConverter> + { + const string TypePropertyName = "$type"; + const string ValuePropertyName = "$value"; + + /// + public override Dictionary? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException($"Expected StartObject, got {reader.TokenType}"); + } + + // Parse the entire object to check if it's wrapped with type metadata + using (JsonDocument doc = JsonDocument.ParseValue(ref reader)) + { + JsonElement root = doc.RootElement; + + // Check if this is a wrapped object with $type metadata + // This can happen if the dictionary itself was serialized as an object value + if (root.TryGetProperty("$type", out JsonElement typeElement) && + root.TryGetProperty("$value", out JsonElement valueElement)) + { + // This is a wrapped dictionary - deserialize the $value + string valueJsonText = valueElement.GetRawText(); + return JsonSerializer.Deserialize>(valueJsonText, options); + } + + // Not wrapped - this is a regular dictionary, deserialize it from the JsonElement + Dictionary dictionary = new(); + + foreach (JsonProperty property in root.EnumerateObject()) + { + // Deserialize the value - this will use our ObjectJsonConverter for object types + object? value = JsonSerializer.Deserialize(property.Value.GetRawText(), options); + dictionary[property.Name] = value ?? null!; + } + + return dictionary; + } + } + + /// + public override void Write(Utf8JsonWriter writer, Dictionary value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + foreach (KeyValuePair kvp in value) + { + writer.WritePropertyName(kvp.Key); + + if (kvp.Value == null) + { + writer.WriteNullValue(); + continue; + } + + Type valueType = kvp.Value.GetType(); + + // For dictionary values, we need to wrap records and complex types with type metadata + // even though ObjectJsonConverter treats records as well-known types + // This ensures proper deserialization when reading from Dictionary + if (ShouldWrapForDictionary(valueType)) + { + // Wrap with type metadata + writer.WriteStartObject(); + writer.WriteString("$type", valueType.AssemblyQualifiedName ?? valueType.FullName ?? valueType.Name); + writer.WritePropertyName("$value"); + JsonSerializer.Serialize(writer, kvp.Value, valueType, options); + writer.WriteEndObject(); + } + else + { + // For primitives and well-known types, serialize normally + JsonSerializer.Serialize(writer, kvp.Value, valueType, options); + } + } + + writer.WriteEndObject(); + } + + /// + /// Determines if a type should be wrapped with type metadata when serialized as a dictionary value. + /// This is more aggressive than IsPrimitiveOrWellKnownType - we wrap records here even though + /// they're treated as well-known types in direct serialization. + /// + static bool ShouldWrapForDictionary(Type type) + { + Type underlyingType = Nullable.GetUnderlyingType(type) ?? type; + + // Primitives - don't wrap + if (underlyingType.IsPrimitive) + { + return false; + } + + // Well-known value types - don't wrap + if (underlyingType == typeof(string) || + underlyingType == typeof(DateTime) || + underlyingType == typeof(DateTimeOffset) || + underlyingType == typeof(TimeSpan) || + underlyingType == typeof(Guid) || + underlyingType == typeof(decimal) || + underlyingType == typeof(Uri)) + { + return false; + } + + // JsonElement and JsonNode - don't wrap + if (underlyingType == typeof(JsonElement)) + { + return false; + } + + string? typeName = underlyingType.FullName; + if (typeName != null && (typeName == "System.Text.Json.Nodes.JsonNode" || + typeName == "System.Text.Json.Nodes.JsonObject" || + typeName == "System.Text.Json.Nodes.JsonArray" || + typeName == "System.Text.Json.Nodes.JsonValue")) + { + return false; + } + + // Arrays of primitives - don't wrap + if (underlyingType.IsArray) + { + Type elementType = underlyingType.GetElementType()!; + if (elementType == typeof(object)) + { + return false; + } + return ShouldWrapForDictionary(elementType); + } + + // Everything else (including records) should be wrapped for dictionary values + return true; + } + } +} diff --git a/test/Abstractions.Tests/Converters/JsonDataConverterTests.cs b/test/Abstractions.Tests/Converters/JsonDataConverterTests.cs new file mode 100644 index 000000000..d66182e90 --- /dev/null +++ b/test/Abstractions.Tests/Converters/JsonDataConverterTests.cs @@ -0,0 +1,272 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.DurableTask.Converters; + +namespace Microsoft.DurableTask.Tests.Converters; + +public class JsonDataConverterTests +{ + // Test types matching ConsoleAppMinimal sample + public record ComponentContext(string Name, string Type, List Dependencies); + public record PlanResult(bool Success, int Status, string Reason); + public record TestActivityInput(Dictionary Properties); + + [Fact] + public void SerializeDeserialize_DictionaryWithComplexTypes_PreservesTypes() + { + // Arrange + var converter = JsonDataConverter.Default; + var componentContext = new ComponentContext( + Name: "loganalytics", + Type: "terraform", + Dependencies: ["resourcegroup"]); + + var planResult = new PlanResult( + Success: true, + Status: 2, + Reason: "replace_because_tainted"); + + var input = new TestActivityInput(new Dictionary + { + { "ComponentContext", componentContext }, + { "PlanResult", planResult } + }); + + // Act + string serialized = converter.Serialize(input); + TestActivityInput? deserialized = converter.Deserialize(serialized); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!.Properties.Should().NotBeNull().And.NotBeEmpty(); + deserialized.Properties.Should().HaveCount(2); + + // Verify ComponentContext is preserved (not JsonElement) + deserialized.Properties["ComponentContext"].Should().BeOfType(); + var deserializedComponent = (ComponentContext)deserialized.Properties["ComponentContext"]; + deserializedComponent.Name.Should().Be("loganalytics"); + deserializedComponent.Type.Should().Be("terraform"); + deserializedComponent.Dependencies.Should().Equal(["resourcegroup"]); + + // Verify PlanResult is preserved (not JsonElement) + deserialized.Properties["PlanResult"].Should().BeOfType(); + var deserializedPlan = (PlanResult)deserialized.Properties["PlanResult"]; + deserializedPlan.Success.Should().BeTrue(); + deserializedPlan.Status.Should().Be(2); + deserializedPlan.Reason.Should().Be("replace_because_tainted"); + } + + [Fact] + public void SerializeDeserialize_DirectComplexType_PreservesType() + { + // Arrange + var converter = JsonDataConverter.Default; + var componentContext = new ComponentContext( + Name: "test", + Type: "type", + Dependencies: ["dep1", "dep2"]); + + // Act + string serialized = converter.Serialize(componentContext); + ComponentContext? deserialized = converter.Deserialize(serialized); + + // Assert + deserialized.Should().NotBeNull(); + ComponentContext result = deserialized!; + result.Name.Should().Be("test"); + result.Type.Should().Be("type"); + result.Dependencies.Should().Equal(["dep1", "dep2"]); + } + + [Fact] + public void SerializeDeserialize_DictionaryWithPrimitives_PreservesTypes() + { + // Arrange + var converter = JsonDataConverter.Default; + var input = new Dictionary + { + { "StringValue", "test" }, + { "IntValue", 42 }, + { "BoolValue", true }, + { "DoubleValue", 3.14 }, + { "NullValue", null! } + }; + + // Act + string serialized = converter.Serialize(input); + Dictionary? deserialized = converter.Deserialize>(serialized); + + // Assert + deserialized.Should().NotBeNull(); + Dictionary result = deserialized!; + result.Should().HaveCount(5); + // Note: Primitives in Dictionary are deserialized as JsonElement + // because they don't have type metadata (primitives are not wrapped). + // This is expected behavior - the converter only wraps complex types. + result["StringValue"].Should().BeOfType().Subject.GetString().Should().Be("test"); + result["IntValue"].Should().BeOfType().Subject.GetInt32().Should().Be(42); + result["BoolValue"].Should().BeOfType().Subject.GetBoolean().Should().Be(true); + result["DoubleValue"].Should().BeOfType().Subject.GetDouble().Should().BeApproximately(3.14, 0.01); + result["NullValue"].Should().BeNull(); + } + + [Fact] + public void SerializeDeserialize_DictionaryWithNestedObjects_PreservesTypes() + { + // Arrange + var converter = JsonDataConverter.Default; + var inner = new ComponentContext("inner", "type", ["dep"]); + var input = new Dictionary + { + { "Outer", new Dictionary { { "Inner", inner } } } + }; + + // Act + string serialized = converter.Serialize(input); + Dictionary? deserialized = converter.Deserialize>(serialized); + + // Assert + deserialized.Should().NotBeNull(); + Dictionary result = deserialized!; + var outer = result["Outer"].Should().BeOfType>().Subject; + var innerDeserialized = outer["Inner"].Should().BeOfType().Subject; + innerDeserialized.Name.Should().Be("inner"); + } + + [Fact] + public void SerializeDeserialize_ArrayOfComplexTypes_PreservesTypes() + { + // Arrange + var converter = JsonDataConverter.Default; + var items = new ComponentContext[] + { + new("item1", "type1", ["dep1"]), + new("item2", "type2", ["dep2"]) + }; + + // Act + string serialized = converter.Serialize(items); + ComponentContext[]? deserialized = converter.Deserialize(serialized); + + // Assert + deserialized.Should().NotBeNull(); + ComponentContext[] result = deserialized!; + result.Should().HaveCount(2); + result[0].Name.Should().Be("item1"); + result[1].Name.Should().Be("item2"); + } + + [Fact] + public void SerializeDeserialize_DictionaryWithArray_PreservesTypes() + { + // Arrange + var converter = JsonDataConverter.Default; + var input = new Dictionary + { + { "Items", new ComponentContext[] + { + new("item1", "type1", ["dep1"]), + new("item2", "type2", ["dep2"]) + } } + }; + + // Act + string serialized = converter.Serialize(input); + Dictionary? deserialized = converter.Deserialize>(serialized); + + // Assert + deserialized.Should().NotBeNull(); + Dictionary result = deserialized!; + var items = result["Items"].Should().BeOfType().Subject; + items.Should().HaveCount(2); + items[0].Name.Should().Be("item1"); + items[1].Name.Should().Be("item2"); + } + + [Fact] + public void SerializeDeserialize_NullValue_HandlesCorrectly() + { + // Arrange + var converter = JsonDataConverter.Default; + + // Act + string? serialized = converter.Serialize(null); + object? deserialized = converter.Deserialize(serialized); + + // Assert + serialized.Should().BeNull(); + deserialized.Should().BeNull(); + } + + [Fact] + public void SerializeDeserialize_JsonElement_UnwrapsTypeMetadata() + { + // Arrange + var converter = JsonDataConverter.Default; + var input = new Dictionary + { + { "Key", "Value" } + }; + + // Act + string serialized = converter.Serialize(input); + JsonElement deserialized = converter.Deserialize(serialized); + + // Assert + deserialized.ValueKind.Should().Be(JsonValueKind.Object); + deserialized.TryGetProperty("Key", out JsonElement keyElement).Should().BeTrue(); + keyElement.GetString().Should().Be("Value"); + } + + [Fact] + public void SerializeDeserialize_ObjectArray_DoesNotWrap() + { + // Arrange + var converter = JsonDataConverter.Default; + object[] input = ["item1", "item2", "item3"]; + + // Act + string serialized = converter.Serialize(input); + object[]? deserialized = converter.Deserialize(serialized); + + // Assert + deserialized.Should().NotBeNull(); + object[] result = deserialized!; + result.Should().HaveCount(3); + // Note: Elements in object[] may be deserialized as JsonElement if they don't have type metadata + // This is expected behavior - object[] arrays are not wrapped to maintain raw JSON format + result[0].Should().BeOfType().Subject.GetString().Should().Be("item1"); + result[1].Should().BeOfType().Subject.GetString().Should().Be("item2"); + result[2].Should().BeOfType().Subject.GetString().Should().Be("item3"); + + // Verify it's not wrapped with type metadata (should be raw JSON array) + serialized.Should().StartWith("["); + serialized.Should().EndWith("]"); + } + + [Fact] + public void SerializeDeserialize_JsonNode_DoesNotWrap() + { + // Arrange + var converter = JsonDataConverter.Default; + JsonNode input = JsonNode.Parse("""{"key": "value"}""")!; + + // Act + string serialized = converter.Serialize(input); + JsonNode? deserialized = converter.Deserialize(serialized); + + // Assert + deserialized.Should().NotBeNull(); + deserialized!["key"]!.GetValue().Should().Be("value"); + + // Verify it's not wrapped with type metadata + serialized.Should().Contain("\"key\""); + serialized.Should().Contain("\"value\""); + serialized.Should().NotContain("$type"); + } +} +