Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectProbeParseTests.cs
T

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