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