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
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
///
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);
+ }
+ }
+}
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.");
+ }
+ }
+}