diff --git a/ReflectorNet.Tests/src/Model/JsonConvertors/SerializedMemberNameOmissionTests.cs b/ReflectorNet.Tests/src/Model/JsonConvertors/SerializedMemberNameOmissionTests.cs new file mode 100644 index 00000000..9b36ff1c --- /dev/null +++ b/ReflectorNet.Tests/src/Model/JsonConvertors/SerializedMemberNameOmissionTests.cs @@ -0,0 +1,168 @@ +/* + * ReflectorNet + * Author: Ivan Murzak (https://github.com/IvanMurzak) + * Copyright (c) 2025 Ivan Murzak + * Licensed under the Apache License, Version 2.0. See LICENSE file in the project root for full license information. + */ + +using System.Text.Json.Nodes; +using com.IvanMurzak.ReflectorNet.Json; +using com.IvanMurzak.ReflectorNet.Model; +using com.IvanMurzak.ReflectorNet.Utils; +using Xunit; +using Xunit.Abstractions; + +namespace com.IvanMurzak.ReflectorNet.Tests.Model.JsonConvertors +{ + /// + /// Regression tests for https://github.com/IvanMurzak/ReflectorNet/issues/86. + /// used to write the "name" property unconditionally, + /// emitting JSON null for value-only / root / unnamed members. The converter's own schema + /// () types "name" as a non-nullable "string" and + /// does NOT list it in "required", so the correct behavior is to omit the key when the value is null. + /// Emitting "name": null violated the advertised output schema and was rejected by strict MCP clients + /// with error -32602. + /// + public class SerializedMemberNameOmissionTests : BaseTest + { + public SerializedMemberNameOmissionTests(ITestOutputHelper output) : base(output) { } + + static JsonObject SerializeToObject(SerializedMember member, Reflector reflector) + { + var json = member.ToJson(reflector); + Assert.False(string.IsNullOrEmpty(json), "Serialization produced empty JSON."); + var node = JsonNode.Parse(json!); + Assert.NotNull(node); + return node!.AsObject(); + } + + [Fact] + public void Write_NullName_OmitsNameKey() + { + var reflector = new Reflector(); + var member = new SerializedMember { name = null, typeName = "System.String" }; + + var obj = SerializeToObject(member, reflector); + _output.WriteLine($"Serialized (null name): {obj.ToJsonString()}"); + + // The "name" key must be ABSENT entirely (not present-with-null). + Assert.False(obj.ContainsKey(nameof(SerializedMember.name)), + $"Expected no 'name' key when name is null, but JSON was: {obj.ToJsonString()}"); + + // The required key must still be present. + Assert.True(obj.ContainsKey(nameof(SerializedMember.typeName))); + } + + [Fact] + public void Write_NonNullName_WritesNameAsString() + { + var reflector = new Reflector(); + var member = new SerializedMember { name = "foo", typeName = "System.String" }; + + var obj = SerializeToObject(member, reflector); + _output.WriteLine($"Serialized (non-null name): {obj.ToJsonString()}"); + + Assert.True(obj.ContainsKey(nameof(SerializedMember.name)), + $"Expected a 'name' key when name is non-null, but JSON was: {obj.ToJsonString()}"); + + var nameNode = obj[nameof(SerializedMember.name)]; + Assert.NotNull(nameNode); + // A present "name" must be a JSON string (matching the schema's {"type":"string"}), not null. + Assert.IsType(nameNode, exactMatch: false); + Assert.Equal("foo", nameNode!.GetValue()); + } + + [Fact] + public void Schema_TypesNameAsString_AndDoesNotRequireIt() + { + // Guard the schema contract this fix relies on: "name" is a non-nullable "string" and is + // NOT in "required". If a future change widens the schema (e.g. to ["string","null"]) or adds + // "name" to "required", the omit-when-null fix would no longer be schema-valid. + var schema = SerializedMemberConverter.Schema.AsObject(); + _output.WriteLine($"Schema: {schema.ToJsonString()}"); + + var properties = schema[JsonSchema.Properties]!.AsObject(); + Assert.True(properties.ContainsKey(nameof(SerializedMember.name))); + + var nameSchema = properties[nameof(SerializedMember.name)]!.AsObject(); + Assert.Equal(JsonSchema.String, nameSchema[JsonSchema.Type]!.ToString()); + + var required = schema[JsonSchema.Required]!.AsArray(); + Assert.DoesNotContain(required, r => r?.ToString() == nameof(SerializedMember.name)); + Assert.Contains(required, r => r?.ToString() == nameof(SerializedMember.typeName)); + } + + [Fact] + public void Write_NullName_IsSchemaValid_AndRoundTripsToNull() + { + var reflector = new Reflector(); + var member = new SerializedMember { name = null, typeName = "System.String" }; + + // 1. Serialized JSON must not carry a "name" key — so it cannot violate the {"type":"string"} schema. + var obj = SerializeToObject(member, reflector); + Assert.False(obj.ContainsKey(nameof(SerializedMember.name))); + + // 2. Round-trip: deserializing the name-less JSON yields a member with no meaningful name + // (Read leaves the default — string.Empty — when the key is absent; it does NOT resurrect + // a JSON null), and re-serializing it STILL omits the key, so the round-trip stays + // schema-valid and never re-introduces the "name": null the fix removed. + var json = obj.ToJsonString(); + var deserialized = reflector.JsonSerializer.Deserialize(json); + Assert.NotNull(deserialized); + Assert.True(string.IsNullOrEmpty(deserialized!.name), + $"Expected null-or-empty name after round-trip, got '{deserialized.name}'."); + Assert.Equal("System.String", deserialized.typeName); + + // Re-serializing a null/empty-name member must not emit "name": null. + var reserialized = SerializeToObject(deserialized, reflector); + if (reserialized.ContainsKey(nameof(SerializedMember.name))) + { + // If a key is present at all (e.g. empty-string default), it must be a string, never null. + Assert.IsType(reserialized[nameof(SerializedMember.name)], exactMatch: false); + } + + // 3. Non-null name round-trips and is preserved. + var named = new SerializedMember { name = "foo", typeName = "System.String" }; + var namedJson = named.ToJson(reflector); + var namedBack = reflector.JsonSerializer.Deserialize(namedJson!); + Assert.NotNull(namedBack); + Assert.Equal("foo", namedBack!.name); + } + + [Fact] + public void Write_NestedChildWithNullName_OmitsNameKeyRecursively() + { + // The recursive case from issue #86: a SerializedMember tree where a CHILD (under fields/props) + // has a null name. The converter serializes children through the same converter, so the omission + // must hold at every depth — not just at the root. This is the most material scenario because + // real tool outputs (assets-get-data, gameobject-component-get, etc.) return nested trees whose + // value-only leaf members legitimately have null names. + var reflector = new Reflector(); + + var fieldChild = new SerializedMember { name = null, typeName = "System.Int32" }; + var propChild = new SerializedMember { name = null, typeName = "System.Boolean" }; + + var parent = new SerializedMember { name = "root", typeName = "Some.Composite.Type" }; + parent.AddField(fieldChild); + parent.AddProperty(propChild); + + var obj = SerializeToObject(parent, reflector); + _output.WriteLine($"Serialized (nested null-name children): {obj.ToJsonString()}"); + + // Parent retains its (non-null) name and typeName. + Assert.Equal("root", obj[nameof(SerializedMember.name)]!.GetValue()); + Assert.Equal("Some.Composite.Type", obj[nameof(SerializedMember.typeName)]!.GetValue()); + + // Each nested child object must omit the "name" key entirely while keeping typeName. + var fieldChildObj = obj[nameof(SerializedMember.fields)]!.AsArray()[0]!.AsObject(); + Assert.False(fieldChildObj.ContainsKey(nameof(SerializedMember.name)), + $"Expected no 'name' key on the nested field child, but JSON was: {fieldChildObj.ToJsonString()}"); + Assert.Equal("System.Int32", fieldChildObj[nameof(SerializedMember.typeName)]!.GetValue()); + + var propChildObj = obj[nameof(SerializedMember.props)]!.AsArray()[0]!.AsObject(); + Assert.False(propChildObj.ContainsKey(nameof(SerializedMember.name)), + $"Expected no 'name' key on the nested prop child, but JSON was: {propChildObj.ToJsonString()}"); + Assert.Equal("System.Boolean", propChildObj[nameof(SerializedMember.typeName)]!.GetValue()); + } + } +} diff --git a/ReflectorNet/src/Converter/Json/SerializedMemberConverter.cs b/ReflectorNet/src/Converter/Json/SerializedMemberConverter.cs index bd1559d3..de38d6e4 100644 --- a/ReflectorNet/src/Converter/Json/SerializedMemberConverter.cs +++ b/ReflectorNet/src/Converter/Json/SerializedMemberConverter.cs @@ -153,7 +153,13 @@ public override void Write(Utf8JsonWriter writer, SerializedMember? value, JsonS writer.WriteStartObject(); - writer.WriteString(nameof(SerializedMember.name), value.name); + // 'name' is nullable (string?) and is NOT in the schema's "required" set, which types it as + // a non-nullable "string". Writing it unconditionally emits JSON null for value-only/root/ + // unnamed members, violating our own advertised output schema (strict MCP clients reject with + // -32602). Omit the key when null: an absent non-required key is schema-valid, and a present key + // is always a string. Do NOT widen the schema to ["string","null"] — PostprocessFields strips null. + if (value.name != null) + writer.WriteString(nameof(SerializedMember.name), value.name); writer.WriteString(nameof(SerializedMember.typeName), value.typeName); if (value.valueJsonElement.HasValue)