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