Files
agent-framework/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs
T
L. Elaine Dazzio 2ad0caf069 .NET: Fix JSON arrays of objects parsed as empty records when no schema is defined (#4199)
* fix: use HasSchema check in DetermineElementType to prevent empty records

When parsing JSON arrays containing objects without a predefined schema,
`DetermineElementType()` was creating a `VariableType` with an empty
(non-null) schema via `targetType.Schema?.Select(...) ?? []`. This caused
`ParseRecord` to take the schema-based parsing path, iterating over zero
schema fields and silently discarding all JSON properties.

The fix checks `targetType.HasSchema` and falls back to
`VariableType.RecordType` (which has `Schema = null`) when no schema is
defined, ensuring `ParseRecord` takes the dynamic `ParseValues()` path
that preserves all JSON properties.

Closes #4195

* test: add regression tests for schema-less JSON array-of-objects parsing (#4195)

Add two regression tests to JsonDocumentExtensionsTests:

1. ParseRecord_ObjectWithArrayOfObjects_NoSchema_PreservesNestedProperties
   - Parses a JSON object containing an array of objects using
     VariableType.RecordType (no schema) and verifies that nested
     object properties (name, role) are preserved in each element.
   - This is the exact scenario from issue #4195 where objects in
     arrays were being returned as empty dictionaries.

2. ParseList_ArrayOfObjects_NoSchema_PreservesProperties
   - Parses a JSON array of objects directly via ParseList with
     VariableType.ListType (no schema) and verifies all properties
     are preserved.

Both tests follow the existing Arrange/Act/Assert pattern and would
have failed before the DetermineElementType() fix (empty dictionaries
instead of populated ones).
2026-02-25 01:02:43 +00:00

463 lines
14 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions;
public sealed class JsonDocumentExtensionsTests
{
[Fact]
public void ParseRecord_Object_PrimitiveFields_Succeeds()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("text", typeof(string)),
("numberInt", typeof(int)),
("numberLong", typeof(long)),
("numberDecimal", typeof(decimal)),
("numberDouble", typeof(double)),
("flag", typeof(bool)),
("date", typeof(DateTime)),
("time", typeof(TimeSpan))
]);
DateTime expectedDateTime = new(2024, 10, 01, 12, 34, 56, DateTimeKind.Utc);
TimeSpan expectedTimeSpan = new(12, 34, 56);
JsonDocument document = JsonDocument.Parse(
"""
{
"text": "hello",
"numberInt": 7,
"numberLong": 9223372036854775807,
"numberDecimal": 12.5,
"numberDouble": 3.99E99,
"flag": true,
"date": "2024-10-01T12:34:56Z",
"time": "12:34:56"
}
""");
// Act
Dictionary<string, object?> result = document.ParseRecord(recordType);
// Assert
Assert.Equal("hello", result["text"]);
Assert.Equal(7, result["numberInt"]);
Assert.Equal(9223372036854775807L, result["numberLong"]);
Assert.Equal(12.5m, result["numberDecimal"]);
Assert.Equal(3.99E99, result["numberDouble"]);
Assert.Equal(true, result["flag"]);
Assert.Equal(expectedDateTime, result["date"]);
Assert.Equal(expectedTimeSpan, result["time"]);
}
[Fact]
public void ParseRecord_Object_NoSchema_Succeeds()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
{
"text": "hello",
"numberInt": 7,
"numberLong": 9223372036854775807,
"numberDecimal": 12.5,
"numberDouble": 3.99E99,
"flag": true,
"date": "2024-10-01T12:34:56Z",
"time": "12:34:56"
}
""");
// Act
Dictionary<string, object?> result = document.ParseRecord(VariableType.RecordType);
// Assert
Assert.Equal("hello", result["text"]);
Assert.Equal(7, result["numberInt"]);
Assert.Equal(9223372036854775807L, result["numberLong"]);
Assert.Equal(12.5m, result["numberDecimal"]);
Assert.Equal(3.99E99, result["numberDouble"]);
Assert.Equal(true, result["flag"]);
Assert.Equal("2024-10-01T12:34:56Z", result["date"]);
Assert.Equal("12:34:56", result["time"]);
}
[Fact]
public void ParseRecord_Object_NestedRecord_Succeeds()
{
// Arrange
VariableType innerRecord =
VariableType.Record(
[
("innerText", typeof(string)),
("innerNumber", typeof(int))
]);
VariableType outerRecord =
VariableType.Record(
[
("outerText", typeof(string)),
("nested", innerRecord)
]);
JsonDocument document = JsonDocument.Parse(
"""
{
"outerText": "outer",
"nested": {
"innerText": "inner",
"innerNumber": 42
}
}
""");
// Act
Dictionary<string, object?> result = document.ParseRecord(outerRecord);
// Assert
Assert.Equal("outer", result["outerText"]);
Dictionary<string, object?> nested = (Dictionary<string, object?>)result["nested"]!;
Assert.NotNull(nested);
Assert.True(nested.ContainsKey("innerText"));
Assert.Equal("inner", nested["innerText"]);
Assert.Equal(42, nested["innerNumber"]);
}
[Fact]
public void ParseRecord_NullRoot_ReturnsEmpty()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("text", typeof(string))
]);
JsonDocument document = JsonDocument.Parse("null");
// Act
Dictionary<string, object?> result = document.ParseRecord(recordType);
// Assert
Assert.Empty(result);
}
[Fact]
public void ParseRecord_ArrayWithSingleRecord_Succeeds()
{
// Arrange
VariableType listType =
VariableType.List(
[
("name", typeof(string)),
("value", typeof(int))
]);
JsonDocument document = JsonDocument.Parse(
"""
[
{
"name": "item",
"value": 5
}
]
""");
// Act
List<object?> result = document.ParseList(listType);
// Assert
Assert.Single(result);
Dictionary<string, object?> element = Assert.IsType<Dictionary<string, object?>>(result[0]);
Assert.Equal("item", element["name"]);
Assert.Equal(5, element["value"]);
}
[Fact]
public void ParseRecord_ArrayWithMultipleRecords_Throws()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("id", typeof(int))
]);
JsonDocument document = JsonDocument.Parse(
"""
[
{ "id": 1 },
{ "id": 2 }
]
""");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseRecord(recordType));
}
[Fact]
public void ParseRecord_InvalidTargetType_Throws()
{
// Arrange
VariableType notARecord = typeof(string);
JsonDocument document = JsonDocument.Parse(
"""
{ "x": 1 }
""");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseRecord(notARecord));
}
[Fact]
public void ParseRecord_InvalidRootKind_Throws()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("text", typeof(string))
]);
JsonDocument document = JsonDocument.Parse(@"""not-an-object""");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseRecord(recordType));
}
[Fact]
public void ParseRecord_UnsupportedPropertyType_Throws()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("unsupported", typeof(Guid))
]);
JsonDocument document = JsonDocument.Parse(
"""
{ "unsupported": "C2556C11-210E-4BB6-BF18-4A8968CB45A8" }
""");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseRecord(recordType));
}
[Fact]
public void ParseRecord_MissingRequiredProperty_Throws()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("required", typeof(bool))
]);
JsonDocument document = JsonDocument.Parse("{}");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseRecord(recordType));
}
[Fact]
public void ParseRecord_MissingNullableProperty_Succeeds()
{
// Arrange
VariableType recordType =
VariableType.Record(
[
("required", typeof(string))
]);
JsonDocument document = JsonDocument.Parse("{}");
// Act
Dictionary<string, object?> result = document.ParseRecord(recordType);
// Assert
Assert.Single(result);
Dictionary<string, object?> element = Assert.IsType<Dictionary<string, object?>>(result);
Assert.Null(element["required"]);
}
[Fact]
public void ParseList_NullRoot_ReturnsEmpty()
{
// Arrange
JsonDocument document = JsonDocument.Parse("null");
// Act
List<object?> result = document.ParseList(typeof(int[]));
// Assert
Assert.Empty(result);
}
[Fact]
public void ParseList_Array_Primitives_Succeeds()
{
// Arrange
JsonDocument document = JsonDocument.Parse("[1,2,3]");
// Act
List<object?> result = document.ParseList(typeof(int[]));
// Assert
Assert.Equal(3, result.Count);
Assert.Equal(1, result[0]);
Assert.Equal(2, result[1]);
Assert.Equal(3, result[2]);
}
[Fact]
public void ParseList_PrimitiveRoot_WrappedAsSingleElement_Succeeds()
{
// Arrange
JsonDocument document = JsonDocument.Parse("7");
// Act
List<object?> result = document.ParseList(typeof(int));
// Assert
Assert.Single(result);
Assert.Equal(7, result[0]);
}
[Fact]
public void ParseList_Array_Records_Succeeds()
{
// Arrange
VariableType listType =
VariableType.List(
[
("id", typeof(int)),
("name", typeof(string))
]);
JsonDocument document = JsonDocument.Parse(
"""
[
{ "id": 1, "name": "a" },
{ "id": 2, "name": "b" }
]
""");
// Act
List<object?> result = document.ParseList(listType);
// Assert
Assert.Equal(2, result.Count);
Dictionary<string, object?> first = (Dictionary<string, object?>)result[0]!;
Dictionary<string, object?> second = (Dictionary<string, object?>)result[1]!;
Assert.NotNull(first);
Assert.Equal(1, first["id"]);
Assert.Equal("a", first["name"]);
Assert.NotNull(second);
Assert.Equal(2, second["id"]);
Assert.Equal("b", second["name"]);
}
[Fact]
public void ParseList_InvalidTargetType_Throws()
{
// Arrange
JsonDocument document = JsonDocument.Parse("[1,2]");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseList(typeof(int)));
}
[Fact]
public void ParseList_Array_MixedTypes_Throws()
{
// Arrange
JsonDocument document = JsonDocument.Parse("[1,\"two\",3]");
// Act / Assert
Assert.Throws<DeclarativeActionException>(() => document.ParseList(typeof(int[])));
}
/// <summary>
/// Regression test for #4195: When a JSON object contains an array of objects
/// and is parsed with <c>VariableType.RecordType</c> (no schema), the nested
/// object properties must be preserved. Before the fix, DetermineElementType()
/// created an empty-schema VariableType, causing ParseRecord to take the
/// ParseSchema path (zero fields) and return empty dictionaries.
/// </summary>
[Fact]
public void ParseRecord_ObjectWithArrayOfObjects_NoSchema_PreservesNestedProperties()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
{
"items": [
{ "name": "Alice", "role": "Engineer" },
{ "name": "Bob", "role": "Designer" },
{ "name": "Carol", "role": "PM" }
]
}
""");
// Act
Dictionary<string, object?> result = document.ParseRecord(VariableType.RecordType);
// Assert
Assert.True(result.ContainsKey("items"));
List<object?> items = Assert.IsType<List<object?>>(result["items"]);
Assert.Equal(3, items.Count);
Dictionary<string, object?> first = Assert.IsType<Dictionary<string, object?>>(items[0]);
Assert.Equal("Alice", first["name"]);
Assert.Equal("Engineer", first["role"]);
Dictionary<string, object?> second = Assert.IsType<Dictionary<string, object?>>(items[1]);
Assert.Equal("Bob", second["name"]);
Assert.Equal("Designer", second["role"]);
Dictionary<string, object?> third = Assert.IsType<Dictionary<string, object?>>(items[2]);
Assert.Equal("Carol", third["name"]);
Assert.Equal("PM", third["role"]);
}
/// <summary>
/// Regression test for #4195: When a JSON array of objects is parsed directly
/// via <c>ParseList</c> with <c>VariableType.ListType</c> (no schema), all
/// object properties must be preserved in each element.
/// </summary>
[Fact]
public void ParseList_ArrayOfObjects_NoSchema_PreservesProperties()
{
// Arrange
JsonDocument document = JsonDocument.Parse(
"""
[
{ "name": "Alice", "role": "Engineer" },
{ "name": "Bob", "role": "Designer" }
]
""");
// Act
List<object?> result = document.ParseList(VariableType.ListType);
// Assert
Assert.Equal(2, result.Count);
Dictionary<string, object?> first = Assert.IsType<Dictionary<string, object?>>(result[0]);
Assert.Equal("Alice", first["name"]);
Assert.Equal("Engineer", first["role"]);
Dictionary<string, object?> second = Assert.IsType<Dictionary<string, object?>>(result[1]);
Assert.Equal("Bob", second["name"]);
Assert.Equal("Designer", second["role"]);
}
}