diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs
index 1d41561e..09543a11 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs
@@ -1,5 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+///
+/// The result of a call, as the
+/// Agent answered it. A transport failure is never represented here — it throws (see the
+/// method's contract), so an outcome always means "the Agent replied, and this is what it said".
+///
+///
+/// true when the Agent took the observation: a 2xx response whose body is not an
+/// MTConnectError document. false is a refusal, and
+/// then always carries text.
+///
+///
+/// Why the Agent refused, in its own words where it supplied any — the joined
+/// errorCode: text of the MTConnectError document, or the HTTP status line for a
+/// non-2xx answer. null if and only if is true.
+///
+/// This is Agent-authored text destined for a log line, never for a driver's
+/// LastError (which is type-only by repo-wide discipline) and never for an OPC UA
+/// status code's own message.
+///
+///
+public sealed record MTConnectWriteOutcome(bool Accepted, string? AgentError);
+
///
/// The seam between the MTConnect driver and an MTConnect Agent's HTTP surface
/// (/probe, /current, /sample). This interface is transport-neutral by
@@ -17,7 +39,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// /sample long poll. (Task 0 planned to build on the TrakHound MTConnect.NET libraries;
/// Tasks 6 and 7 proved they can neither parse a document nor frame the stream socket-free, and
/// dropped the references — see the Task 0 CORRECTION block in the plan.) A fake for tests only
-/// needs to implement these three instance members; is a static
+/// needs to implement these four instance members; is a static
/// protocol rule that lives here, rather than on the implementation, so a consumer written
/// against this seam never has to reference the concrete client.
///
@@ -88,6 +110,67 @@ public interface IMTConnectAgentClient : IDisposable
/// Cancellation token; cancelling is the contracted way to end the stream.
IAsyncEnumerable SampleAsync(long from, CancellationToken ct);
+ ///
+ /// Sets the Agent's observation for one DataItem, through the Agent's AllowPut
+ /// surface (POST {agent}/{deviceName}?{dataItemId}={value}).
+ ///
+ ///
+ ///
+ /// This is observation injection, not machine command. The value lands in the
+ /// Agent's observation buffer exactly as if an adapter had reported it — every consumer
+ /// of that Agent then sees it, and the next adapter/SHDR update for the same DataItem
+ /// overwrites it. Nothing is sent to the machine. It is also gated twice and off at both
+ /// gates by default: the Agent's own AllowPut (agent.cfg) and the driver's
+ /// write.enabled.
+ ///
+ ///
+ /// A refusal is a return value; a transport failure is an exception. The split is
+ /// the whole point of the outcome type, and it is what lets the driver map the two to
+ /// different OPC UA status codes without inspecting exception text:
+ ///
+ /// -
+ /// The Agent answered and took the observation (2xx, body empty or not an
+ /// MTConnectError) ⇒ . An
+ /// empty 2xx body is acceptance — that is what a cppagent actually sends,
+ /// so treating a body-less answer as suspect would refuse every successful write.
+ ///
+ /// -
+ /// The Agent answered and refused — a non-2xx status, or an
+ /// MTConnectError document served (as Agents do) under HTTP 200 ⇒
+ /// not accepted, with the Agent's own text in
+ /// . The 200-with-error shape is
+ /// why the status code alone can never decide this.
+ ///
+ /// -
+ /// The Agent could not be reached or did not answer in time ⇒ throws
+ /// (, or a for
+ /// the per-call deadline; when
+ /// is cancelled or the client is disposed).
+ /// Folding these into a false outcome would make "the machine's Agent is
+ /// unreachable" indistinguishable from "the Agent rejected this DataItem" —
+ /// identical to the caller, opposite in what an operator must do about it.
+ ///
+ ///
+ ///
+ ///
+ /// The write is a URL, so escaping is this seam's job, not the caller's.
+ /// and are passed unescaped
+ /// and are percent-encoded by the implementation. A caller that pre-escaped would
+ /// double-encode and write a literal %20 into the Agent's buffer, which the Agent
+ /// accepts — so the corruption is silent and shows up only as a wrong value on read-back.
+ ///
+ ///
+ ///
+ /// The device that owns the DataItem — the path segment of the request. Required even on a
+ /// single-device Agent, because the Agent's PUT surface is addressed per device.
+ ///
+ /// The DataItem's id, as declared by /probe. Unescaped.
+ /// The value to report, already rendered to its invariant string form. Unescaped.
+ /// Cancellation token.
+ /// What the Agent said — see the remarks.
+ Task WriteObservationAsync(
+ string deviceName, string dataItemId, string value, CancellationToken cancellationToken);
+
///
/// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
/// when the chunk's oldest retained sequence is strictly newer than
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
index 8ce654b1..a3b39450 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
@@ -1,6 +1,8 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
+using System.Xml;
+using System.Xml.Linq;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
@@ -75,6 +77,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
private readonly ILogger? _logger;
private readonly Uri _probeUri;
private readonly Uri _currentUri;
+ private readonly string _agentRoot;
private readonly string _sampleUriBase;
private readonly string _sampleQuerySuffix;
private readonly TimeSpan _requestTimeout;
@@ -133,6 +136,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
// Agent and an idle one both emit at least one chunk per heartbeat.
_streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);
+ _agentRoot = ResolveAgentRoot(options);
_probeUri = BuildRequestUri(options, "probe");
_currentUri = BuildRequestUri(options, "current");
_sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
@@ -242,6 +246,139 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
}
}
+ ///
+ ///
+ /// Issued on _http, the unary client — the write is a short request/response
+ /// and must be bounded by RequestTimeoutMs like the other unary legs. Sending it on
+ /// the streaming client would inherit that client's infinite timeout, and a frozen Agent
+ /// would park a write forever with nothing to bound it (the streaming leg's heartbeat
+ /// watchdog protects a multipart body, not a one-shot POST).
+ ///
+ public async Task WriteObservationAsync(
+ string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
+ ArgumentNullException.ThrowIfNull(value);
+
+ var uri = BuildWriteUri(deviceName, dataItemId, value);
+
+ using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
+ deadline.CancelAfter(_requestTimeout);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, uri);
+
+ HttpResponseMessage response;
+ try
+ {
+ response = await _http.SendAsync(request, deadline.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
+ {
+ throw TimedOut(uri);
+ }
+
+ using (response)
+ {
+ string body;
+ try
+ {
+ body = await response.Content.ReadAsStringAsync(deadline.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
+ {
+ throw TimedOut(uri);
+ }
+
+ // Deliberately NOT EnsureSuccessStatusCode: a refused write is an answer, and the caller
+ // needs it as a value it can map to a per-tag status code rather than as an exception
+ // indistinguishable from the Agent being unreachable.
+ var agentError = DescribeAgentError(body);
+ if (!response.IsSuccessStatusCode)
+ {
+ var status = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".TrimEnd();
+
+ return new MTConnectWriteOutcome(
+ Accepted: false, agentError is null ? status : $"{status}: {agentError}");
+ }
+
+ // An MTConnectError document under HTTP 200 is how an Agent actually refuses; the status
+ // code alone would report it as a success.
+ return agentError is null
+ ? new MTConnectWriteOutcome(Accepted: true, AgentError: null)
+ : new MTConnectWriteOutcome(Accepted: false, agentError);
+ }
+ }
+
+ ///
+ /// The Agent's AllowPut URL: the device as a path segment, the DataItem id and value
+ /// as a query pair. Every caller-supplied part is percent-encoded here — see the seam's
+ /// contract for why escaping cannot be the caller's job.
+ ///
+ private Uri BuildWriteUri(string deviceName, string dataItemId, string value)
+ {
+ var device = Uri.EscapeDataString(deviceName.Trim());
+ var query = $"{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)}";
+
+ return new Uri($"{_agentRoot}/{device}?{query}", UriKind.Absolute);
+ }
+
+ ///
+ /// The Agent's own refusal text when is an MTConnectError
+ /// document, else null (including for an empty body, a non-XML body, and any other
+ /// MTConnect document).
+ ///
+ ///
+ /// Read by only, like every other document this driver parses:
+ /// the namespace URI carries the MTConnect version, so a qualified match would silently stop
+ /// recognising errors the day an Agent is upgraded — and an unrecognised error reads as a
+ /// successful write.
+ ///
+ private static string? DescribeAgentError(string body)
+ {
+ if (string.IsNullOrWhiteSpace(body))
+ {
+ return null;
+ }
+
+ XElement? root;
+ try
+ {
+ root = XDocument.Parse(body).Root;
+ }
+ catch (XmlException)
+ {
+ // Not XML at all — an Agent that answered 2xx with a plain-text or HTML body has not
+ // refused anything, so this is not the place to invent a failure.
+ return null;
+ }
+
+ if (root is null || !MTConnectXml.IsNamed(root, "MTConnectError"))
+ {
+ return null;
+ }
+
+ var errors = root.Descendants()
+ .Where(e => MTConnectXml.IsNamed(e, "Error"))
+ .Select(e =>
+ {
+ var code = MTConnectXml.OptionalAttribute(e, "errorCode");
+ var text = e.Value.Trim();
+
+ return code is null ? text : $"{code}: {text}";
+ })
+ .Where(text => text.Length > 0)
+ .ToList();
+
+ // An error document with no readable detail is still a refusal; reporting it as null would
+ // turn it back into an acceptance.
+ return errors.Count == 0
+ ? "The Agent answered with an MTConnectError document carrying no error detail."
+ : string.Join("; ", errors);
+ }
+
///
public void Dispose()
{
@@ -379,7 +516,31 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
return boundary;
}
- private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
+ ///
+ /// A read path's request URI, scoped to the configured device. Reads ,
+ /// so it may only be called after that field is assigned — which the constructor does first.
+ ///
+ private Uri BuildRequestUri(MTConnectDriverOptions options, string request)
+ {
+ var deviceName = options.DeviceName?.Trim();
+ var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
+
+ return new Uri($"{_agentRoot}{deviceSegment}/{request}", UriKind.Absolute);
+ }
+
+ ///
+ /// Validates and reduces it to the base every
+ /// request path is appended to: its left part up to and including the path (so a proxied
+ /// Agent keeps its prefix), with any trailing slash removed so callers can append
+ /// /segment unconditionally.
+ ///
+ ///
+ /// Extracted rather than duplicated because the write path resolves its device at call time,
+ /// not at construction: two copies of this rule would drift, and the drift would surface as
+ /// writes landing on a URL the reads never used — against an Agent that answers 200 either
+ /// way.
+ ///
+ private static string ResolveAgentRoot(MTConnectDriverOptions options)
{
var agentUri = options.AgentUri?.Trim();
if (string.IsNullOrEmpty(agentUri))
@@ -395,11 +556,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
}
- var root = parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
- var deviceName = options.DeviceName?.Trim();
- var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
-
- return new Uri($"{root}{deviceSegment}/{request}", UriKind.Absolute);
+ return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
}
///
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs
index 521e0e98..d6627661 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs
@@ -355,6 +355,32 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
}
}
+ ///
+ /// Every write this Agent was asked to take, in order — the record a driver-level write test
+ /// asserts against (which DataItem, on which device, carrying what).
+ ///
+ public ConcurrentQueue<(string DeviceName, string DataItemId, string Value)> Writes { get; } = new();
+
+ ///
+ /// What answers with. Defaults to acceptance; set it to a
+ /// refusal to script the Agent's MTConnectError leg.
+ ///
+ public MTConnectWriteOutcome WriteOutcome { get; set; } = new(Accepted: true, AgentError: null);
+
+ /// When set, a write throws this instead of answering — the transport-failure leg.
+ public Exception? WriteFailure { get; set; }
+
+ ///
+ public Task WriteObservationAsync(
+ string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(IsDisposed, this);
+ cancellationToken.ThrowIfCancellationRequested();
+ Writes.Enqueue((deviceName, dataItemId, value));
+
+ return WriteFailure is null ? Task.FromResult(WriteOutcome) : throw WriteFailure;
+ }
+
///
/// Queues a chunk onto the scripted /sample stream and waits until the consumer has
/// processed it. This is the deterministic replacement for "wait a bit and hope the pump
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs
new file mode 100644
index 00000000..cadf4af1
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs
@@ -0,0 +1,307 @@
+using System.Net;
+using System.Text;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// The wire contract of — the Agent's
+/// AllowPut surface. Everything here runs through a stubbed
+/// via the internal test-seam constructor, so the verb, the
+/// exact request URI, and each response shape are asserted with no socket and no Agent.
+///
+///
+///
+/// The URI assertions are the point of this class, not incidental detail. A write is
+/// nothing but a URL: get the escaping or the device segment wrong and the Agent still
+/// answers 200, having stored a value under a DataItem id nobody asked about — a corruption
+/// with no error anywhere, visible only on a later read-back. There is no encoder to unit
+/// test, so this is the only place that shape is pinned.
+///
+///
+/// The refusal legs pin the split the seam contract rests on: an Agent that answered
+/// and said no is a returned outcome, while an Agent that could not be reached throws. The
+/// 200-carrying-an-MTConnectError case is why the status code alone can never decide
+/// it — that is the shape a real cppagent uses to report every objection.
+///
+///
+[Trait("Category", "Unit")]
+public sealed class MTConnectAgentClientWriteTests
+{
+ // ── stub handler ─────────────────────────────────────────────────────────
+
+ /// Answers every request with a canned response (or throws) via a delegate, with no socket.
+ private sealed class StubHandler(Func> respond)
+ : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) =>
+ respond(request, ct);
+ }
+
+ /// Records the requests it is handed, then answers each with .
+ private sealed class RecordingHandler(Func respond) : HttpMessageHandler
+ {
+ public List<(HttpMethod Method, Uri Uri)> Requests { get; } = [];
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ Requests.Add((request.Method, request.RequestUri!));
+
+ return Task.FromResult(respond(request));
+ }
+ }
+
+ private static MTConnectDriverOptions Options(string agentUri = "http://stub-agent:5000") =>
+ new() { AgentUri = agentUri };
+
+ private static HttpResponseMessage Ok(string body = "") => new(HttpStatusCode.OK)
+ {
+ Content = new StringContent(body, Encoding.UTF8, "text/plain"),
+ };
+
+ private static HttpResponseMessage XmlOk(string xml) => new(HttpStatusCode.OK)
+ {
+ Content = new StringContent(xml, Encoding.UTF8, "text/xml"),
+ };
+
+ /// Exactly the shape a cppagent uses to refuse: an error document served under HTTP 200.
+ private const string MTConnectErrorXml =
+ """
+
+
+
+
+ Cannot find device data item: nosuchitem
+
+
+ """;
+
+ // ── verb + URI ───────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task A_write_is_issued_as_a_POST()
+ {
+ var handler = new RecordingHandler(_ => Ok());
+ using var client = new MTConnectAgentClient(Options(), handler);
+
+ await client.WriteObservationAsync("StubDevice", "dev1_msg", "hello", CancellationToken.None);
+
+ handler.Requests.ShouldHaveSingleItem().Method.ShouldBe(HttpMethod.Post);
+ }
+
+ ///
+ /// The device is a path segment and the DataItem id/value are a query pair —
+ /// the Agent's documented AllowPut shape. Asserted as the whole URI rather than in parts,
+ /// because a fragment-wise assertion passes on a URI that is assembled in the wrong order.
+ ///
+ [Fact]
+ public async Task The_request_uri_is_the_agents_AllowPut_shape()
+ {
+ var handler = new RecordingHandler(_ => Ok());
+ using var client = new MTConnectAgentClient(Options(), handler);
+
+ await client.WriteObservationAsync("StubDevice", "dev1_msg", "hello", CancellationToken.None);
+
+ handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
+ .ShouldBe("http://stub-agent:5000/StubDevice?dev1_msg=hello");
+ }
+
+ ///
+ /// A value carrying a space, an & and a = — each of which silently ends the
+ /// query pair early if it reaches the wire raw — plus a DataItem id needing the same
+ /// treatment. Unescaped, the Agent would take a 200 and store the truncated value.
+ ///
+ [Fact]
+ public async Task The_dataItemId_and_value_are_percent_encoded()
+ {
+ var handler = new RecordingHandler(_ => Ok());
+ using var client = new MTConnectAgentClient(Options(), handler);
+
+ await client.WriteObservationAsync(
+ "Stub Device", "dev1 msg&x", "Coolant low = 20% & rising", CancellationToken.None);
+
+ handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri.ShouldBe(
+ "http://stub-agent:5000/Stub%20Device?dev1%20msg%26x=Coolant%20low%20%3D%2020%25%20%26%20rising");
+ }
+
+ ///
+ /// The base URI's own path is preserved (an Agent behind a reverse-proxy prefix) and a
+ /// trailing slash does not double up — the same left-part rule the read paths use.
+ ///
+ [Fact]
+ public async Task A_based_agent_uri_keeps_its_path_prefix_and_does_not_double_its_slash()
+ {
+ var handler = new RecordingHandler(_ => Ok());
+ using var client = new MTConnectAgentClient(Options("http://stub-agent:5000/mtconnect/"), handler);
+
+ await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
+
+ handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
+ .ShouldBe("http://stub-agent:5000/mtconnect/StubDevice?dev1_msg=hi");
+ }
+
+ ///
+ /// The deviceName argument decides the path — not the options' device scope. A
+ /// multi-device Agent is addressed per write, so an implementation that reused the read
+ /// paths' scoped URI would write every DataItem onto one device.
+ ///
+ [Fact]
+ public async Task The_device_segment_comes_from_the_argument_not_the_configured_device_scope()
+ {
+ var handler = new RecordingHandler(_ => Ok());
+ using var client = new MTConnectAgentClient(
+ new MTConnectDriverOptions { AgentUri = "http://stub-agent:5000", DeviceName = "ScopedDevice" },
+ handler);
+
+ await client.WriteObservationAsync("OtherDevice", "dev2_msg", "hi", CancellationToken.None);
+
+ handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
+ .ShouldBe("http://stub-agent:5000/OtherDevice?dev2_msg=hi");
+ }
+
+ // ── acceptance ───────────────────────────────────────────────────────────
+
+ ///
+ /// An empty 2xx body is what a real cppagent sends on success. Treating a body-less answer
+ /// as anything but acceptance would refuse every write that actually worked.
+ ///
+ [Fact]
+ public async Task An_empty_200_is_accepted()
+ {
+ using var client = new MTConnectAgentClient(Options(), new StubHandler((_, _) => Task.FromResult(Ok())));
+
+ var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
+
+ outcome.Accepted.ShouldBeTrue();
+ outcome.AgentError.ShouldBeNull();
+ }
+
+ /// A 200 carrying a non-MTConnect body is still acceptance — only an error document refuses.
+ [Fact]
+ public async Task A_200_whose_body_is_not_an_error_document_is_accepted()
+ {
+ using var client = new MTConnectAgentClient(
+ Options(), new StubHandler((_, _) => Task.FromResult(Ok("OK"))));
+
+ var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
+
+ outcome.Accepted.ShouldBeTrue();
+ outcome.AgentError.ShouldBeNull();
+ }
+
+ // ── refusal ──────────────────────────────────────────────────────────────
+
+ ///
+ /// The load-bearing refusal: HTTP 200 with an MTConnectError body.
+ /// EnsureSuccessStatusCode never fires here, so an implementation that trusted the
+ /// status code would report every rejected write as a success.
+ ///
+ [Fact]
+ public async Task An_MTConnectError_under_200_is_a_refusal_carrying_the_agents_own_text()
+ {
+ using var client = new MTConnectAgentClient(
+ Options(), new StubHandler((_, _) => Task.FromResult(XmlOk(MTConnectErrorXml))));
+
+ var outcome = await client.WriteObservationAsync(
+ "StubDevice", "nosuchitem", "hi", CancellationToken.None);
+
+ outcome.Accepted.ShouldBeFalse();
+ outcome.AgentError.ShouldNotBeNull();
+ outcome.AgentError.ShouldContain("UNSUPPORTED");
+ outcome.AgentError.ShouldContain("Cannot find device data item: nosuchitem");
+ }
+
+ [Fact]
+ public async Task A_non_success_status_is_a_refusal_naming_the_status()
+ {
+ using var client = new MTConnectAgentClient(
+ Options(),
+ new StubHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
+ {
+ Content = new StringContent(string.Empty),
+ })));
+
+ var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
+
+ outcome.Accepted.ShouldBeFalse();
+ outcome.AgentError.ShouldNotBeNull();
+ outcome.AgentError.ShouldContain("404");
+ }
+
+ // ── transport failure throws, never a false outcome ──────────────────────
+
+ ///
+ /// "Unreachable" must stay distinguishable from "rejected". Returning
+ /// Accepted = false here would collapse a comms fault into a per-tag refusal, which
+ /// the driver maps to a different status code and an operator resolves differently.
+ ///
+ [Fact]
+ public async Task A_transport_failure_propagates_rather_than_becoming_a_refusal()
+ {
+ var boom = new HttpRequestException("dial failed");
+ using var client = new MTConnectAgentClient(
+ Options(), new StubHandler((_, _) => Task.FromException(boom)));
+
+ var thrown = await Should.ThrowAsync(
+ () => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
+
+ thrown.ShouldBeSameAs(boom);
+ }
+
+ ///
+ /// A frozen Agent must surface as a bounded naming the URI,
+ /// not park the caller — the same per-call deadline discipline the read legs carry
+ /// (arch-review R2-01).
+ ///
+ [Fact(Timeout = 10000)]
+ public async Task A_frozen_agent_times_out_within_the_per_call_deadline()
+ {
+ using var client = new MTConnectAgentClient(
+ new MTConnectDriverOptions { AgentUri = "http://stub-agent:5000", RequestTimeoutMs = 300 },
+ new StubHandler(async (_, ct) =>
+ {
+ await Task.Delay(Timeout.Infinite, ct);
+
+ throw new UnreachableException();
+ }));
+
+ await Should.ThrowAsync(
+ () => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
+ }
+
+ /// Local stand-in so the stub handler's "never returns" branch has a throw statement to satisfy flow analysis.
+ private sealed class UnreachableException : Exception;
+
+ /// Cancelling is cancellation, never a refusal — the caller's own token wins over the deadline.
+ [Fact]
+ public async Task A_cancelled_token_surfaces_as_cancellation_not_as_a_refusal()
+ {
+ using var cts = new CancellationTokenSource();
+ using var client = new MTConnectAgentClient(
+ Options(),
+ new StubHandler(async (_, ct) =>
+ {
+ await Task.Delay(Timeout.Infinite, ct);
+
+ throw new UnreachableException();
+ }));
+
+ var write = client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", cts.Token);
+ await cts.CancelAsync();
+
+ await Should.ThrowAsync(() => write);
+ }
+
+ // ── disposal ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Writing_through_a_disposed_client_throws_ObjectDisposedException()
+ {
+ var client = new MTConnectAgentClient(Options(), new StubHandler((_, _) => Task.FromResult(Ok())));
+ client.Dispose();
+
+ await Should.ThrowAsync(
+ () => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
+ }
+}