From d5369f5b83b4e6e66b3df2fff82466de9fdc0628 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 1/7] 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 416cd1b039e1e0dca5451669160f73fb4c683e26 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 2/7] 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 95700a0dc9859555815488cbf778f97960b65b6c 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 3/7] 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 9d1e17253eaea0d0535f42f8059c2bf043453bfc 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 4/7] 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 b3ed13b9875f6a22890e48c23b1f73034eecb8a9 Mon Sep 17 00:00:00 2001 From: Patrick Ritchie Date: Fri, 26 Jun 2026 15:16:46 -0400 Subject: [PATCH 5/7] 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 05d1bacfe530700b0bb05b629c5b8ff56a121649 Mon Sep 17 00:00:00 2001 From: Patrick Ritchie Date: Fri, 26 Jun 2026 15:20:05 -0400 Subject: [PATCH 6/7] 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 a61a3647b6133f7bc56780ba49f854291935b6e5 Mon Sep 17 00:00:00 2001 From: Patrick Ritchie Date: Fri, 26 Jun 2026 16:10:17 -0400 Subject: [PATCH 7/7] 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 ///