diff --git a/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonComponent.cs b/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonComponent.cs index 9d3d78ee1..ad1f2bee3 100644 --- a/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonComponent.cs +++ b/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonComponent.cs @@ -11,6 +11,7 @@ public class JsonComponent public string Id { get; set; } [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string Name { get; set; } [JsonPropertyName("nativeName")] @@ -55,7 +56,7 @@ public JsonComponent(IComponent component) { Id = component.Id; Uuid = component.Uuid; - Name = component.Name; + if (!string.IsNullOrEmpty(component.Name)) Name = component.Name; NativeName = component.NativeName; //Type = component.Type; if (component.Description != null) Description = new JsonDescription(component.Description); diff --git a/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonComposition.cs b/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonComposition.cs index 7400bbeda..5687f5886 100644 --- a/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonComposition.cs +++ b/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonComposition.cs @@ -16,6 +16,7 @@ public class JsonComposition public string Type { get; set; } [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string Name { get; set; } [JsonPropertyName("nativeName")] @@ -54,7 +55,7 @@ public JsonComposition(IComposition composition) { Id = composition.Id; Uuid = composition.Uuid; - Name = composition.Name; + if (!string.IsNullOrEmpty(composition.Name)) Name = composition.Name; NativeName = composition.NativeName; Type = composition.Type; if (composition.Description != null) Description = new JsonDescription(composition.Description); diff --git a/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonDataItem.cs b/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonDataItem.cs index b609496be..c126def9e 100644 --- a/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonDataItem.cs +++ b/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonDataItem.cs @@ -25,6 +25,7 @@ public class JsonDataItem public string CoordinateSystemIdRef { get; set; } [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string Name { get; set; } [JsonPropertyName("nativeScale")] @@ -84,7 +85,7 @@ public JsonDataItem(IDataItem dataItem) { DataItemCategory = dataItem.Category.ToString(); Id = dataItem.Id; - Name = dataItem.Name; + if (!string.IsNullOrEmpty(dataItem.Name)) Name = dataItem.Name; Type = dataItem.Type; SubType = dataItem.SubType; NativeUnits = dataItem.NativeUnits; @@ -98,32 +99,32 @@ public JsonDataItem(IDataItem dataItem) var relationships = new JsonRelationshipContainer(); foreach (var relationship in dataItem.Relationships) { - // ComponentRelationship - if (typeof(IComponentRelationship).IsAssignableFrom(relationship.GetType())) + // Use a switch with pattern matching so each relationship is + // classified by a single is-check + cast, instead of four + // typeof().IsAssignableFrom(GetType()) reflection probes per + // element. Behavior is preserved per + // JsonDataItemRelationshipCategorizationTests. + switch (relationship) { - if (relationships.ComponentRelationships == null) relationships.ComponentRelationships = new List(); - relationships.ComponentRelationships.Add(new JsonRelationship((IComponentRelationship)relationship)); - } - - // DataItemRelationship - if (typeof(IDataItemRelationship).IsAssignableFrom(relationship.GetType())) - { - if (relationships.DataItemRelationships == null) relationships.DataItemRelationships = new List(); - relationships.DataItemRelationships.Add(new JsonRelationship((IDataItemRelationship)relationship)); - } - - // DeviceRelationship - if (typeof(IDeviceRelationship).IsAssignableFrom(relationship.GetType())) - { - if (relationships.DeviceRelationships == null) relationships.DeviceRelationships = new List(); - relationships.DeviceRelationships.Add(new JsonRelationship((IDeviceRelationship)relationship)); - } - - // SpecificationRelationship - if (typeof(ISpecificationRelationship).IsAssignableFrom(relationship.GetType())) - { - if (relationships.SpecificationRelationships == null) relationships.SpecificationRelationships = new List(); - relationships.SpecificationRelationships.Add(new JsonRelationship((ISpecificationRelationship)relationship)); + case IComponentRelationship componentRelationship: + if (relationships.ComponentRelationships == null) relationships.ComponentRelationships = new List(); + relationships.ComponentRelationships.Add(new JsonRelationship(componentRelationship)); + break; + + case IDataItemRelationship dataItemRelationship: + if (relationships.DataItemRelationships == null) relationships.DataItemRelationships = new List(); + relationships.DataItemRelationships.Add(new JsonRelationship(dataItemRelationship)); + break; + + case IDeviceRelationship deviceRelationship: + if (relationships.DeviceRelationships == null) relationships.DeviceRelationships = new List(); + relationships.DeviceRelationships.Add(new JsonRelationship(deviceRelationship)); + break; + + case ISpecificationRelationship specificationRelationship: + if (relationships.SpecificationRelationships == null) relationships.SpecificationRelationships = new List(); + relationships.SpecificationRelationships.Add(new JsonRelationship(specificationRelationship)); + break; } } Relationships = relationships; diff --git a/libraries/MTConnect.NET-JSON/Devices/JsonDataItem.cs b/libraries/MTConnect.NET-JSON/Devices/JsonDataItem.cs index 98e21c307..cf90429a1 100644 --- a/libraries/MTConnect.NET-JSON/Devices/JsonDataItem.cs +++ b/libraries/MTConnect.NET-JSON/Devices/JsonDataItem.cs @@ -84,7 +84,7 @@ public JsonDataItem(IDataItem dataItem) { DataItemCategory = dataItem.Category.ToString(); Id = dataItem.Id; - Name = dataItem.Name; + if (!string.IsNullOrEmpty(dataItem.Name)) Name = dataItem.Name; Type = dataItem.Type; SubType = dataItem.SubType; NativeUnits = dataItem.NativeUnits; @@ -98,32 +98,32 @@ public JsonDataItem(IDataItem dataItem) var relationships = new JsonRelationshipContainer(); foreach (var relationship in dataItem.Relationships) { - // ComponentRelationship - if (typeof(IComponentRelationship).IsAssignableFrom(relationship.GetType())) + // Use a switch with pattern matching so each relationship is + // classified by a single is-check + cast, instead of four + // typeof().IsAssignableFrom(GetType()) reflection probes per + // element. Behavior is preserved per + // JsonDataItemRelationshipCategorizationTests. + switch (relationship) { - if (relationships.ComponentRelationships == null) relationships.ComponentRelationships = new List(); - relationships.ComponentRelationships.Add(new JsonRelationship((IComponentRelationship)relationship)); - } - - // DataItemRelationship - if (typeof(IDataItemRelationship).IsAssignableFrom(relationship.GetType())) - { - if (relationships.DataItemRelationships == null) relationships.DataItemRelationships = new List(); - relationships.DataItemRelationships.Add(new JsonRelationship((IDataItemRelationship)relationship)); - } - - // DeviceRelationship - if (typeof(IDeviceRelationship).IsAssignableFrom(relationship.GetType())) - { - if (relationships.DeviceRelationships == null) relationships.DeviceRelationships = new List(); - relationships.DeviceRelationships.Add(new JsonRelationship((IDeviceRelationship)relationship)); - } - - // SpecificationRelationship - if (typeof(ISpecificationRelationship).IsAssignableFrom(relationship.GetType())) - { - if (relationships.SpecificationRelationships == null) relationships.SpecificationRelationships = new List(); - relationships.SpecificationRelationships.Add(new JsonRelationship((ISpecificationRelationship)relationship)); + case IComponentRelationship componentRelationship: + if (relationships.ComponentRelationships == null) relationships.ComponentRelationships = new List(); + relationships.ComponentRelationships.Add(new JsonRelationship(componentRelationship)); + break; + + case IDataItemRelationship dataItemRelationship: + if (relationships.DataItemRelationships == null) relationships.DataItemRelationships = new List(); + relationships.DataItemRelationships.Add(new JsonRelationship(dataItemRelationship)); + break; + + case IDeviceRelationship deviceRelationship: + if (relationships.DeviceRelationships == null) relationships.DeviceRelationships = new List(); + relationships.DeviceRelationships.Add(new JsonRelationship(deviceRelationship)); + break; + + case ISpecificationRelationship specificationRelationship: + if (relationships.SpecificationRelationships == null) relationships.SpecificationRelationships = new List(); + relationships.SpecificationRelationships.Add(new JsonRelationship(specificationRelationship)); + break; } } Relationships = relationships; diff --git a/tests/IntegrationTests/ClientAgentCommunicationTests.cs b/tests/IntegrationTests/ClientAgentCommunicationTests.cs index 2754f66e1..bde735db5 100644 --- a/tests/IntegrationTests/ClientAgentCommunicationTests.cs +++ b/tests/IntegrationTests/ClientAgentCommunicationTests.cs @@ -116,7 +116,11 @@ public ClientAgentCommunicationTests( var configuration = new HttpServerConfiguration { - Port = _fixture.CurrentAgentPort + Port = _fixture.CurrentAgentPort, + // Bind to loopback only so an in-process integration run + // cannot accidentally expose the test agent on a + // non-loopback interface of the dev machine. + Server = "127.0.0.1" }; _server = new MTConnectHttpServer(configuration, _agent); _server.Start(); diff --git a/tests/IntegrationTests/HttpServerLoopbackBindingTests.cs b/tests/IntegrationTests/HttpServerLoopbackBindingTests.cs new file mode 100644 index 000000000..a701cccd3 --- /dev/null +++ b/tests/IntegrationTests/HttpServerLoopbackBindingTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.IO; +using System.Reflection; +using System.Text.RegularExpressions; +using Xunit; + +namespace IntegrationTests +{ + /// + /// Source-grep regression guard for the loopback-only binding contract. + /// Pins that + /// in ClientAgentCommunicationTests binds the embedded HTTP + /// server to loopback (127.0.0.1) so an in-process + /// integration run cannot accidentally expose the test agent on a + /// non-loopback interface of the dev machine. + /// + /// Pinned via file content rather than a runtime assertion because + /// the fixture brings the server up in its constructor and we want + /// the guard to fail at unit-test time (not when the server is + /// already bound to the wrong interface). + /// + public class HttpServerLoopbackBindingTests + { + private static string LocateRepoRoot() + { + var dir = new DirectoryInfo( + Path.GetDirectoryName(typeof(HttpServerLoopbackBindingTests).Assembly.Location)!); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "MTConnect.NET.sln"))) + return dir.FullName; + + dir = dir.Parent!; + } + + throw new DirectoryNotFoundException( + "Could not locate MTConnect.NET.sln walking up from the test assembly."); + } + + [Fact] + public void ClientAgentCommunicationTests_HttpServerConfiguration_binds_to_loopback() + { + var path = Path.Combine(LocateRepoRoot(), + "tests", "IntegrationTests", "ClientAgentCommunicationTests.cs"); + + Assert.True(File.Exists(path), $"Expected source file at {path}"); + + var src = File.ReadAllText(path); + + // The fixture must initialize HttpServerConfiguration with the + // Server property pinned to "127.0.0.1" inside the same object + // initializer that sets Port. A {...} block setting Port + Server + // satisfies the pin regardless of declaration order. + var loopbackInitializer = new Regex( + @"new\s+HttpServerConfiguration\s*\{[^}]*Server\s*=\s*""127\.0\.0\.1""[^}]*\}", + RegexOptions.Singleline); + + Assert.True(loopbackInitializer.IsMatch(src), + "ClientAgentCommunicationTests.cs must construct HttpServerConfiguration with Server = \"127.0.0.1\" " + + "so the embedded HTTP server binds to loopback only."); + } + } +} diff --git a/tests/MTConnect.NET-JSON-Tests/Devices/JsonDataItemEmptyNameOmissionTests.cs b/tests/MTConnect.NET-JSON-Tests/Devices/JsonDataItemEmptyNameOmissionTests.cs new file mode 100644 index 000000000..af6282632 --- /dev/null +++ b/tests/MTConnect.NET-JSON-Tests/Devices/JsonDataItemEmptyNameOmissionTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Text.Json; +using MTConnect.Devices; +using MTConnect.Devices.DataItems; +using MTConnect.Devices.Json; +using NUnit.Framework; + +namespace MTConnect.Tests.Json.Devices +{ + /// + /// Pins the base JSON Probe DataItem behavior: the `name` JSON property must be + /// omitted when the source IDataItem.Name is null or empty, and emitted with the + /// original value otherwise. + /// + /// Source authority: + /// - XSD: https://schemas.mtconnect.org/schemas/MTConnectDevices_2.5.xsd — + /// `DataItem/@name` is declared `use="optional"`. An optional attribute with no + /// value must be omitted from the wire, not emitted as an empty placeholder. + /// - Reference shape: libraries/MTConnect.NET-XML/Devices/XmlDataItem.cs already + /// guards the XML attribute write with `string.IsNullOrEmpty(dataItem.Name)`. + /// - Public defect tracker: https://github.com/TrakHound/MTConnect.NET/issues/138. + /// + [TestFixture] + [Category("DataItemNameOmissionWhenUnsetOrEmpty")] + public class JsonDataItemEmptyNameOmissionTests + { + [Test] + public void Constructor_with_null_Name_source_does_not_serialize_name_key() + { + var source = new DataItem + { + Id = "item-1", + Type = "TEMPERATURE", + Category = DataItemCategory.SAMPLE, + Name = null + }; + + var json = new JsonDataItem(source).ToString(); + using var doc = JsonDocument.Parse(json); + + Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.False, + "Base JSON Probe DataItem must omit 'name' when source Name is null"); + } + + [Test] + public void Constructor_with_empty_Name_source_does_not_serialize_name_key() + { + var source = new DataItem + { + Id = "item-2", + Type = "TEMPERATURE", + Category = DataItemCategory.SAMPLE, + Name = string.Empty + }; + + var json = new JsonDataItem(source).ToString(); + using var doc = JsonDocument.Parse(json); + + Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.False, + "Base JSON Probe DataItem must omit 'name' when source Name is empty"); + } + + [Test] + public void Constructor_with_explicit_Name_source_serializes_name_key() + { + var source = new DataItem + { + Id = "item-3", + Type = "TEMPERATURE", + Category = DataItemCategory.SAMPLE, + Name = "temp" + }; + + var json = new JsonDataItem(source).ToString(); + using var doc = JsonDocument.Parse(json); + + Assert.That(doc.RootElement.TryGetProperty("name", out var nameElement), Is.True, + "Base JSON Probe DataItem must emit 'name' when source Name has a value"); + Assert.That(nameElement.GetString(), Is.EqualTo("temp")); + } + + [Test] + public void Constructor_with_typed_DataItem_unset_Name_does_not_serialize_name_key() + { + var source = new TemperatureDataItem + { + Id = "item-4", + Name = string.Empty + }; + + var json = new JsonDataItem(source).ToString(); + using var doc = JsonDocument.Parse(json); + + Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.False, + "Cleared Name on a typed DataItem must produce no 'name' key"); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonComponentEmptyNameOmissionTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonComponentEmptyNameOmissionTests.cs new file mode 100644 index 000000000..10ccc8ea4 --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonComponentEmptyNameOmissionTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Text.Json; +using MTConnect.Devices; +using MTConnect.Devices.Json; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Devices +{ + /// + /// Pins the JSON-cppagent Probe Component behavior: the `name` JSON property must be + /// omitted when the source IComponent.Name is null or empty, and emitted with the + /// original value otherwise. Same rationale as the sibling DataItem fixture — every + /// optional NameType slot in the v2.7 Devices XSD is `use="optional"` and the + /// reference cppagent printer omits the attribute when the model-side value is null. + /// + /// Source authority: + /// - XSD: https://schemas.mtconnect.org/schemas/MTConnectDevices_2.7.xsd — + /// Component `name` is declared `use="optional"`. + /// - Reference shape: libraries/MTConnect.NET-XML/Devices/XmlComponent.cs already + /// guards the XML attribute write with `string.IsNullOrEmpty(component.Name)`. + /// - Public defect tracker: https://github.com/TrakHound/MTConnect.NET/issues/138. + /// + [TestFixture] + [Category("DataItemNameOmissionWhenUnsetOrEmpty")] + public class JsonComponentEmptyNameOmissionTests + { + [Test] + public void Constructor_with_null_Name_source_does_not_serialize_name_key() + { + var source = new Component { Id = "axis-1", Name = null }; + var json = new JsonComponent(source).ToString(); + using var doc = JsonDocument.Parse(json); + Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.False, + "JSON-cppagent Probe Component must omit 'name' when source Name is null"); + } + + [Test] + public void Constructor_with_empty_Name_source_does_not_serialize_name_key() + { + var source = new Component { Id = "axis-2", Name = string.Empty }; + var json = new JsonComponent(source).ToString(); + using var doc = JsonDocument.Parse(json); + Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.False, + "JSON-cppagent Probe Component must omit 'name' when source Name is empty"); + } + + [Test] + public void Constructor_with_explicit_Name_source_serializes_name_key() + { + var source = new Component { Id = "axis-3", Name = "X" }; + var json = new JsonComponent(source).ToString(); + using var doc = JsonDocument.Parse(json); + Assert.That(doc.RootElement.TryGetProperty("name", out var nameElement), Is.True, + "JSON-cppagent Probe Component must emit 'name' when source Name has a value"); + Assert.That(nameElement.GetString(), Is.EqualTo("X")); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonComponentNameAttributePinTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonComponentNameAttributePinTests.cs new file mode 100644 index 000000000..ca9ff7d5f --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonComponentNameAttributePinTests.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using MTConnect.Devices.Json; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Devices +{ + /// + /// Metadata-level pin for the JSON-cppagent Probe Component `name` contract. + /// Mirrors JsonDataItemNameAttributePinTests — the constructor guard only covers + /// the project's own helper path; metadata-level omission is required so external + /// consumers (dime-connector et al.) that hit the type with a fresh JsonSerializer + /// still see `name` skipped on null. + /// + /// Source authority: + /// - XSD: https://schemas.mtconnect.org/schemas/MTConnectDevices_2.7.xsd — + /// Component `@name` is `use="optional"`. + /// - cppagent reference: lib/mtconnect/printer/json_printer_helper.hpp omits + /// absent optionals. + /// - Public defect tracker: https://github.com/TrakHound/MTConnect.NET/issues/138. + /// - CONVENTIONS §15: tests on serialization contracts cite spec + reference impl. + /// + [TestFixture] + [Category("DataItemNameOmissionWhenUnsetOrEmpty")] + public class JsonComponentNameAttributePinTests + { + [Test] + public void Name_property_carries_JsonIgnore_WhenWritingNull_attribute() + { + var prop = typeof(JsonComponent).GetProperty(nameof(JsonComponent.Name)); + var attr = prop?.GetCustomAttribute(); + Assert.That(attr, Is.Not.Null, + "JsonComponent.Name must carry [JsonIgnore] so raw System.Text.Json serialisation omits the property when null."); + Assert.That(attr!.Condition, Is.EqualTo(JsonIgnoreCondition.WhenWritingNull), + "JsonComponent.Name [JsonIgnore] condition must be WhenWritingNull."); + } + + [Test] + public void Raw_System_Text_Json_serialise_omits_name_when_null() + { + var dto = new JsonComponent { Id = "x", Name = null }; + var json = JsonSerializer.Serialize(dto); + using var doc = JsonDocument.Parse(json); + Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.False, + "Raw System.Text.Json (no JsonFunctions.DefaultOptions) must still omit 'name' when null on JsonComponent."); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonCompositionEmptyNameOmissionTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonCompositionEmptyNameOmissionTests.cs new file mode 100644 index 000000000..c03a94dae --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonCompositionEmptyNameOmissionTests.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Text.Json; +using MTConnect.Devices; +using MTConnect.Devices.Json; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Devices +{ + /// + /// Pins the JSON-cppagent Probe Composition behavior: the `name` JSON property + /// must be omitted when the source IComposition.Name is null or empty, and + /// emitted with the original value otherwise. Same rationale as the sibling + /// DataItem and Component fixtures — every optional NameType slot in the v2.7 + /// Devices XSD is `use="optional"` and the reference cppagent printer omits + /// the attribute when the model-side value is null. + /// + /// Source authority: + /// - XSD: https://schemas.mtconnect.org/schemas/MTConnectDevices_2.7.xsd — + /// Composition `name` is declared `use="optional"`. + /// - Reference shape: libraries/MTConnect.NET-XML/Devices/XmlComposition.cs already + /// guards the XML attribute write with `string.IsNullOrEmpty(composition.Name)`. + /// - Public defect tracker: https://github.com/TrakHound/MTConnect.NET/issues/138. + /// + [TestFixture] + [Category("DataItemNameOmissionWhenUnsetOrEmpty")] + public class JsonCompositionEmptyNameOmissionTests + { + [Test] + public void Constructor_with_null_Name_source_does_not_serialize_name_key() + { + var source = new Composition { Id = "comp-1", Type = "ELECTRIC_MOTOR", Name = null }; + var json = new JsonComposition(source).ToString(); + using var doc = JsonDocument.Parse(json); + Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.False, + "JSON-cppagent Probe Composition must omit 'name' when source Name is null"); + } + + [Test] + public void Constructor_with_empty_Name_source_does_not_serialize_name_key() + { + var source = new Composition { Id = "comp-2", Type = "ELECTRIC_MOTOR", Name = string.Empty }; + var json = new JsonComposition(source).ToString(); + using var doc = JsonDocument.Parse(json); + Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.False, + "JSON-cppagent Probe Composition must omit 'name' when source Name is empty"); + } + + [Test] + public void Constructor_with_explicit_Name_source_serializes_name_key() + { + var source = new Composition { Id = "comp-3", Type = "ELECTRIC_MOTOR", Name = "Spindle Motor" }; + var json = new JsonComposition(source).ToString(); + using var doc = JsonDocument.Parse(json); + Assert.That(doc.RootElement.TryGetProperty("name", out var nameElement), Is.True, + "JSON-cppagent Probe Composition must emit 'name' when source Name has a value"); + Assert.That(nameElement.GetString(), Is.EqualTo("Spindle Motor")); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonCompositionNameAttributePinTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonCompositionNameAttributePinTests.cs new file mode 100644 index 000000000..4555e21ea --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonCompositionNameAttributePinTests.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using MTConnect.Devices.Json; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Devices +{ + /// + /// Metadata-level pin for the JSON-cppagent Probe Composition `name` contract. + /// Mirrors JsonDataItemNameAttributePinTests — the constructor guard only covers + /// the project's own helper path; metadata-level omission is required so external + /// consumers that hit the type with a fresh JsonSerializer still see `name` + /// skipped on null. + /// + /// Source authority: + /// - XSD: https://schemas.mtconnect.org/schemas/MTConnectDevices_2.7.xsd — + /// Composition `@name` is `use="optional"`. + /// - cppagent reference: lib/mtconnect/printer/json_printer_helper.hpp omits + /// absent optionals. + /// - Public defect tracker: https://github.com/TrakHound/MTConnect.NET/issues/138. + /// - CONVENTIONS §15: tests on serialization contracts cite spec + reference impl. + /// + [TestFixture] + [Category("DataItemNameOmissionWhenUnsetOrEmpty")] + public class JsonCompositionNameAttributePinTests + { + [Test] + public void Name_property_carries_JsonIgnore_WhenWritingNull_attribute() + { + var prop = typeof(JsonComposition).GetProperty(nameof(JsonComposition.Name)); + var attr = prop?.GetCustomAttribute(); + Assert.That(attr, Is.Not.Null, + "JsonComposition.Name must carry [JsonIgnore] so raw System.Text.Json serialisation omits the property when null."); + Assert.That(attr!.Condition, Is.EqualTo(JsonIgnoreCondition.WhenWritingNull), + "JsonComposition.Name [JsonIgnore] condition must be WhenWritingNull."); + } + + [Test] + public void Raw_System_Text_Json_serialise_omits_name_when_null() + { + var dto = new JsonComposition { Id = "x", Type = "T", Name = null }; + var json = JsonSerializer.Serialize(dto); + using var doc = JsonDocument.Parse(json); + Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.False, + "Raw System.Text.Json (no JsonFunctions.DefaultOptions) must still omit 'name' when null on JsonComposition."); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDataItemEmptyNameOmissionTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDataItemEmptyNameOmissionTests.cs new file mode 100644 index 000000000..f4a39c3b1 --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDataItemEmptyNameOmissionTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Text.Json; +using MTConnect.Devices; +using MTConnect.Devices.DataItems; +using MTConnect.Devices.Json; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Devices +{ + /// + /// Pins the JSON-cppagent Probe DataItem behavior: the `name` JSON property must be + /// omitted when the source IDataItem.Name is null or empty, and emitted with the + /// original value otherwise. + /// + /// Source authority: + /// - XSD: https://schemas.mtconnect.org/schemas/MTConnectDevices_2.5.xsd — + /// `DataItem/@name` is declared `use="optional"`. An optional attribute with no + /// value must be omitted from the wire, not emitted as an empty placeholder. + /// - Reference shape: libraries/MTConnect.NET-XML/Devices/XmlDataItem.cs already + /// guards the XML attribute write with `string.IsNullOrEmpty(dataItem.Name)`. + /// - Public defect tracker: https://github.com/TrakHound/MTConnect.NET/issues/138. + /// + [TestFixture] + [Category("DataItemNameOmissionWhenUnsetOrEmpty")] + public class JsonDataItemEmptyNameOmissionTests + { + [Test] + public void Constructor_with_null_Name_source_does_not_serialize_name_key() + { + var source = new DataItem + { + Id = "item-1", + Type = "TEMPERATURE", + Category = DataItemCategory.SAMPLE, + Name = null + }; + + var json = new JsonDataItem(source).ToString(); + using var doc = JsonDocument.Parse(json); + + Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.False, + "JSON-cppagent Probe DataItem must omit 'name' when source Name is null"); + } + + [Test] + public void Constructor_with_empty_Name_source_does_not_serialize_name_key() + { + var source = new DataItem + { + Id = "item-2", + Type = "TEMPERATURE", + Category = DataItemCategory.SAMPLE, + Name = string.Empty + }; + + var json = new JsonDataItem(source).ToString(); + using var doc = JsonDocument.Parse(json); + + Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.False, + "JSON-cppagent Probe DataItem must omit 'name' when source Name is empty"); + } + + [Test] + public void Constructor_with_explicit_Name_source_serializes_name_key() + { + var source = new DataItem + { + Id = "item-3", + Type = "TEMPERATURE", + Category = DataItemCategory.SAMPLE, + Name = "temp" + }; + + var json = new JsonDataItem(source).ToString(); + using var doc = JsonDocument.Parse(json); + + Assert.That(doc.RootElement.TryGetProperty("name", out var nameElement), Is.True, + "JSON-cppagent Probe DataItem must emit 'name' when source Name has a value"); + Assert.That(nameElement.GetString(), Is.EqualTo("temp")); + } + + [Test] + public void Constructor_with_typed_DataItem_unset_Name_does_not_serialize_name_key() + { + // TemperatureDataItem default ctor sets Name = NameId ("temp"); a caller + // that wants to clear it can assign an empty string. Confirm the wire + // honors the cleared state. + var source = new TemperatureDataItem + { + Id = "item-4", + Name = string.Empty + }; + + var json = new JsonDataItem(source).ToString(); + using var doc = JsonDocument.Parse(json); + + Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.False, + "Cleared Name on a typed DataItem must produce no 'name' key"); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDataItemNameAttributePinTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDataItemNameAttributePinTests.cs new file mode 100644 index 000000000..bd10ef428 --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDataItemNameAttributePinTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using MTConnect.Devices.Json; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Devices +{ + /// + /// Metadata-level pin for the JSON-cppagent Probe DataItem `name` contract. + /// + /// The constructor-side guard (`if (!string.IsNullOrEmpty(name)) Name = name`) only + /// covers the runtime path through MTConnect.NET's own JSON helper which already + /// ignores defaulted strings via `JsonFunctions.DefaultOptions`. Down-stream + /// consumers (dime-connector, third-party code) that probe the property metadata + /// directly — or hand the DTO to a fresh `JsonSerializer` instance with no project + /// options — must still see the property omitted when null. The + /// `[JsonIgnore(Condition = WhenWritingNull)]` attribute pins that contract on the + /// type itself so it travels with the DTO. + /// + /// Source authority: + /// - XSD: https://schemas.mtconnect.org/schemas/MTConnectDevices_2.7.xsd — + /// `DataItem/@name` is declared `use="optional"`. An optional attribute with no + /// value must be omitted from the wire. + /// - cppagent reference: lib/mtconnect/printer/json_printer_helper.hpp — the printer + /// skips fields whose underlying optional has no value, never emits empty. + /// - Public defect tracker: https://github.com/TrakHound/MTConnect.NET/issues/138. + /// - CONVENTIONS §15: tests on serialization contracts cite spec + reference impl. + /// + [TestFixture] + [Category("DataItemNameOmissionWhenUnsetOrEmpty")] + public class JsonDataItemNameAttributePinTests + { + [Test] + public void Name_property_carries_JsonIgnore_WhenWritingNull_attribute() + { + var prop = typeof(JsonDataItem).GetProperty(nameof(JsonDataItem.Name)); + var attr = prop?.GetCustomAttribute(); + Assert.That(attr, Is.Not.Null, + "JsonDataItem.Name must carry [JsonIgnore] so raw System.Text.Json serialisation omits the property when null."); + Assert.That(attr!.Condition, Is.EqualTo(JsonIgnoreCondition.WhenWritingNull), + "JsonDataItem.Name [JsonIgnore] condition must be WhenWritingNull."); + } + + [Test] + public void Raw_System_Text_Json_serialise_omits_name_when_null() + { + var dto = new JsonDataItem { Id = "x", Type = "T", Name = null }; + var json = JsonSerializer.Serialize(dto); + using var doc = JsonDocument.Parse(json); + Assert.That(doc.RootElement.TryGetProperty("name", out _), Is.False, + "Raw System.Text.Json (no JsonFunctions.DefaultOptions) must still omit 'name' when null on JsonDataItem."); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDataItemRelationshipCategorizationTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDataItemRelationshipCategorizationTests.cs new file mode 100644 index 000000000..11eac969c --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDataItemRelationshipCategorizationTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Collections.Generic; +using MTConnect.Devices; +using MTConnect.Devices.Json; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Devices +{ + /// + /// Pins the contract that + /// 's relationship classifier routes each + /// IAbstractDataItemRelationship instance into the matching + /// container bucket on : + /// - -> DataItemRelationships + /// - -> SpecificationRelationships + /// + /// Lets the relationship-classifier perf optimization replace the + /// IsAssignableFrom chain with switch pattern matching + /// without regressing the routing behavior. The Component- and + /// Device-relationship branches in the existing classifier are + /// preserved structurally even though those types do not implement + /// IAbstractDataItemRelationship and so cannot reach the + /// loop body via DataItem.Relationships. + /// + [TestFixture] + public class JsonDataItemRelationshipCategorizationTests + { + [Test] + public void Both_abstract_relationship_kinds_route_to_their_matching_container_bucket() + { + var dataItem = new DataItemRelationship { Name = "di" }; + var spec = new SpecificationRelationship { Name = "spec" }; + + var source = new DataItem + { + Id = "id1", + Type = "TEST", + Relationships = new List + { + dataItem, + spec, + }, + }; + + var json = new JsonDataItem(source); + + Assert.That(json.Relationships, Is.Not.Null, + "JsonDataItem must populate Relationships when the source has any."); + Assert.That(json.Relationships.DataItemRelationships, Has.Count.EqualTo(1)); + Assert.That(json.Relationships.SpecificationRelationships, Has.Count.EqualTo(1)); + Assert.That(json.Relationships.ComponentRelationships, Is.Null); + Assert.That(json.Relationships.DeviceRelationships, Is.Null); + } + + [Test] + public void Empty_relationships_collection_leaves_container_null() + { + var source = new DataItem + { + Id = "id1", + Type = "TEST", + Relationships = new List(), + }; + + var json = new JsonDataItem(source); + + Assert.That(json.Relationships, Is.Null, + "JsonDataItem must skip the relationship container when the source has none."); + } + + [Test] + public void Multiple_relationships_of_same_kind_aggregate_in_their_bucket() + { + var first = new DataItemRelationship { Name = "d1" }; + var second = new DataItemRelationship { Name = "d2" }; + var source = new DataItem + { + Id = "id1", + Type = "TEST", + Relationships = new List { first, second }, + }; + + var json = new JsonDataItem(source); + + Assert.That(json.Relationships, Is.Not.Null); + Assert.That(json.Relationships.DataItemRelationships, Has.Count.EqualTo(2)); + Assert.That(json.Relationships.SpecificationRelationships, Is.Null); + Assert.That(json.Relationships.ComponentRelationships, Is.Null); + Assert.That(json.Relationships.DeviceRelationships, Is.Null); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDataItemSourceGuardTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDataItemSourceGuardTests.cs new file mode 100644 index 000000000..fd0844a88 --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDataItemSourceGuardTests.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.IO; +using System.Text.RegularExpressions; +using MTConnect.NET_JSON_cppagent_Tests.TestHelpers; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Devices +{ + /// + /// Source-grep regression guard. Ensures the JSON `JsonDataItem(IDataItem)` + /// constructor in either the cppagent or the base JSON formatter does not + /// regress to an unconditional `Name = dataItem.Name;` copy. + /// + /// Source authority: + /// - Reference shape: libraries/MTConnect.NET-XML/Devices/XmlDataItem.cs + /// guards the `name` write with `string.IsNullOrEmpty`. + /// - XSD: https://schemas.mtconnect.org/schemas/MTConnectDevices_2.5.xsd — + /// `DataItem/@name` is `use="optional"`. + /// - Public defect tracker: https://github.com/TrakHound/MTConnect.NET/issues/138. + /// + [TestFixture] + public class JsonDataItemSourceGuardTests + { + private static readonly string[] WatchedSources = + { + "libraries/MTConnect.NET-JSON-cppagent/Devices/JsonDataItem.cs", + "libraries/MTConnect.NET-JSON/Devices/JsonDataItem.cs" + }; + + [Test] + public void JsonDataItem_constructors_must_not_copy_Name_unconditionally() + { + var repoRoot = RepoRootLocator.LocateRoot(); + var unguardedCopy = new Regex(@"(?m)^\s*Name\s*=\s*dataItem\.Name\s*;\s*$"); + + foreach (var relativePath in WatchedSources) + { + var fullPath = Path.Combine(repoRoot, relativePath); + Assert.That(File.Exists(fullPath), Is.True, $"Watched source not found: {fullPath}"); + + var src = File.ReadAllText(fullPath); + Assert.That(unguardedCopy.IsMatch(src), Is.False, + $"{relativePath}: Name copy must be guarded by string.IsNullOrEmpty " + + "to avoid emitting an empty 'name' attribute on Probe DataItems."); + } + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDevicesResponseDocumentNameOmissionE2ETests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDevicesResponseDocumentNameOmissionE2ETests.cs new file mode 100644 index 000000000..9a74e35ad --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDevicesResponseDocumentNameOmissionE2ETests.cs @@ -0,0 +1,124 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using MTConnect.Devices; +using MTConnect.Devices.Components; +using MTConnect.Devices.DataItems; +using MTConnect.Devices.Json; +using MTConnect.Headers; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Devices +{ + /// + /// End-to-end serialization pin: constructs a programmatic Device with a mix + /// of named and unnamed DataItems, runs it through the full + /// `JsonDevicesResponseDocument` serializer (the cppagent Probe response wire + /// path), and asserts the JSON output omits the `name` key on the unnamed + /// items and keeps it on the named ones. This exercises the surface a real + /// MQTT/HTTP agent goes through, with the runtime serializer options applied. + /// + /// Source authority: + /// - XSD: https://schemas.mtconnect.org/schemas/MTConnectDevices_2.5.xsd — + /// `DataItem/@name` is `use="optional"`. The Probe response document MUST + /// omit the attribute when no value is supplied. + /// - Public defect tracker: https://github.com/TrakHound/MTConnect.NET/issues/138. + /// + [TestFixture] + public class JsonDevicesResponseDocumentNameOmissionE2ETests + { + [Test] + public void Probe_response_omits_name_on_unnamed_dataitems_keeps_it_on_named() + { + // Build a Device with one Heating component containing two + // Temperature DataItems: one cleared name, one explicit name. + var heating = new HeatingComponent { Name = "MainController" }; + heating.AddDataItem(new TemperatureDataItem("dev") + { + Id = "t1", + Name = string.Empty, + Units = "CELSIUS" + }); + heating.AddDataItem(new TemperatureDataItem("dev") + { + Id = "t2", + Name = "temp", + Units = "CELSIUS" + }); + + var device = new Device + { + Id = "dev", + Name = "ExampleDevice", + Uuid = "ExampleDevice" + }; + // Attach the Heating component directly to the device's + // Components collection rather than via `device.AddComponent`. + // The latter auto-wraps any component whose `TypeId` is in + // `Organizers.GetOrganizerType` (e.g. `Heating` -> + // `Systems`); whether `Heating` is wrapped depends on the + // production library's `Organizers.Systems` membership, + // which evolves with the SysML model. This fixture pins the + // wire-shape `name`-omission contract on a `DataItem` — + // independent of where the Heating component lives in the + // organizer tree — by bypassing the auto-wrap explicitly. + device.Components = new List { heating }; + + var document = new DevicesResponseDocument + { + Header = new MTConnectDevicesHeader { Version = "6.9.0" }, + Devices = new List { device } + }; + + var json = JsonFunctions.Convert(new JsonDevicesResponseDocument(document)); + Assert.That(json, Is.Not.Null); + using var doc = JsonDocument.Parse(json!); + + // Drill into the Heating component's DataItems. The cppagent JSON + // shape wraps Device and Component arrays in named container + // objects: `Devices.Device[]`, `Components.Heating[]`, + // `DataItems.DataItem[]`. The fixture above attaches Heating + // directly under `Device.Components`, so `Components.Heating[0]` + // is the deterministic path regardless of organizer membership. + var heatingDataItems = doc.RootElement + .GetProperty("MTConnectDevices") + .GetProperty("Devices") + .GetProperty("Device")[0] + .GetProperty("Components") + .GetProperty("Heating")[0] + .GetProperty("DataItems") + .GetProperty("DataItem"); + + // The component contains exactly two DataItems; one is the cleared + // name (no `name` key on the wire), the other is the explicit + // `name = "temp"`. + Assert.That(heatingDataItems.GetArrayLength(), Is.EqualTo(2)); + + // Identify the items by the presence/value of the `name` key. + JsonElement clearedItem = default; + JsonElement explicitItem = default; + foreach (var item in heatingDataItems.EnumerateArray()) + { + if (item.TryGetProperty("name", out var nameElement)) + explicitItem = item; + else + clearedItem = item; + } + + Assert.That(clearedItem.ValueKind, Is.EqualTo(JsonValueKind.Object), + "Expected one DataItem without a 'name' key"); + Assert.That(clearedItem.GetProperty("type").GetString(), Is.EqualTo("TEMPERATURE")); + + Assert.That(explicitItem.ValueKind, Is.EqualTo(JsonValueKind.Object), + "Expected one DataItem with a 'name' key"); + Assert.That(explicitItem.GetProperty("name").GetString(), Is.EqualTo("temp")); + + // Wire-shape regression check: no DataItem object in the document + // exposes the bug's `"name": ""` placeholder. + Assert.That(json, Does.Not.Contain("\"name\":\"\"")); + } + + } +} diff --git a/tests/MTConnect.NET-XML-Tests/Devices/XmlDataItemEmptyNameOmissionTests.cs b/tests/MTConnect.NET-XML-Tests/Devices/XmlDataItemEmptyNameOmissionTests.cs new file mode 100644 index 000000000..c3c4621d0 --- /dev/null +++ b/tests/MTConnect.NET-XML-Tests/Devices/XmlDataItemEmptyNameOmissionTests.cs @@ -0,0 +1,87 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.IO; +using System.Text; +using System.Xml; +using MTConnect.Devices; +using MTConnect.Devices.DataItems; +using MTConnect.Devices.Xml; +using NUnit.Framework; + +namespace MTConnect.Tests.XML.Devices +{ + /// + /// Parity pin: the XML formatter is the reference shape for the JSON-cppagent + /// fix on the `name` attribute (see https://github.com/TrakHound/MTConnect.NET/issues/138). + /// This fixture confirms `XmlDataItem.WriteXml` already omits the `name` + /// attribute when the source `IDataItem.Name` is null or empty, and emits it + /// otherwise. + /// + /// Source authority: + /// - XSD: https://schemas.mtconnect.org/schemas/MTConnectDevices_2.5.xsd — + /// `DataItem/@name` is `use="optional"`. An optional attribute with no + /// value must be omitted from the wire, not emitted as `name=""`. + /// + [TestFixture] + public class XmlDataItemEmptyNameOmissionTests + { + [Test] + public void Xml_formatter_omits_name_attribute_when_source_Name_is_null() + { + var source = new DataItem + { + Id = "item-1", + Type = "TEMPERATURE", + Category = DataItemCategory.SAMPLE, + Name = null + }; + + var xml = WriteXml(source); + Assert.That(xml, Does.Not.Contain("name=\"\"")); + Assert.That(xml, Does.Not.Contain("name=")); + } + + [Test] + public void Xml_formatter_omits_name_attribute_when_source_Name_is_empty() + { + var source = new DataItem + { + Id = "item-2", + Type = "TEMPERATURE", + Category = DataItemCategory.SAMPLE, + Name = string.Empty + }; + + var xml = WriteXml(source); + Assert.That(xml, Does.Not.Contain("name=\"\"")); + Assert.That(xml, Does.Not.Contain("name=")); + } + + [Test] + public void Xml_formatter_emits_name_attribute_when_source_Name_is_set() + { + var source = new DataItem + { + Id = "item-3", + Type = "TEMPERATURE", + Category = DataItemCategory.SAMPLE, + Name = "temp" + }; + + var xml = WriteXml(source); + Assert.That(xml, Does.Contain("name=\"temp\"")); + } + + private static string WriteXml(IDataItem dataItem) + { + var settings = new XmlWriterSettings { OmitXmlDeclaration = true }; + var sb = new StringBuilder(); + using (var writer = XmlWriter.Create(sb, settings)) + { + XmlDataItem.WriteXml(writer, dataItem); + } + return sb.ToString(); + } + } +}