using System.Diagnostics; using System.Globalization; using System.Net; using System.Net.Http.Headers; using System.Text; using Microsoft.Extensions.Logging; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; /// /// Task 7 — the /current + /sample legs: /// turning an MTConnectStreams document into , the /// ring-buffer sequence-gap signal (), and the /// multipart/x-mixed-replace chunk framing + heartbeat watchdog on the long-poll leg. /// Every test runs against canned fixtures or a stubbed — no /// socket is opened anywhere in this file. /// public sealed class MTConnectStreamsParseTests { private const string CurrentFixture = "Fixtures/current.xml"; private const string SampleFixture = "Fixtures/sample.xml"; private const string GapFixture = "Fixtures/sample-gap.xml"; private const string UnavailableFixture = "Fixtures/current-unavailable.xml"; /// The fixtures deliberately share one instanceId, so a gap — not a restart — is under test. private const long FixtureInstanceId = 1655000000L; private static readonly string[] AllFixtureDataItemIds = [ "dev1_avail", "dev1_pos", "dev1_vibration_ts", "dev1_partcount", "dev1_execution", "dev1_program", "dev1_system_cond" ]; // ------------------------------------------------------------------ header sequence parsing [Fact] public void Current_parse_reads_header_sequences_and_observations() { var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); result.NextSequence.ShouldBeGreaterThan(0); result.Observations.ShouldNotBeEmpty(); } [Theory] [InlineData(CurrentFixture, 1L, 108L)] [InlineData(SampleFixture, 108L, 113L)] [InlineData(GapFixture, 5000L, 5005L)] [InlineData(UnavailableFixture, 200L, 207L)] public void Parse_reads_first_and_next_sequence_and_the_instance_id_from_every_fixture( string fixture, long firstSequence, long nextSequence) { var result = MTConnectStreamsParser.Parse(File.ReadAllText(fixture)); result.FirstSequence.ShouldBe(firstSequence); result.NextSequence.ShouldBe(nextSequence); result.InstanceId.ShouldBe(FixtureInstanceId); } // ------------------------------------------------------------------------ observation values [Fact] public void Current_parse_keys_every_observation_by_its_dataitem_id() { var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); result.Observations.Select(o => o.DataItemId).ShouldBe(AllFixtureDataItemIds, ignoreOrder: true); } [Fact] public void Current_parse_reads_the_good_value_the_agent_reported() { var observations = ParseCurrent(); // The observation ELEMENT is , not ; the value is its text content. observations["dev1_pos"].Value.ShouldBe("123.4567"); observations["dev1_execution"].Value.ShouldBe("ACTIVE"); observations["dev1_program"].Value.ShouldBe("O1234"); observations["dev1_avail"].Value.ShouldBe("AVAILABLE"); } [Fact] public void Current_parse_carries_the_unavailable_text_sentinel_through_verbatim() { // Task 8 maps UNAVAILABLE => BadNoCommunication; this layer must not pre-interpret it. ParseCurrent()["dev1_partcount"].Value.ShouldBe("UNAVAILABLE"); } [Fact] public void Current_parse_keeps_a_time_series_vector_as_its_raw_string() { ParseCurrent()["dev1_vibration_ts"].Value.ShouldBe("1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0"); } // ---------------------------------------------------------------------- CONDITION observations [Fact] public void A_condition_observations_value_is_its_element_name_not_its_empty_text() { // — the element name IS the state, and the // element is empty, so a text-content read yields "" and loses the observation entirely. ParseCurrent()["dev1_system_cond"].Value.ShouldBe("Normal"); } [Theory] [InlineData("Normal", "Normal")] [InlineData("Warning", "Warning")] [InlineData("Fault", "Fault")] public void A_condition_state_element_name_becomes_the_value(string element, string expected) { var result = MTConnectStreamsParser.Parse(StreamsDocument( $"""<{element} dataItemId="c" timestamp="2026-07-24T12:00:00Z" sequence="1" type="SYSTEM"/>""")); result.Observations.ShouldHaveSingleItem().Value.ShouldBe(expected); } [Fact] public void A_condition_with_text_content_still_takes_its_value_from_the_element_name() { // Agents put the operator-facing condition message in the element text; the STATE is the name. var result = MTConnectStreamsParser.Parse(StreamsDocument( """Spindle overtemperature""")); result.Observations.ShouldHaveSingleItem().Value.ShouldBe("Fault"); } [Fact] public void An_unavailable_condition_element_is_the_unavailable_sentinel() { // is genuinely no-comms and must land on the exact same sentinel Task 8 // matches for a Sample/Event's literal UNAVAILABLE text. var observations = ParseFixture(UnavailableFixture); observations["dev1_system_cond"].Value.ShouldBe("UNAVAILABLE"); } [Fact] public void The_all_unavailable_fixture_is_all_unavailable() { var result = MTConnectStreamsParser.Parse(File.ReadAllText(UnavailableFixture)); result.Observations.Select(o => o.DataItemId).ShouldBe(AllFixtureDataItemIds, ignoreOrder: true); result.Observations.ShouldAllBe(o => o.Value == "UNAVAILABLE"); } // ------------------------------------------------------------------------------- timestamps [Fact] public void Parse_normalizes_every_timestamp_to_utc() { // A wrong Kind silently shifts the OPC UA SourceTimestamp by the machine's UTC offset. var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); result.Observations.ShouldAllBe(o => o.TimestampUtc.Kind == DateTimeKind.Utc); ParseCurrent()["dev1_pos"].TimestampUtc .ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc)); } [Fact] public void An_offset_timestamp_is_converted_to_utc_rather_than_taken_at_face_value() { var result = MTConnectStreamsParser.Parse(StreamsDocument( """O1""")); var observation = result.Observations.ShouldHaveSingleItem(); observation.TimestampUtc.Kind.ShouldBe(DateTimeKind.Utc); observation.TimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)); } // -------------------------------------------------------------- sequence-gap detection (both ways) [Fact] public void Sequence_gap_is_detected_when_firstSequence_exceeds_requested_from() { var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture)); IMTConnectAgentClient.IsSequenceGap(expectedFrom: 1, chunk).ShouldBeTrue(); } [Fact] public void No_sequence_gap_is_reported_for_a_contiguous_chunk() { // The falsifiability leg: a helper hard-wired to true would pass the gap test alone. var contiguous = MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture)); IMTConnectAgentClient.IsSequenceGap(expectedFrom: 108, contiguous).ShouldBeFalse(); } [Theory] [InlineData(4999L, true)] // expected older than the buffer holds — data was lost [InlineData(5000L, false)] // exactly the oldest retained sequence — nothing was lost [InlineData(5001L, false)] // the agent simply had nothing older to send public void Sequence_gap_is_strictly_firstSequence_greater_than_expected_from(long expectedFrom, bool expected) { var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture)); IMTConnectAgentClient.IsSequenceGap(expectedFrom, chunk).ShouldBe(expected); } // --------------------------------------------------------------- legal but degenerate documents [Fact] public void A_document_with_a_valid_header_and_no_observations_parses_rather_than_throwing() { // /sample chunks are deltas; an idle agent legitimately sends a document with nothing in it. const string Xml = """
"""; var result = MTConnectStreamsParser.Parse(Xml); result.Observations.ShouldBeEmpty(); result.InstanceId.ShouldBe(7); result.NextSequence.ShouldBe(10); result.FirstSequence.ShouldBe(10); } [Fact] public void A_component_stream_may_omit_any_of_samples_events_and_condition() { // sample.xml carries no at all; absence is normal, not an error. var result = MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture)); result.Observations.Select(o => o.DataItemId).ShouldBe( ["dev1_pos", "dev1_vibration_ts", "dev1_partcount", "dev1_execution", "dev1_program"], ignoreOrder: true); } [Fact] public void Parse_collects_observations_across_every_device_and_component_stream() { const string Xml = """
AVAILABLE 1.0 """; var result = MTConnectStreamsParser.Parse(Xml); result.Observations.Select(o => o.DataItemId).ShouldBe(["a_avail", "b_pos", "b_cond"]); result.Observations[2].Value.ShouldBe("Warning"); } [Theory] [InlineData("urn:mtconnect.org:MTConnectStreams:1.3")] [InlineData("urn:mtconnect.org:MTConnectStreams:2.0")] [InlineData("")] public void Parse_is_agnostic_to_the_document_namespace_version(string namespaceUri) { var xmlns = namespaceUri.Length == 0 ? string.Empty : $" xmlns=\"{namespaceUri}\""; var xml = $"""
O1 """; MTConnectStreamsParser.Parse(xml).Observations.ShouldHaveSingleItem().DataItemId.ShouldBe("p"); } [Fact] public void Parse_from_a_stream_matches_the_string_overload() { using var stream = File.OpenRead(CurrentFixture); var fromStream = MTConnectStreamsParser.Parse(stream); var fromString = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); fromStream.NextSequence.ShouldBe(fromString.NextSequence); fromStream.Observations.ShouldBe(fromString.Observations); } // ------------------------------------------------------------- fails loudly, never silently empty [Theory] [InlineData("")] [InlineData(" ")] [InlineData("not xml at all")] [InlineData("")] public void Parse_throws_on_malformed_xml(string xml) { Should.Throw(() => MTConnectStreamsParser.Parse(xml)); } [Fact] public void Parse_throws_on_a_devices_document_served_where_streams_were_expected() { var ex = Should.Throw( () => MTConnectStreamsParser.Parse("")); ex.Message.ShouldContain("MTConnectStreams"); ex.Message.ShouldContain("MTConnectDevices"); } [Fact] public void Parse_throws_on_an_mtconnect_error_document_and_surfaces_the_agent_error() { // An agent answers an out-of-range `from` with an MTConnectError document under HTTP 200. const string Xml = """
'from' must be greater than 100 """; var ex = Should.Throw(() => MTConnectStreamsParser.Parse(Xml)); ex.Message.ShouldContain("OUT_OF_RANGE"); ex.Message.ShouldContain("'from' must be greater than 100"); } [Theory] [InlineData("")] // no
at all [InlineData("""
""")] // no nextSequence [InlineData("""
""")] // no firstSequence [InlineData("""
""")] // no instanceId [InlineData("""
""")] // non-numeric [InlineData("""
""")] // non-numeric public void Parse_throws_on_an_unusable_header(string headerXml) { var xml = $"{headerXml}"; Should.Throw(() => MTConnectStreamsParser.Parse(xml)); } [Theory] [InlineData("""1""")] // no dataItemId [InlineData("""1""")] // no timestamp [InlineData("""1""")] // unparseable timestamp public void Parse_throws_on_a_malformed_observation(string observationXml) { Should.Throw( () => MTConnectStreamsParser.Parse(StreamsDocument($"{observationXml}"))); } [Fact] public void Parse_reads_only_unqualified_attributes() { // A prefixed x:dataItemId is a vendor extension, not the observation's identity — reading it // would invent a data item that the probe never declared. var xml = StreamsDocument( """1"""); Should.Throw(() => MTConnectStreamsParser.Parse(xml)); } // ------------------------------------------------------------- MTConnectAgentClient.CurrentAsync [Fact] public async Task CurrentAsync_requests_the_agent_current_path_and_returns_the_parsed_snapshot() { var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var result = await client.CurrentAsync(Ct); handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/current"); result.NextSequence.ShouldBe(108); result.Observations.Count.ShouldBe(7); } [Fact] public async Task CurrentAsync_scopes_the_request_to_the_configured_device_name() { var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct)); using var client = NewClient( handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000/", DeviceName = "VMC 3Axis" }); await client.CurrentAsync(Ct); handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/VMC%203Axis/current"); } [Fact] public async Task CurrentAsync_bounds_a_hung_agent_by_the_configured_request_timeout() { using var client = NewClient( StubHandler.Hanging(), new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 }); var started = Stopwatch.StartNew(); var ex = await Should.ThrowAsync(async () => await client.CurrentAsync(Ct)); ex.ShouldNotBeAssignableTo(); ex.Message.ShouldContain("http://agent:5000/current"); started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); } [Fact] public async Task CurrentAsync_throws_on_an_error_document_served_under_http_200() { var handler = StubHandler.RespondingWith( """ nope """); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var ex = await Should.ThrowAsync(async () => await client.CurrentAsync(Ct)); ex.Message.ShouldContain("NO_DEVICE"); } [Fact] public async Task CurrentAsync_throws_on_a_non_success_status() { var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); await Should.ThrowAsync(async () => await client.CurrentAsync(Ct)); } // -------------------------------------------------------------- MTConnectAgentClient.SampleAsync [Fact] public async Task SampleAsync_composes_the_from_interval_count_and_heartbeat_query() { var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000", DeviceName = "VMC-3Axis", SampleIntervalMs = 250, SampleCount = 64, HeartbeatMs = 7000 }); await DrainAsync(client.SampleAsync(108, Ct)); handler.LastRequestUri!.AbsoluteUri.ShouldBe( "http://agent:5000/VMC-3Axis/sample?from=108&interval=250&count=64&heartbeat=7000"); } [Fact] public async Task SampleAsync_yields_one_result_per_multipart_chunk() { var handler = StubHandler.RespondingWithMultipart( "--------bnd", await File.ReadAllTextAsync(SampleFixture, Ct), await File.ReadAllTextAsync(GapFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = await DrainAsync(client.SampleAsync(108, Ct)); chunks.Count.ShouldBe(2); chunks[0].FirstSequence.ShouldBe(108); chunks[0].NextSequence.ShouldBe(113); chunks[1].FirstSequence.ShouldBe(5000); chunks[1].NextSequence.ShouldBe(5005); } [Fact] public async Task SampleAsync_frames_chunks_from_the_boundary_when_the_agent_omits_content_length() { var handler = StubHandler.RespondingWithMultipart( "bnd", withContentLength: false, await File.ReadAllTextAsync(SampleFixture, Ct), await File.ReadAllTextAsync(GapFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = await DrainAsync(client.SampleAsync(108, Ct)); chunks.Select(c => c.FirstSequence).ShouldBe([108L, 5000L]); } [Fact] public async Task SampleAsync_exposes_the_gap_signal_to_the_caller_on_the_chunk_that_carries_it() { // Task 11's pump: advance `from` to NextSequence while contiguous, re-baseline on a gap. var handler = StubHandler.RespondingWithMultipart( "bnd", await File.ReadAllTextAsync(SampleFixture, Ct), await File.ReadAllTextAsync(GapFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var from = 108L; var gaps = new List(); await Should.ThrowAsync(async () => { await foreach (var chunk in client.SampleAsync(from, Ct)) { // `from` is the RUNNING cursor, not the original argument — comparing every chunk // against the opening `from` would report a gap on every chunk once the Agent's // buffer rolls past it (I1). gaps.Add(IMTConnectAgentClient.IsSequenceGap(from, chunk)); from = chunk.NextSequence; } }); gaps.ShouldBe([false, true]); from.ShouldBe(5005); } [Fact] public async Task SampleAsync_ignores_an_empty_keepalive_part() { var handler = StubHandler.RespondingWithMultipart( "bnd", await File.ReadAllTextAsync(SampleFixture, Ct), "", await File.ReadAllTextAsync(GapFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = await DrainAsync(client.SampleAsync(108, Ct)); chunks.Count.ShouldBe(2); } [Fact] public async Task SampleAsync_yields_a_heartbeat_document_that_carries_no_observations() { // The agent's real keep-alive is a well-formed but observation-free MTConnectStreams // document — it advances the sequence, so it must reach the caller, not be swallowed. const string Heartbeat = """
"""; var handler = StubHandler.RespondingWithMultipart("bnd", Heartbeat); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = await DrainAsync(client.SampleAsync(113, Ct)); chunks.ShouldHaveSingleItem().Observations.ShouldBeEmpty(); IMTConnectAgentClient.IsSequenceGap(113, chunks[0]).ShouldBeFalse(); } [Fact(Timeout = 30_000)] public async Task SampleAsync_faults_on_the_heartbeat_watchdog_rather_than_hanging_on_a_silent_agent() { // The R2-01 frozen-peer shape: the agent accepts the connection, sends one chunk, then goes // silent forever. Without a watchdog the pump wedges with no timeout ever firing. var prefix = StubHandler.MultipartBody("bnd", withContentLength: true, closing: false, await File.ReadAllTextAsync(SampleFixture, Ct)); var handler = StubHandler.RespondingWithStallingStream("bnd", prefix); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 100, HeartbeatMs = 100 }); var started = Stopwatch.StartNew(); await using var chunks = client.SampleAsync(108, Ct).GetAsyncEnumerator(Ct); (await chunks.MoveNextAsync()).ShouldBeTrue(); chunks.Current.FirstSequence.ShouldBe(108); var ex = await Should.ThrowAsync(async () => await chunks.MoveNextAsync()); ex.ShouldNotBeAssignableTo(); ex.Message.ShouldContain("sample"); started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(20)); } [Fact(Timeout = 30_000)] public async Task SampleAsync_bounds_the_response_header_phase_by_the_request_timeout() { // A peer that accepts the TCP connection but never answers must not wedge the pump either. using var client = NewClient( StubHandler.Hanging(), new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 }); var ex = await Should.ThrowAsync(async () => await DrainAsync(client.SampleAsync(1, Ct))); ex.ShouldNotBeAssignableTo(); ex.Message.ShouldContain("/sample"); } [Fact] public async Task SampleAsync_throws_on_an_error_document_served_under_http_200() { // Not a multipart body at all — the agent answers a bad `from` with an error document. var handler = StubHandler.RespondingWith( """ bad from """); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var ex = await Should.ThrowAsync( async () => await DrainAsync(client.SampleAsync(1, Ct))); ex.Message.ShouldContain("OUT_OF_RANGE"); } [Fact] public async Task SampleAsync_throws_on_a_non_success_status() { var handler = StubHandler.RespondingWith("nope", HttpStatusCode.BadRequest); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); await Should.ThrowAsync(async () => await DrainAsync(client.SampleAsync(1, Ct))); } [Fact(Timeout = 30_000)] public async Task SampleAsync_honours_the_callers_cancellation_token() { var handler = StubHandler.RespondingWithStallingStream("bnd", []); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); await Should.ThrowAsync( async () => await DrainAsync(client.SampleAsync(1, cts.Token))); } [Fact] public async Task SampleAsync_reports_the_closing_boundary_rather_than_ending_quietly() { // C1. The seam contracts SampleAsync to run until cancelled, so a pump has no reason to // handle normal completion: returning here would either drop the subscription silently or // hot-loop reconnecting on an enumerator that ends immediately (#485). var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = new List(); var ex = await Should.ThrowAsync(async () => { await foreach (var chunk in client.SampleAsync(108, Ct)) { chunks.Add(chunk); } }); chunks.ShouldHaveSingleItem(); ex.Reason.ShouldBe(MTConnectStreamEndReason.ClosingBoundary); ex.ChunksDelivered.ShouldBe(1); ex.AgentUri.ShouldContain("/sample?from=108"); } [Fact] public async Task SampleAsync_reports_a_connection_that_drops_mid_stream() { // No closing boundary — the body simply stops. Distinguished from a deliberate close so an // operator can tell a flaky network from an Agent honouring a bounded request. var truncated = StubHandler.MultipartBody( "bnd", withContentLength: true, closing: false, await File.ReadAllTextAsync(SampleFixture, Ct)); var handler = StubHandler.RespondingWithBody("bnd", truncated); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var ex = await Should.ThrowAsync( async () => await EnumerateAsync(client.SampleAsync(108, Ct))); ex.Reason.ShouldBe(MTConnectStreamEndReason.ConnectionClosed); ex.ChunksDelivered.ShouldBe(1); } [Fact] public async Task SampleAsync_reports_a_non_multipart_answer_as_a_configuration_error() { // The worst C1 shape: the Agent ignored the streaming request entirely. Yielding the one // snapshot it did return and finishing would report a dead subscription as a healthy one. var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var ex = await Should.ThrowAsync( async () => await EnumerateAsync(client.SampleAsync(108, Ct))); ex.Message.ShouldContain("multipart/x-mixed-replace"); ex.Message.ShouldContain("text/xml"); } [Fact] public async Task SampleAsync_yields_nothing_at_all_from_a_non_multipart_answer() { // Explicitly pinned: the parsed document must NOT reach the caller. One snapshot plus a // finished stream is precisely how a dead data path disguises itself as a working one. var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = new List(); await Should.ThrowAsync(async () => { await foreach (var chunk in client.SampleAsync(108, Ct)) { chunks.Add(chunk); } }); chunks.ShouldBeEmpty(); } [Fact] public async Task SampleAsync_fails_fast_when_a_multipart_response_carries_no_boundary_parameter() { // M2. Falling through to whole-body reading would surface only as a watchdog TimeoutException // much later, pointing an operator at the network instead of at the malformed header. var handler = StubHandler.RespondingWithBoundarylessMultipart(); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var ex = await Should.ThrowAsync( async () => await EnumerateAsync(client.SampleAsync(1, Ct))); ex.Message.ShouldContain("boundary"); } [Fact] public async Task A_stream_end_and_a_stream_misconfiguration_share_one_catchable_base_type() { // A pump that only wants "the stream is over" should not have to name both. var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var ended = await Should.ThrowAsync( async () => await EnumerateAsync(client.SampleAsync(108, Ct))); ended.ShouldBeAssignableTo(); typeof(MTConnectStreamException).IsAssignableFrom(typeof(MTConnectStreamNotSupportedException)).ShouldBeTrue(); } [Fact] public void SampleAsync_opens_no_connection_until_it_is_enumerated() { var handler = StubHandler.Hanging(); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); _ = client.SampleAsync(1, CancellationToken.None); handler.RequestCount.ShouldBe(0); } // ------------------------------------------- I2: framing when the transport splits every read /// /// Serving the whole body from one buffer completes every frame in a single /// ReadAsync, so the split-boundary path, the split-header path and the multi-fill /// loop are never entered — on the one behaviour whose headline risk is framing. These drive /// the same fixtures through a transport that hands back only N bytes at a time; at N = 1 /// every delimiter, every header line and every document is split maximally. /// [Theory] [InlineData(1)] [InlineData(3)] [InlineData(7)] public async Task SampleAsync_frames_correctly_when_the_transport_splits_every_boundary_and_header(int bytesPerRead) { var handler = StubHandler.RespondingWithChunkedMultipart( "--------bnd", bytesPerRead, withContentLength: true, await File.ReadAllTextAsync(SampleFixture, Ct), await File.ReadAllTextAsync(GapFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = await DrainAsync(client.SampleAsync(108, Ct)); chunks.Select(c => c.FirstSequence).ShouldBe([108L, 5000L]); chunks.Select(c => c.NextSequence).ShouldBe([113L, 5005L]); chunks[0].Observations.Count.ShouldBe(5); } [Theory] [InlineData(1)] [InlineData(3)] [InlineData(7)] public async Task SampleAsync_boundary_scan_framing_survives_a_split_transport_too(int bytesPerRead) { var handler = StubHandler.RespondingWithChunkedMultipart( "bnd", bytesPerRead, withContentLength: false, await File.ReadAllTextAsync(SampleFixture, Ct), await File.ReadAllTextAsync(GapFixture, Ct)); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = await DrainAsync(client.SampleAsync(108, Ct)); chunks.Select(c => c.FirstSequence).ShouldBe([108L, 5000L]); } [Fact] public async Task SampleAsync_grows_its_buffer_for_a_part_larger_than_the_initial_read_buffer() { // Forces Array.Resize + a Consume across the grown buffer, which a 2 KB fixture never does. var large = LargeStreamsDocument(observationCount: 900); Encoding.UTF8.GetByteCount(large).ShouldBeGreaterThan(16 * 1024); var handler = StubHandler.RespondingWithChunkedMultipart( "bnd", bytesPerRead: 1024, withContentLength: true, large, large); using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); var chunks = await DrainAsync(client.SampleAsync(1, Ct)); chunks.Count.ShouldBe(2); chunks[0].Observations.Count.ShouldBe(900); chunks[1].Observations.Count.ShouldBe(900); } // ------------------------------------------------------- I3: disposal during a live enumeration [Fact(Timeout = 30_000)] public async Task Disposing_the_client_mid_enumeration_surfaces_as_cancellation() { // Task 9 re-initializes by disposing and rebuilding this client, which can happen while a // pump is still enumerating. An unclassified ObjectDisposedException/IOException from the // socket is something no caller is written to expect. var prefix = StubHandler.MultipartBody( "bnd", withContentLength: true, closing: false, await File.ReadAllTextAsync(SampleFixture, Ct)); var handler = StubHandler.RespondingWithStallingStream("bnd", prefix); var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 5000, HeartbeatMs = 20_000 }); await using var chunks = client.SampleAsync(108, Ct).GetAsyncEnumerator(Ct); (await chunks.MoveNextAsync()).ShouldBeTrue(); var pending = chunks.MoveNextAsync().AsTask(); client.Dispose(); var ex = await Should.ThrowAsync(async () => await pending); ex.ShouldNotBeOfType(); } [Fact] public async Task Using_a_disposed_client_throws_object_disposed_rather_than_dialling() { var handler = StubHandler.Hanging(); var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); client.Dispose(); await Should.ThrowAsync(async () => await client.CurrentAsync(Ct)); await Should.ThrowAsync( async () => await EnumerateAsync(client.SampleAsync(1, Ct))); handler.RequestCount.ShouldBe(0); } [Fact] public void Dispose_is_idempotent() { var client = NewClient(StubHandler.Hanging(), new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); client.Dispose(); Should.NotThrow(client.Dispose); } // ------------------------------------------- I4: the degraded framing mode is operator-visible [Fact] public async Task A_part_without_content_length_warns_once_that_framing_is_degraded() { var logger = new RecordingLogger(); var handler = StubHandler.RespondingWithMultipart( "bnd", withContentLength: false, await File.ReadAllTextAsync(SampleFixture, Ct), await File.ReadAllTextAsync(GapFixture, Ct)); using var client = new MTConnectAgentClient( new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler, logger); await DrainAsync(client.SampleAsync(108, Ct)); // Once, not once per part — a per-chunk warning on a long poll is its own outage. logger.Warnings.Count.ShouldBe(1); logger.Warnings[0].ShouldContain("Content-length"); logger.Warnings[0].ShouldContain("http://agent:5000/sample"); } [Fact] public async Task The_normal_content_length_path_warns_about_nothing() { var logger = new RecordingLogger(); var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct)); using var client = new MTConnectAgentClient( new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler, logger); await DrainAsync(client.SampleAsync(108, Ct)); logger.Warnings.ShouldBeEmpty(); } // ---------------------------------------- I5: operator-authorable options that reach the wire [Theory] [InlineData(0)] [InlineData(-1)] public void Ctor_rejects_a_non_positive_heartbeat(int heartbeatMs) { // heartbeat=0 goes on the query string, and an Agent answers it with an MTConnectError // under HTTP 200 — surfacing as a parse failure that never names the offending key. var ex = Should.Throw(() => new MTConnectAgentClient( new MTConnectDriverOptions { AgentUri = "http://agent:5000", HeartbeatMs = heartbeatMs })); ex.Message.ShouldContain(nameof(MTConnectDriverOptions.HeartbeatMs)); } [Theory] [InlineData(0)] [InlineData(-1)] public void Ctor_rejects_a_non_positive_sample_interval(int sampleIntervalMs) { var ex = Should.Throw(() => new MTConnectAgentClient( new MTConnectDriverOptions { AgentUri = "http://agent:5000", SampleIntervalMs = sampleIntervalMs })); ex.Message.ShouldContain(nameof(MTConnectDriverOptions.SampleIntervalMs)); } [Theory] [InlineData(0)] [InlineData(-1)] public void Ctor_rejects_a_non_positive_sample_count(int sampleCount) { var ex = Should.Throw(() => new MTConnectAgentClient( new MTConnectDriverOptions { AgentUri = "http://agent:5000", SampleCount = sampleCount })); ex.Message.ShouldContain(nameof(MTConnectDriverOptions.SampleCount)); } // ------------------------------------------- structured (DATA_SET / TABLE) observation signal [Fact] public void An_observation_whose_content_is_child_entries_is_flagged_structured() { // children concatenate to "12" through element.Value — keys gone, values run // together. The index codes this BadNotSupported rather than publishing noise as Good. var result = MTConnectStreamsParser.Parse(StreamsDocument( """ 12 """)); var observation = result.Observations.ShouldHaveSingleItem(); observation.IsStructured.ShouldBeTrue(); observation.DataItemId.ShouldBe("ds"); } [Fact] public void A_plain_text_observation_containing_spaces_is_NOT_flagged_structured() { // The load-bearing case: this proves the signal is "the element had element children", not // a whitespace heuristic. A Message reading "Coolant level low" is perfectly valid data and // is textually indistinguishable from concatenated entries. var result = MTConnectStreamsParser.Parse(StreamsDocument( """Coolant level low""")); var observation = result.Observations.ShouldHaveSingleItem(); observation.IsStructured.ShouldBeFalse(); observation.Value.ShouldBe("Coolant level low"); } [Fact] public void Every_observation_in_the_committed_fixtures_is_unstructured() { // Includes the space-separated TIME_SERIES vector, which is text and must stay unflagged. var current = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); current.Observations.ShouldAllBe(o => !o.IsStructured); } [Fact] public void A_condition_is_never_flagged_structured_even_with_child_content() { // A condition's value comes from the element NAME, so child content cannot corrupt it. // Flagging one would throw away a perfectly well-determined state. var result = MTConnectStreamsParser.Parse(StreamsDocument( """ vendor extension """)); var observation = result.Observations.ShouldHaveSingleItem(); observation.IsStructured.ShouldBeFalse(); observation.Value.ShouldBe("Fault"); } // ------------------------------------------------------------------------------------ helpers private static CancellationToken Ct => TestContext.Current.CancellationToken; private static MTConnectAgentClient NewClient(HttpMessageHandler handler, MTConnectDriverOptions options) => new(options, handler); private static async Task> DrainAsync(IAsyncEnumerable source) { var results = new List(); try { await foreach (var item in source) { results.Add(item); } } catch (MTConnectStreamEndedException) { // Expected under the C1 contract: every non-cancellation end of a /sample stream is // loud. Tests that care about HOW it ended assert on the exception directly. } return results; } /// Enumerates to completion, discarding chunks and letting every exception escape. private static async Task EnumerateAsync(IAsyncEnumerable source) { await foreach (var _ in source) { // Drained for effect only. } } /// A streams document big enough to force the frame reader to grow its buffer. private static string LargeStreamsDocument(int observationCount) { var samples = new StringBuilder(); for (var i = 0; i < observationCount; i++) { samples.Append(CultureInfo.InvariantCulture, $"""{i}.5"""); } return $"""
{samples} """; } private static Dictionary ParseCurrent() => ParseFixture(CurrentFixture); private static Dictionary ParseFixture(string path) => MTConnectStreamsParser.Parse(File.ReadAllText(path)).Observations.ToDictionary(o => o.DataItemId); /// Wraps observation-container XML in a minimal but valid streams document. private static string StreamsDocument(string componentStreamBody) => $"""
{componentStreamBody} """; /// A canned — no socket is ever opened. private sealed class StubHandler : HttpMessageHandler { private readonly Func? _responder; private readonly bool _hang; private StubHandler(Func? responder, bool hang) { _responder = responder; _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(() => new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "text/xml") }, hang: false); public static StubHandler Hanging() => new(responder: null, hang: true); public static StubHandler RespondingWithMultipart(string boundary, params string[] parts) => RespondingWithMultipart(boundary, withContentLength: true, parts); public static StubHandler RespondingWithMultipart(string boundary, bool withContentLength, params string[] parts) { var body = MultipartBody(boundary, withContentLength, closing: true, parts); return new StubHandler(() => Multipart(boundary, new ByteArrayContent(body)), hang: false); } public static StubHandler RespondingWithStallingStream(string boundary, byte[] prefix) => new(() => Multipart(boundary, new StallingContent(prefix)), hang: false); /// Serves an already-built multipart body verbatim (e.g. one with no closing delimiter). public static StubHandler RespondingWithBody(string boundary, byte[] body) => new(() => Multipart(boundary, new ByteArrayContent(body)), hang: false); /// /// Serves a multipart body through a transport that hands back at most /// bytes per ReadAsync, so boundaries, part /// headers and documents are all split across reads. /// public static StubHandler RespondingWithChunkedMultipart( string boundary, int bytesPerRead, bool withContentLength, params string[] parts) { var body = MultipartBody(boundary, withContentLength, closing: true, parts); return new StubHandler(() => Multipart(boundary, new ChunkedContent(body, bytesPerRead)), hang: false); } /// A response that claims multipart but omits the boundary parameter. public static StubHandler RespondingWithBoundarylessMultipart() => new( () => { var content = new ByteArrayContent([]); content.Headers.ContentType = new MediaTypeHeaderValue("multipart/x-mixed-replace"); return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; }, hang: false); /// Builds a multipart/x-mixed-replace body exactly as an Agent writes one. public static byte[] MultipartBody(string boundary, bool withContentLength, bool closing, params string[] parts) { var body = new List(); foreach (var part in parts) { var payload = Encoding.UTF8.GetBytes(part); var header = new StringBuilder() .Append("--").Append(boundary).Append("\r\n") .Append("Content-type: text/xml\r\n"); if (withContentLength) { header.Append("Content-length: ").Append(payload.Length).Append("\r\n"); } header.Append("\r\n"); body.AddRange(Encoding.ASCII.GetBytes(header.ToString())); body.AddRange(payload); body.AddRange("\r\n"u8.ToArray()); } if (closing) { body.AddRange(Encoding.ASCII.GetBytes($"--{boundary}--\r\n")); } return [.. body]; } private static HttpResponseMessage Multipart(string boundary, HttpContent content) { content.Headers.ContentType = MediaTypeHeaderValue.Parse($"multipart/x-mixed-replace; boundary={boundary}"); return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; } protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { LastRequestUri = request.RequestUri; RequestCount++; if (_hang) { await Task.Delay(Timeout.Infinite, cancellationToken); } return _responder!(); } } /// Hands back at most bytesPerRead bytes per read — the split-transport double. private sealed class ChunkedContent(byte[] body, int bytesPerRead) : HttpContent { protected override Task CreateContentReadStreamAsync() => Task.FromResult(new ChunkedStream(body, bytesPerRead)); protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => stream.WriteAsync(body, 0, body.Length); protected override bool TryComputeLength(out long length) { length = body.Length; return true; } } private sealed class ChunkedStream(byte[] body, int bytesPerRead) : Stream { private int _position; public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; public override long Length => body.Length; public override long Position { get => _position; set => throw new NotSupportedException(); } public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); if (_position >= body.Length) { return ValueTask.FromResult(0); } var count = Math.Min(Math.Min(bytesPerRead, buffer.Length), body.Length - _position); body.AsMemory(_position, count).CopyTo(buffer); _position += count; return ValueTask.FromResult(count); } public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); public override void Flush() { } public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); 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(); } /// Captures warning-level log lines so the degraded-framing notice can be asserted. private sealed class RecordingLogger : ILogger { public List Warnings { get; } = []; public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { if (logLevel == LogLevel.Warning) { Warnings.Add(formatter(state, exception)); } } } /// Serves , then never completes another read. private sealed class StallingContent(byte[] prefix) : HttpContent { protected override Task CreateContentReadStreamAsync() => Task.FromResult(new StallingStream(prefix)); protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => Task.CompletedTask; protected override bool TryComputeLength(out long length) { length = -1; return false; } } private sealed class StallingStream(byte[] prefix) : Stream { private int _position; 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 => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { if (_position < prefix.Length) { var count = Math.Min(buffer.Length, prefix.Length - _position); prefix.AsMemory(_position, count).CopyTo(buffer); _position += count; return count; } await Task.Delay(Timeout.Infinite, cancellationToken); return 0; } public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); public override void Flush() { } public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); 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(); } }