diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs index 8a807c379..7e455f4c6 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs @@ -1112,54 +1112,76 @@ private Device NormalizeDevice(IDevice device) obj.Compositions = NormalizeCompositions(device.Compositions, obj, obj); obj.Components = NormalizeComponents(device.Components, obj, obj); + // Required DataItem backfill: enumerate `obj.DataItems` once + // into a List and project the type set into a HashSet + // so each required-type lookup is O(1) instead of O(n) and we + // avoid the per-required-type ToList() allocation. Preserves the + // existing behavior pinned by NormalizeDeviceRequiredDataItemsTests. + var dataItemList = obj.DataItems != null + ? new List(obj.DataItems) + : new List(); +#if NET472_OR_GREATER || NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_0_OR_GREATER + var dataItemTypes = new HashSet(dataItemList.Count); +#else + var dataItemTypes = new HashSet(); +#endif + for (var i = 0; i < dataItemList.Count; i++) + { + var t = dataItemList[i]?.Type; + if (t != null) dataItemTypes.Add(t); + } + // Add Required Availability DataItem - if (obj.DataItems.IsNullOrEmpty() || !obj.DataItems.Any(o => o.Type == AvailabilityDataItem.TypeId)) + if (!dataItemTypes.Contains(AvailabilityDataItem.TypeId)) { var availability = new AvailabilityDataItem(obj.Id); availability.Device = obj; availability.Container = obj; availability.Name = AvailabilityDataItem.NameId; - var x = obj.DataItems.ToList(); - x.Add(availability); - obj.DataItems = x; + dataItemList.Add(availability); + dataItemTypes.Add(AvailabilityDataItem.TypeId); } // Add Required AssetChanged DataItem - if (obj.DataItems.IsNullOrEmpty() || !obj.DataItems.Any(o => o.Type == AssetChangedDataItem.TypeId)) + if (!dataItemTypes.Contains(AssetChangedDataItem.TypeId)) { var assetChanged = new AssetChangedDataItem(obj.Id); assetChanged.Device = obj; assetChanged.Container = obj; assetChanged.Name = AssetChangedDataItem.NameId; - var x = obj.DataItems.ToList(); - x.Add(assetChanged); - obj.DataItems = x; + dataItemList.Add(assetChanged); + dataItemTypes.Add(AssetChangedDataItem.TypeId); } // Add Required AssetRemoved DataItem - if (obj.DataItems.IsNullOrEmpty() || !obj.DataItems.Any(o => o.Type == AssetRemovedDataItem.TypeId)) + if (!dataItemTypes.Contains(AssetRemovedDataItem.TypeId)) { var assetRemoved = new AssetRemovedDataItem(obj.Id); assetRemoved.Device = obj; assetRemoved.Container = obj; assetRemoved.Name = AssetRemovedDataItem.NameId; - var x = obj.DataItems.ToList(); - x.Add(assetRemoved); - obj.DataItems = x; + dataItemList.Add(assetRemoved); + dataItemTypes.Add(AssetRemovedDataItem.TypeId); } // Add Required AssetCount DataItem - if (obj.DataItems.IsNullOrEmpty() || !obj.DataItems.Any(o => o.Type == AssetCountDataItem.TypeId)) + if (!dataItemTypes.Contains(AssetCountDataItem.TypeId)) { var assetcount = new AssetCountDataItem(obj.Id); assetcount.Device = obj; assetcount.Container = obj; assetcount.Name = AssetCountDataItem.NameId; - var x = obj.DataItems.ToList(); - x.Add(assetcount); - obj.DataItems = x; + // ASSET_COUNT is a DATA_SET representation per MTConnect Part 2 + // (UML _19_0_3_68e0225_1640602520420_217627_44). The generated + // AssetCountDataItem still defaults Representation to VALUE; override + // it here so the auto-injected DataItem matches the spec. + assetcount.Representation = DataItemRepresentation.DATA_SET; + dataItemList.Add(assetcount); + dataItemTypes.Add(AssetCountDataItem.TypeId); } + obj.DataItems = dataItemList; + // Generic Components var genericComponents = obj.GetComponents()?.Where(o => o.GetType() == typeof(Component)); diff --git a/libraries/MTConnect.NET-Common/Headers/IMTConnectAssestsHeader.cs b/libraries/MTConnect.NET-Common/Headers/IMTConnectAssestsHeader.cs index 0fe33725d..9fb4d7500 100644 --- a/libraries/MTConnect.NET-Common/Headers/IMTConnectAssestsHeader.cs +++ b/libraries/MTConnect.NET-Common/Headers/IMTConnectAssestsHeader.cs @@ -22,6 +22,12 @@ public interface IMTConnectAssetsHeader /// string Version { get; } + /// + /// The major and minor number of the MTConnect Standard schema the Response Document conforms to (for example "2.7"). + /// Mirrors the cppagent v2 wire shape that emits `schemaVersion` on every Header. + /// + string SchemaVersion { get; } + /// /// An identification defining where the Agent that published the Response Document is installed or hosted. /// diff --git a/libraries/MTConnect.NET-Common/Headers/IMTConnectDevicesHeader.cs b/libraries/MTConnect.NET-Common/Headers/IMTConnectDevicesHeader.cs index b9103c589..46335ff99 100644 --- a/libraries/MTConnect.NET-Common/Headers/IMTConnectDevicesHeader.cs +++ b/libraries/MTConnect.NET-Common/Headers/IMTConnectDevicesHeader.cs @@ -22,6 +22,12 @@ public interface IMTConnectDevicesHeader /// string Version { get; } + /// + /// The major and minor number of the MTConnect Standard schema the Response Document conforms to (for example "2.7"). + /// Mirrors the cppagent v2 wire shape that emits `schemaVersion` on every Header. + /// + string SchemaVersion { get; } + /// /// An identification defining where the Agent that published the Response Document is installed or hosted. /// diff --git a/libraries/MTConnect.NET-Common/Headers/IMTConnectStreamsHeader.cs b/libraries/MTConnect.NET-Common/Headers/IMTConnectStreamsHeader.cs index b2bac0dd4..eb305f60c 100644 --- a/libraries/MTConnect.NET-Common/Headers/IMTConnectStreamsHeader.cs +++ b/libraries/MTConnect.NET-Common/Headers/IMTConnectStreamsHeader.cs @@ -22,6 +22,12 @@ public interface IMTConnectStreamsHeader /// string Version { get; } + /// + /// The major and minor number of the MTConnect Standard schema the Response Document conforms to (for example "2.7"). + /// Mirrors the cppagent v2 wire shape that emits `schemaVersion` on every Header. + /// + string SchemaVersion { get; } + /// /// An identification defining where the Agent that published the Response Document is installed or hosted. /// diff --git a/libraries/MTConnect.NET-Common/Headers/MTConnectAssestsHeader.cs b/libraries/MTConnect.NET-Common/Headers/MTConnectAssestsHeader.cs index bd098b08e..0df2034d0 100644 --- a/libraries/MTConnect.NET-Common/Headers/MTConnectAssestsHeader.cs +++ b/libraries/MTConnect.NET-Common/Headers/MTConnectAssestsHeader.cs @@ -22,6 +22,12 @@ public class MTConnectAssetsHeader : IMTConnectAssetsHeader /// public string Version { get; set; } + /// + /// The major and minor number of the MTConnect Standard schema the Response Document conforms to (for example "2.7"). + /// Mirrors the cppagent v2 wire shape that emits `schemaVersion` on every Header. + /// + public string SchemaVersion { get; set; } + /// /// An identification defining where the Agent that published the Response Document is installed or hosted. /// diff --git a/libraries/MTConnect.NET-Common/Headers/MTConnectDevicesHeader.cs b/libraries/MTConnect.NET-Common/Headers/MTConnectDevicesHeader.cs index 61426373c..f5c5af650 100644 --- a/libraries/MTConnect.NET-Common/Headers/MTConnectDevicesHeader.cs +++ b/libraries/MTConnect.NET-Common/Headers/MTConnectDevicesHeader.cs @@ -22,6 +22,12 @@ public class MTConnectDevicesHeader : IMTConnectDevicesHeader /// public string Version { get; set; } + /// + /// The major and minor number of the MTConnect Standard schema the Response Document conforms to (for example "2.7"). + /// Mirrors the cppagent v2 wire shape that emits `schemaVersion` on every Header. + /// + public string SchemaVersion { get; set; } + /// /// An identification defining where the Agent that published the Response Document is installed or hosted. /// diff --git a/libraries/MTConnect.NET-Common/Headers/MTConnectStreamsHeader.cs b/libraries/MTConnect.NET-Common/Headers/MTConnectStreamsHeader.cs index 8c6dfd2a5..5109ce080 100644 --- a/libraries/MTConnect.NET-Common/Headers/MTConnectStreamsHeader.cs +++ b/libraries/MTConnect.NET-Common/Headers/MTConnectStreamsHeader.cs @@ -22,6 +22,12 @@ public class MTConnectStreamsHeader : IMTConnectStreamsHeader /// public string Version { get; set; } + /// + /// The major and minor number of the MTConnect Standard schema the Response Document conforms to (for example "2.7"). + /// Mirrors the cppagent v2 wire shape that emits `schemaVersion` on every Header. + /// + public string SchemaVersion { get; set; } + /// /// An identification defining where the Agent that published the Response Document is installed or hosted. /// diff --git a/libraries/MTConnect.NET-JSON-cppagent/Assets/JsonAssetsHeader.cs b/libraries/MTConnect.NET-JSON-cppagent/Assets/JsonAssetsHeader.cs index ed5e2ae79..8e5c188ac 100644 --- a/libraries/MTConnect.NET-JSON-cppagent/Assets/JsonAssetsHeader.cs +++ b/libraries/MTConnect.NET-JSON-cppagent/Assets/JsonAssetsHeader.cs @@ -15,6 +15,13 @@ public class JsonAssetsHeader [JsonPropertyName("version")] public string Version { get; set; } + /// + /// The major and minor number of the MTConnect Standard schema the Response Document conforms to (for example "2.7"). + /// Mirrors the cppagent v2 wire shape that emits `schemaVersion` on every Header. + /// + [JsonPropertyName("schemaVersion")] + public string SchemaVersion { get; set; } + [JsonPropertyName("sender")] public string Sender { get; set; } @@ -30,6 +37,13 @@ public class JsonAssetsHeader [JsonPropertyName("testIndicator")] public bool TestIndicator { get; set; } + /// + /// Indicates if the MTConnect Agent is validating against the normative model. + /// Mirrors the cppagent v2 wire shape that emits `validation` on every Header. + /// + [JsonPropertyName("validation")] + public bool Validation { get; set; } + [JsonPropertyName("creationTime")] public DateTime CreationTime { get; set; } @@ -42,11 +56,13 @@ public JsonAssetsHeader(IMTConnectAssetsHeader header) { InstanceId = header.InstanceId; Version = header.Version; + SchemaVersion = header.SchemaVersion; Sender = header.Sender; AssetBufferSize = header.AssetBufferSize; AssetCount = header.AssetCount; DeviceModelChangeTime = header.DeviceModelChangeTime; TestIndicator = header.TestIndicator; + Validation = header.Validation; CreationTime = header.CreationTime; } } @@ -57,11 +73,13 @@ public virtual IMTConnectAssetsHeader ToAssetsHeader() var header = new MTConnectAssetsHeader(); header.InstanceId = InstanceId; header.Version = Version; + header.SchemaVersion = SchemaVersion; header.Sender = Sender; header.AssetBufferSize = AssetBufferSize; header.AssetCount = AssetCount; header.DeviceModelChangeTime = DeviceModelChangeTime; header.TestIndicator = TestIndicator; + header.Validation = Validation; header.CreationTime = CreationTime; return header; } diff --git a/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonDevicesHeader.cs b/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonDevicesHeader.cs index 3d2a679fd..74bb8bddd 100644 --- a/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonDevicesHeader.cs +++ b/libraries/MTConnect.NET-JSON-cppagent/Devices/JsonDevicesHeader.cs @@ -36,6 +36,13 @@ public class JsonDevicesHeader [JsonPropertyName("testIndicator")] public bool TestIndicator { get; set; } + /// + /// Indicates if the MTConnect Agent is validating against the normative model. + /// Mirrors the cppagent v2 wire shape that emits `validation` on every Header. + /// + [JsonPropertyName("validation")] + public bool Validation { get; set; } + [JsonPropertyName("creationTime")] public DateTime CreationTime { get; set; } @@ -48,12 +55,14 @@ public JsonDevicesHeader(IMTConnectDevicesHeader header) { InstanceId = header.InstanceId; Version = header.Version; + SchemaVersion = header.SchemaVersion; Sender = header.Sender; BufferSize = header.BufferSize; AssetBufferSize = header.AssetBufferSize; AssetCount = header.AssetCount; DeviceModelChangeTime = header.DeviceModelChangeTime; TestIndicator = header.TestIndicator; + Validation = header.Validation; CreationTime = header.CreationTime; } } @@ -64,12 +73,14 @@ public IMTConnectDevicesHeader ToDevicesHeader() var header = new MTConnectDevicesHeader(); header.InstanceId = InstanceId; header.Version = Version; + header.SchemaVersion = SchemaVersion; header.Sender = Sender; header.BufferSize = BufferSize; header.AssetBufferSize = AssetBufferSize; header.AssetCount = AssetCount; header.DeviceModelChangeTime = DeviceModelChangeTime; header.TestIndicator = TestIndicator; + header.Validation = Validation; header.CreationTime = CreationTime; return header; } diff --git a/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonStreamsHeader.cs b/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonStreamsHeader.cs index c2ee22557..8aea7a4f5 100644 --- a/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonStreamsHeader.cs +++ b/libraries/MTConnect.NET-JSON-cppagent/Streams/JsonStreamsHeader.cs @@ -15,6 +15,13 @@ public class JsonStreamsHeader [JsonPropertyName("version")] public string Version { get; set; } + /// + /// The major and minor number of the MTConnect Standard schema the Response Document conforms to (for example "2.7"). + /// Mirrors the cppagent v2 wire shape that emits `schemaVersion` on every Header. + /// + [JsonPropertyName("schemaVersion")] + public string SchemaVersion { get; set; } + [JsonPropertyName("sender")] public string Sender { get; set; } @@ -36,6 +43,13 @@ public class JsonStreamsHeader [JsonPropertyName("testIndicator")] public bool TestIndicator { get; set; } + /// + /// Indicates if the MTConnect Agent is validating against the normative model. + /// Mirrors the cppagent v2 wire shape that emits `validation` on every Header. + /// + [JsonPropertyName("validation")] + public bool Validation { get; set; } + [JsonPropertyName("creationTime")] public DateTime CreationTime { get; set; } @@ -48,6 +62,7 @@ public JsonStreamsHeader(IMTConnectStreamsHeader header) { InstanceId = header.InstanceId; Version = header.Version; + SchemaVersion = header.SchemaVersion; Sender = header.Sender; BufferSize = header.BufferSize; FirstSequence = header.FirstSequence; @@ -55,6 +70,7 @@ public JsonStreamsHeader(IMTConnectStreamsHeader header) NextSequence = header.NextSequence; DeviceModelChangeTime = header.DeviceModelChangeTime; TestIndicator = header.TestIndicator; + Validation = header.Validation; CreationTime = header.CreationTime; } } @@ -65,6 +81,7 @@ public virtual IMTConnectStreamsHeader ToStreamsHeader() var header = new MTConnectStreamsHeader(); header.InstanceId = InstanceId; header.Version = Version; + header.SchemaVersion = SchemaVersion; header.Sender = Sender; header.BufferSize = BufferSize; header.FirstSequence = FirstSequence; @@ -72,6 +89,7 @@ public virtual IMTConnectStreamsHeader ToStreamsHeader() header.NextSequence = NextSequence; header.DeviceModelChangeTime = DeviceModelChangeTime; header.TestIndicator = TestIndicator; + header.Validation = Validation; header.CreationTime = CreationTime; return header; } diff --git a/tests/MTConnect.NET-Common-Tests/Agents/NormalizeDeviceRequiredDataItemsTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/NormalizeDeviceRequiredDataItemsTests.cs new file mode 100644 index 000000000..000796346 --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Agents/NormalizeDeviceRequiredDataItemsTests.cs @@ -0,0 +1,112 @@ +// 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.Linq; +using MTConnect.Agents; +using MTConnect.Devices; +using MTConnect.Devices.DataItems; +using NUnit.Framework; + +namespace MTConnect.Tests.Common.Agents +{ + /// + /// Pins the contract that backfills + /// the four required Device-level DataItems exactly once each, regardless + /// of the starting state of device.DataItems: + /// - Availability + /// - AssetChanged + /// - AssetRemoved + /// - AssetCount + /// + /// Lets the inner-loop perf optimization (cast DataItems once and use a + /// HashSet for type-id checks) refactor without regressing behavior. + /// + [TestFixture] + public class NormalizeDeviceRequiredDataItemsTests + { + private static readonly string[] RequiredTypeIds = + { + AvailabilityDataItem.TypeId, + AssetChangedDataItem.TypeId, + AssetRemovedDataItem.TypeId, + AssetCountDataItem.TypeId, + }; + + private static IEnumerable? StartingDataItemsForCase(string startingState) + { + return startingState switch + { + "null" => null, + "empty" => new List(), + "with_availability" => new List + { + new AvailabilityDataItem("dev1"), + }, + "with_all_required" => new List + { + new AvailabilityDataItem("dev1"), + new AssetChangedDataItem("dev1"), + new AssetRemovedDataItem("dev1"), + new AssetCountDataItem("dev1"), + }, + _ => null, + }; + } + + [TestCase("null")] + [TestCase("empty")] + [TestCase("with_availability")] + [TestCase("with_all_required")] + public void AddDevice_backfills_all_required_dataItems_exactly_once(string startingState) + { + using var agent = new MTConnectAgent(uuid: "test-agent", initializeAgentDevice: false); + var device = new Device + { + Id = "dev1", + Uuid = "dev1-uuid", + Name = "dev1", + Type = Device.TypeId, + DataItems = StartingDataItemsForCase(startingState), + }; + + var added = agent.AddDevice(device, initializeDataItems: false); + + Assert.That(added, Is.Not.Null, "AddDevice must return the normalized device."); + Assert.That(added.DataItems, Is.Not.Null, "Normalized DataItems must not be null after backfill."); + + var types = added.DataItems!.Select(d => d.Type).ToList(); + foreach (var requiredType in RequiredTypeIds) + { + Assert.That(types.Count(t => t == requiredType), Is.EqualTo(1), + $"Required DataItem type '{requiredType}' must appear exactly once after AddDevice."); + } + } + + [Test] + public void AddDevice_preserves_user_provided_dataItems_alongside_required_ones() + { + using var agent = new MTConnectAgent(uuid: "test-agent", initializeAgentDevice: false); + var custom = new DataItem(DataItemCategory.EVENT, "PROGRAM", null, "dev1-program"); + var device = new Device + { + Id = "dev1", + Uuid = "dev1-uuid", + Name = "dev1", + Type = Device.TypeId, + DataItems = new List { custom }, + }; + + var added = agent.AddDevice(device, initializeDataItems: false); + + Assert.That(added!.DataItems, Is.Not.Null); + Assert.That(added.DataItems!.Any(d => d.Id == "dev1-program"), Is.True, + "User-provided DataItems must survive the required-DataItem backfill."); + foreach (var requiredType in RequiredTypeIds) + { + Assert.That(added.DataItems!.Any(d => d.Type == requiredType), Is.True, + $"Required DataItem type '{requiredType}' must still be backfilled when custom DataItems are present."); + } + } + } +} diff --git a/tests/MTConnect.NET-Common-Tests/Headers/HeaderXmlDocExampleVersionTests.cs b/tests/MTConnect.NET-Common-Tests/Headers/HeaderXmlDocExampleVersionTests.cs new file mode 100644 index 000000000..546dbf4e4 --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Headers/HeaderXmlDocExampleVersionTests.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 NUnit.Framework; + +namespace MTConnect.Tests.Common.Headers +{ + /// + /// Pins the SchemaVersion XML-doc example string on the six Header + /// interfaces and classes. The example must reference the current + /// MTConnect Standard release ("2.7") rather than the stale "2.5" + /// snapshot, so consumers reading the IntelliSense don't think + /// the agent only supports an older revision. + /// + /// Files covered (line 27 in each): + /// - libraries/MTConnect.NET-Common/Headers/IMTConnectAssestsHeader.cs + /// - libraries/MTConnect.NET-Common/Headers/IMTConnectDevicesHeader.cs + /// - libraries/MTConnect.NET-Common/Headers/IMTConnectStreamsHeader.cs + /// - libraries/MTConnect.NET-Common/Headers/MTConnectAssestsHeader.cs + /// - libraries/MTConnect.NET-Common/Headers/MTConnectDevicesHeader.cs + /// - libraries/MTConnect.NET-Common/Headers/MTConnectStreamsHeader.cs + /// + [TestFixture] + public class HeaderXmlDocExampleVersionTests + { + private static readonly string[] HeaderFileRelativePaths = + { + "libraries/MTConnect.NET-Common/Headers/IMTConnectAssestsHeader.cs", + "libraries/MTConnect.NET-Common/Headers/IMTConnectDevicesHeader.cs", + "libraries/MTConnect.NET-Common/Headers/IMTConnectStreamsHeader.cs", + "libraries/MTConnect.NET-Common/Headers/MTConnectAssestsHeader.cs", + "libraries/MTConnect.NET-Common/Headers/MTConnectDevicesHeader.cs", + "libraries/MTConnect.NET-Common/Headers/MTConnectStreamsHeader.cs", + }; + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir != null) + { + if (Directory.Exists(Path.Combine(dir.FullName, "libraries")) && + Directory.Exists(Path.Combine(dir.FullName, "tests"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + Assert.Fail("Could not locate repo root from test working directory."); + return string.Empty; + } + + [TestCaseSource(nameof(HeaderFileRelativePaths))] + public void Header_xmldoc_example_does_not_reference_stale_version_2_5(string relativePath) + { + var fullPath = Path.Combine(FindRepoRoot(), relativePath); + Assert.That(File.Exists(fullPath), Is.True, $"Header source file not found at {fullPath}."); + + var text = File.ReadAllText(fullPath); + + Assert.That(text.Contains("(for example \"2.5\")"), Is.False, + $"{relativePath} must not pin its SchemaVersion XML-doc example to the stale \"2.5\" string."); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Assets/JsonAssetsHeaderSchemaVersionTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Assets/JsonAssetsHeaderSchemaVersionTests.cs new file mode 100644 index 000000000..24ba4ca0f --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Assets/JsonAssetsHeaderSchemaVersionTests.cs @@ -0,0 +1,111 @@ +// 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.Assets.Json; +using MTConnect.Headers; +using MTConnect.Tests.JsonCppagent.TestHelpers; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Assets +{ + /// + /// Pins the JSON-cppagent Assets Header behavior: every emitted + /// `MTConnectAssets.Header` envelope must include `schemaVersion` mapped + /// from the source `IMTConnectAssetsHeader.SchemaVersion`, plus the + /// existing `testIndicator` regression pin. + /// + /// Source authority: + /// - Reference shape: cppagent v2.7.0.7 emits `Header.schemaVersion` and + /// `Header.testIndicator` on every Assets envelope. + /// - Public defect tracker: + /// https://github.com/TrakHound/MTConnect.NET/issues/130, + /// https://github.com/TrakHound/MTConnect.NET/issues/131. + /// + [TestFixture] + [Category("CppAgentHeaderFieldsPresent")] + [Category("ComplianceMatrix")] + public class JsonAssetsHeaderSchemaVersionTests + { + [TestCaseSource(typeof(JsonHeaderWireShapeMatrix), nameof(JsonHeaderWireShapeMatrix.SchemaVersionCases))] + public void Constructor_with_source_header_copies_schemaVersion(string schemaVersion) + { + var source = new MTConnectAssetsHeader + { + InstanceId = 1, + Version = $"{schemaVersion}.0.0", + SchemaVersion = schemaVersion, + Sender = "agent", + }; + + var json = new JsonAssetsHeader(source); + + Assert.That(json.SchemaVersion, Is.EqualTo(schemaVersion), + "JsonAssetsHeader must copy SchemaVersion from the source IMTConnectAssetsHeader."); + } + + [TestCaseSource(typeof(JsonHeaderWireShapeMatrix), nameof(JsonHeaderWireShapeMatrix.SchemaVersionCases))] + public void Serialized_assets_header_emits_schemaVersion_property(string schemaVersion) + { + var source = new MTConnectAssetsHeader + { + SchemaVersion = schemaVersion, + }; + + var jsonHeader = new JsonAssetsHeader(source); + var serialized = JsonSerializer.Serialize(jsonHeader); + using var doc = JsonDocument.Parse(serialized); + + Assert.That(doc.RootElement.TryGetProperty("schemaVersion", out var v), Is.True, + "Serialized JsonAssetsHeader must expose 'schemaVersion' on the wire."); + Assert.That(v.GetString(), Is.EqualTo(schemaVersion)); + } + + [Test] + public void Serialized_assets_header_emits_testIndicator_property() + { + var source = new MTConnectAssetsHeader + { + TestIndicator = false, + }; + + var jsonHeader = new JsonAssetsHeader(source); + var serialized = JsonSerializer.Serialize(jsonHeader); + using var doc = JsonDocument.Parse(serialized); + + Assert.That(doc.RootElement.TryGetProperty("testIndicator", out var v), Is.True, + "Serialized JsonAssetsHeader must expose 'testIndicator' on the wire."); + Assert.That(v.GetBoolean(), Is.False); + } + + [Test] + public void Reverse_mapping_round_trips_schemaVersion() + { + var source = new MTConnectAssetsHeader + { + SchemaVersion = "2.5", + }; + + var roundTripped = new JsonAssetsHeader(source).ToAssetsHeader(); + + Assert.That(roundTripped.SchemaVersion, Is.EqualTo("2.5"), + "ToAssetsHeader must preserve SchemaVersion through the round trip."); + } + + [Test] + public void Constructor_with_null_source_does_not_throw() + { + var jsonHeader = new JsonAssetsHeader(null); + + Assert.That(jsonHeader.SchemaVersion, Is.Null); + } + + [Test] + public void Default_constructor_leaves_schemaVersion_unset() + { + var jsonHeader = new JsonAssetsHeader(); + + Assert.That(jsonHeader.SchemaVersion, Is.Null); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Assets/JsonAssetsHeaderValidationTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Assets/JsonAssetsHeaderValidationTests.cs new file mode 100644 index 000000000..4e3c644d9 --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Assets/JsonAssetsHeaderValidationTests.cs @@ -0,0 +1,77 @@ +// 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.Assets.Json; +using MTConnect.Headers; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Assets +{ + /// + /// Pins the JSON-cppagent Assets Header behavior: every emitted + /// `MTConnectAssets.Header` envelope must include `validation` + /// mapped from the source `IMTConnectAssetsHeader.Validation`, + /// matching the cppagent v2 wire shape. + /// + /// Source authority: + /// - Reference shape: cppagent v2.7.0.7 emits `Header.validation` + /// on every Assets envelope. + /// - Public defect tracker: + /// https://github.com/TrakHound/MTConnect.NET/issues/130, + /// https://github.com/TrakHound/MTConnect.NET/issues/131. + /// + [TestFixture] + [Category("CppAgentHeaderFieldsPresent")] + public class JsonAssetsHeaderValidationTests + { + [Test] + public void Constructor_with_source_header_copies_validation() + { + var source = new MTConnectAssetsHeader + { + InstanceId = 1, + Version = "2.5.0.0", + SchemaVersion = "2.5", + Sender = "agent", + Validation = true, + }; + + var json = new JsonAssetsHeader(source); + + Assert.That(json.Validation, Is.True, + "JsonAssetsHeader must copy Validation from the source IMTConnectAssetsHeader."); + } + + [Test] + public void Serialized_assets_header_emits_validation_property() + { + var source = new MTConnectAssetsHeader + { + Validation = true, + }; + + var jsonHeader = new JsonAssetsHeader(source); + var serialized = JsonSerializer.Serialize(jsonHeader); + using var doc = JsonDocument.Parse(serialized); + + Assert.That(doc.RootElement.TryGetProperty("validation", out var v), Is.True, + "Serialized JsonAssetsHeader must expose 'validation' on the wire."); + Assert.That(v.GetBoolean(), Is.True); + } + + [Test] + public void Reverse_mapping_round_trips_validation() + { + var source = new MTConnectAssetsHeader + { + Validation = true, + }; + + var roundTripped = new JsonAssetsHeader(source).ToAssetsHeader(); + + Assert.That(roundTripped.Validation, Is.True, + "ToAssetsHeader must preserve Validation through the round trip."); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/CppAgentHeaderFieldsRegressionGuardTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/CppAgentHeaderFieldsRegressionGuardTests.cs new file mode 100644 index 000000000..8a3b55255 --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/CppAgentHeaderFieldsRegressionGuardTests.cs @@ -0,0 +1,71 @@ +// 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.Serialization; +using MTConnect.Assets.Json; +using MTConnect.Devices.Json; +using MTConnect.Streams.Json; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent +{ + /// + /// Reflection guard. Pins the contract: every JSON-cppagent header DTO must + /// expose `schemaVersion` and `testIndicator` as serializable properties so a + /// future regression cannot silently drop either field from the wire. + /// + /// Source authority: + /// - Reference shape: cppagent v2.7.0.7 emits `Header.schemaVersion` and + /// `Header.testIndicator` on every Streams / Devices / Assets envelope. + /// - Public defect tracker: + /// https://github.com/TrakHound/MTConnect.NET/issues/130 (schemaVersion), + /// https://github.com/TrakHound/MTConnect.NET/issues/131 (testIndicator). + /// + [TestFixture] + public class CppAgentHeaderFieldsRegressionGuardTests + { + private static readonly System.Type[] HeaderDtos = + { + typeof(JsonStreamsHeader), + typeof(JsonDevicesHeader), + typeof(JsonAssetsHeader), + }; + + [TestCaseSource(nameof(HeaderDtos))] + public void Header_dto_exposes_schemaVersion_property(System.Type headerType) + { + var property = headerType.GetProperty("SchemaVersion", + BindingFlags.Public | BindingFlags.Instance); + + Assert.That(property, Is.Not.Null, + $"{headerType.Name} must expose a public SchemaVersion property."); + Assert.That(property!.PropertyType, Is.EqualTo(typeof(string)), + $"{headerType.Name}.SchemaVersion must be a string."); + + var jsonAttr = property.GetCustomAttribute(); + Assert.That(jsonAttr, Is.Not.Null, + $"{headerType.Name}.SchemaVersion must carry [JsonPropertyName] for cppagent wire shape."); + Assert.That(jsonAttr!.Name, Is.EqualTo("schemaVersion"), + $"{headerType.Name}.SchemaVersion must serialize as 'schemaVersion'."); + } + + [TestCaseSource(nameof(HeaderDtos))] + public void Header_dto_exposes_testIndicator_property(System.Type headerType) + { + var property = headerType.GetProperty("TestIndicator", + BindingFlags.Public | BindingFlags.Instance); + + Assert.That(property, Is.Not.Null, + $"{headerType.Name} must expose a public TestIndicator property."); + Assert.That(property!.PropertyType, Is.EqualTo(typeof(bool)), + $"{headerType.Name}.TestIndicator must be a bool."); + + var jsonAttr = property.GetCustomAttribute(); + Assert.That(jsonAttr, Is.Not.Null, + $"{headerType.Name}.TestIndicator must carry [JsonPropertyName] for cppagent wire shape."); + Assert.That(jsonAttr!.Name, Is.EqualTo("testIndicator"), + $"{headerType.Name}.TestIndicator must serialize as 'testIndicator'."); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDevicesHeaderSchemaVersionTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDevicesHeaderSchemaVersionTests.cs new file mode 100644 index 000000000..988603a3e --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDevicesHeaderSchemaVersionTests.cs @@ -0,0 +1,111 @@ +// 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.Json; +using MTConnect.Headers; +using MTConnect.Tests.JsonCppagent.TestHelpers; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Devices +{ + /// + /// Pins the JSON-cppagent Devices Header behavior: every emitted + /// `MTConnectDevices.Header` envelope must include `schemaVersion` mapped + /// from the source `IMTConnectDevicesHeader.SchemaVersion`, plus the + /// existing `testIndicator` regression pin. + /// + /// Source authority: + /// - Reference shape: cppagent v2.7.0.7 emits `Header.schemaVersion` and + /// `Header.testIndicator` on every Devices envelope. + /// - Public defect tracker: + /// https://github.com/TrakHound/MTConnect.NET/issues/130, + /// https://github.com/TrakHound/MTConnect.NET/issues/131. + /// + [TestFixture] + [Category("CppAgentHeaderFieldsPresent")] + [Category("ComplianceMatrix")] + public class JsonDevicesHeaderSchemaVersionTests + { + [TestCaseSource(typeof(JsonHeaderWireShapeMatrix), nameof(JsonHeaderWireShapeMatrix.SchemaVersionCases))] + public void Constructor_with_source_header_copies_schemaVersion(string schemaVersion) + { + var source = new MTConnectDevicesHeader + { + InstanceId = 1, + Version = $"{schemaVersion}.0.0", + SchemaVersion = schemaVersion, + Sender = "agent", + }; + + var json = new JsonDevicesHeader(source); + + Assert.That(json.SchemaVersion, Is.EqualTo(schemaVersion), + "JsonDevicesHeader must copy SchemaVersion from the source IMTConnectDevicesHeader."); + } + + [TestCaseSource(typeof(JsonHeaderWireShapeMatrix), nameof(JsonHeaderWireShapeMatrix.SchemaVersionCases))] + public void Serialized_devices_header_emits_schemaVersion_property(string schemaVersion) + { + var source = new MTConnectDevicesHeader + { + SchemaVersion = schemaVersion, + }; + + var jsonHeader = new JsonDevicesHeader(source); + var serialized = JsonSerializer.Serialize(jsonHeader); + using var doc = JsonDocument.Parse(serialized); + + Assert.That(doc.RootElement.TryGetProperty("schemaVersion", out var v), Is.True, + "Serialized JsonDevicesHeader must expose 'schemaVersion' on the wire."); + Assert.That(v.GetString(), Is.EqualTo(schemaVersion)); + } + + [Test] + public void Serialized_devices_header_emits_testIndicator_property() + { + var source = new MTConnectDevicesHeader + { + TestIndicator = false, + }; + + var jsonHeader = new JsonDevicesHeader(source); + var serialized = JsonSerializer.Serialize(jsonHeader); + using var doc = JsonDocument.Parse(serialized); + + Assert.That(doc.RootElement.TryGetProperty("testIndicator", out var v), Is.True, + "Serialized JsonDevicesHeader must expose 'testIndicator' on the wire."); + Assert.That(v.GetBoolean(), Is.False); + } + + [Test] + public void Reverse_mapping_round_trips_schemaVersion() + { + var source = new MTConnectDevicesHeader + { + SchemaVersion = "2.5", + }; + + var roundTripped = new JsonDevicesHeader(source).ToDevicesHeader(); + + Assert.That(roundTripped.SchemaVersion, Is.EqualTo("2.5"), + "ToDevicesHeader must preserve SchemaVersion through the round trip."); + } + + [Test] + public void Constructor_with_null_source_does_not_throw() + { + var jsonHeader = new JsonDevicesHeader(null); + + Assert.That(jsonHeader.SchemaVersion, Is.Null); + } + + [Test] + public void Default_constructor_leaves_schemaVersion_unset() + { + var jsonHeader = new JsonDevicesHeader(); + + Assert.That(jsonHeader.SchemaVersion, Is.Null); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDevicesHeaderValidationTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDevicesHeaderValidationTests.cs new file mode 100644 index 000000000..bf0c1d8a5 --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Devices/JsonDevicesHeaderValidationTests.cs @@ -0,0 +1,77 @@ +// 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.Json; +using MTConnect.Headers; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Devices +{ + /// + /// Pins the JSON-cppagent Devices Header behavior: every emitted + /// `MTConnectDevices.Header` envelope must include `validation` + /// mapped from the source `IMTConnectDevicesHeader.Validation`, + /// matching the cppagent v2 wire shape. + /// + /// Source authority: + /// - Reference shape: cppagent v2.7.0.7 emits `Header.validation` + /// on every Devices envelope. + /// - Public defect tracker: + /// https://github.com/TrakHound/MTConnect.NET/issues/130, + /// https://github.com/TrakHound/MTConnect.NET/issues/131. + /// + [TestFixture] + [Category("CppAgentHeaderFieldsPresent")] + public class JsonDevicesHeaderValidationTests + { + [Test] + public void Constructor_with_source_header_copies_validation() + { + var source = new MTConnectDevicesHeader + { + InstanceId = 1, + Version = "2.5.0.0", + SchemaVersion = "2.5", + Sender = "agent", + Validation = true, + }; + + var json = new JsonDevicesHeader(source); + + Assert.That(json.Validation, Is.True, + "JsonDevicesHeader must copy Validation from the source IMTConnectDevicesHeader."); + } + + [Test] + public void Serialized_devices_header_emits_validation_property() + { + var source = new MTConnectDevicesHeader + { + Validation = true, + }; + + var jsonHeader = new JsonDevicesHeader(source); + var serialized = JsonSerializer.Serialize(jsonHeader); + using var doc = JsonDocument.Parse(serialized); + + Assert.That(doc.RootElement.TryGetProperty("validation", out var v), Is.True, + "Serialized JsonDevicesHeader must expose 'validation' on the wire."); + Assert.That(v.GetBoolean(), Is.True); + } + + [Test] + public void Reverse_mapping_round_trips_validation() + { + var source = new MTConnectDevicesHeader + { + Validation = true, + }; + + var roundTripped = new JsonDevicesHeader(source).ToDevicesHeader(); + + Assert.That(roundTripped.Validation, Is.True, + "ToDevicesHeader must preserve Validation through the round trip."); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/JsonHeaderSchemaVersionXmlDocTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/JsonHeaderSchemaVersionXmlDocTests.cs new file mode 100644 index 000000000..055fa9cf5 --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/JsonHeaderSchemaVersionXmlDocTests.cs @@ -0,0 +1,66 @@ +// 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 NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent +{ + /// + /// Pins the XML-doc summary requirement on the SchemaVersion property in + /// the three JSON-cppagent header DTO files. Each property must carry a + /// `` block immediately above the `[JsonPropertyName("schemaVersion")]` + /// attribute so consumers reading IntelliSense see the cppagent v2 + /// wire-shape semantics inline. + /// + /// Files covered: + /// - libraries/MTConnect.NET-JSON-cppagent/Assets/JsonAssetsHeader.cs + /// - libraries/MTConnect.NET-JSON-cppagent/Streams/JsonStreamsHeader.cs + /// - libraries/MTConnect.NET-JSON-cppagent/Devices/JsonDevicesHeader.cs + /// + [TestFixture] + public class JsonHeaderSchemaVersionXmlDocTests + { + private static readonly string[] HeaderFileRelativePaths = + { + "libraries/MTConnect.NET-JSON-cppagent/Assets/JsonAssetsHeader.cs", + "libraries/MTConnect.NET-JSON-cppagent/Streams/JsonStreamsHeader.cs", + "libraries/MTConnect.NET-JSON-cppagent/Devices/JsonDevicesHeader.cs", + }; + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir != null) + { + if (Directory.Exists(Path.Combine(dir.FullName, "libraries")) && + Directory.Exists(Path.Combine(dir.FullName, "tests"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + Assert.Fail("Could not locate repo root from test working directory."); + return string.Empty; + } + + [TestCaseSource(nameof(HeaderFileRelativePaths))] + public void Header_dto_schemaVersion_has_xmldoc_summary(string relativePath) + { + var fullPath = Path.Combine(FindRepoRoot(), relativePath); + Assert.That(File.Exists(fullPath), Is.True, $"Header source file not found at {fullPath}."); + + var text = File.ReadAllText(fullPath); + + // Match a `...` block followed (with optional + // whitespace and other attributes) by `[JsonPropertyName("schemaVersion")]`. + var pattern = new Regex( + @"[\s\S]+?\s*(?:///[^\n]*\n\s*)*\[JsonPropertyName\(""schemaVersion""\)\]", + RegexOptions.Multiline); + + Assert.That(pattern.IsMatch(text), Is.True, + $"{relativePath} must precede [JsonPropertyName(\"schemaVersion\")] with a XML-doc block."); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/JsonHeaderWireShapeE2ETests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/JsonHeaderWireShapeE2ETests.cs new file mode 100644 index 000000000..97a74f70f --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/JsonHeaderWireShapeE2ETests.cs @@ -0,0 +1,115 @@ +// 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.Assets.Json; +using MTConnect.Devices.Json; +using MTConnect.Headers; +using MTConnect.Streams.Json; +using MTConnect.Tests.JsonCppagent.TestHelpers; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent +{ + /// + /// End-to-end wire-shape pin: every JSON-cppagent envelope's `Header` element + /// must carry both `schemaVersion` and `testIndicator` after serialization with + /// the default `JsonSerializer` settings, regardless of which field on the + /// source DTO carries a non-default value. The (envelopeKind, schemaVersion) + /// matrix is sourced from so the + /// per-envelope and cross-envelope E2E sets stay in lockstep. + /// + /// Source authority: + /// - Reference shape: cppagent v2.7.0.7 emits `Header.schemaVersion` and + /// `Header.testIndicator` on every Streams / Devices / Assets envelope. + /// - Public defect tracker: + /// https://github.com/TrakHound/MTConnect.NET/issues/130 (schemaVersion), + /// https://github.com/TrakHound/MTConnect.NET/issues/131 (testIndicator). + /// + [TestFixture] + [Category("ComplianceMatrix")] + public class JsonHeaderWireShapeE2ETests + { + [TestCaseSource(typeof(JsonHeaderWireShapeMatrix), nameof(JsonHeaderWireShapeMatrix.Cases))] + public void Envelope_carries_schemaVersion_and_testIndicator(string envelopeKind, string schemaVersion) + { + string serialized = envelopeKind switch + { + "Streams" => JsonSerializer.Serialize(new JsonStreamsHeader(new MTConnectStreamsHeader + { + InstanceId = 42, + Version = $"{schemaVersion}.0.0", + SchemaVersion = schemaVersion, + Sender = "agent", + TestIndicator = false, + })), + "Devices" => JsonSerializer.Serialize(new JsonDevicesHeader(new MTConnectDevicesHeader + { + InstanceId = 42, + Version = $"{schemaVersion}.0.0", + SchemaVersion = schemaVersion, + Sender = "agent", + TestIndicator = false, + })), + "Assets" => JsonSerializer.Serialize(new JsonAssetsHeader(new MTConnectAssetsHeader + { + InstanceId = 42, + Version = $"{schemaVersion}.0.0", + SchemaVersion = schemaVersion, + Sender = "agent", + TestIndicator = false, + })), + _ => throw new System.ArgumentOutOfRangeException(nameof(envelopeKind), envelopeKind, null), + }; + + using var doc = JsonDocument.Parse(serialized); + Assert.That(doc.RootElement.GetProperty("schemaVersion").GetString(), Is.EqualTo(schemaVersion)); + Assert.That(doc.RootElement.GetProperty("testIndicator").GetBoolean(), Is.False); + } + + [Test] + public void Streams_envelope_round_trip_preserves_both_fields() + { + var source = new MTConnectStreamsHeader + { + SchemaVersion = "2.5", + TestIndicator = true, + }; + + var roundTripped = new JsonStreamsHeader(source).ToStreamsHeader(); + + Assert.That(roundTripped.SchemaVersion, Is.EqualTo("2.5")); + Assert.That(roundTripped.TestIndicator, Is.True); + } + + [Test] + public void Devices_envelope_round_trip_preserves_both_fields() + { + var source = new MTConnectDevicesHeader + { + SchemaVersion = "2.5", + TestIndicator = true, + }; + + var roundTripped = new JsonDevicesHeader(source).ToDevicesHeader(); + + Assert.That(roundTripped.SchemaVersion, Is.EqualTo("2.5")); + Assert.That(roundTripped.TestIndicator, Is.True); + } + + [Test] + public void Assets_envelope_round_trip_preserves_both_fields() + { + var source = new MTConnectAssetsHeader + { + SchemaVersion = "2.5", + TestIndicator = true, + }; + + var roundTripped = new JsonAssetsHeader(source).ToAssetsHeader(); + + Assert.That(roundTripped.SchemaVersion, Is.EqualTo("2.5")); + Assert.That(roundTripped.TestIndicator, Is.True); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/JsonHeaderWireShapeMatrixTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/JsonHeaderWireShapeMatrixTests.cs new file mode 100644 index 000000000..319e38221 --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/JsonHeaderWireShapeMatrixTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Linq; +using MTConnect.Tests.JsonCppagent.TestHelpers; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent +{ + /// + /// Pins the shared (envelopeKind, version) compliance matrix used by both + /// per-envelope and cross-envelope wire-shape fixtures. Centralizing the + /// matrix prevents per-envelope tests from drifting away from the cross- + /// envelope E2E set as new schema versions are added. + /// + /// Source authority: + /// - Reference shape: cppagent v2.7.0.7 emits `Header.schemaVersion` and + /// `Header.testIndicator` on every envelope. + /// - Public defect tracker: + /// https://github.com/TrakHound/MTConnect.NET/issues/130 (schemaVersion), + /// https://github.com/TrakHound/MTConnect.NET/issues/131 (testIndicator). + /// + [TestFixture] + [Category("ComplianceMatrix")] + public class JsonHeaderWireShapeMatrixTests + { + [Test] + public void Matrix_exposes_three_envelope_kinds() + { + var kinds = JsonHeaderWireShapeMatrix.Cases + .Select(c => c.Arguments[0]!.ToString()) + .Distinct() + .OrderBy(s => s) + .ToArray(); + + Assert.That(kinds, Is.EqualTo(new[] { "Assets", "Devices", "Streams" })); + } + + [Test] + public void Matrix_covers_v20_v23_v25_for_each_envelope() + { + var expectedVersions = new[] { "2.0", "2.3", "2.5" }; + + foreach (var kind in new[] { "Assets", "Devices", "Streams" }) + { + var versions = JsonHeaderWireShapeMatrix.Cases + .Where(c => (string)c.Arguments[0]! == kind) + .Select(c => (string)c.Arguments[1]!) + .OrderBy(v => v) + .ToArray(); + + Assert.That(versions, Is.EqualTo(expectedVersions), + $"{kind} envelope must include v2.0, v2.3, and v2.5 in the compliance matrix."); + } + } + + [Test] + public void Matrix_class_is_internal_static() + { + var t = typeof(JsonHeaderWireShapeMatrix); + Assert.That(t.IsAbstract && t.IsSealed, Is.True, + "JsonHeaderWireShapeMatrix must be a static class."); + Assert.That(t.IsNotPublic, Is.True, + "JsonHeaderWireShapeMatrix must be internal to the test project."); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonStreamsHeaderSchemaVersionTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonStreamsHeaderSchemaVersionTests.cs new file mode 100644 index 000000000..8c356356c --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonStreamsHeaderSchemaVersionTests.cs @@ -0,0 +1,114 @@ +// 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.Headers; +using MTConnect.Streams.Json; +using MTConnect.Tests.JsonCppagent.TestHelpers; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Streams +{ + /// + /// Pins the JSON-cppagent Streams Header behavior: every emitted + /// `MTConnectStreams.Header` envelope must include `schemaVersion`, + /// matching the cppagent v2 wire shape, and `testIndicator` must + /// remain on the wire (regression pin against #131 reverting). + /// + /// Source authority: + /// - Reference shape: cppagent v2.7.0.7 emits `Header.schemaVersion` and + /// `Header.testIndicator` on every Streams envelope. + /// - Public defect tracker: + /// https://github.com/TrakHound/MTConnect.NET/issues/130 (schemaVersion), + /// https://github.com/TrakHound/MTConnect.NET/issues/131 (testIndicator). + /// + [TestFixture] + [Category("CppAgentHeaderFieldsPresent")] + [Category("ComplianceMatrix")] + public class JsonStreamsHeaderSchemaVersionTests + { + [TestCaseSource(typeof(JsonHeaderWireShapeMatrix), nameof(JsonHeaderWireShapeMatrix.SchemaVersionCases))] + public void Constructor_with_source_header_copies_schemaVersion(string schemaVersion) + { + var source = new MTConnectStreamsHeader + { + InstanceId = 1, + Version = $"{schemaVersion}.0.0", + SchemaVersion = schemaVersion, + Sender = "agent", + }; + + var json = new JsonStreamsHeader(source); + + Assert.That(json.SchemaVersion, Is.EqualTo(schemaVersion), + "JsonStreamsHeader must copy SchemaVersion from the source IMTConnectStreamsHeader."); + } + + [TestCaseSource(typeof(JsonHeaderWireShapeMatrix), nameof(JsonHeaderWireShapeMatrix.SchemaVersionCases))] + public void Serialized_streams_header_emits_schemaVersion_property(string schemaVersion) + { + var source = new MTConnectStreamsHeader + { + SchemaVersion = schemaVersion, + }; + + var jsonHeader = new JsonStreamsHeader(source); + var serialized = JsonSerializer.Serialize(jsonHeader); + using var doc = JsonDocument.Parse(serialized); + + Assert.That(doc.RootElement.TryGetProperty("schemaVersion", out var v), Is.True, + "Serialized JsonStreamsHeader must expose 'schemaVersion' on the wire."); + Assert.That(v.GetString(), Is.EqualTo(schemaVersion)); + } + + [Test] + public void Serialized_streams_header_emits_testIndicator_property() + { + // Regression pin against #131: testIndicator must remain on the wire + // even when the source flag is the default `false`. + var source = new MTConnectStreamsHeader + { + TestIndicator = false, + }; + + var jsonHeader = new JsonStreamsHeader(source); + var serialized = JsonSerializer.Serialize(jsonHeader); + using var doc = JsonDocument.Parse(serialized); + + Assert.That(doc.RootElement.TryGetProperty("testIndicator", out var v), Is.True, + "Serialized JsonStreamsHeader must expose 'testIndicator' on the wire."); + Assert.That(v.GetBoolean(), Is.False); + } + + [Test] + public void Reverse_mapping_round_trips_schemaVersion() + { + var source = new MTConnectStreamsHeader + { + SchemaVersion = "2.5", + }; + + var roundTripped = new JsonStreamsHeader(source).ToStreamsHeader(); + + Assert.That(roundTripped.SchemaVersion, Is.EqualTo("2.5"), + "ToStreamsHeader must preserve SchemaVersion through the round trip."); + } + + [Test] + public void Constructor_with_null_source_does_not_throw() + { + // Null-source ctor branch is covered for 100% line coverage. + var jsonHeader = new JsonStreamsHeader(null); + + Assert.That(jsonHeader.SchemaVersion, Is.Null); + } + + [Test] + public void Default_constructor_leaves_schemaVersion_unset() + { + var jsonHeader = new JsonStreamsHeader(); + + Assert.That(jsonHeader.SchemaVersion, Is.Null); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonStreamsHeaderValidationTests.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonStreamsHeaderValidationTests.cs new file mode 100644 index 000000000..21b1fb88f --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/Streams/JsonStreamsHeaderValidationTests.cs @@ -0,0 +1,77 @@ +// 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.Headers; +using MTConnect.Streams.Json; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.Streams +{ + /// + /// Pins the JSON-cppagent Streams Header behavior: every emitted + /// `MTConnectStreams.Header` envelope must include `validation` + /// mapped from the source `IMTConnectStreamsHeader.Validation`, + /// matching the cppagent v2 wire shape. + /// + /// Source authority: + /// - Reference shape: cppagent v2.7.0.7 emits `Header.validation` + /// on every Streams envelope. + /// - Public defect tracker: + /// https://github.com/TrakHound/MTConnect.NET/issues/130, + /// https://github.com/TrakHound/MTConnect.NET/issues/131. + /// + [TestFixture] + [Category("CppAgentHeaderFieldsPresent")] + public class JsonStreamsHeaderValidationTests + { + [Test] + public void Constructor_with_source_header_copies_validation() + { + var source = new MTConnectStreamsHeader + { + InstanceId = 1, + Version = "2.5.0.0", + SchemaVersion = "2.5", + Sender = "agent", + Validation = true, + }; + + var json = new JsonStreamsHeader(source); + + Assert.That(json.Validation, Is.True, + "JsonStreamsHeader must copy Validation from the source IMTConnectStreamsHeader."); + } + + [Test] + public void Serialized_streams_header_emits_validation_property() + { + var source = new MTConnectStreamsHeader + { + Validation = true, + }; + + var jsonHeader = new JsonStreamsHeader(source); + var serialized = JsonSerializer.Serialize(jsonHeader); + using var doc = JsonDocument.Parse(serialized); + + Assert.That(doc.RootElement.TryGetProperty("validation", out var v), Is.True, + "Serialized JsonStreamsHeader must expose 'validation' on the wire."); + Assert.That(v.GetBoolean(), Is.True); + } + + [Test] + public void Reverse_mapping_round_trips_validation() + { + var source = new MTConnectStreamsHeader + { + Validation = true, + }; + + var roundTripped = new JsonStreamsHeader(source).ToStreamsHeader(); + + Assert.That(roundTripped.Validation, Is.True, + "ToStreamsHeader must preserve Validation through the round trip."); + } + } +} diff --git a/tests/MTConnect.NET-JSON-cppagent-Tests/TestHelpers/JsonHeaderWireShapeMatrix.cs b/tests/MTConnect.NET-JSON-cppagent-Tests/TestHelpers/JsonHeaderWireShapeMatrix.cs new file mode 100644 index 000000000..cc03566c6 --- /dev/null +++ b/tests/MTConnect.NET-JSON-cppagent-Tests/TestHelpers/JsonHeaderWireShapeMatrix.cs @@ -0,0 +1,77 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System.Collections.Generic; +using NUnit.Framework; + +namespace MTConnect.Tests.JsonCppagent.TestHelpers +{ + /// + /// Shared compliance matrix yielding (envelopeKind, schemaVersion) tuples + /// consumed by per-envelope and cross-envelope JSON-cppagent header + /// wire-shape fixtures. Centralizing the matrix prevents per-envelope + /// tests from drifting away from the cross-envelope E2E set as new + /// schema versions are added. + /// + /// envelopeKind values: "Assets", "Streams", "Devices". + /// schemaVersion values: "2.0", "2.3", "2.5". + /// + /// Source authority: + /// - Reference shape: cppagent v2.7.0.7 emits `Header.schemaVersion` and + /// `Header.testIndicator` on every Streams / Devices / Assets envelope. + /// - Public defect tracker: + /// https://github.com/TrakHound/MTConnect.NET/issues/130 (schemaVersion), + /// https://github.com/TrakHound/MTConnect.NET/issues/131 (testIndicator). + /// + internal static class JsonHeaderWireShapeMatrix + { + public static readonly string[] EnvelopeKinds = + { + "Streams", + "Devices", + "Assets", + }; + + public static readonly string[] SchemaVersions = + { + "2.0", + "2.3", + "2.5", + }; + + /// + /// NUnit TestCaseSource-shaped enumeration of (envelopeKind, schemaVersion). + /// Use as `[TestCaseSource(typeof(JsonHeaderWireShapeMatrix), nameof(Cases))]`. + /// + public static IEnumerable Cases + { + get + { + foreach (var kind in EnvelopeKinds) + { + foreach (var version in SchemaVersions) + { + yield return new TestCaseData(kind, version) + .SetName($"{kind}_envelope_v{version}"); + } + } + } + } + + /// + /// Per-envelope schema-version cases for fixtures scoped to a single + /// envelope kind. Use as + /// `[TestCaseSource(typeof(JsonHeaderWireShapeMatrix), nameof(SchemaVersions))]`. + /// + public static IEnumerable SchemaVersionCases + { + get + { + foreach (var version in SchemaVersions) + { + yield return new TestCaseData(version).SetName($"v{version}"); + } + } + } + } +}