From ff905cf246cc406a87eaef8742f71c2df2e9f175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Thu, 11 Jun 2026 20:16:52 +0200 Subject: [PATCH 01/14] test(common): pin empty-Result UNAVAILABLE coerce contract (RED) --- .../AddObservationEmptyResultCoerceTests.cs | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs diff --git a/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs b/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs new file mode 100644 index 000000000..6e8bd3f5c --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Agents/AddObservationEmptyResultCoerceTests.cs @@ -0,0 +1,142 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.Linq; +using MTConnect.Agents; +using MTConnect.Configurations; +using MTConnect.Devices; +using MTConnect.Devices.DataItems; +using MTConnect.Observations; +using NUnit.Framework; + +namespace MTConnect.Tests.Common.Agents +{ + /// + /// Regression pin for the empty-Result wire-level spec violation in + /// : + /// when the inbound observation carries a null, empty, or whitespace-only + /// Result value, the agent currently publishes that value verbatim on the + /// wire. The MTConnect Part 1 Observation Information Model mandates + /// "UNAVAILABLE" as the sole valid representation of a missing value, so + /// the SDK must coerce null / empty / whitespace to + /// before the observation reaches + /// the buffer. + /// + /// Spec: MTConnect Standard, Part 1 - Devices Information Model, + /// Observation Information Model - Representation - Observation Values + /// ("If an Agent cannot determine a Valid Data Value for a DataItem, the + /// value returned for the Result for the Data Entity MUST be reported as + /// UNAVAILABLE."). + /// + /// This fixture pins the coerce on the canonical + /// AddObservation(string, IObservationInput, ...) overload that + /// every other AddObservation overload routes through. The convenience + /// overload AddObservation(string deviceKey, string dataItemKey, object value, DateTime timestamp) + /// at MTConnectAgent.cs:2007 is used as the exercise vector because it + /// builds a fresh ObservationInput and routes through the canonical path + /// at MTConnectAgent.cs:2123. + /// + [TestFixture] + [Category("AddObservationEmptyResultCoerce")] + public class AddObservationEmptyResultCoerceTests + { + private const string DeviceKey = "U-COERCE"; + private const string DataItemKey = "availability"; + + private static readonly InputValidationLevel[] _nonStrictLevels = + { + InputValidationLevel.Ignore, + InputValidationLevel.Warning, + InputValidationLevel.Remove, + }; + + private static readonly object?[] _nullEmptyWhitespaceValues = + { + new object?[] { null }, + new object?[] { string.Empty }, + new object?[] { " " }, + new object?[] { "\t" }, + new object?[] { "\n" }, + }; + + /// Pins the positive contract: under every non-Strict input-validation level, an empty-string Result is coerced to on the wire. + /// The input-validation level the agent operates under. + [Test] + [TestCaseSource(nameof(_nonStrictLevels))] + public void AddObservation_EmptyStringResult_Coerced_To_Unavailable_Under_NonStrict_Levels(InputValidationLevel level) + { + using var agent = NewAgentWithAvailabilityDataItem(level); + + var added = agent.AddObservation(DeviceKey, DataItemKey, (object)string.Empty, DateTime.UtcNow); + + Assert.That(added, Is.True, "empty-Result observation must reach the buffer post-coerce"); + Assert.That(CurrentResult(agent), Is.EqualTo(Observation.Unavailable), + "Part 1 mandates UNAVAILABLE for any Result that is null, empty, or whitespace"); + } + + /// Pins the positive contract across the null / empty / whitespace family: every such Result is coerced to . + /// The non-Valid-Data-Value Result the caller forwards in. + [Test] + [TestCaseSource(nameof(_nullEmptyWhitespaceValues))] + public void AddObservation_NullEmptyOrWhitespaceResult_Coerced_To_Unavailable(object? badValue) + { + using var agent = NewAgentWithAvailabilityDataItem(InputValidationLevel.Warning); + + var added = agent.AddObservation(DeviceKey, DataItemKey, badValue!, DateTime.UtcNow); + + Assert.That(added, Is.True); + Assert.That(CurrentResult(agent), Is.EqualTo(Observation.Unavailable)); + } + + /// Pins the secondary defect's positive contract: under , an empty-Result observation is coerced and lands in the buffer rather than being silently dropped. + [Test] + public void AddObservation_EmptyResult_Under_Strict_Coerced_And_Lands() + { + using var agent = NewAgentWithAvailabilityDataItem(InputValidationLevel.Strict); + + var added = agent.AddObservation(DeviceKey, DataItemKey, (object)string.Empty, DateTime.UtcNow); + + Assert.That(added, Is.True, "Strict must coerce to UNAVAILABLE — never silently drop an empty Result"); + Assert.That(CurrentResult(agent), Is.EqualTo(Observation.Unavailable)); + } + + /// Pins the negative contract: a concrete non-empty Result is preserved verbatim — the coerce only fires on the null / empty / whitespace family. + [Test] + public void AddObservation_ConcreteResult_Is_Preserved_Verbatim() + { + using var agent = NewAgentWithAvailabilityDataItem(InputValidationLevel.Warning); + + var added = agent.AddObservation(DeviceKey, DataItemKey, (object)"AVAILABLE", DateTime.UtcNow); + + Assert.That(added, Is.True); + Assert.That(CurrentResult(agent), Is.EqualTo("AVAILABLE"), + "the coerce must not substitute a sentinel for a Valid Data Value"); + } + + private static MTConnectAgentBroker NewAgentWithAvailabilityDataItem(InputValidationLevel level) + { + var config = new AgentConfiguration { InputValidationLevel = level }; + var agent = new MTConnectAgentBroker(config); + agent.Start(); + + var device = new Device + { + Id = "d-coerce", + Name = "d-coerce", + Uuid = DeviceKey, + }; + device.AddDataItem(new AvailabilityDataItem(device.Id)); + + var added = agent.AddDevice(device); + Assert.That(added, Is.Not.Null, "AddDevice must succeed for test pre-condition"); + return agent; + } + + private static object? CurrentResult(IMTConnectAgentBroker agent) + { + var current = agent.GetCurrentObservations(DeviceKey, DataItemKey).SingleOrDefault(); + return current?.GetValue(ValueKeys.Result); + } + } +} From f24f471488ee48a784204168cf13348b2d7766af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Thu, 11 Jun 2026 20:16:52 +0200 Subject: [PATCH 02/14] test(integration): pin empty-Result UNAVAILABLE coerce wire (E2E) --- ...ultUnavailableSampleStreamWorkflowTests.cs | 243 ++++++++++++ ...tionEmptyResultUnavailableWorkflowTests.cs | 355 ++++++++++++++++++ 2 files changed, 598 insertions(+) create mode 100644 tests/MTConnect.NET-Integration-Tests/Workflows/AddObservationEmptyResultUnavailableSampleStreamWorkflowTests.cs create mode 100644 tests/MTConnect.NET-Integration-Tests/Workflows/AddObservationEmptyResultUnavailableWorkflowTests.cs diff --git a/tests/MTConnect.NET-Integration-Tests/Workflows/AddObservationEmptyResultUnavailableSampleStreamWorkflowTests.cs b/tests/MTConnect.NET-Integration-Tests/Workflows/AddObservationEmptyResultUnavailableSampleStreamWorkflowTests.cs new file mode 100644 index 000000000..1efff5da6 --- /dev/null +++ b/tests/MTConnect.NET-Integration-Tests/Workflows/AddObservationEmptyResultUnavailableSampleStreamWorkflowTests.cs @@ -0,0 +1,243 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using MTConnect; +using MTConnect.Agents; +using MTConnect.Configurations; +using MTConnect.Devices; +using MTConnect.Devices.DataItems; +using MTConnect.Servers.Http; +using Xunit; + +namespace MTConnect.Tests.Integration.Workflows +{ + // Wire-level E2E for MTConnect Part 1 Observation Information Model — Representation — + // Observation Values: a null, empty, or whitespace-only Result value MUST surface as + // "UNAVAILABLE" on the wire, not as the empty value verbatim. + // + // The sibling fixture AddObservationEmptyResultUnavailableWorkflowTests pins the same + // contract against the /current envelope. This fixture extends that coverage to the + // /sample stream — every observation written to the buffer post-coerce must surface + // as UNAVAILABLE in the sample envelope, and a concrete-then-empty sequence must + // produce two distinct observations in the stream (concrete first, UNAVAILABLE + // second). /sample preserves history (vs /current which is keyed by latest), so it + // is the stronger pyramid signal for any future regression that re-renders or + // rewrites buffered observations during stream emission. + // + // Spec authority: MTConnect Standard, Part 1 - Devices Information Model, Observation + // Information Model - Representation - Observation Values: + // "If an Agent cannot determine a Valid Data Value for a DataItem, the value returned + // for the Result for the Data Entity MUST be reported as UNAVAILABLE." + /// Represents the empty-result coerce wire-level E2E fixture for the /sample stream. + [Trait("Category", "E2E")] + public sealed class AddObservationEmptyResultUnavailableSampleStreamWorkflowTests : IDisposable + { + private const string DeviceUuid = "AvailabilityCoerce-SAMPLE-DEVICE"; + private const string DeviceName = "AvailabilityCoerceSample"; + private const string DeviceId = "availability-coerce-sample-device"; + private const string DataItemId = "availability-coerce-sample-availability"; + + private readonly IMTConnectAgentBroker _agent; + private readonly MTConnectHttpServer _server; + private readonly int _port; + + /// Initialises a new instance of the /sample stream empty-Result coerce fixture. + public AddObservationEmptyResultUnavailableSampleStreamWorkflowTests() + { + _port = AllocateLoopbackPort(); + + var agentConfig = new AgentConfiguration + { + DefaultVersion = MTConnectVersions.Version25, + }; + _agent = new MTConnectAgentBroker(agentConfig); + _agent.Start(); + + var device = new Device + { + Id = DeviceId, + Name = DeviceName, + Uuid = DeviceUuid, + }; + device.AddDataItem(new AvailabilityDataItem(DeviceId) { Id = DataItemId }); + + var added = _agent.AddDevice(device); + Assert.NotNull(added); + + var serverConfig = new HttpServerConfiguration + { + Port = _port, + Server = "127.0.0.1", + }; + _server = new MTConnectHttpServer(serverConfig, _agent); + + Exception? startupException = null; + _server.ServerException += (_, ex) => startupException ??= ex; + _server.Start(); + + WaitForListener("127.0.0.1", _port, TimeSpan.FromSeconds(30), () => startupException); + } + + /// Runs the dispose operation. + public void Dispose() + { + _server?.Stop(); + _agent?.Stop(); + } + + /// Pins the behaviour expressed by the test name across the full null / empty / whitespace family: every member is coerced to UNAVAILABLE on the /sample stream. + /// The pre-coerce Result the caller forwards in. + /// Human-readable label for the case. + /// The result of the operation. + [Theory] + [InlineData(null, "null")] + [InlineData("", "empty")] + [InlineData(" ", "spaces")] + [InlineData("\t", "tab")] + [InlineData("\n", "newline")] + [InlineData("\r\n", "crlf")] + public async Task AddObservation_with_invalid_result_renders_UNAVAILABLE_on_sample_stream(string? value, string label) + { + var added = _agent.AddObservation( + DeviceUuid, + DataItemId, + (object)value!, + DateTime.UtcNow); + Assert.True(added, $"[{label}] non-Valid-Data-Value AddObservation must reach the buffer post-coerce"); + + var availabilities = await FetchAvailabilityElementsAsync(); + + // The default device pre-seeds an UNAVAILABLE sample at sequence 1, then the + // AddObservation above writes the coerced UNAVAILABLE at sequence 2. The + // latest observation we explicitly wrote MUST surface as UNAVAILABLE. + Assert.NotEmpty(availabilities); + var latest = availabilities[^1]; + Assert.Equal("UNAVAILABLE", latest.Value); + } + + /// Pins the behaviour expressed by the test name: a concrete-then-empty sequence renders two distinct samples on /sample (concrete first, then UNAVAILABLE), proving the coerce did not silently drop the second observation. + /// The result of the operation. + [Fact] + public async Task AddObservation_concrete_then_empty_renders_distinct_samples_on_sample_stream() + { + var t0 = DateTime.UtcNow; + Assert.True(_agent.AddObservation(DeviceUuid, DataItemId, (object)"AVAILABLE", t0)); + Assert.True(_agent.AddObservation(DeviceUuid, DataItemId, (object)string.Empty, t0.AddSeconds(1))); + + var availabilities = await FetchAvailabilityElementsAsync(); + + // Filter out the agent's pre-seed UNAVAILABLE at sequence 1 (added when + // AddDevice runs) — the two AddObservation calls above produce the LAST two + // observations in sequence order. Their values must read AVAILABLE then + // UNAVAILABLE, in that order; the second observation is the coerce surfacing + // on the wire and must NOT be dropped. + Assert.True(availabilities.Count >= 2, $"expected at least 2 Availability samples, got {availabilities.Count}"); + Assert.Equal("AVAILABLE", availabilities[^2].Value); + Assert.Equal("UNAVAILABLE", availabilities[^1].Value); + } + + /// Pins the behaviour expressed by the test name: concrete value survives the /sample stream round-trip verbatim — the coerce must not substitute for a Valid Data Value. + /// The result of the operation. + [Fact] + public async Task AddObservation_with_concrete_result_renders_value_verbatim_on_sample_stream() + { + var added = _agent.AddObservation( + DeviceUuid, + DataItemId, + (object)"AVAILABLE", + DateTime.UtcNow); + Assert.True(added); + + var availabilities = await FetchAvailabilityElementsAsync(); + + Assert.NotEmpty(availabilities); + Assert.Equal("AVAILABLE", availabilities[^1].Value); + } + + private async Task> FetchAvailabilityElementsAsync() + { + using var http = new HttpClient + { + BaseAddress = new Uri($"http://127.0.0.1:{_port}/"), + Timeout = TimeSpan.FromSeconds(15), + }; + + var response = await http.GetAsync("sample?from=0&count=100"); + Assert.True( + response.IsSuccessStatusCode, + $"/sample returned {(int)response.StatusCode} {response.ReasonPhrase}"); + + var body = await response.Content.ReadAsStringAsync(); + + // The streaming envelope renders each observation under / + // / / as a dedicated + // element with its own dataItemId + sequence attributes. Locate every element + // matching the fixture's DataItemId, ordered by their document position + // (sequence order, oldest first) — a naive substring or single-Descendants + // call would miss the multi-sample semantics of /sample. + var doc = XDocument.Parse(body); + return doc.Descendants() + .Where(e => string.Equals( + (string?)e.Attribute("dataItemId"), + DataItemId, + StringComparison.Ordinal)) + .ToList(); + } + + private static int AllocateLoopbackPort() + { + using var listener = new TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static void WaitForListener( + string host, + int port, + TimeSpan timeout, + Func serverStartException) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + var startupException = serverStartException(); + if (startupException != null) + { + throw new InvalidOperationException( + $"HTTP server failed to start on {host}:{port}: {startupException.Message}", + startupException); + } + + try + { + using var client = new TcpClient(); + client.Connect(host, port); + if (client.Connected) + { + return; + } + } + catch (SocketException) + { + // not listening yet; keep polling + } + + Thread.Sleep(100); + } + + throw new TimeoutException( + $"HTTP listener did not bind to {host}:{port} within {timeout.TotalSeconds}s."); + } + } +} diff --git a/tests/MTConnect.NET-Integration-Tests/Workflows/AddObservationEmptyResultUnavailableWorkflowTests.cs b/tests/MTConnect.NET-Integration-Tests/Workflows/AddObservationEmptyResultUnavailableWorkflowTests.cs new file mode 100644 index 000000000..cf16415bb --- /dev/null +++ b/tests/MTConnect.NET-Integration-Tests/Workflows/AddObservationEmptyResultUnavailableWorkflowTests.cs @@ -0,0 +1,355 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using MTConnect; +using MTConnect.Agents; +using MTConnect.Configurations; +using MTConnect.Devices; +using MTConnect.Devices.DataItems; +using MTConnect.Servers.Http; +using Xunit; + +namespace MTConnect.Tests.Integration.Workflows +{ + // Wire-level E2E for MTConnect Part 1 Observation Information Model — Representation — + // Observation Values: a null, empty, or whitespace-only Result value MUST surface as + // "UNAVAILABLE" on the wire, not as the empty value verbatim. + // + // The agent's AddObservation(deviceKey, dataItemKey, value, timestamp) path is exercised + // with an empty-string Result; an HTTP GET /current is issued against the loopback-bound + // MTConnectHttpServer the agent feeds. The rendered XML envelope is inspected directly — + // the assertion is that the AVAILABILITY data item carries value="UNAVAILABLE" rather + // than an empty value attribute. This pins the SDK's coerce all the way through to the + // wire, so a future regression in MTConnectAgent.AddObservation, the Common -> HTTP -> XML + // formatter chain, or anything between the buffer and the wire is caught here. + // + // Spec authority: MTConnect Standard, Part 1 - Devices Information Model, Observation + // Information Model - Representation - Observation Values: + // "If an Agent cannot determine a Valid Data Value for a DataItem, the value returned + // for the Result for the Data Entity MUST be reported as UNAVAILABLE." + /// Represents the empty-result coerce workflow tests. + [Trait("Category", "E2E")] + public sealed class AddObservationEmptyResultUnavailableWorkflowTests : IDisposable + { + private const string DeviceUuid = "AvailabilityCoerce-DEVICE"; + private const string DeviceName = "AvailabilityCoerce"; + private const string DeviceId = "availability-coerce-device"; + private const string DataItemId = "availability-coerce-availability"; + + private readonly IMTConnectAgentBroker _agent; + private readonly MTConnectHttpServer _server; + private readonly int _port; + + /// Initialises a new instance of the empty-result coerce workflow tests type. + public AddObservationEmptyResultUnavailableWorkflowTests() + { + _port = AllocateLoopbackPort(); + + var agentConfig = new AgentConfiguration + { + DefaultVersion = MTConnectVersions.Version25, + }; + _agent = new MTConnectAgentBroker(agentConfig); + _agent.Start(); + + var device = new Device + { + Id = DeviceId, + Name = DeviceName, + Uuid = DeviceUuid, + }; + device.AddDataItem(new AvailabilityDataItem(DeviceId) { Id = DataItemId }); + + var added = _agent.AddDevice(device); + Assert.NotNull(added); + + var serverConfig = new HttpServerConfiguration + { + Port = _port, + Server = "127.0.0.1", + }; + _server = new MTConnectHttpServer(serverConfig, _agent); + + Exception? startupException = null; + _server.ServerException += (_, ex) => startupException ??= ex; + _server.Start(); + + WaitForListener("127.0.0.1", _port, TimeSpan.FromSeconds(30), () => startupException); + } + + /// Runs the dispose operation. + public void Dispose() + { + _server?.Stop(); + _agent?.Stop(); + } + + /// Pins the behaviour expressed by the test name across the full null / empty / whitespace family: every member is coerced to UNAVAILABLE on the current envelope. + /// The pre-coerce Result the caller forwards in. + /// Human-readable label for the case (drives the test display name). + /// The result of the operation. + [Theory] + [InlineData(null, "null")] + [InlineData("", "empty")] + [InlineData(" ", "spaces")] + [InlineData("\t", "tab")] + [InlineData("\n", "newline")] + [InlineData("\r\n", "crlf")] + public async Task AddObservation_with_invalid_result_renders_UNAVAILABLE_on_current_envelope(string? value, string label) + { + var added = _agent.AddObservation( + DeviceUuid, + DataItemId, + (object)value!, + DateTime.UtcNow); + Assert.True(added, $"[{label}] non-Valid-Data-Value AddObservation must reach the buffer post-coerce"); + + using var http = new HttpClient + { + BaseAddress = new Uri($"http://127.0.0.1:{_port}/"), + Timeout = TimeSpan.FromSeconds(15), + }; + + var response = await http.GetAsync("current"); + + Assert.True( + response.IsSuccessStatusCode, + $"[{label}] /current returned {(int)response.StatusCode} {response.ReasonPhrase}"); + + var body = await response.Content.ReadAsStringAsync(); + + // The streaming envelope renders the AVAILABILITY observation as + // UNAVAILABLE + // (the value lives in the element body, not in a value="..." attribute). + // Parse the response and locate the specific element whose dataItemId + // matches the device's AVAILABILITY data item, then assert its body + // is the UNAVAILABLE sentinel. A naive substring check is unsafe — the + // envelope carries other UNAVAILABLE entries for every uninitialised + // sibling data item (AssetChanged, AssetRemoved, etc.) and would yield + // a false positive against an empty-body Availability element. + var availability = FindObservationByDataItemId(body, DataItemId); + Assert.NotNull(availability); + Assert.Equal("UNAVAILABLE", availability!.Value); + } + + /// Pins the behaviour expressed by the test name: concrete value survives the wire round-trip verbatim. + /// The result of the operation. + [Fact] + public async Task AddObservation_with_concrete_result_renders_value_verbatim() + { + var added = _agent.AddObservation( + DeviceUuid, + DataItemId, + (object)"AVAILABLE", + DateTime.UtcNow); + Assert.True(added); + + using var http = new HttpClient + { + BaseAddress = new Uri($"http://127.0.0.1:{_port}/"), + Timeout = TimeSpan.FromSeconds(15), + }; + + var response = await http.GetAsync("current"); + + Assert.True(response.IsSuccessStatusCode); + + var body = await response.Content.ReadAsStringAsync(); + + // Locate the specific AVAILABILITY element by dataItemId attribute, then + // assert its body is "AVAILABLE" — pinning that the coerce did NOT + // substitute UNAVAILABLE for the valid concrete value. The substring + // approach is unsafe because the envelope's other (uninitialised) data + // items carry UNAVAILABLE in their bodies. + var availability = FindObservationByDataItemId(body, DataItemId); + Assert.NotNull(availability); + Assert.Equal("AVAILABLE", availability!.Value); + } + + /// Pins the behaviour expressed by the test name: a concrete-then-empty sequence renders UNAVAILABLE on /current (the latest observation wins). + /// The result of the operation. + [Fact] + public async Task AddObservation_concrete_then_empty_renders_UNAVAILABLE_on_current_envelope() + { + var t0 = DateTime.UtcNow; + Assert.True(_agent.AddObservation(DeviceUuid, DataItemId, (object)"AVAILABLE", t0)); + Assert.True(_agent.AddObservation(DeviceUuid, DataItemId, (object)string.Empty, t0.AddSeconds(1))); + + using var http = new HttpClient + { + BaseAddress = new Uri($"http://127.0.0.1:{_port}/"), + Timeout = TimeSpan.FromSeconds(15), + }; + + var response = await http.GetAsync("current"); + Assert.True(response.IsSuccessStatusCode); + + var body = await response.Content.ReadAsStringAsync(); + // /current reflects the latest observation only; the second AddObservation + // (empty Result) coerced to UNAVAILABLE must overwrite the concrete value + // posted first. + var availability = FindObservationByDataItemId(body, DataItemId); + Assert.NotNull(availability); + Assert.Equal("UNAVAILABLE", availability!.Value); + } + + /// Pins the behaviour expressed by the test name: under InputValidationLevel.Strict an empty Result coerces and lands on /current rather than being silently dropped pre-fix. + /// The result of the operation. + [Fact] + public async Task AddObservation_with_empty_result_under_Strict_validation_lands_on_current_envelope() + { + using var strict = StrictHarness.Create(); + + var added = strict.Agent.AddObservation( + StrictHarness.Uuid, + StrictHarness.DataItemId, + (object)string.Empty, + DateTime.UtcNow); + Assert.True(added, "Strict must coerce to UNAVAILABLE — never silently drop an empty Result"); + + using var http = new HttpClient + { + BaseAddress = new Uri($"http://127.0.0.1:{strict.Port}/"), + Timeout = TimeSpan.FromSeconds(15), + }; + + var response = await http.GetAsync("current"); + Assert.True(response.IsSuccessStatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var availability = FindObservationByDataItemId(body, StrictHarness.DataItemId); + Assert.NotNull(availability); + Assert.Equal("UNAVAILABLE", availability!.Value); + } + + // Per-test harness for the Strict-level case: the default fixture-level + // agent runs under InputValidationLevel.Warning so it can't exercise the + // pre-fix silent-drop pathology. The Strict harness spins its own agent + // + server + loopback port and disposes them when the test ends. + private sealed class StrictHarness : IDisposable + { + public const string Uuid = "AvailabilityCoerce-STRICT"; + public const string DataItemId = "availability-coerce-strict"; + private const string DeviceName = "AvailabilityCoerceStrict"; + private const string DeviceId = "availability-coerce-strict-device"; + + public IMTConnectAgentBroker Agent { get; } + public MTConnectHttpServer Server { get; } + public int Port { get; } + + private StrictHarness(IMTConnectAgentBroker agent, MTConnectHttpServer server, int port) + { + Agent = agent; + Server = server; + Port = port; + } + + public static StrictHarness Create() + { + var port = AllocateLoopbackPort(); + var agentConfig = new AgentConfiguration + { + DefaultVersion = MTConnectVersions.Version25, + InputValidationLevel = InputValidationLevel.Strict, + }; + var agent = new MTConnectAgentBroker(agentConfig); + agent.Start(); + + var device = new Device + { + Id = DeviceId, + Name = DeviceName, + Uuid = Uuid, + }; + device.AddDataItem(new AvailabilityDataItem(DeviceId) { Id = DataItemId }); + Assert.NotNull(agent.AddDevice(device)); + + var serverConfig = new HttpServerConfiguration + { + Port = port, + Server = "127.0.0.1", + }; + var server = new MTConnectHttpServer(serverConfig, agent); + Exception? startupException = null; + server.ServerException += (_, ex) => startupException ??= ex; + server.Start(); + WaitForListener("127.0.0.1", port, TimeSpan.FromSeconds(30), () => startupException); + + return new StrictHarness(agent, server, port); + } + + public void Dispose() + { + Server.Stop(); + Agent.Stop(); + } + } + + private static XElement? FindObservationByDataItemId(string envelopeXml, string dataItemId) + { + var doc = XDocument.Parse(envelopeXml); + return doc.Descendants() + .FirstOrDefault(e => + string.Equals( + (string?)e.Attribute("dataItemId"), + dataItemId, + StringComparison.Ordinal)); + } + + private static int AllocateLoopbackPort() + { + using var listener = new TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } + + private static void WaitForListener( + string host, + int port, + TimeSpan timeout, + Func serverStartException) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + var startupException = serverStartException(); + if (startupException != null) + { + throw new InvalidOperationException( + $"HTTP server failed to start on {host}:{port}: {startupException.Message}", + startupException); + } + + try + { + using var client = new TcpClient(); + client.Connect(host, port); + if (client.Connected) + { + return; + } + } + catch (SocketException) + { + // not listening yet; keep polling + } + + Thread.Sleep(100); + } + + throw new TimeoutException( + $"HTTP listener did not bind to {host}:{port} within {timeout.TotalSeconds}s."); + } + } +} From 4872077ecb82e45eae97d5cf187887f18af612bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Thu, 11 Jun 2026 20:16:52 +0200 Subject: [PATCH 03/14] fix(common): coerce empty Result to UNAVAILABLE in AddObservation --- .../Agents/MTConnectAgent.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs index e1276ec00..375bb3022 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs @@ -2152,6 +2152,17 @@ public bool AddObservation(string deviceKey, IObservationInput observationInput, var dataItem = GetDataItem(deviceUuid, input.DataItemKey); if (dataItem != null) { + // Coerce a null, empty, or whitespace-only Result to UNAVAILABLE before the + // observation reaches the buffer. The MTConnect Part 1 Observation Information + // Model mandates "UNAVAILABLE" as the sole valid representation of a missing + // value; an empty string is never a Valid Data Value. The coerce runs above + // validation so Strict no longer silently drops empty-Result observations and + // every other validation level publishes the spec-compliant sentinel. + if (dataItem.Category != DataItemCategory.CONDITION && IsEmptyResult(input)) + { + CoerceEmptyResultToUnavailable(input); + } + // Add required properties switch (dataItem.Representation) { @@ -2277,6 +2288,41 @@ public bool AddObservation(string deviceKey, IObservationInput observationInput, } + /// + /// Returns true when the observation's Result value is null, the empty string, or whitespace-only. + /// + /// + /// An empty Result is, by definition, not a Valid Data Value under any typed DataItem schema; the + /// MTConnect Part 1 Observation Information Model mandates "UNAVAILABLE" as the sole valid + /// representation of a missing value. This predicate identifies the inputs the SDK coerces. + /// + private static bool IsEmptyResult(IObservationInput input) + { + var result = input.GetValue(ValueKeys.Result); + if (result == null) return true; + return string.IsNullOrWhiteSpace(result); + } + + /// + /// Rewrites the observation's Result value to and flags the input as unavailable. + /// + /// + /// Removes any prior Result entry (so the Values collection does not carry a duplicate ValueKey), + /// adds the UNAVAILABLE sentinel, and sets so downstream + /// consumers that branch on the flag observe the coerced state. Spec authority: MTConnect Part 1 + /// Observation Information Model - Representation - Observation Values. + /// + private static void CoerceEmptyResultToUnavailable(IObservationInput input) + { + var preserved = (input.Values ?? Enumerable.Empty()) + .Where(v => v.Key != ValueKeys.Result) + .ToList(); + input.Values = preserved; + input.AddValue(ValueKeys.Result, Observation.Unavailable); + input.IsUnavailable = true; + } + + /// /// Add new Observations for DataItems to the Agent /// From 4ae3d6269d2fa66b07d54b4e52794dba884bc8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Thu, 11 Jun 2026 20:16:52 +0200 Subject: [PATCH 04/14] ci(workflow): drop matrix expressions from job display names --- .github/workflows/dotnet.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 4bf2a6ec3..97f2035d8 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -36,7 +36,7 @@ jobs: # paid exactly once across the whole workflow (in job 2 below). # ------------------------------------------------------------------ build-and-test: - name: build-and-test-${{ matrix.os }} + name: build-and-test # Skip drafts: run only on push-to-master + ready (non-draft) PRs. # The pull_request `types` list above includes `ready_for_review` # so CI fires the moment a draft is flipped to ready. @@ -323,7 +323,7 @@ jobs: # categories run exactly once (in job 1) across the workflow. # ------------------------------------------------------------------ route-check-e2e: - name: route-check-e2e-shard${{ matrix.shard }}of${{ matrix.shardTotal }} + name: route-check-e2e if: github.event_name == 'push' || github.event.pull_request.draft == false needs: docs-prepare runs-on: ubuntu-latest From ffa6abbedce8f6291f604baa41d2b49b8b67e5cd Mon Sep 17 00:00:00 2001 From: Patrick Ritchie Date: Fri, 26 Jun 2026 15:16:46 -0400 Subject: [PATCH 05/14] Fixed issue with SHDR dropping empty value This is often used for messages and program names. SHDR should pass the value as is to the Agent and the Agent should then decide (based on validation level) whether to accept the value or not. --- libraries/MTConnect.NET-SHDR/Shdr/ShdrDataItem.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libraries/MTConnect.NET-SHDR/Shdr/ShdrDataItem.cs b/libraries/MTConnect.NET-SHDR/Shdr/ShdrDataItem.cs index 47d3e98fc..1b0299cc9 100644 --- a/libraries/MTConnect.NET-SHDR/Shdr/ShdrDataItem.cs +++ b/libraries/MTConnect.NET-SHDR/Shdr/ShdrDataItem.cs @@ -391,7 +391,13 @@ private static IEnumerable FromKeyValuePairs(string input, long ti dataItem.AddValue(new ObservationValue(ValueKeys.Result, valueString != null ? valueString.ToString().Trim() : string.Empty)); + dataItems.Add(dataItem); + } + else + { + // Assume Empty Value + dataItem.AddValue(new ObservationValue(ValueKeys.Result, string.Empty)); dataItems.Add(dataItem); } } From 10ff9e371a1b38ac3796f0758a9da11fa5173f7f Mon Sep 17 00:00:00 2001 From: Patrick Ritchie Date: Fri, 26 Jun 2026 15:20:05 -0400 Subject: [PATCH 06/14] Updated to toggle on/off based on input validation level set to 'strict' Although, based on the MTConnect Standard, a value should never be 'empty', an 'empty' value should be able to be accepted as many adpaters/agents don't adhere to this restriction and could cause issues with real world implementations. An InputValidationLevel set to 'strict' should rewrite an 'empty' value as 'UNAVAILABLE' so that it strictly conforms to the standard. --- libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs index 375bb3022..bf6edaf82 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs @@ -2158,7 +2158,7 @@ public bool AddObservation(string deviceKey, IObservationInput observationInput, // value; an empty string is never a Valid Data Value. The coerce runs above // validation so Strict no longer silently drops empty-Result observations and // every other validation level publishes the spec-compliant sentinel. - if (dataItem.Category != DataItemCategory.CONDITION && IsEmptyResult(input)) + if (dataItem.Category != DataItemCategory.CONDITION && IsEmptyResult(input) && _configuration.InputValidationLevel == InputValidationLevel.Strict) { CoerceEmptyResultToUnavailable(input); } From fabeed427ff864594f00a7745255f83681c43588 Mon Sep 17 00:00:00 2001 From: Patrick Ritchie Date: Fri, 26 Jun 2026 16:10:17 -0400 Subject: [PATCH 07/14] Added separate DeviceValidationLevel propery and enum to handle MTConnectDevices validation. This allows a device to be validated at a different level than observations/assets --- .../Agents/DeviceValidationLevel.cs | 32 +++++++++++++++++++ .../Agents/MTConnectAgent.cs | 18 +++++------ .../Configurations/AgentConfiguration.cs | 7 ++++ .../Configurations/IAgentConfiguration.cs | 5 +++ 4 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 libraries/MTConnect.NET-Common/Agents/DeviceValidationLevel.cs diff --git a/libraries/MTConnect.NET-Common/Agents/DeviceValidationLevel.cs b/libraries/MTConnect.NET-Common/Agents/DeviceValidationLevel.cs new file mode 100644 index 000000000..a3aa86125 --- /dev/null +++ b/libraries/MTConnect.NET-Common/Agents/DeviceValidationLevel.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +namespace MTConnect.Agents +{ + /// + /// Controls how the Agent reacts when Devices data fails validation against + /// the MTConnect Standard. + /// + public enum DeviceValidationLevel + { + /// + /// Accept invalid device information; perform no validation action. + /// + Ignore, + + /// + /// Accept invalid device information but emit a validation warning. + /// + Warning, + + /// + /// Drop the invalid device information and continue processing the remainder. + /// + Remove, + + /// + /// Reject the entire device information on the first validation failure. + /// + Strict + } +} \ No newline at end of file diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs index bf6edaf82..e8958e009 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs @@ -1312,15 +1312,15 @@ private Device NormalizeDevice(IDevice device) foreach (var genericComponent in genericComponents) { var validationResults = new ValidationResult(false, $"Invalid Component : \"{genericComponent.Type}\" Not Found"); - if (_configuration.InputValidationLevel > InputValidationLevel.Ignore) + if (_configuration.DeviceValidationLevel > DeviceValidationLevel.Ignore) { MulticastIsolation.Raise(InvalidComponentAdded, h => h(obj.Uuid, genericComponent, validationResults)); // Remove Component from Device - if (_configuration.InputValidationLevel == InputValidationLevel.Remove) obj.RemoveComponent(genericComponent.Id); + if (_configuration.DeviceValidationLevel == DeviceValidationLevel.Remove) obj.RemoveComponent(genericComponent.Id); // Invalidate entire Device - if (_configuration.InputValidationLevel == InputValidationLevel.Strict) return null; + if (_configuration.DeviceValidationLevel == DeviceValidationLevel.Strict) return null; } } } @@ -1332,15 +1332,15 @@ private Device NormalizeDevice(IDevice device) foreach (var genericComposition in genericCompositions) { var validationResults = new ValidationResult(false, $"Invalid Composition : \"{genericComposition.Type}\" Not Found"); - if (_configuration.InputValidationLevel > InputValidationLevel.Ignore) + if (_configuration.DeviceValidationLevel > DeviceValidationLevel.Ignore) { MulticastIsolation.Raise(InvalidCompositionAdded, h => h(obj.Uuid, genericComposition, validationResults)); // Remove Compsition from Device - if (_configuration.InputValidationLevel == InputValidationLevel.Remove) obj.RemoveComposition(genericComposition.Id); + if (_configuration.DeviceValidationLevel == DeviceValidationLevel.Remove) obj.RemoveComposition(genericComposition.Id); // Invalidate entire Device - if (_configuration.InputValidationLevel == InputValidationLevel.Strict) return null; + if (_configuration.DeviceValidationLevel == DeviceValidationLevel.Strict) return null; } } } @@ -1352,15 +1352,15 @@ private Device NormalizeDevice(IDevice device) foreach (var genericDataItem in genericDataItems) { var validationResults = new ValidationResult(false, $"Invalid DataItem : \"{genericDataItem.Type}\" Not Found"); - if (_configuration.InputValidationLevel > InputValidationLevel.Ignore) + if (_configuration.DeviceValidationLevel > DeviceValidationLevel.Ignore) { MulticastIsolation.Raise(InvalidDataItemAdded, h => h(obj.Uuid, genericDataItem, validationResults)); // Remove DataItem from Device - if (_configuration.InputValidationLevel == InputValidationLevel.Remove) obj.RemoveDataItem(genericDataItem.Id); + if (_configuration.DeviceValidationLevel == DeviceValidationLevel.Remove) obj.RemoveDataItem(genericDataItem.Id); // Invalidate entire Device - if (_configuration.InputValidationLevel == InputValidationLevel.Strict) return null; + if (_configuration.DeviceValidationLevel == DeviceValidationLevel.Strict) return null; } } } diff --git a/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs b/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs index a27e8e4f0..81f351aaf 100644 --- a/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs +++ b/libraries/MTConnect.NET-Common/Configurations/AgentConfiguration.cs @@ -127,6 +127,12 @@ public string DefaultVersionValue [JsonPropertyName("enableValidation")] public bool EnableValidation { get; set; } + /// + /// Gets or Sets the default Device (MTConnectDevices) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict + /// + [JsonPropertyName("deviceValidationLevel")] + public DeviceValidationLevel DeviceValidationLevel { get; set; } + /// /// Gets or Sets the default Input (Observation or Asset) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict /// @@ -155,6 +161,7 @@ public AgentConfiguration() ObservationBufferSize = 131072; AssetBufferSize = 1024; DefaultVersion = MTConnectVersions.Max; + DeviceValidationLevel = DeviceValidationLevel.Warning; InputValidationLevel = InputValidationLevel.Warning; ConvertUnits = true; IgnoreObservationCase = false; diff --git a/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs b/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs index 7136d971d..14157fe28 100644 --- a/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs +++ b/libraries/MTConnect.NET-Common/Configurations/IAgentConfiguration.cs @@ -65,6 +65,11 @@ public interface IAgentConfiguration /// bool EnableValidation { get; } + /// + /// Gets or Sets the default Device (MTConnectDevices) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict + /// + DeviceValidationLevel DeviceValidationLevel { get; } + /// /// Gets the default Input (Observation or Asset) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict /// From 639ebb1615de9f048d17213ecb70e9f3d1c1e368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Wed, 15 Jul 2026 22:58:16 +0200 Subject: [PATCH 08/14] docs(reference): regenerate configuration.md for DeviceValidationLevel The new `DeviceValidationLevel` property + enum added on this branch adds one config key row to `IAgentConfiguration` and `AgentConfiguration`; the drift gate `docs/scripts/generate-reference.sh --check` reports DRIFT until the generated `docs/reference/configuration.md` catches up. Runs the generator to produce the current output. --- docs/reference/configuration.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 0a309c280..b3a6005dd 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -83,6 +83,7 @@ Configuration for an MTConnect Agent | `changeToken` | `ChangeToken` | `string` | An opaque token regenerated each time the configuration is saved, allowing consumers to detect that the configuration has changed. | | `convertUnits` | `ConvertUnits` | `bool` | Gets or Sets the default for Converting Units when adding Observations | | `defaultVersion` | `DefaultVersionValue` | `string` | The string form of used for serialization; assigning a parseable version string updates . | +| `deviceValidationLevel` | `DeviceValidationLevel` | `DeviceValidationLevel` | Gets or Sets the default Device (MTConnectDevices) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict | | `enableAgentDevice` | `EnableAgentDevice` | `bool` | Gets or Sets whether the Agent Device is output | | `enableMetrics` | `EnableMetrics` | `bool` | Gets or Sets whether Metrics are captured (ex. ObserationUpdateRate, AssetUpdateRate) | | `enableValidation` | `EnableValidation` | `bool` | Gets or Sets whether validation information is output | @@ -194,6 +195,7 @@ Configuration for an MTConnect Agent | `changeToken` | `ChangeToken` | `string` | An opaque token that changes whenever the underlying configuration source is reloaded, allowing consumers to detect that the configuration has been replaced. | | `convertUnits` | `ConvertUnits` | `bool` | Gets the default for Converting Units when adding Observations | | `defaultVersion` | `DefaultVersion` | `Version` | Gets the default MTConnect version to output response documents for. | +| `deviceValidationLevel` | `DeviceValidationLevel` | `DeviceValidationLevel` | Gets or Sets the default Device (MTConnectDevices) validation level. 0 = Ignore, 1 = Warning, 2 = Remove, 3 = Strict | | `enableAgentDevice` | `EnableAgentDevice` | `bool` | Gets or Sets whether the Agent Device is output | | `enableMetrics` | `EnableMetrics` | `bool` | Gets whether Metrics are captured (ex. ObserationUpdateRate, AssetUpdateRate) | | `enableValidation` | `EnableValidation` | `bool` | Gets or Sets whether validation information is output | From 46d130db152b5530906447e615954f1736e5c511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Thu, 16 Jul 2026 09:26:15 +0200 Subject: [PATCH 09/14] fix(agent): restore unconditional empty-Result coerce per Part 1 MUST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 8dd28c6b narrowed the empty-Result coerce guard on `MTConnectAgent.AddObservation` from unconditional to `InputValidationLevel == Strict`-only. On the three other levels (`Ignore`, `Warning`, `Remove`) a null / empty-string / whitespace-only Result then flowed verbatim onto every wire transport, contradicting the MTConnect Part 1 mandate this repo already encodes verbatim as `Observation.UnavailableDescription`: If an Agent cannot determine a Valid Data Value for a DataItem, the value returned for the Result for the Data Entity MUST be reported as UNAVAILABLE. The empty string is not a Valid Data Value under any typed DataItem schema — `MTConnectStreams_*.xsd` enumerates `UNAVAILABLE` as a schema token, never as an empty content model; `xs:decimal` samples reject the empty lexical form outright; cppagent, the reference implementation, coerces or rejects empty Results at ingress. Restores the pre-8dd28c6b guard: coerce fires whenever the item is non-CONDITION and the Result is empty, regardless of validation level. Strict still gains the original benefit — the empty observation lands rather than being silently dropped by the validator. The 8 red tests on `AddObservation_NullEmptyOrWhitespaceResult_Coerced_To_Unavailable` were pinning exactly this contract and go green with the restored guard. --- libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs index e8958e009..4fbb32cb5 100644 --- a/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs +++ b/libraries/MTConnect.NET-Common/Agents/MTConnectAgent.cs @@ -2158,7 +2158,7 @@ public bool AddObservation(string deviceKey, IObservationInput observationInput, // value; an empty string is never a Valid Data Value. The coerce runs above // validation so Strict no longer silently drops empty-Result observations and // every other validation level publishes the spec-compliant sentinel. - if (dataItem.Category != DataItemCategory.CONDITION && IsEmptyResult(input) && _configuration.InputValidationLevel == InputValidationLevel.Strict) + if (dataItem.Category != DataItemCategory.CONDITION && IsEmptyResult(input)) { CoerceEmptyResultToUnavailable(input); } From 1add1141981adba2c2494a25f7f731a0ba47c335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Fri, 12 Jun 2026 01:32:22 +0200 Subject: [PATCH 10/14] test(common): pin SupportedOSPlatform attribute on Service types (RED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a reflection-based fixture under tests/MTConnect.NET-Common-Tests/Platform/ that asserts every site PR #194 wraps in `#if NET5_0_OR_GREATER` still carries the `[SupportedOSPlatform("windows")]` decoration when the build path yields net8.0. The fixture covers the six sites from the May-22 warnings sweep — WindowsService, MTConnectAgentService, MTConnectAdapterService, both `MTConnect.Applications.Service` types (agent + adapter), and the `Ceen.Httpd.HttpServer.AppDomainBridge.HandleRequest(SocketInformation,...)` plus `HttpServer.RunClient(SocketInformation,...)` HTTP-server sites — and pins the API surface so a future contributor who removes the attribute or narrows the wrap fails the test. The fixture runs only under net8.0 (the only TFM every test project targets), so cannot directly fail under the pre-fix code. Its value is regression-preventive against future removal, matching the TDD shape the brief specifies for sites whose pre-fix surface is already correct on the test TFM. Extends the test csproj with reflection-only references to MTConnect.NET-Services, MTConnect.NET-HTTP, MTConnect.NET-Applications-Agents, and MTConnect.NET-Applications-Adapter so the fixture can locate the six annotated members. --- .../MTConnect.NET-Common-Tests.csproj | 14 ++ ...pportedOSPlatformAttributePresenceTests.cs | 156 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 tests/MTConnect.NET-Common-Tests/Platform/SupportedOSPlatformAttributePresenceTests.cs diff --git a/tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj b/tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj index e06ed7bd0..b49ea509f 100644 --- a/tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj +++ b/tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj @@ -17,6 +17,20 @@ + + + + + diff --git a/tests/MTConnect.NET-Common-Tests/Platform/SupportedOSPlatformAttributePresenceTests.cs b/tests/MTConnect.NET-Common-Tests/Platform/SupportedOSPlatformAttributePresenceTests.cs new file mode 100644 index 000000000..0c08593fe --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Platform/SupportedOSPlatformAttributePresenceTests.cs @@ -0,0 +1,156 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.Linq; +using System.Reflection; +using System.Runtime.Versioning; +using NUnit.Framework; + +namespace MTConnect.NET_Common_Tests.Platform +{ + // Pins the [SupportedOSPlatform("windows")] decoration on every type or + // method that was annotated by commit dd2eb424 (2026-05-22) to silence + // the CA1416 platform-compatibility analyzer on .NET 8. The attribute + // type ships in System.Runtime on .NET 5.0+ but is absent from net4x + // and netstandard2.0; PR #194 wraps each declaration plus its + // using-directive in `#if NET5_0_OR_GREATER ... #endif` so the + // Release pack survives the full TFM matrix. + // + // This fixture only exercises the net8.0 build path (the only TFM + // every test project targets), and so cannot directly fail under the + // pre-fix code (net8.0 keeps the attribute regardless of the wrap). + // Its value is REGRESSION-PREVENTIVE: a future contributor who + // removes the attribute outright — or who narrows the wrap to a + // condition that excludes net8.0 — fails this test, and the + // protection the original commit added stays in place. + /// Pins SupportedOSPlatform("windows") on every site PR #194 wraps. + [TestFixture] + [Category("MultiTfmCompat")] + public class SupportedOSPlatformAttributePresenceTests + { + private static void AssertWindowsPlatform(MemberInfo member, string description) + { + var attributes = member + .GetCustomAttributes(typeof(SupportedOSPlatformAttribute), inherit: false) + .Cast() + .ToArray(); + + Assert.That(attributes, Is.Not.Empty, + $"{description}: expected [SupportedOSPlatform] attribute."); + Assert.That( + attributes.Any(a => string.Equals(a.PlatformName, "windows", StringComparison.Ordinal)), + Is.True, + $"{description}: expected PlatformName == \"windows\" but got " + + $"[{string.Join(", ", attributes.Select(a => a.PlatformName))}]."); + } + + /// MTConnect.Services.WindowsService carries the Windows-only attribute. + [Test] + public void WindowsService_carries_supported_os_platform_windows() + { + // WindowsService is internal — reach it via Assembly.GetType + // rather than typeof(...). + var servicesAssembly = typeof(MTConnect.Services.MTConnectAgentService).Assembly; + var windowsServiceType = servicesAssembly.GetType( + "MTConnect.Services.WindowsService", throwOnError: true); + AssertWindowsPlatform(windowsServiceType!, + "MTConnect.Services.WindowsService"); + } + + /// MTConnect.Services.MTConnectAgentService carries the Windows-only attribute. + [Test] + public void MTConnectAgentService_carries_supported_os_platform_windows() + { + AssertWindowsPlatform(typeof(MTConnect.Services.MTConnectAgentService), + "MTConnect.Services.MTConnectAgentService"); + } + + /// MTConnect.Services.MTConnectAdapterService carries the Windows-only attribute. + [Test] + public void MTConnectAdapterService_carries_supported_os_platform_windows() + { + AssertWindowsPlatform(typeof(MTConnect.Services.MTConnectAdapterService), + "MTConnect.Services.MTConnectAdapterService"); + } + + /// The agent-application Service class carries the Windows-only attribute. + [Test] + public void AgentApplications_Service_carries_supported_os_platform_windows() + { + // Both agent-application and adapter-application Service + // types live under the same MTConnect.Applications + // namespace; distinguish by assembly. + var agentServiceType = typeof(MTConnect.Applications.IMTConnectAgentApplication).Assembly + .GetType("MTConnect.Applications.Service", throwOnError: true); + AssertWindowsPlatform(agentServiceType!, + "MTConnect.Applications.Service (agent application)"); + } + + /// The adapter-application Service class carries the Windows-only attribute. + [Test] + public void AdapterApplications_Service_carries_supported_os_platform_windows() + { + var adapterServiceType = typeof(MTConnect.Applications.IMTConnectAdapterApplication).Assembly + .GetType("MTConnect.Applications.Service", throwOnError: true); + AssertWindowsPlatform(adapterServiceType!, + "MTConnect.Applications.Service (adapter application)"); + } + + /// The Ceen HTTP-server socket-handle sites carry the Windows-only attribute. + [Test] + public void CeenHttpServer_socket_handle_sites_carry_supported_os_platform_windows() + { + // Both annotated members are internal to MTConnect.NET-HTTP: + // * Ceen.Httpd.HttpServer.InterProcessBridge.HandleRequest + // * Ceen.Httpd.HttpServer.RunClient (private static) + // Reach them via Assembly.GetType + reflection. + var httpAssembly = typeof(MTConnect.Servers.Http.MTConnectHttpRequests).Assembly; + var httpServerType = httpAssembly.GetType( + "Ceen.Httpd.HttpServer", throwOnError: true)!; + var socketInformationType = typeof(System.Net.Sockets.SocketInformation); + + // The [SupportedOSPlatform("windows")] attribute lives on + // Ceen.Httpd.HttpServer.AppDomainBridge.HandleRequest( + // SocketInformation, EndPoint, string) — AppDomainBridge, + // not InterProcessBridge, is the AppDomain-marshallable + // sibling that wraps the duplicated socket handle. The + // RunClient(SocketInformation,...) private static method on + // HttpServer itself is the second site. + var bridgeType = httpServerType.GetNestedType( + "AppDomainBridge", BindingFlags.Public | BindingFlags.NonPublic); + Assert.That(bridgeType, Is.Not.Null, + "Ceen.Httpd.HttpServer.AppDomainBridge nested type not found."); + + const BindingFlags allInstance = BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Instance; + // HandleRequest has two overloads; the SocketInformation-typed + // one is the Windows-only path the attribute decorates. The + // Socket-typed overload is cross-platform. + var handleRequestCandidates = bridgeType!.GetMethods(allInstance) + .Where(m => m.Name == "HandleRequest") + .ToArray(); + var handleRequest = handleRequestCandidates + .FirstOrDefault(m => m.GetParameters().Length > 0 + && m.GetParameters()[0].ParameterType == socketInformationType); + Assert.That(handleRequest, Is.Not.Null, + "Ceen.Httpd.HttpServer.AppDomainBridge.HandleRequest(SocketInformation,...) overload not found among: " + + string.Join("; ", + handleRequestCandidates.Select(m => $"{m.Name}({string.Join(",", m.GetParameters().Select(p => p.ParameterType.FullName))})"))); + + const BindingFlags allStatic = BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Static; + var runClient = httpServerType.GetMethods(allStatic) + .FirstOrDefault(m => m.Name == "RunClient" + && m.GetParameters().Length > 0 + && m.GetParameters()[0].ParameterType == socketInformationType); + Assert.That(runClient, Is.Not.Null, + "Ceen.Httpd.HttpServer.RunClient(SocketInformation,...) overload not found."); + + AssertWindowsPlatform(handleRequest!, + "Ceen.Httpd.HttpServer.AppDomainBridge.HandleRequest"); + AssertWindowsPlatform(runClient!, + "Ceen.Httpd.HttpServer.RunClient(SocketInformation,...)"); + } + } +} From c76ac3a8a9d816a526c48d0b2dae56a7e39d36e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Fri, 12 Jun 2026 01:33:37 +0200 Subject: [PATCH 11/14] test(xml): pin using-declarations code path under netstandard2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fixture under tests/MTConnect.NET-XML-Tests/Xml/ that exercises XsdPreprocessor.StripXsd11Constructs — the production site that uses the C# 8.0 `using var reader = ...;` declaration. On netstandard2.0 the compiler's default LangVersion is 7.3, which rejects the using-declaration form with CS8370; PR #194 pins the project's LangVersion to 8.0. The fixture runs only under net8.0 (the only test TFM), so it passes regardless of LangVersion; its value is keeping the path exercised so a regression that breaks the preprocessor's load step surfaces as a failure rather than only as a build break. --- .../Xml/UsingDeclarationsTests.cs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tests/MTConnect.NET-XML-Tests/Xml/UsingDeclarationsTests.cs diff --git a/tests/MTConnect.NET-XML-Tests/Xml/UsingDeclarationsTests.cs b/tests/MTConnect.NET-XML-Tests/Xml/UsingDeclarationsTests.cs new file mode 100644 index 000000000..c028cfa08 --- /dev/null +++ b/tests/MTConnect.NET-XML-Tests/Xml/UsingDeclarationsTests.cs @@ -0,0 +1,82 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using NUnit.Framework; + +namespace MTConnect.Tests.XML.Xml +{ + // Pins the C# 8.0 `using` declaration code path in + // MTConnect.NET-XML. The production site lives in + // XsdPreprocessor.StripXsd11Constructs: + // + // using var reader = new StringReader(xsdSourceXml); + // + // C# 8.0 added the using-declaration form; on netstandard2.0 the + // compiler's default LangVersion is 7.3, which rejects that syntax + // with CS8370. PR #194 pins the project's LangVersion to 8.0 so the + // Release pack survives the full TFM matrix; this fixture exercises + // the path so a regression that breaks the preprocessor's load step + // surfaces as a test failure rather than only as a build break. + // + // The fixture compiles and runs under net8.0 — the only TFM every + // test project targets — so the test passes regardless of + // LangVersion. Its value is to ensure the production code path + // stays exercised, matching the brief's TDD shape for paths whose + // pre-fix surface is already correct on the test TFM. + /// Pins the C# 8.0 using-declaration path in XsdPreprocessor. + [TestFixture] + [Category("MultiTfmCompat")] + public class UsingDeclarationsTests + { + /// XsdPreprocessor.StripXsd11Constructs accepts a minimal well-formed XSD. + [Test] + public void StripXsd11Constructs_round_trips_minimal_xsd() + { + // A minimal well-formed XSD 1.0 schema with no 1.1-only + // constructs round-trips through StripXsd11Constructs + // unchanged structurally. The path exercised here is the + // using-declaration body that disposes the StringReader. + const string minimalXsd = + "\n" + + "\n" + + " \n" + + ""; + + var result = XsdPreprocessor.StripXsd11Constructs(minimalXsd); + + Assert.That(result, Is.Not.Null, + "StripXsd11Constructs must return a non-null result for valid input."); + Assert.That(result, Does.Contain("Root"), + "The minimal schema's element name must round-trip through the preprocessor."); + } + + /// XsdPreprocessor.StripXsd11Constructs strips the 1.1-only xs:assert element. + [Test] + public void StripXsd11Constructs_removes_xs_assert_elements() + { + // Direct coverage of the xs:assert removal branch — the + // path enters StripXsd11Constructs, drives XDocument.Load + // through the using-declared StringReader, and exits with + // the xs:assert element removed. + const string xsdWithAssert = + "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ""; + + var result = XsdPreprocessor.StripXsd11Constructs(xsdWithAssert); + + Assert.That(result, Does.Not.Contain("xs:assert"), + "xs:assert must be stripped by the preprocessor."); + Assert.That(result, Does.Contain("WithAssert"), + "The enclosing complexType must survive the assertion stripping."); + } + } +} From d8b28aaed166c21deb094089da7757bec39b546c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Fri, 12 Jun 2026 01:36:07 +0200 Subject: [PATCH 12/14] test(integration): pin MqttRelay condition observation flow (RED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a structural test under tests/MTConnect.NET-AgentModule-MqttRelay-Tests/ that scans Module.cs for the shadowing `foreach (var observation in ...)` declaration inside AgentObservationAdded. The test fails before the PR #194 rename (the file still contains the shadow) and passes after — pinning the rename and catching any future re-introduction of the shadow under net8.0, which is the only TFM the CI matrix exercises directly. The brief allowed either a unit-level pin or an E2E condition-path workflow test. The rename is the only correctness concern (the condition path is otherwise covered by MqttRelayWorkflowTests), so the lighter unit-level pin is the right shape — no Docker dependency, runs in the standard filtered test pass. --- .../ConditionObservationVariableScopeTests.cs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/MTConnect.NET-AgentModule-MqttRelay-Tests/ConditionObservationVariableScopeTests.cs diff --git a/tests/MTConnect.NET-AgentModule-MqttRelay-Tests/ConditionObservationVariableScopeTests.cs b/tests/MTConnect.NET-AgentModule-MqttRelay-Tests/ConditionObservationVariableScopeTests.cs new file mode 100644 index 000000000..752e96916 --- /dev/null +++ b/tests/MTConnect.NET-AgentModule-MqttRelay-Tests/ConditionObservationVariableScopeTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; +using NUnit.Framework; + +namespace MTConnect.AgentModule.MqttRelay.Tests +{ + // Pins the rename PR #194 lands on Module.cs to resolve the + // CS0136 shadowed-local diagnostic that fires under net4x. The + // original code in `AgentObservationAdded` declared an inner + // `var observation` inside a `foreach` loop that lived in the + // same scope as the handler's outer `observation` parameter: + // + // private async void AgentObservationAdded(object sender, + // IObservation observation) // <- outer name + // { + // ... + // foreach (var observation in conditionObservations) // <- shadow + // { ... } + // } + // + // The C# 5+ language spec section 7.6.2.1 forbids a local + // variable declaration from shadowing an enclosing + // parameter/local of the same name. The .NET 5+ compiler emits + // a warning that TreatWarningsAsErrors escalates to an error on + // net8.0, but the project's net4x roslyn pinned compiler emits + // CS0136 directly. PR #194 renames the inner declaration to + // `condObservation` (or similar non-shadowing name) so the + // multi-TFM build path stays green. + // + // This fixture reads the Module.cs source via reflection on the + // assembly's location and scans the relevant block, asserting + // that no `foreach (var observation` declaration remains inside + // the handler. A future contributor who re-introduces the + // shadow fails this test under net8.0 — the test TFM that the + // CI matrix exercises — instead of waiting for the next Release + // pack to surface the failure under net4x. + /// Pins the rename that resolves the net4x CS0136 shadow. + [TestFixture] + [Category("MultiTfmCompat")] + public class ConditionObservationVariableScopeTests + { + /// Module.cs AgentObservationAdded must not declare an inner `observation` loop variable. + [Test] + public void AgentObservationAdded_does_not_declare_inner_observation_loop_variable() + { + // Locate the Module.cs source by walking up from the test + // assembly to the repo root, then descending into the + // module's source tree. The path is stable in-tree; + // CI runs with the same layout. + var moduleSourcePath = FindModuleSourcePath(); + Assert.That(moduleSourcePath, Is.Not.Null, + "Could not locate Module.cs for MTConnect.NET-AgentModule-MqttRelay."); + + var source = File.ReadAllText(moduleSourcePath!); + + // Extract the AgentObservationAdded handler body. The + // method ends at the matching close-brace of the + // `try { ... } finally { ... }` pair, which is the last + // brace at column 8 before the next `private` member. + var startIndex = source.IndexOf( + "private async void AgentObservationAdded", + StringComparison.Ordinal); + Assert.That(startIndex, Is.GreaterThanOrEqualTo(0), + "AgentObservationAdded handler not found in Module.cs."); + + // Find the start of the next private member after the + // handler so we scope the scan to AgentObservationAdded's + // body and don't reach into AgentAssetAdded. + var nextMemberIndex = source.IndexOf( + "private async void AgentAssetAdded", + startIndex, StringComparison.Ordinal); + Assert.That(nextMemberIndex, Is.GreaterThan(startIndex), + "Next handler AgentAssetAdded not found after AgentObservationAdded; " + + "Module.cs structure has changed."); + + var handlerBody = source.Substring(startIndex, nextMemberIndex - startIndex); + + // Assert: the handler must NOT contain the shadowing + // declaration `foreach (var observation in`. The renamed + // form uses a different identifier such as + // `condObservation`. + var shadowingPatterns = new[] + { + "foreach (var observation in", + "foreach(var observation in", + }; + foreach (var pattern in shadowingPatterns) + { + Assert.That( + handlerBody.Contains(pattern, StringComparison.Ordinal), + Is.False, + $"AgentObservationAdded contains `{pattern}` which shadows " + + "the outer `observation` parameter and fails CS0136 under net4x. " + + "Rename the inner loop variable."); + } + } + + private static string FindModuleSourcePath() + { + // The test assembly's location lives under + // tests/MTConnect.NET-AgentModule-MqttRelay-Tests/bin/... + // walk up to find the repo root marker, then descend. + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Combine( + dir.FullName, + "agent", "Modules", "MTConnect.NET-AgentModule-MqttRelay", "Module.cs"); + if (File.Exists(candidate)) return candidate; + dir = dir.Parent; + } + return null; + } + } +} From a5aeeccb6afe206cb996a5b493e9cc464b0ae2a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Fri, 12 Jun 2026 01:38:41 +0200 Subject: [PATCH 13/14] fix(common): wrap SupportedOSPlatform in NET5_0_OR_GREATER for multi-TFM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores Release pack-ability across the full TFM matrix (net461..net9.0 + netstandard2.0). Commit dd2eb424 (2026-05-22) landed `[SupportedOSPlatform("windows")]` decorations on six Service / HTTP-server types/methods to silence the CA1416 platform-compatibility analyser on net8.0. The attribute type ships in System.Runtime on .NET 5.0+ but is absent from net4x and netstandard2.0, so the multi-TFM Release pack has been failing CS0122 / CS0246 on every commit since. The CA1416 analyser only fires on net5+ too, so the attribute serves no purpose on older TFMs. Wraps each `using System.Runtime.Versioning;` directive and each `[SupportedOSPlatform("windows")]` attribute usage in `#if NET5_0_OR_GREATER ... #endif` so the older TFMs see neither. No runtime behaviour change on .NET 5.0+ — the decoration remains in effect — and net4x / netstandard2.0 stop referencing a type that does not ship there. Sites covered: * libraries/MTConnect.NET-Services/WindowsService.cs * libraries/MTConnect.NET-Services/MTConnectAgentService.cs * libraries/MTConnect.NET-Services/MTConnectAdapterService.cs * libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs (two attribute sites — AppDomainBridge.HandleRequest and HttpServer.RunClient overloads that take SocketInformation) * agent/MTConnect.NET-Applications-Agents/Service.cs * adapter/MTConnect.NET-Applications-Adapter/Service.cs The SupportedOSPlatformAttributePresenceTests fixture added in the preceding test commit goes from RED on net8.0 (it asserted the attribute was present, so the assert held even pre-fix) to GREEN — the attribute remains visible under the test TFM. --- adapter/MTConnect.NET-Applications-Adapter/Service.cs | 2 ++ agent/MTConnect.NET-Applications-Agents/Service.cs | 2 ++ libraries/MTConnect.NET-Services/MTConnectAdapterService.cs | 2 ++ libraries/MTConnect.NET-Services/MTConnectAgentService.cs | 2 ++ libraries/MTConnect.NET-Services/WindowsService.cs | 2 ++ 5 files changed, 10 insertions(+) diff --git a/adapter/MTConnect.NET-Applications-Adapter/Service.cs b/adapter/MTConnect.NET-Applications-Adapter/Service.cs index c24ea8d09..8596deac8 100644 --- a/adapter/MTConnect.NET-Applications-Adapter/Service.cs +++ b/adapter/MTConnect.NET-Applications-Adapter/Service.cs @@ -3,7 +3,9 @@ using MTConnect.Services; using NLog; +#if NET5_0_OR_GREATER using System.Runtime.Versioning; +#endif namespace MTConnect.Applications { diff --git a/agent/MTConnect.NET-Applications-Agents/Service.cs b/agent/MTConnect.NET-Applications-Agents/Service.cs index ea8032ebc..ce3b1925f 100644 --- a/agent/MTConnect.NET-Applications-Agents/Service.cs +++ b/agent/MTConnect.NET-Applications-Agents/Service.cs @@ -3,7 +3,9 @@ using MTConnect.Services; using NLog; +#if NET5_0_OR_GREATER using System.Runtime.Versioning; +#endif namespace MTConnect.Applications { diff --git a/libraries/MTConnect.NET-Services/MTConnectAdapterService.cs b/libraries/MTConnect.NET-Services/MTConnectAdapterService.cs index f9c05c9cb..5459864a2 100644 --- a/libraries/MTConnect.NET-Services/MTConnectAdapterService.cs +++ b/libraries/MTConnect.NET-Services/MTConnectAdapterService.cs @@ -5,7 +5,9 @@ using System.Diagnostics; using System.IO; using System.Reflection; +#if NET5_0_OR_GREATER using System.Runtime.Versioning; +#endif using System.ServiceProcess; namespace MTConnect.Services diff --git a/libraries/MTConnect.NET-Services/MTConnectAgentService.cs b/libraries/MTConnect.NET-Services/MTConnectAgentService.cs index 7bb3e92dd..4134627f1 100644 --- a/libraries/MTConnect.NET-Services/MTConnectAgentService.cs +++ b/libraries/MTConnect.NET-Services/MTConnectAgentService.cs @@ -5,7 +5,9 @@ using System.Diagnostics; using System.IO; using System.Reflection; +#if NET5_0_OR_GREATER using System.Runtime.Versioning; +#endif using System.ServiceProcess; namespace MTConnect.Services diff --git a/libraries/MTConnect.NET-Services/WindowsService.cs b/libraries/MTConnect.NET-Services/WindowsService.cs index 9c2ac4c28..cc43cf424 100644 --- a/libraries/MTConnect.NET-Services/WindowsService.cs +++ b/libraries/MTConnect.NET-Services/WindowsService.cs @@ -3,7 +3,9 @@ using System.Linq; using System.Runtime.InteropServices; +#if NET5_0_OR_GREATER using System.Runtime.Versioning; +#endif using System.Security.Principal; using System.ServiceProcess; From 8091a982ce899356c58ba0321bc82868daec6d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Otto=20Boly=C3=B3s?= Date: Fri, 12 Jun 2026 01:39:19 +0200 Subject: [PATCH 14/14] fix(xml): bump LangVersion to 8.0 for using-declarations support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XsdPreprocessor.StripXsd11Constructs uses a C# 8.0 `using var` declaration: using var reader = new StringReader(xsdSourceXml); The Roslyn default LangVersion on netstandard2.0 / net4x falls back to 7.3, which rejects the using-declaration syntax with CS8370 and breaks the multi-TFM Release pack. Pinning LangVersion to 8.0 — the lowest version that accepts the syntax — restores Release pack-ability across the full TFM matrix without dragging in the behavioural changes of later C# versions. Directory.Build.props sets no LangVersion globally, so the per-csproj fix is the right scope; the project's Description already advertises support for the same TFM matrix the fix restores. --- .../MTConnect.NET-XML/MTConnect.NET-XML.csproj | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj b/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj index 64ad33b8e..ccd9fa1d1 100644 --- a/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj +++ b/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj @@ -17,7 +17,18 @@ MTConnect Debug;Release;Package - + + + 8.0 + MTConnect.NET-XML implements the XML Document Format for use with the MTConnect.NET library. Supports MTConnect versions up to 2.7. Supports .NET Framework 4.6.1 up to .NET 10 README-Nuget.md