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