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