feat(mtconnect): agent client /probe parse to device model (Task 6)
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// The production <see cref="IMTConnectAgentClient"/> — a thin <see cref="HttpClient"/> wrapper
|
||||
/// over an MTConnect Agent's REST surface that hands every response body to a pure parser and
|
||||
/// returns only the neutral DTOs in <c>MTConnectDtos.cs</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The constructor opens no connection.</b> It only composes the request URIs and builds
|
||||
/// an <see cref="HttpClient"/> (which itself dials nothing until a request is issued), so a
|
||||
/// throwaway instance is safe to construct — the universal discovery browser's
|
||||
/// <c>CanBrowse</c> pattern depends on that.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every call is deadline-bounded.</b> Each request runs under a
|
||||
/// <see cref="CancellationTokenSource"/> linked to the caller's token and cancelled after
|
||||
/// <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>, <i>and</i>
|
||||
/// <see cref="HttpClient.Timeout"/> is set to the same bound. A frozen Agent surfaces as a
|
||||
/// <see cref="TimeoutException"/> 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 <c>0</c> can never come to mean "wait forever"
|
||||
/// (arch-review 01/S-6).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class does not swallow failures into an empty model: an unreachable Agent, a non-2xx
|
||||
/// status, or an unparseable body all throw. <c>InitializeAsync</c> (Task 9) is what catches
|
||||
/// that and moves the driver to Faulted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly Uri _probeUri;
|
||||
private readonly TimeSpan _requestTimeout;
|
||||
|
||||
/// <summary>Creates a client for the Agent described by <paramref name="options"/>.</summary>
|
||||
/// <param name="options">The driver options carrying the Agent URI, device scope, and per-call deadline.</param>
|
||||
/// <exception cref="ArgumentException"><see cref="MTConnectDriverOptions.AgentUri"/> is missing or is not an absolute HTTP(S) URI.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException"><see cref="MTConnectDriverOptions.RequestTimeoutMs"/> is not positive.</exception>
|
||||
public MTConnectAgentClient(MTConnectDriverOptions options)
|
||||
: this(options, handler: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: as <see cref="MTConnectAgentClient(MTConnectDriverOptions)"/>, but sends through
|
||||
/// the supplied handler so the request/response round trip can be exercised with no socket.
|
||||
/// The caller retains ownership of <paramref name="handler"/>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<MTConnectProbeModel> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<MTConnectStreamsResult> 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.");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IAsyncEnumerable<MTConnectStreamsResult> 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.");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose() => _http.Dispose();
|
||||
|
||||
private async Task<HttpResponseMessage> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using System.Globalization;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
|
||||
/// <summary>
|
||||
/// Turns an MTConnect Agent <c>/probe</c> response (an <c>MTConnectDevices</c> XML document)
|
||||
/// into the neutral <see cref="MTConnectProbeModel"/>. Deliberately a pure, socket-free static
|
||||
/// so the whole parse is unit-testable against canned fixtures.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why hand-rolled rather than TrakHound.</b> The referenced TrakHound packages
|
||||
/// (<c>MTConnect.NET-Common</c> + <c>-HTTP</c> 6.9.0.2) contain <b>no</b>
|
||||
/// <c>IResponseDocumentFormatter</c> implementation — the XML formatter ships in the
|
||||
/// separate, unreferenced <c>MTConnect.NET-XML</c> package — so
|
||||
/// <c>ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream)</c> answers
|
||||
/// <c>Success = false</c> / "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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Three shapes of the wire format this parser is built around.</b>
|
||||
/// (1) A device may declare data items <i>directly</i> on <c><Device><DataItems></c>,
|
||||
/// outside any component — a walker that only recurses <c><Components></c> silently
|
||||
/// drops them.
|
||||
/// (2) The children of <c><Components></c> are named for the component <i>type</i>
|
||||
/// (<c><Controller></c>, <c><Path></c>, <c><Axes></c>, …) — there is no
|
||||
/// literal <c><Component></c> element — so <i>every</i> element child of
|
||||
/// <c><Components></c> is a component.
|
||||
/// (3) The document is XML-namespaced and the namespace URI carries the MTConnect version
|
||||
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>). Elements are therefore
|
||||
/// matched on <see cref="XName.LocalName"/> 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. <c>xsi:type</c>) can never be mistaken for a DataItem's <c>type</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Failure posture.</b> Anything that is not a well-formed MTConnect device model throws
|
||||
/// <see cref="InvalidDataException"/>. 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class MTConnectProbeParser
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private const int MaxComponentDepth = 64;
|
||||
|
||||
/// <summary>Parses a <c>/probe</c> response held as a string.</summary>
|
||||
/// <param name="xml">The raw <c>MTConnectDevices</c> XML document.</param>
|
||||
/// <exception cref="InvalidDataException">
|
||||
/// The payload is empty, not well-formed XML, not an <c>MTConnectDevices</c> document (an
|
||||
/// Agent <c>MTConnectError</c> document included — Agents answer a bad device path with one
|
||||
/// under HTTP 200), or declares no usable device.
|
||||
/// </exception>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Parses a <c>/probe</c> response read from a stream.</summary>
|
||||
/// <param name="stream">A stream positioned at the start of the XML document.</param>
|
||||
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
|
||||
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 <Devices> 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));
|
||||
|
||||
/// <summary>
|
||||
/// Every element child of this container's <c><Components></c> element, whatever it is
|
||||
/// named — the element name is the component type, not the literal string "Component".
|
||||
/// </summary>
|
||||
private static IReadOnlyList<MTConnectComponent> 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<MTConnectDataItem> 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<XElement> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an unqualified attribute, normalizing a present-but-empty value to <c>null</c>.
|
||||
/// Only unqualified attributes are considered, so a namespace-prefixed attribute such as
|
||||
/// <c>xsi:type</c> can never be read as a DataItem's <c>type</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the message for a root element that is not <c>MTConnectDevices</c>, lifting the
|
||||
/// Agent's own error text when the response is an <c>MTConnectError</c> document (which
|
||||
/// Agents return under HTTP 200 for, e.g., an unknown device name).
|
||||
/// </summary>
|
||||
private static string DescribeUnexpectedRoot(XElement root)
|
||||
{
|
||||
var message =
|
||||
$"MTConnect /probe response root element is <{root.Name.LocalName}>, expected <MTConnectDevices>.";
|
||||
|
||||
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)}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Task 6 — <c>/probe</c> parse into the neutral <see cref="MTConnectProbeModel"/>, plus the
|
||||
/// <see cref="MTConnectAgentClient.ProbeAsync"/> leg that feeds it (URL composition + the
|
||||
/// per-op deadline), all exercised with no socket.
|
||||
/// </summary>
|
||||
public sealed class MTConnectProbeParseTests
|
||||
{
|
||||
private const string ProbeFixturePath = "Fixtures/probe.xml";
|
||||
|
||||
/// <summary>Every DataItem id the committed fixture declares — all seven, at every depth.</summary>
|
||||
private static readonly string[] AllFixtureDataItemIds =
|
||||
[
|
||||
"dev1_avail", // sits directly on <Device><DataItems>, 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 <Components> 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 <Component> 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 = $"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MTConnectDevices{xmlns}>
|
||||
<Header instanceId="1" bufferSize="131072"/>
|
||||
<Devices>
|
||||
<Device id="d" name="D">
|
||||
<DataItems><DataItem id="d_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
|
||||
</Device>
|
||||
</Devices>
|
||||
</MTConnectDevices>
|
||||
""";
|
||||
|
||||
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 = """
|
||||
<MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:2.0">
|
||||
<Devices>
|
||||
<Device id="d">
|
||||
<DataItems><DataItem id="l0" category="EVENT" type="AVAILABILITY"/></DataItems>
|
||||
<Components>
|
||||
<Axes id="ax">
|
||||
<DataItems><DataItem id="l1" category="EVENT" type="PART_COUNT"/></DataItems>
|
||||
<Components>
|
||||
<Linear id="x">
|
||||
<DataItems><DataItem id="l2" category="SAMPLE" type="POSITION"/></DataItems>
|
||||
<Components>
|
||||
<Motor id="m">
|
||||
<DataItems><DataItem id="l3" category="SAMPLE" type="TEMPERATURE"/></DataItems>
|
||||
</Motor>
|
||||
</Components>
|
||||
</Linear>
|
||||
</Components>
|
||||
</Axes>
|
||||
</Components>
|
||||
</Device>
|
||||
</Devices>
|
||||
</MTConnectDevices>
|
||||
""";
|
||||
|
||||
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 = """
|
||||
<MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:2.0">
|
||||
<Devices>
|
||||
<Device id="agent" name="Agent">
|
||||
<DataItems><DataItem id="agent_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
|
||||
</Device>
|
||||
<Device id="d1" name="One">
|
||||
<DataItems><DataItem id="d1_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
|
||||
</Device>
|
||||
</Devices>
|
||||
</MTConnectDevices>
|
||||
""";
|
||||
|
||||
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("<MTConnectDevices><Devices>")] // truncated mid-document
|
||||
public void Probe_parse_throws_on_malformed_xml(string xml)
|
||||
{
|
||||
Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(xml));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_parse_throws_on_a_non_mtconnect_document()
|
||||
{
|
||||
var ex = Should.Throw<InvalidDataException>(
|
||||
() => MTConnectProbeParser.Parse("<html><body>404 Not Found</body></html>"));
|
||||
|
||||
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 = """
|
||||
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
|
||||
<Header instanceId="1"/>
|
||||
<Errors>
|
||||
<Error errorCode="NO_DEVICE">Could not find the device 'nope'</Error>
|
||||
</Errors>
|
||||
</MTConnectError>
|
||||
""";
|
||||
|
||||
var ex = Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(Xml));
|
||||
|
||||
ex.Message.ShouldContain("NO_DEVICE");
|
||||
ex.Message.ShouldContain("Could not find the device 'nope'");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("<MTConnectDevices><Header instanceId=\"1\"/></MTConnectDevices>")] // no <Devices>
|
||||
[InlineData("<MTConnectDevices><Header instanceId=\"1\"/><Devices/></MTConnectDevices>")] // empty <Devices>
|
||||
public void Probe_parse_throws_when_the_document_declares_no_devices(string xml)
|
||||
{
|
||||
Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(xml));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// DataItem missing its required id / category / type, and a garbage sampleCount.
|
||||
[InlineData("<DataItem category=\"EVENT\" type=\"AVAILABILITY\"/>")]
|
||||
[InlineData("<DataItem id=\"x\" type=\"AVAILABILITY\"/>")]
|
||||
[InlineData("<DataItem id=\"x\" category=\"EVENT\"/>")]
|
||||
[InlineData("<DataItem id=\"x\" category=\"SAMPLE\" type=\"POSITION\" sampleCount=\"lots\"/>")]
|
||||
public void Probe_parse_throws_on_a_malformed_dataitem(string dataItemXml)
|
||||
{
|
||||
var xml = $"""
|
||||
<MTConnectDevices>
|
||||
<Devices><Device id="d"><DataItems>{dataItemXml}</DataItems></Device></Devices>
|
||||
</MTConnectDevices>
|
||||
""";
|
||||
|
||||
Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(xml));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_parse_throws_when_a_device_has_no_id()
|
||||
{
|
||||
const string Xml = """
|
||||
<MTConnectDevices>
|
||||
<Devices><Device name="nameless"/></Devices>
|
||||
</MTConnectDevices>
|
||||
""";
|
||||
|
||||
Should.Throw<InvalidDataException>(() => MTConnectProbeParser.Parse(Xml));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_parse_throws_when_a_component_has_no_id()
|
||||
{
|
||||
const string Xml = """
|
||||
<MTConnectDevices>
|
||||
<Devices><Device id="d"><Components><Controller name="c"/></Components></Device></Devices>
|
||||
</MTConnectDevices>
|
||||
""";
|
||||
|
||||
Should.Throw<InvalidDataException>(() => 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<TimeoutException>(
|
||||
async () => await client.ProbeAsync(TestContext.Current.CancellationToken));
|
||||
|
||||
// A deadline hit must NOT be reported as a plain cancellation — the caller never cancelled.
|
||||
ex.ShouldNotBeAssignableTo<OperationCanceledException>();
|
||||
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<OperationCanceledException>(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<HttpRequestException>(
|
||||
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<ArgumentOutOfRangeException>(() => 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<ArgumentException>(() => 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<NotImplementedException>(
|
||||
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<NotImplementedException>(() => client.SampleAsync(1, CancellationToken.None));
|
||||
ex.Message.ShouldContain("Task 7");
|
||||
}
|
||||
|
||||
private static Task<string> ReadFixtureAsync() =>
|
||||
File.ReadAllTextAsync(ProbeFixturePath, TestContext.Current.CancellationToken);
|
||||
|
||||
/// <summary>A canned <see cref="HttpMessageHandler"/> — no socket is ever opened.</summary>
|
||||
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<HttpResponseMessage> 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")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user