diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgentBroker.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgentBroker.cs index 78ee9fc7b..693ef54eb 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgentBroker.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgentBroker.cs @@ -13,6 +13,7 @@ using MTConnect.Observations.Output; using MTConnect.Streams.Output; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -466,7 +467,7 @@ private MTConnectDevicesHeader GetDevicesHeader(Version mtconnectVersion = null) DeviceModelChangeTime = DeviceModelChangeTime.ToString("o"), InstanceId = InstanceId, Sender = Sender, - Version = Version.ToString(), + Version = FormatHeaderVersion(version), TestIndicator = false, Validation = Configuration.EnableValidation }; @@ -490,7 +491,7 @@ private MTConnectStreamsHeader GetStreamsHeader(IObservationBufferResults result DeviceModelChangeTime = DeviceModelChangeTime.ToString("o"), InstanceId = InstanceId, Sender = Sender, - Version = Version.ToString(), + Version = FormatHeaderVersion(version), FirstSequence = results.FirstSequence, LastSequence = results.LastSequence, NextSequence = results.NextSequence, @@ -516,7 +517,7 @@ private MTConnectAssetsHeader GetAssetsHeader(Version mtconnectVersion = null) DeviceModelChangeTime = DeviceModelChangeTime.ToString("o"), InstanceId = InstanceId, Sender = Sender, - Version = Version.ToString(), + Version = FormatHeaderVersion(version), TestIndicator = false, Validation = Configuration.EnableValidation }; @@ -527,19 +528,44 @@ private MTConnectAssetsHeader GetAssetsHeader(Version mtconnectVersion = null) return header; } - private MTConnectErrorHeader GetErrorHeader() + private MTConnectErrorHeader GetErrorHeader(Version mtconnectVersion = null) { + var version = mtconnectVersion != null ? mtconnectVersion : MTConnectVersion; + return new MTConnectErrorHeader { AssetBufferSize = _assetBuffer.BufferSize, CreationTime = DateTime.UtcNow, InstanceId = InstanceId, Sender = Sender, - Version = Version.ToString(), + Version = FormatHeaderVersion(version), TestIndicator = false }; } + // Memoizes the formatted Header.version string per configured + // MTConnect release so the formatter does not re-allocate a + // Version + ToString() on every Devices/Streams/Assets/Error + // response. Keyed on Version equality (not reference identity) + // because callers commonly construct fresh Version instances + // per request. + private static readonly ConcurrentDictionary _formattedVersionCache = new ConcurrentDictionary(); + + // Formats the configured MTConnect Standard release for the + // `version` attribute on every response document Header. + // Per , + // this attribute is the MTConnect release the agent serves + // (Part 1.0 §3 Header), not the library assembly version. + // Pads build + revision with zero so the emitted shape matches + // the cppagent reference (e.g. "2.5.0.0") regardless of how + // many segments the source `Version` carried. + private static string FormatHeaderVersion(Version mtconnectVersion) + { + return _formattedVersionCache.GetOrAdd( + mtconnectVersion, + v => new Version(v.Major, v.Minor, 0, 0).ToString()); + } + #endregion #region "Devices" @@ -560,10 +586,7 @@ public IDevicesResponseDocument GetDevicesResponseDocument(Version mtconnectVers var doc = new DevicesResponseDocument(); doc.Version = version; - var header = GetDevicesHeader(version); - header.Version = Version.ToString(); - - doc.Header = header; + doc.Header = GetDevicesHeader(version); doc.Devices = ProcessDevices(devices, version); DevicesResponseSent?.Invoke(doc); @@ -593,10 +616,7 @@ public IDevicesResponseDocument GetDevicesResponseDocument(string deviceKey, Ver var doc = new DevicesResponseDocument(); doc.Version = version; - var header = GetDevicesHeader(version); - header.Version = Version.ToString(); - - doc.Header = header; + doc.Header = GetDevicesHeader(version); doc.Devices = ProcessDevices(new List { device }, version); DevicesResponseSent?.Invoke(doc); @@ -1318,7 +1338,6 @@ public IAssetsResponseDocument GetAssetsResponseDocument(string deviceKey = null // Create AssetsHeader var header = GetAssetsHeader(version); - header.Version = Version.ToString(); header.InstanceId = InstanceId; // Create MTConnectAssets Response Document @@ -1365,7 +1384,6 @@ public IAssetsResponseDocument GetAssetsResponseDocument(IEnumerable ass // Create AssetsHeader var header = GetAssetsHeader(version); - header.Version = Version.ToString(); header.InstanceId = InstanceId; // Create MTConnectAssets Response Document @@ -1639,10 +1657,7 @@ public IErrorResponseDocument GetErrorResponseDocument(ErrorCode errorCode, stri var doc = new ErrorResponseDocument(); doc.Version = version; - var header = GetErrorHeader(); - header.Version = Version.ToString(); - - doc.Header = header; + doc.Header = GetErrorHeader(version); doc.Errors = new List { new Error(errorCode, value) @@ -1665,10 +1680,7 @@ public IErrorResponseDocument GetErrorResponseDocument(IEnumerable error var doc = new ErrorResponseDocument(); doc.Version = version; - var header = GetErrorHeader(); - header.Version = Version.ToString(); - - doc.Header = header; + doc.Header = GetErrorHeader(version); doc.Errors = errors != null ? errors.ToList() : null; ErrorResponseSent?.Invoke(doc); diff --git a/tests/MTConnect.NET-Common-Tests/Headers/HeaderVersionFormattingCacheTests.cs b/tests/MTConnect.NET-Common-Tests/Headers/HeaderVersionFormattingCacheTests.cs new file mode 100644 index 000000000..ef4e8d57c --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Headers/HeaderVersionFormattingCacheTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) 2025 TrakHound Inc., All Rights Reserved. + +// Pins the caching contract for the broker's `Header.version` +// formatter. Each Devices/Streams/Assets/Error response on a hot +// path passes the same configured Version through the formatter, +// so the formatted four-segment string must be memoized rather +// than allocated per response. +// +// Independent of HeaderVersionRegressionTests (which pins the +// emitted value): this fixture pins the *identity* of the +// returned string across repeated calls to prove the cache. + +using System; +using System.Reflection; +using NUnit.Framework; + +namespace MTConnect.Tests.Common.Headers +{ + [TestFixture] + public class HeaderVersionFormattingCacheTests + { + private static MethodInfo GetFormatter() + { + var brokerType = typeof(MTConnect.Agents.MTConnectAgentBroker); + var method = brokerType.GetMethod( + "FormatHeaderVersion", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.That(method, Is.Not.Null, + "MTConnectAgentBroker.FormatHeaderVersion(Version) must exist as a private static method."); + return method!; + } + + private static string Invoke(MethodInfo formatter, Version version) + { + return (string)formatter.Invoke(null, new object[] { version })!; + } + + [Test] + public void FormatHeaderVersion_returns_same_string_instance_on_repeated_calls_for_same_version() + { + var formatter = GetFormatter(); + var version = new Version(2, 5); + + var first = Invoke(formatter, version); + var second = Invoke(formatter, version); + + Assert.That(second, Is.SameAs(first), + "Repeated calls with the same Version must return the cached string instance, not allocate a new one."); + } + + [Test] + public void FormatHeaderVersion_returns_same_string_instance_for_distinct_but_equal_version_instances() + { + var formatter = GetFormatter(); + var versionA = new Version(2, 7); + var versionB = new Version(2, 7); + Assert.That(versionA, Is.Not.SameAs(versionB), "Sanity: two distinct Version instances under test."); + Assert.That(versionA, Is.EqualTo(versionB), "Sanity: the two Version instances must be Equals-equal."); + + var first = Invoke(formatter, versionA); + var second = Invoke(formatter, versionB); + + Assert.That(second, Is.SameAs(first), + "Cache must key on Version equality, not reference identity, so equal Version instances reuse the formatted string."); + } + + [Test] + public void FormatHeaderVersion_caches_independently_per_version() + { + var formatter = GetFormatter(); + + var v25 = Invoke(formatter, new Version(2, 5)); + var v27 = Invoke(formatter, new Version(2, 7)); + + Assert.That(v25, Is.Not.EqualTo(v27), + "Different MTConnect releases must produce different formatted strings."); + + var v25Again = Invoke(formatter, new Version(2, 5)); + var v27Again = Invoke(formatter, new Version(2, 7)); + + Assert.That(v25Again, Is.SameAs(v25)); + Assert.That(v27Again, Is.SameAs(v27)); + } + } +} diff --git a/tests/MTConnect.NET-Common-Tests/Headers/HeaderVersionRegressionTests.cs b/tests/MTConnect.NET-Common-Tests/Headers/HeaderVersionRegressionTests.cs new file mode 100644 index 000000000..0b3bf0f0b --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Headers/HeaderVersionRegressionTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) 2025 TrakHound Inc., All Rights Reserved. + +// Pins TrakHound/MTConnect.NET#127 — every response Header.version +// equals the configured MTConnect Standard release the agent serves, +// not the library assembly version. +// +// Spec sources: +// - https://docs.mtconnect.org/ Part 1.0 §3 (Header), Part 2.0 §7 +// (Streams envelope), Part 3.0 §5 (Devices envelope), Part 4.0 §5 +// (Assets envelope) — Header.version is the MTConnect Standard +// release the agent serves. +// - XSD: MTConnectDevices_.xsd, MTConnectStreams_.xsd, +// MTConnectAssets_.xsd, MTConnectError_.xsd at +// https://schemas.mtconnect.org — the Header element's `version` +// attribute is xs:string formatted as the four-segment release +// string by the cppagent reference implementation. + +using System; +using System.Linq; +using MTConnect.Agents; +using MTConnect.Configurations; +using MTConnect.Devices; +using MTConnect.Errors; +using MTConnect.Tests.Common.TestHelpers; +using NUnit.Framework; + +namespace MTConnect.Tests.Common.Headers +{ + [TestFixture] + public class HeaderVersionRegressionTests + { + // Returns the canonical four-segment string an agent configured + // for `configuredVersion` must emit in Header.version. Pads + // build + revision with zero to match the cppagent reference + // shape (e.g. cppagent emits `2.7.0.0` for v2.7). + private static string ExpectedHeaderVersion(Version configuredVersion) + { + return new Version( + configuredVersion.Major, + configuredVersion.Minor, + 0, + 0).ToString(); + } + + private static MTConnectAgentBroker BuildBroker(Version configuredVersion) + { + var configuration = new AgentConfiguration + { + DefaultVersion = configuredVersion + }; + var broker = new MTConnectAgentBroker(configuration); + + // Add a device that the broker can serve at every supported + // MTConnect release. The default Agent device introduced in + // v1.7 (see MTConnect.Devices.Agent.MinimumVersion) drops + // out of the response below v1.7, which is unrelated to + // this fixture's `Header.version` assertion. Adding a + // bare Device keeps the response document non-null across + // every row of the version matrix. + broker.AddDevice(new Device { Uuid = "test-device", Name = "TestDevice" }); + return broker; + } + + [TestCaseSource(typeof(MTConnectVersionMatrix), nameof(MTConnectVersionMatrix.All))] + public void Devices_header_version_equals_configured_mtconnect_release(Version configuredVersion) + { + using var broker = BuildBroker(configuredVersion); + var document = broker.GetDevicesResponseDocument(); + + Assert.That(document, Is.Not.Null, + "Broker must yield a Devices response document for the default agent device."); + Assert.That(document!.Header, Is.Not.Null); + Assert.That( + document.Header.Version, + Is.EqualTo(ExpectedHeaderVersion(configuredVersion))); + } + + [TestCaseSource(typeof(MTConnectVersionMatrix), nameof(MTConnectVersionMatrix.All))] + public void Assets_header_version_equals_configured_mtconnect_release(Version configuredVersion) + { + using var broker = BuildBroker(configuredVersion); + var document = broker.GetAssetsResponseDocument(); + + Assert.That(document, Is.Not.Null, + "Broker must yield an Assets response document (empty assets list permitted)."); + Assert.That(document!.Header, Is.Not.Null); + Assert.That( + document.Header.Version, + Is.EqualTo(ExpectedHeaderVersion(configuredVersion))); + } + + [TestCaseSource(typeof(MTConnectVersionMatrix), nameof(MTConnectVersionMatrix.All))] + public void Error_header_version_equals_configured_mtconnect_release(Version configuredVersion) + { + using var broker = BuildBroker(configuredVersion); + var document = broker.GetErrorResponseDocument(ErrorCode.UNSUPPORTED, "test"); + + Assert.That(document, Is.Not.Null); + Assert.That(document!.Header, Is.Not.Null); + Assert.That( + document.Header.Version, + Is.EqualTo(ExpectedHeaderVersion(configuredVersion))); + } + + [TestCaseSource(typeof(MTConnectVersionMatrix), nameof(MTConnectVersionMatrix.All))] + public void Devices_header_version_equals_configured_release_when_passed_explicitly(Version configuredVersion) + { + // Independent path: the optional `mtconnectVersion` parameter + // overrides the broker's default and must be reflected in + // Header.version. Pins the explicit-version overload so a + // future regression on either path is caught. + using var broker = BuildBroker(MTConnectVersions.Version10); + var document = broker.GetDevicesResponseDocument(configuredVersion); + + Assert.That(document, Is.Not.Null); + Assert.That(document!.Header, Is.Not.Null); + Assert.That( + document.Header.Version, + Is.EqualTo(ExpectedHeaderVersion(configuredVersion))); + } + + [Test] + public void No_response_envelope_emits_the_library_assembly_version() + { + // Cheap paranoia check — guards against any future + // diagnostic-style emission of MTConnectAgent.Version + // re-leaking into a wire-format header. + using var broker = BuildBroker(MTConnectVersions.Version25); + + var libraryVersion = typeof(MTConnectAgent).Assembly + .GetName().Version!.ToString(); + + var devices = broker.GetDevicesResponseDocument(); + var assets = broker.GetAssetsResponseDocument(); + var error = broker.GetErrorResponseDocument(ErrorCode.UNSUPPORTED, "test"); + + Assert.Multiple(() => + { + Assert.That(devices!.Header.Version, Is.Not.EqualTo(libraryVersion), + "Devices Header.version must not echo the library assembly version."); + Assert.That(assets!.Header.Version, Is.Not.EqualTo(libraryVersion), + "Assets Header.version must not echo the library assembly version."); + Assert.That(error!.Header.Version, Is.Not.EqualTo(libraryVersion), + "Error Header.version must not echo the library assembly version."); + }); + } + } +} diff --git a/tests/MTConnect.NET-Common-Tests/TestHelpers/MTConnectVersionMatrix.cs b/tests/MTConnect.NET-Common-Tests/TestHelpers/MTConnectVersionMatrix.cs new file mode 100644 index 000000000..52258748f --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/TestHelpers/MTConnectVersionMatrix.cs @@ -0,0 +1,27 @@ +// Copyright (c) 2025 TrakHound Inc., All Rights Reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace MTConnect.Tests.Common.TestHelpers +{ + /// + /// Discovers every public static field on + /// via reflection. Adding a + /// new constant on the production type automatically extends the + /// parametric matrix without per-test edits. + /// + public static class MTConnectVersionMatrix + { + /// + /// All MTConnect Standard release constants exposed by the library. + /// + public static IEnumerable All => typeof(MTConnect.MTConnectVersions) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.FieldType == typeof(Version)) + .Select(f => (Version)f.GetValue(null)!) + .ToArray(); + } +} diff --git a/tests/MTConnect.NET-XML-Tests/Headers/HeaderVersionXmlRoundTripTests.cs b/tests/MTConnect.NET-XML-Tests/Headers/HeaderVersionXmlRoundTripTests.cs new file mode 100644 index 000000000..aa4f8cbc4 --- /dev/null +++ b/tests/MTConnect.NET-XML-Tests/Headers/HeaderVersionXmlRoundTripTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) 2025 TrakHound Inc., All Rights Reserved. + +// Pins TrakHound/MTConnect.NET#127 — the XML response document +// emitted by the agent contains the configured MTConnect Standard +// release in the Header `version` attribute. This is the +// wire-format-shape end of the issue: the previous bug was visible +// to consumers as `version="6.9.0.0"` in the XML payload, regardless +// of the agent's DefaultVersion. +// +// Spec sources: +// - https://docs.mtconnect.org/ Part 1.0 §3 (Header) — defines the +// `version` attribute on the Header element as the MTConnect +// release the agent serves. +// - https://schemas.mtconnect.org/schemas/MTConnectDevices_2.5.xsd +// constrains the Header element's `version` attribute as +// xs:string; cppagent emits a four-segment release string. + +using System; +using System.IO; +using System.Linq; +using System.Xml; +using System.Xml.Linq; +using MTConnect.Agents; +using MTConnect.Configurations; +using MTConnect.Devices; +using MTConnect.Devices.Xml; +using NUnit.Framework; + +namespace MTConnect.Tests.Xml.Headers +{ + [TestFixture] + public class HeaderVersionXmlRoundTripTests + { + // Versions for which the MTConnect XML wire-format library + // (libraries/MTConnect.NET-XML) currently provides namespace + // and schema-location mappings via Namespaces.GetDevices / + // Schemas.GetDevices. The set is hardcoded rather than + // reflected from MTConnect.MTConnectVersions so that adding a + // new MTConnectVersions constant (which only declares the + // version, not the XML namespace/schema mappings) does not + // silently introduce a parametric test case that the XML + // library cannot serialize. Versions added beyond this set + // require matching entries in Namespaces.cs and Schemas.cs + // before the test case can be added here. + public static System.Collections.Generic.IEnumerable AllSupportedVersions() + { + return new[] + { + new Version(1, 0), + new Version(1, 1), + new Version(1, 2), + new Version(1, 3), + new Version(1, 4), + new Version(1, 5), + new Version(1, 6), + new Version(1, 7), + new Version(1, 8), + new Version(2, 0), + new Version(2, 1), + new Version(2, 2), + new Version(2, 3), + new Version(2, 4), + new Version(2, 5), + }; + } + + private static string ExpectedHeaderVersion(Version configuredVersion) + { + return new Version( + configuredVersion.Major, + configuredVersion.Minor, + 0, + 0).ToString(); + } + + [TestCaseSource(nameof(AllSupportedVersions))] + public void Devices_xml_payload_carries_configured_mtconnect_release_in_header_version(Version configuredVersion) + { + using var broker = BuildBroker(configuredVersion); + var document = broker.GetDevicesResponseDocument(); + + Assert.That(document, Is.Not.Null); + + using var xmlStream = XmlDevicesResponseDocument.ToXmlStream(document!); + Assert.That(xmlStream, Is.Not.Null); + + xmlStream!.Position = 0; + using var reader = new StreamReader(xmlStream); + var xml = reader.ReadToEnd(); + + // Use XmlReader to locate the Header element robustly + // (XML is namespace-qualified per the MTConnect schema). + var doc = XDocument.Parse(xml); + var header = doc.Root!.Elements() + .First(e => e.Name.LocalName == "Header"); + + Assert.That( + header.Attribute("version")?.Value, + Is.EqualTo(ExpectedHeaderVersion(configuredVersion)), + "Header.version attribute on the wire-format XML envelope must equal the configured MTConnect release."); + } + + private static MTConnectAgentBroker BuildBroker(Version configuredVersion) + { + var configuration = new AgentConfiguration + { + DefaultVersion = configuredVersion + }; + var broker = new MTConnectAgentBroker(configuration); + // Construct the test Device with every required field set + // explicitly. The Device default constructor is not guaranteed + // to populate Id / Name / Uuid (older revisions auto-generated + // them; newer revisions strip those defaults to honor the XSD + // `uuid` "for entire life" identity contract). Setting them + // here keeps the test green across both shapes. + broker.AddDevice(new Device + { + Id = "round-trip-device", + Uuid = "round-trip-device", + Name = "RoundTripDevice", + }); + return broker; + } + } +}