using System.Net; using System.Text; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; /// /// Gitea #804 — the gaps a parity review against the ignitionoee Java MTConnect driver verified in this /// driver, one region per gap. Each test failed against the pre-#804 driver; the capability matrix that /// says which Java behaviours were already covered (and by which existing tests) is /// docs/drivers/MTConnect.md §"Java reference parity (#804)". /// public sealed class MTConnectJavaParityTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; private const string OutOfRangeError = """
'from' must be greater than 59962705 """; private const string NoDeviceError = """ Could not find the device 'nosuch' """; private static MTConnectAgentClient NewClient(HttpMessageHandler handler) => new(new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 5000 }, handler); private static async Task DrainAsync(IAsyncEnumerable stream) { await foreach (var _ in stream) { } } // ───────────── gap 1: an Agent error document under a non-2xx status is the Agent's refusal ───────────── // cppagent 2.x (the fixture's mtconnect/agent:2.7, verified live) answers an evicted `from` and a count at or // above its buffer with HTTP 400 + MTConnectError OUT_OF_RANGE. Pre-#804 the client threw a bare // HttpRequestException, which the pump treats as a transient transport loss: it reconnected at the SAME // evicted cursor for ever and the OUT_OF_RANGE re-baseline never ran against a real 2.x Agent. [Fact] public async Task Sample_answered_400_with_an_out_of_range_document_surfaces_as_the_agents_refusal() { using var client = NewClient(new StubHandler(_ => Respond(HttpStatusCode.BadRequest, OutOfRangeError))); var ex = await Should.ThrowAsync(async () => await DrainAsync(client.SampleAsync(1, Ct))); ex.Message.ShouldContain("OUT_OF_RANGE: 'from' must be greater than 59962705"); ex.Message.ShouldContain("HTTP 400"); MTConnectXml.IsAgentRefusal(ex).ShouldBeTrue(); } [Fact] public async Task Current_answered_404_with_a_no_device_document_is_an_agent_refusal_not_a_comms_fault() { using var client = NewClient(new StubHandler(_ => Respond(HttpStatusCode.NotFound, NoDeviceError))); var ex = await Should.ThrowAsync(() => client.CurrentAsync(Ct)); ex.Message.ShouldContain("NO_DEVICE"); MTConnectXml.IsAgentRefusal(ex).ShouldBeTrue(); } [Fact] public async Task Probe_answered_404_with_an_error_document_carries_the_agents_own_words() { using var client = NewClient(new StubHandler(_ => Respond(HttpStatusCode.NotFound, NoDeviceError))); var ex = await Should.ThrowAsync(() => client.ProbeAsync(Ct)); ex.Message.ShouldContain("Could not find the device 'nosuch'"); ex.Message.ShouldContain("expected "); } /// Positive control: a non-2xx body that is NOT an MTConnect error document keeps the status-only failure. [Fact] public async Task A_non_2xx_proxy_page_is_still_a_plain_http_failure() { using var client = NewClient(new StubHandler(_ => Respond(HttpStatusCode.BadGateway, "502"))); await Should.ThrowAsync(() => client.CurrentAsync(Ct)); } /// The failure body is read only up to the cap: a huge error page never becomes a huge string. [Fact] public async Task An_error_document_beyond_the_body_cap_is_not_read_in_full() { var padding = new string(' ', 70 * 1024); // past the 64 KiB failure-body cap using var client = NewClient(new StubHandler(_ => Respond(HttpStatusCode.BadRequest, padding + OutOfRangeError))); // The document starts past the cap, so it is never seen: status-only failure, not a refusal. await Should.ThrowAsync(async () => await DrainAsync(client.SampleAsync(1, Ct))); } // ───────────── gap 2 (driver-level, MTConnectSubscribeTests): repeated re-baselines without progress ───────────── // ───────────── gap 3: streams parser nesting bound ───────────── // ReadElement/ReadChildren recurse per nesting level; an unbounded depth from an unauthenticated peer is a // StackOverflowException, which .NET cannot catch — the whole server process dies. private static string Streams(string body) => $"""
{body} """; [Fact] public void Streams_parse_refuses_absurd_nesting_instead_of_recursing_without_bound() { const int depth = 80; // beyond the 64-level bound var xml = Streams(string.Concat(Enumerable.Repeat("", depth)) + string.Concat(Enumerable.Repeat("", depth))); var ex = Should.Throw(() => MTConnectStreamsParser.Parse(xml)); ex.Message.ShouldContain("levels deep"); } /// Positive control: a real document's nesting (with a TABLE's Entry/Cell) parses under the bound. [Fact] public void Streams_parse_accepts_ordinary_device_nesting() { var xml = Streams( """ 1.0 ACTIVE """); var result = MTConnectStreamsParser.Parse(xml); result.Observations.Select(o => o.DataItemId).ShouldBe(["wo", "exec"]); } // ───────────── gap 4: DOCTYPE refused on every tree-building parse ───────────── // The streams parser already prohibited DTDs; the /probe parse (and the write-answer parse) went through // XDocument.Parse, which PARSES a DTD and expands its internal entities. private const string ProbeWithDoctype = """ ]>
"""; [Fact] public void Probe_parse_refuses_a_doctype_from_a_string() { var ex = Should.Throw(() => MTConnectProbeParser.Parse(ProbeWithDoctype)); ex.Message.ShouldContain("not well-formed XML"); } [Fact] public void Probe_parse_refuses_a_doctype_from_a_stream() { using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ProbeWithDoctype)); Should.Throw(() => MTConnectProbeParser.Parse(stream)); } /// A write answered with a DOCTYPE'd document cannot be read, so it is NOT reported as accepted. [Fact] public async Task A_write_answered_with_a_doctype_document_is_not_confirmed() { const string body = """ ]> &e; """; using var client = NewClient(new StubHandler(_ => Respond(HttpStatusCode.OK, body))); var outcome = await client.WriteObservationAsync("d", "item", "v", Ct); outcome.Accepted.ShouldBeFalse(); outcome.AgentError.ShouldNotBeNull().ShouldContain("DOCTYPE"); } // ───────────── gap 5: no fabricated non-finite floats ───────────── // .NET parses an overflowing literal to ±Infinity and accepts NaN/Infinity spellings; all were Good. [Theory] [InlineData(DriverDataType.Float64, "1e400", 0x803C0000u)] // BadOutOfRange — a number the type cannot hold [InlineData(DriverDataType.Float64, "-1e400", 0x803C0000u)] [InlineData(DriverDataType.Float32, "1e39", 0x803C0000u)] // fits a double, overflows a float [InlineData(DriverDataType.Float64, "NaN", 0x80740000u)] // BadTypeMismatch — not a measurement [InlineData(DriverDataType.Float64, "Infinity", 0x80740000u)] [InlineData(DriverDataType.Float32, "-Infinity", 0x80740000u)] public void A_non_finite_float_is_never_served_as_good(DriverDataType type, string text, uint expected) { var snap = Coerce(type, text); snap.StatusCode.ShouldBe(expected); snap.Value.ShouldBeNull(); } // ───────────── gap 6: an integer-valued decimal on an integral tag ───────────── // Agents format integer-valued items as "42.000000"; that was BadOutOfRange on every integral tag. [Theory] [InlineData(DriverDataType.Int32, "42.000000", 42)] [InlineData(DriverDataType.Int64, "1.5e3", 1500L)] [InlineData(DriverDataType.Int16, "-7.0", (short)-7)] [InlineData(DriverDataType.UInt32, "4000000000.0", 4000000000u)] public void An_integer_valued_decimal_converts_losslessly(DriverDataType type, string text, object expected) { var snap = Coerce(type, text); snap.StatusCode.ShouldBe(0u); snap.Value.ShouldBe(expected); } /// …but a REAL fraction is still refused, never truncated (deliberately stricter than the Java driver). [Theory] [InlineData(DriverDataType.Int32, "42.5")] [InlineData(DriverDataType.Int16, "40000.0")] [InlineData(DriverDataType.UInt16, "-1.0")] [InlineData(DriverDataType.Int64, "1e30")] public void A_fraction_or_an_out_of_range_decimal_stays_out_of_range(DriverDataType type, string text) { var snap = Coerce(type, text); snap.StatusCode.ShouldBe(0x803C0000u); snap.Value.ShouldBeNull(); } private static DataValueSnapshot Coerce(DriverDataType type, string text) { var index = new MTConnectObservationIndex([new MTConnectTagDefinition("x", type)]); index.Apply(new MTConnectStreamsResult( 1L, 2L, 1L, [new MTConnectObservation("x", text, new DateTime(2026, 11, 10, 12, 0, 0, DateTimeKind.Utc))])); return index.Get("x"); } // ───────────── gap 7: sibling containers resolving to one folder name ───────────── // Two same-named sibling folders share one path key in the browse picker; the second one's data items were // listed but unreachable on expand. Merged into ONE folder (what the Java driver does). private const string CollidingProbe = """
"""; [Fact] public async Task Sibling_components_resolving_to_one_name_share_one_folder_and_keep_every_data_item() { var client = CannedAgentClient.FromFixtures(); client.Probe = MTConnectProbeParser.Parse(CollidingProbe); var driver = new MTConnectDriver(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, "mt1", _ => client); await driver.InitializeAsync("{}", Ct); var cap = new CapturingBuilder(); await driver.DiscoverAsync(cap, Ct); cap.Folders.Select(f => f.Path).ShouldBe(["Mill", "Mill/Axes", "Mill/Axes/C", "Mill/Axes/X"]); cap.Variables.Single(v => v.Attr.FullName == "c_angle").ParentPath.ShouldBe("Mill/Axes/C"); cap.Variables.Single(v => v.Attr.FullName == "c_pos").ParentPath.ShouldBe("Mill/Axes/C"); cap.Variables.Single(v => v.Attr.FullName == "x_pos").ParentPath.ShouldBe("Mill/Axes/X"); // id fallback cap.Variables.Single(v => v.Attr.FullName == "x2_pos").ParentPath.ShouldBe("Mill/Axes/X"); // name } // ───────────── gap 8: structural elements are not components ───────────── [Fact] public void A_structural_element_under_components_or_devices_is_skipped_not_fatal() { const string xml = """
agent-wide note stray, schema-invalid """; var model = MTConnectProbeParser.Parse(xml); model.Devices.ShouldHaveSingleItem().Components.ShouldHaveSingleItem().Id.ShouldBe("ctl"); } // ───────────── plumbing ───────────── private static HttpResponseMessage Respond(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body, Encoding.UTF8, "application/xml") }; private sealed class StubHandler(Func respond) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => Task.FromResult(respond(request)); } // ───────────── Java regression scenarios this driver already met (coverage, not gaps) ───────────── // Ported from the Java MTConnectMultipartReaderTest / MTConnectStreamsParserTest cases that had no // .NET counterpart. Each passed before #804; they pin behaviour the matrix records as "covered". private static string Doc(long first, long next, string observations = "") => Streams( $"""{observations}""") .Replace("firstSequence=\"1\"", $"firstSequence=\"{first}\"").Replace("nextSequence=\"6\"", $"nextSequence=\"{next}\""); private static HttpResponseMessage MultipartResponse(byte[] body) => MultipartResponse(new ByteArrayContent(body)); private static HttpResponseMessage MultipartResponse(HttpContent content) { content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("multipart/x-mixed-replace; boundary=b0"); return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; } private static async Task> CollectAsync(MTConnectAgentClient client, int count) { var results = new List(); await foreach (var chunk in client.SampleAsync(1, Ct)) { results.Add(chunk); if (results.Count == count) { break; } } return results; } /// Java contentLengthIsHonoredOverBoundaryScanning: a document whose text contains the boundary token is returned whole. [Fact] public async Task A_declared_content_length_is_authoritative_over_a_boundary_token_inside_the_document() { var doc = Doc(1, 2, "see\r\n--b0 inside the text"); var payload = Encoding.UTF8.GetBytes(doc); var body = Encoding.ASCII.GetBytes($"--b0\r\nContent-type: text/xml\r\nContent-length: {payload.Length}\r\n\r\n") .Concat(payload).Concat("\r\n--b0--\r\n"u8.ToArray()).ToArray(); using var client = NewClient(new StubHandler(_ => MultipartResponse(body))); var chunk = (await CollectAsync(client, 1)).ShouldHaveSingleItem(); chunk.Observations.ShouldHaveSingleItem().Value.ShouldContain("--b0 inside the text"); } /// Java preambleBeforeTheFirstBoundaryIsSkipped + lineFeedOnlyFramingIsAccepted. [Fact] public async Task A_preamble_is_skipped_and_line_feed_only_framing_is_accepted() { var first = Encoding.UTF8.GetBytes(Doc(1, 2)); var second = Encoding.UTF8.GetBytes(Doc(2, 3)); var body = Encoding.ASCII.GetBytes("this is preamble text\n--b0\nContent-length: " + first.Length + "\n\n") .Concat(first) .Concat(Encoding.ASCII.GetBytes("\n--b0\nContent-length: " + second.Length + "\n\n")) .Concat(second) .Concat("\n--b0--\n"u8.ToArray()).ToArray(); using var client = NewClient(new StubHandler(_ => MultipartResponse(body))); var chunks = await CollectAsync(client, 2); chunks.Select(c => c.NextSequence).ShouldBe([2L, 3L]); } /// Java aBodyShorterThanItsDeclaredContentLengthThrows: truncation is a lost stream, never an orderly end. [Fact] public async Task A_part_shorter_than_its_declared_content_length_is_a_lost_stream() { var payload = Encoding.UTF8.GetBytes(Doc(1, 2)); var body = Encoding.ASCII.GetBytes($"--b0\r\nContent-length: {payload.Length + 500}\r\n\r\n").Concat(payload).ToArray(); using var client = NewClient(new StubHandler(_ => MultipartResponse(body))); await Should.ThrowAsync(async () => await DrainAsync(client.SampleAsync(1, Ct))); } /// Java aPartLargerThanTheBufferCapIsRefused: a peer that never frames a part cannot grow the buffer unbounded. [Fact] public async Task A_part_larger_than_the_buffer_cap_is_refused() { var head = "--b0\r\nContent-type: text/xml\r\n\r\n"u8.ToArray(); using var client = NewClient(new StubHandler(_ => MultipartResponse(new StreamContent(new EndlessPart(head))))); var ex = await Should.ThrowAsync(async () => await DrainAsync(client.SampleAsync(1, Ct))); ex.Message.ShouldContain("refusing to buffer further"); } /// Java timestampWithANonZuluOffsetIsNormalizedToTheSameInstant: the SourceTimestamp is the Agent's instant, in UTC. [Fact] public void A_timestamp_with_a_non_zulu_offset_is_normalized_to_the_same_utc_instant() { var result = MTConnectStreamsParser.Parse( Doc(1, 2, """ACTIVE""")); var at = result.Observations.ShouldHaveSingleItem().TimestampUtc; at.ShouldBe(new DateTime(2026, 11, 10, 12, 0, 0, 500, DateTimeKind.Utc)); at.Kind.ShouldBe(DateTimeKind.Utc); } /// /// Java anAbsurdlyDeepErrorDocumentStillYieldsTheAgentsWords: the new nesting bound applies to the /// observation walk only, so a deeply nested error document still reports the Agent's own text. /// [Fact] public void An_absurdly_deep_error_document_still_yields_the_agents_words() { var nest = string.Concat(Enumerable.Repeat("", 100)); var unnest = string.Concat(Enumerable.Repeat("", 100)); var xml = $"""{nest}deep{unnest}"""; var ex = Should.Throw(() => MTConnectStreamsParser.Parse(xml)); ex.Message.ShouldContain("OUT_OF_RANGE: deep"); } /// A read-only stream that serves head then an endless run of document bytes with no boundary. private sealed class EndlessPart(byte[] head) : Stream { private long _position; public override int Read(byte[] buffer, int offset, int count) { var written = 0; while (written < count) { buffer[offset + written] = _position < head.Length ? head[_position] : (byte)'a'; _position++; written++; } return written; } public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; public override long Length => throw new NotSupportedException(); public override long Position { get => _position; set => throw new NotSupportedException(); } public override void Flush() { } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } }