diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
new file mode 100644
index 00000000..77c42de6
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
@@ -0,0 +1,142 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// The production — a thin wrapper
+/// over an MTConnect Agent's REST surface that hands every response body to a pure parser and
+/// returns only the neutral DTOs in MTConnectDtos.cs.
+///
+///
+///
+/// The constructor opens no connection. It only composes the request URIs and builds
+/// an (which itself dials nothing until a request is issued), so a
+/// throwaway instance is safe to construct — the universal discovery browser's
+/// CanBrowse pattern depends on that.
+///
+///
+/// Every call is deadline-bounded. Each request runs under a
+/// linked to the caller's token and cancelled after
+/// , and
+/// is set to the same bound. A frozen Agent surfaces as a
+/// rather than wedging the caller — the lesson from this
+/// repo's S7 read-leg wall-clock gap (arch-review R2-01). A non-positive timeout is rejected
+/// at construction so an operator-authored 0 can never come to mean "wait forever"
+/// (arch-review 01/S-6).
+///
+///
+/// This class does not swallow failures into an empty model: an unreachable Agent, a non-2xx
+/// status, or an unparseable body all throw. InitializeAsync (Task 9) is what catches
+/// that and moves the driver to Faulted.
+///
+///
+public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
+{
+ private readonly HttpClient _http;
+ private readonly Uri _probeUri;
+ private readonly TimeSpan _requestTimeout;
+
+ /// Creates a client for the Agent described by .
+ /// The driver options carrying the Agent URI, device scope, and per-call deadline.
+ /// is missing or is not an absolute HTTP(S) URI.
+ /// is not positive.
+ public MTConnectAgentClient(MTConnectDriverOptions options)
+ : this(options, handler: null)
+ {
+ }
+
+ ///
+ /// Test seam: as , but sends through
+ /// the supplied handler so the request/response round trip can be exercised with no socket.
+ /// The caller retains ownership of .
+ ///
+ internal MTConnectAgentClient(MTConnectDriverOptions options, HttpMessageHandler? handler)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (options.RequestTimeoutMs <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(options),
+ options.RequestTimeoutMs,
+ $"{nameof(MTConnectDriverOptions.RequestTimeoutMs)} must be positive; a non-positive per-call deadline would let a frozen Agent wedge the driver indefinitely.");
+ }
+
+ _requestTimeout = TimeSpan.FromMilliseconds(options.RequestTimeoutMs);
+ _probeUri = BuildRequestUri(options, "probe");
+
+ _http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
+ _http.Timeout = _requestTimeout;
+ }
+
+ ///
+ public async Task ProbeAsync(CancellationToken ct)
+ {
+ // ResponseContentRead (the default) buffers the whole body *under* this awaited, token-bound
+ // call, so the parse below reads from memory — no network read can outlive the deadline.
+ using var response = await SendAsync(_probeUri, ct).ConfigureAwait(false);
+
+ await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+
+ return MTConnectProbeParser.Parse(body);
+ }
+
+ ///
+ public Task CurrentAsync(CancellationToken ct) =>
+ // Task 7 adds the /current leg (MTConnectStreamsParser); deliberately not stubbed as an
+ // empty result, which would read as a working call that silently returns nothing.
+ throw new NotImplementedException("The MTConnect /current leg lands in Task 7.");
+
+ ///
+ public IAsyncEnumerable SampleAsync(long from, CancellationToken ct) =>
+ // Task 7 adds the /sample long-poll leg (multipart chunk framing + sequence-gap detection).
+ throw new NotImplementedException("The MTConnect /sample leg lands in Task 7.");
+
+ ///
+ public void Dispose() => _http.Dispose();
+
+ private async Task SendAsync(Uri uri, CancellationToken ct)
+ {
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ deadline.CancelAfter(_requestTimeout);
+
+ HttpResponseMessage response;
+ try
+ {
+ response = await _http.GetAsync(uri, deadline.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ // The caller did not cancel, so this is our own deadline (or HttpClient.Timeout) firing.
+ // Reported as a TimeoutException because a bare "A task was canceled" tells an operator
+ // nothing about which Agent stopped answering.
+ throw new TimeoutException(
+ $"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
+ }
+
+ response.EnsureSuccessStatusCode();
+
+ return response;
+ }
+
+ private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
+ {
+ var agentUri = options.AgentUri?.Trim();
+ if (string.IsNullOrEmpty(agentUri))
+ {
+ throw new ArgumentException(
+ $"{nameof(MTConnectDriverOptions.AgentUri)} is required (e.g. 'http://agent:5000').", nameof(options));
+ }
+
+ if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) ||
+ (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
+ {
+ throw new ArgumentException(
+ $"{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);
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs
new file mode 100644
index 00000000..6250d3bf
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs
@@ -0,0 +1,254 @@
+using System.Globalization;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// Turns an MTConnect Agent /probe response (an MTConnectDevices XML document)
+/// into the neutral . Deliberately a pure, socket-free static
+/// so the whole parse is unit-testable against canned fixtures.
+///
+///
+///
+/// Why hand-rolled rather than TrakHound. The referenced TrakHound packages
+/// (MTConnect.NET-Common + -HTTP 6.9.0.2) contain no
+/// IResponseDocumentFormatter implementation — the XML formatter ships in the
+/// separate, unreferenced MTConnect.NET-XML package — so
+/// ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream) answers
+/// Success = false / "Document Formatter Not found for "xml"" (verified by
+/// reflection + live invocation against this repo's own fixture, 2026-07-24). TrakHound also
+/// offers no socket-free public parse entry point, which the plan requires.
+///
+///
+/// Three shapes of the wire format this parser is built around.
+/// (1) A device may declare data items directly on <Device><DataItems>,
+/// outside any component — a walker that only recurses <Components> silently
+/// drops them.
+/// (2) The children of <Components> are named for the component type
+/// (<Controller>, <Path>, <Axes>, …) — there is no
+/// literal <Component> element — so every element child of
+/// <Components> is a component.
+/// (3) The document is XML-namespaced and the namespace URI carries the MTConnect version
+/// (urn:mtconnect.org:MTConnectDevices:1.3 … :2.0). Elements are therefore
+/// matched on and the namespace is ignored entirely — nothing
+/// version-specific is hard-coded, and a vendor-extension component in its own namespace
+/// (whose standard child elements still inherit the default MTConnect namespace) is not
+/// silently dropped. Attributes, by contrast, are read unqualified so a prefixed attribute
+/// (e.g. xsi:type) can never be mistaken for a DataItem's type.
+///
+///
+/// Failure posture. Anything that is not a well-formed MTConnect device model throws
+/// . It never degrades to an empty-but-successful model:
+/// an empty device model that looks successful is precisely the defect class that has bitten
+/// this codebase before (#485), and here it would tear the driver's browse tree down to
+/// nothing while reporting healthy.
+///
+///
+internal static class MTConnectProbeParser
+{
+ ///
+ /// Bound on component nesting depth. Real device models nest a handful of levels; this only
+ /// exists so a pathological or hostile document cannot recurse the parser into a
+ /// process-fatal (uncatchable) stack overflow.
+ ///
+ private const int MaxComponentDepth = 64;
+
+ /// Parses a /probe response held as a string.
+ /// The raw MTConnectDevices XML document.
+ ///
+ /// The payload is empty, not well-formed XML, not an MTConnectDevices document (an
+ /// Agent MTConnectError document included — Agents answer a bad device path with one
+ /// under HTTP 200), or declares no usable device.
+ ///
+ public static MTConnectProbeModel Parse(string xml)
+ {
+ if (string.IsNullOrWhiteSpace(xml))
+ {
+ throw new InvalidDataException("MTConnect /probe response was empty; expected an MTConnectDevices XML document.");
+ }
+
+ XDocument document;
+ try
+ {
+ document = XDocument.Parse(xml);
+ }
+ catch (XmlException ex)
+ {
+ throw new InvalidDataException($"MTConnect /probe response is not well-formed XML: {ex.Message}", ex);
+ }
+
+ return Build(document);
+ }
+
+ /// Parses a /probe response read from a stream.
+ /// A stream positioned at the start of the XML document.
+ /// As for .
+ public static MTConnectProbeModel Parse(Stream stream)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+
+ XDocument document;
+ try
+ {
+ document = XDocument.Load(stream);
+ }
+ catch (XmlException ex)
+ {
+ throw new InvalidDataException($"MTConnect /probe response is not well-formed XML: {ex.Message}", ex);
+ }
+
+ return Build(document);
+ }
+
+ private static MTConnectProbeModel Build(XDocument document)
+ {
+ var root = document.Root
+ ?? throw new InvalidDataException("MTConnect /probe response has no root element.");
+
+ if (!IsNamed(root, "MTConnectDevices"))
+ {
+ throw new InvalidDataException(DescribeUnexpectedRoot(root));
+ }
+
+ var devicesContainer = ChildrenNamed(root, "Devices").FirstOrDefault()
+ ?? throw new InvalidDataException(
+ "MTConnect /probe response has no element; it does not describe a device model.");
+
+ var devices = devicesContainer.Elements().Select(ReadDevice).ToList();
+ if (devices.Count == 0)
+ {
+ throw new InvalidDataException(
+ "MTConnect /probe response declares no devices; refusing to report an empty device model as a successful probe.");
+ }
+
+ return new MTConnectProbeModel(devices);
+ }
+
+ private static MTConnectDevice ReadDevice(XElement element) =>
+ new(
+ RequiredAttribute(element, "id", "Device"),
+ OptionalAttribute(element, "name"),
+ ReadComponents(element, depth: 1),
+ ReadDataItems(element));
+
+ private static MTConnectComponent ReadComponent(XElement element, int depth) =>
+ new(
+ RequiredAttribute(element, "id", $"Component <{element.Name.LocalName}>"),
+ OptionalAttribute(element, "name"),
+ ReadComponents(element, depth + 1),
+ ReadDataItems(element));
+
+ ///
+ /// Every element child of this container's <Components> element, whatever it is
+ /// named — the element name is the component type, not the literal string "Component".
+ ///
+ private static IReadOnlyList ReadComponents(XElement container, int depth)
+ {
+ if (depth > MaxComponentDepth)
+ {
+ throw new InvalidDataException(
+ $"MTConnect /probe response nests components more than {MaxComponentDepth} levels deep; refusing to recurse further.");
+ }
+
+ return ChildrenNamed(container, "Components")
+ .SelectMany(components => components.Elements())
+ .Select(element => ReadComponent(element, depth))
+ .ToList();
+ }
+
+ private static IReadOnlyList ReadDataItems(XElement container) =>
+ ChildrenNamed(container, "DataItems")
+ .SelectMany(dataItems => ChildrenNamed(dataItems, "DataItem"))
+ .Select(ReadDataItem)
+ .ToList();
+
+ private static MTConnectDataItem ReadDataItem(XElement element)
+ {
+ var id = RequiredAttribute(element, "id", "DataItem");
+
+ return new MTConnectDataItem(
+ id,
+ OptionalAttribute(element, "name"),
+ RequiredAttribute(element, "category", $"DataItem '{id}'"),
+ RequiredAttribute(element, "type", $"DataItem '{id}'"),
+ OptionalAttribute(element, "subType"),
+ OptionalAttribute(element, "units"),
+ OptionalAttribute(element, "representation"),
+ OptionalIntAttribute(element, "sampleCount", id));
+ }
+
+ private static bool IsNamed(XElement element, string localName) =>
+ string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal);
+
+ private static IEnumerable ChildrenNamed(XElement parent, string localName) =>
+ parent.Elements().Where(child => IsNamed(child, localName));
+
+ private static string RequiredAttribute(XElement element, string name, string owner)
+ {
+ var value = OptionalAttribute(element, name);
+
+ return value ?? throw new InvalidDataException(
+ $"MTConnect /probe response is malformed: {owner} has no '{name}' attribute.");
+ }
+
+ ///
+ /// Reads an unqualified attribute, normalizing a present-but-empty value to null.
+ /// Only unqualified attributes are considered, so a namespace-prefixed attribute such as
+ /// xsi:type can never be read as a DataItem's type.
+ ///
+ private static string? OptionalAttribute(XElement element, string name)
+ {
+ var value = element.Attribute(name)?.Value;
+
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+
+ private static int? OptionalIntAttribute(XElement element, string name, string dataItemId)
+ {
+ var raw = OptionalAttribute(element, name);
+ if (raw is null)
+ {
+ return null;
+ }
+
+ if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
+ {
+ throw new InvalidDataException(
+ $"MTConnect /probe response is malformed: DataItem '{dataItemId}' has a non-numeric '{name}' attribute ('{raw}').");
+ }
+
+ return value;
+ }
+
+ ///
+ /// Builds the message for a root element that is not MTConnectDevices, lifting the
+ /// Agent's own error text when the response is an MTConnectError document (which
+ /// Agents return under HTTP 200 for, e.g., an unknown device name).
+ ///
+ private static string DescribeUnexpectedRoot(XElement root)
+ {
+ var message =
+ $"MTConnect /probe response root element is <{root.Name.LocalName}>, expected .";
+
+ if (!IsNamed(root, "MTConnectError"))
+ {
+ return message;
+ }
+
+ var errors = root.Descendants()
+ .Where(e => IsNamed(e, "Error"))
+ .Select(e =>
+ {
+ var code = OptionalAttribute(e, "errorCode");
+ var text = e.Value.Trim();
+ return code is null ? text : $"{code}: {text}";
+ })
+ .Where(text => text.Length > 0)
+ .ToList();
+
+ return errors.Count == 0
+ ? message
+ : $"{message} The Agent reported: {string.Join("; ", errors)}";
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectProbeParseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectProbeParseTests.cs
new file mode 100644
index 00000000..019758f0
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectProbeParseTests.cs
@@ -0,0 +1,474 @@
+using System.Diagnostics;
+using System.Net;
+using System.Text;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Task 6 — /probe parse into the neutral , plus the
+/// leg that feeds it (URL composition + the
+/// per-op deadline), all exercised with no socket.
+///
+public sealed class MTConnectProbeParseTests
+{
+ private const string ProbeFixturePath = "Fixtures/probe.xml";
+
+ /// Every DataItem id the committed fixture declares — all seven, at every depth.
+ private static readonly string[] AllFixtureDataItemIds =
+ [
+ "dev1_avail", // sits directly on , NOT under a component
+ "dev1_pos",
+ "dev1_vibration_ts",
+ "dev1_partcount",
+ "dev1_execution",
+ "dev1_program",
+ "dev1_system_cond" // CONDITION
+ ];
+
+ // ---------------------------------------------------------------- parse: the committed fixture
+
+ [Fact]
+ public async Task Probe_parse_yields_nested_components_and_all_dataitems()
+ {
+ var xml = await File.ReadAllTextAsync(ProbeFixturePath, TestContext.Current.CancellationToken);
+
+ var model = MTConnectProbeParser.Parse(xml);
+
+ model.Devices.ShouldHaveSingleItem();
+ var ids = model.Devices[0].AllDataItems().Select(d => d.Id).ToList();
+ ids.ShouldContain("dev1_system_cond");
+ model.Devices[0].Components.ShouldNotBeEmpty(); // nesting preserved
+ }
+
+ [Fact]
+ public async Task Probe_parse_finds_every_dataitem_including_the_device_level_one()
+ {
+ var model = MTConnectProbeParser.Parse(await ReadFixtureAsync());
+
+ var ids = model.Devices[0].AllDataItems().Select(d => d.Id).ToList();
+
+ // All seven — a walker that only recurses silently drops dev1_avail.
+ ids.ShouldBe(AllFixtureDataItemIds, ignoreOrder: true);
+ model.Devices[0].DataItems.Select(d => d.Id).ShouldBe(["dev1_avail"]);
+ }
+
+ [Fact]
+ public async Task Probe_parse_reads_the_device_identity()
+ {
+ var device = MTConnectProbeParser.Parse(await ReadFixtureAsync()).Devices[0];
+
+ device.Id.ShouldBe("dev1");
+ device.Name.ShouldBe("VMC-3Axis");
+ }
+
+ [Fact]
+ public async Task Probe_parse_preserves_the_component_nesting_chain()
+ {
+ var device = MTConnectProbeParser.Parse(await ReadFixtureAsync()).Devices[0];
+
+ // Device -> Controller -> Path. The element names are the component TYPES (there is no
+ // literal element in MTConnect Devices XML).
+ var controller = device.Components.ShouldHaveSingleItem();
+ controller.Id.ShouldBe("dev1_controller");
+ controller.Name.ShouldBe("controller");
+ controller.DataItems.ShouldBeEmpty();
+
+ var path = controller.Components.ShouldHaveSingleItem();
+ path.Id.ShouldBe("dev1_path");
+ path.Name.ShouldBe("path");
+ path.Components.ShouldBeEmpty();
+ path.DataItems.Select(d => d.Id).ShouldBe(
+ ["dev1_pos", "dev1_vibration_ts", "dev1_partcount", "dev1_execution", "dev1_program", "dev1_system_cond"]);
+ }
+
+ [Fact]
+ public async Task Probe_parse_round_trips_every_dataitem_attribute()
+ {
+ var items = MTConnectProbeParser.Parse(await ReadFixtureAsync())
+ .Devices[0].AllDataItems().ToDictionary(d => d.Id);
+
+ var pos = items["dev1_pos"];
+ pos.Category.ShouldBe("SAMPLE");
+ pos.Type.ShouldBe("POSITION");
+ pos.SubType.ShouldBe("ACTUAL");
+ pos.Units.ShouldBe("MILLIMETER");
+ pos.Representation.ShouldBeNull();
+ pos.SampleCount.ShouldBeNull();
+
+ var timeSeries = items["dev1_vibration_ts"];
+ timeSeries.Category.ShouldBe("SAMPLE");
+ timeSeries.Type.ShouldBe("PATH_FEEDRATE");
+ timeSeries.Representation.ShouldBe("TIME_SERIES");
+ timeSeries.SampleCount.ShouldBe(10);
+ timeSeries.Units.ShouldBe("MILLIMETER/SECOND");
+
+ var condition = items["dev1_system_cond"];
+ condition.Category.ShouldBe("CONDITION");
+ condition.Type.ShouldBe("SYSTEM");
+
+ var availability = items["dev1_avail"];
+ availability.Category.ShouldBe("EVENT");
+ availability.Type.ShouldBe("AVAILABILITY");
+ availability.Name.ShouldBeNull();
+ availability.SubType.ShouldBeNull();
+ availability.Units.ShouldBeNull();
+ availability.Representation.ShouldBeNull();
+ availability.SampleCount.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Probe_parse_from_a_stream_matches_the_string_overload()
+ {
+ await using var stream = File.OpenRead(ProbeFixturePath);
+
+ var fromStream = MTConnectProbeParser.Parse(stream);
+
+ // Records compare their IReadOnlyList members by reference, so compare the flattened
+ // content rather than the graphs themselves.
+ var fromString = MTConnectProbeParser.Parse(await ReadFixtureAsync());
+ fromStream.Devices.Select(d => d.Id).ShouldBe(fromString.Devices.Select(d => d.Id));
+ fromStream.Devices[0].AllDataItems().ShouldBe(fromString.Devices[0].AllDataItems());
+ fromStream.Devices[0].Components[0].Components[0].Id.ShouldBe("dev1_path");
+ }
+
+ // ------------------------------------------------------- parse: not shaped to the 1.3 fixture
+
+ [Theory]
+ [InlineData("urn:mtconnect.org:MTConnectDevices:1.3")]
+ [InlineData("urn:mtconnect.org:MTConnectDevices:1.5")]
+ [InlineData("urn:mtconnect.org:MTConnectDevices:2.0")]
+ [InlineData("")] // no namespace at all
+ public void Probe_parse_is_agnostic_to_the_document_namespace_version(string namespaceUri)
+ {
+ var xmlns = namespaceUri.Length == 0 ? string.Empty : $" xmlns=\"{namespaceUri}\"";
+ var xml = $"""
+
+
+
+
+
+
+
+
+
+ """;
+
+ var model = MTConnectProbeParser.Parse(xml);
+
+ model.Devices.ShouldHaveSingleItem().AllDataItems().Select(d => d.Id).ShouldBe(["d_avail"]);
+ }
+
+ [Fact]
+ public void Probe_parse_recurses_components_to_arbitrary_depth()
+ {
+ // Four levels deep, and each level carries its own data item — deeper than the fixture, and
+ // with component element names the parser has never seen.
+ const string Xml = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ var device = MTConnectProbeParser.Parse(Xml).Devices[0];
+
+ device.AllDataItems().Select(d => d.Id).ShouldBe(["l0", "l1", "l2", "l3"]);
+ device.Components[0].Components[0].Components[0].Id.ShouldBe("m");
+ }
+
+ [Fact]
+ public void Probe_parse_reads_every_device_of_a_multi_device_agent()
+ {
+ const string Xml = """
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ var model = MTConnectProbeParser.Parse(Xml);
+
+ model.Devices.Select(d => d.Id).ShouldBe(["agent", "d1"]);
+ }
+
+ // ------------------------------------------------------------------ parse: fails loudly, never
+ // a silently-empty model
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not xml at all")]
+ [InlineData("")] // truncated mid-document
+ public void Probe_parse_throws_on_malformed_xml(string xml)
+ {
+ Should.Throw(() => MTConnectProbeParser.Parse(xml));
+ }
+
+ [Fact]
+ public void Probe_parse_throws_on_a_non_mtconnect_document()
+ {
+ var ex = Should.Throw(
+ () => MTConnectProbeParser.Parse("404 Not Found"));
+
+ ex.Message.ShouldContain("MTConnectDevices");
+ ex.Message.ShouldContain("html");
+ }
+
+ [Fact]
+ public void Probe_parse_throws_on_an_mtconnect_error_document_and_surfaces_the_agent_error()
+ {
+ // A real Agent answers an unknown device path with an MTConnectError document under HTTP 200.
+ const string Xml = """
+
+
+
+ Could not find the device 'nope'
+
+
+ """;
+
+ var ex = Should.Throw(() => MTConnectProbeParser.Parse(Xml));
+
+ ex.Message.ShouldContain("NO_DEVICE");
+ ex.Message.ShouldContain("Could not find the device 'nope'");
+ }
+
+ [Theory]
+ [InlineData("")] // no
+ [InlineData("")] // empty
+ public void Probe_parse_throws_when_the_document_declares_no_devices(string xml)
+ {
+ Should.Throw(() => MTConnectProbeParser.Parse(xml));
+ }
+
+ [Theory]
+ // DataItem missing its required id / category / type, and a garbage sampleCount.
+ [InlineData("")]
+ [InlineData("")]
+ [InlineData("")]
+ [InlineData("")]
+ public void Probe_parse_throws_on_a_malformed_dataitem(string dataItemXml)
+ {
+ var xml = $"""
+
+ {dataItemXml}
+
+ """;
+
+ Should.Throw(() => MTConnectProbeParser.Parse(xml));
+ }
+
+ [Fact]
+ public void Probe_parse_throws_when_a_device_has_no_id()
+ {
+ const string Xml = """
+
+
+
+ """;
+
+ Should.Throw(() => MTConnectProbeParser.Parse(Xml));
+ }
+
+ [Fact]
+ public void Probe_parse_throws_when_a_component_has_no_id()
+ {
+ const string Xml = """
+
+
+
+ """;
+
+ Should.Throw(() => MTConnectProbeParser.Parse(Xml));
+ }
+
+ // ---------------------------------------------------------------- MTConnectAgentClient.ProbeAsync
+
+ [Fact]
+ public async Task ProbeAsync_requests_the_agent_probe_path_and_returns_the_parsed_model()
+ {
+ var handler = StubHandler.RespondingWith(await ReadFixtureAsync());
+ using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);
+
+ var model = await client.ProbeAsync(TestContext.Current.CancellationToken);
+
+ handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/probe");
+ model.Devices.ShouldHaveSingleItem().Id.ShouldBe("dev1");
+ }
+
+ [Theory]
+ [InlineData("http://agent:5000", null, "http://agent:5000/probe")]
+ [InlineData("http://agent:5000/", null, "http://agent:5000/probe")]
+ [InlineData("http://agent:5000", "VMC-3Axis", "http://agent:5000/VMC-3Axis/probe")]
+ [InlineData("http://agent:5000/", "VMC 3Axis", "http://agent:5000/VMC%203Axis/probe")]
+ public async Task ProbeAsync_scopes_the_request_to_the_configured_device_name(
+ string agentUri, string? deviceName, string expectedUri)
+ {
+ var handler = StubHandler.RespondingWith(await ReadFixtureAsync());
+ using var client = new MTConnectAgentClient(
+ new MTConnectDriverOptions { AgentUri = agentUri, DeviceName = deviceName }, handler);
+
+ await client.ProbeAsync(TestContext.Current.CancellationToken);
+
+ handler.LastRequestUri!.AbsoluteUri.ShouldBe(expectedUri);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_bounds_a_hung_agent_by_the_configured_request_timeout()
+ {
+ // The R2-01 frozen-peer lesson: a wedged agent must surface as a cancelled call, not a hang.
+ var handler = StubHandler.Hanging();
+ using var client = new MTConnectAgentClient(
+ new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 }, handler);
+
+ var started = Stopwatch.StartNew();
+ var ex = await Should.ThrowAsync(
+ async () => await client.ProbeAsync(TestContext.Current.CancellationToken));
+
+ // A deadline hit must NOT be reported as a plain cancellation — the caller never cancelled.
+ ex.ShouldNotBeAssignableTo();
+ ex.Message.ShouldContain("http://agent:5000/probe");
+ started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
+ }
+
+ [Fact]
+ public async Task ProbeAsync_honours_the_callers_cancellation_token()
+ {
+ var handler = StubHandler.Hanging();
+ using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ await Should.ThrowAsync(async () => await client.ProbeAsync(cts.Token));
+ }
+
+ [Fact]
+ public async Task ProbeAsync_throws_rather_than_returning_an_empty_model_when_the_agent_errors()
+ {
+ var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable);
+ using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);
+
+ await Should.ThrowAsync(
+ async () => await client.ProbeAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Ctor_rejects_a_non_positive_request_timeout(int requestTimeoutMs)
+ {
+ // An operator-authorable 0 must never mean "wait forever" (arch-review 01/S-6).
+ Should.Throw(() => new MTConnectAgentClient(
+ new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = requestTimeoutMs }));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not a uri")]
+ public void Ctor_rejects_an_unusable_agent_uri(string agentUri)
+ {
+ Should.Throw(() => new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = agentUri }));
+ }
+
+ [Fact]
+ public void Ctor_opens_no_connection()
+ {
+ // The universal browser's throwaway-instance CanBrowse pattern depends on this.
+ var handler = StubHandler.Hanging();
+
+ using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);
+
+ handler.RequestCount.ShouldBe(0);
+ }
+
+ // ------------------------------------------------------------------- scope boundary: Task 7 legs
+
+ [Fact]
+ public async Task CurrentAsync_is_not_implemented_until_task_7()
+ {
+ using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
+
+ var ex = await Should.ThrowAsync(
+ async () => await client.CurrentAsync(TestContext.Current.CancellationToken));
+ ex.Message.ShouldContain("Task 7");
+ }
+
+ [Fact]
+ public void SampleAsync_is_not_implemented_until_task_7()
+ {
+ using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
+
+ var ex = Should.Throw(() => client.SampleAsync(1, CancellationToken.None));
+ ex.Message.ShouldContain("Task 7");
+ }
+
+ private static Task ReadFixtureAsync() =>
+ File.ReadAllTextAsync(ProbeFixturePath, TestContext.Current.CancellationToken);
+
+ /// A canned — no socket is ever opened.
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ private readonly string? _body;
+ private readonly HttpStatusCode _status;
+ private readonly bool _hang;
+
+ private StubHandler(string? body, HttpStatusCode status, bool hang)
+ {
+ _body = body;
+ _status = status;
+ _hang = hang;
+ }
+
+ public Uri? LastRequestUri { get; private set; }
+
+ public int RequestCount { get; private set; }
+
+ public static StubHandler RespondingWith(string body, HttpStatusCode status = HttpStatusCode.OK) =>
+ new(body, status, hang: false);
+
+ public static StubHandler Hanging() => new(null, HttpStatusCode.OK, hang: true);
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ LastRequestUri = request.RequestUri;
+ RequestCount++;
+
+ if (_hang)
+ {
+ await Task.Delay(Timeout.Infinite, cancellationToken);
+ }
+
+ return new HttpResponseMessage(_status)
+ {
+ Content = new StringContent(_body ?? string.Empty, Encoding.UTF8, "text/xml")
+ };
+ }
+ }
+}