475 lines
20 KiB
C#
475 lines
20 KiB
C#
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")
|
|
};
|
|
}
|
|
}
|
|
}
|