Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs
T
Joseph Doherty 5ba9f1be6f fix(mtconnect): make a /sample stream end loud, and correct the gap contract (Task 7 review)
Code review of c46540ae approved the framing algorithm but moved the risk
onto the contract around it.

C1 (critical). SampleAsync returned normally on three different events:
EOF, the closing --boundary--, and a response that was not multipart at
all (where it yielded one parsed snapshot and finished). The seam
documents the stream as yielding until ct is cancelled, so a Task 11 pump
written to that contract would silently drop its subscription or hot-loop
reconnecting; the non-multipart leg reported a configuration error as a
healthy finished stream, which is the #485 quiet-successful-termination
shape aimed at the very next task. Every non-cancellation end now throws:
MTConnectStreamEndedException (ConnectionClosed / ClosingBoundary -
transient) or MTConnectStreamNotSupportedException (configuration -
reconnecting reproduces it forever), sharing one catchable base. The
non-multipart body is still parsed first so an Agent's own MTConnectError
text wins, but the document is NOT yielded.

I1. IsSequenceGap moved to IMTConnectAgentClient (static) and its first
parameter is now expectedFrom. The old name and doc were wrong for every
chunk after the first - the correct comparand is the PREVIOUS chunk's
NextSequence, and comparing against the opening "from" argument reports a
gap on every chunk once the ring buffer rolls, i.e. an endless /current
re-baseline storm. Both doc blocks also now record that cppagent answers
from < firstSequence with an OUT_OF_RANGE MTConnectError under HTTP 200,
which surfaces as InvalidDataException and NOT as a gap - so Task 11 must
treat that parse failure as a re-baseline trigger too.

I2. Every multipart test served the whole body from one buffer, so every
framing test completed in a single ReadAsync - the split-boundary path,
the split-header path and the multi-fill loop were correct by inspection
only. A ChunkedStream double (N bytes per read, N = 1/3/7) now drives the
fixtures through a split transport, plus a >16 KB document that forces a
buffer resize. Falsifiability: removing either scan overlap, or
collapsing the fill loop, fails ONLY the new chunked tests.

I3. Disposal mid-enumeration surfaces as OperationCanceledException via
an internal dispose-linked token, not a raw ObjectDisposedException that
Task 9's re-init would inflict on an enumerating pump.

I5. HeartbeatMs, SampleIntervalMs and SampleCount join RequestTimeoutMs
in construction-time positive-value validation. All three reach the query
string and an Agent answers heartbeat=0 with an HTTP-200 MTConnectError
that names no config key.

I4/M2. The no-Content-length framing fallback logs a one-shot warning
(the client takes an optional ILogger); a multipart response with no
boundary parameter fails fast instead of degrading into a watchdog
timeout.

M5/M6. Shared element/attribute reading rules extracted to MTConnectXml;
the probe parser documents why it alone does not trim BOM/whitespace. The
literal U+FEFF in source became a '' escape.

Also, from Task 8: MTConnectObservation gained IsStructured (default
false), set from element.HasElements. A DATA_SET/TABLE observation's
<Entry> children concatenate through element.Value to nonsense ("12" for
two entries) that the index would publish as Good, and only the parser
can tell that apart from a legitimate space-bearing Message. False for
CONDITION observations - their value comes from the element name, so
child content cannot corrupt it.

275/275 green in this suite's own files.
2026-07-27 13:57:46 -04:00

1346 lines
56 KiB
C#

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;
/// <summary>
/// Task 7 — the <c>/current</c> + <c>/sample</c> legs: <see cref="MTConnectStreamsParser"/>
/// turning an <c>MTConnectStreams</c> document into <see cref="MTConnectStreamsResult"/>, the
/// ring-buffer sequence-gap signal (<see cref="IMTConnectAgentClient.IsSequenceGap"/>), and the
/// <c>multipart/x-mixed-replace</c> chunk framing + heartbeat watchdog on the long-poll leg.
/// Every test runs against canned fixtures or a stubbed <see cref="HttpMessageHandler"/> — no
/// socket is opened anywhere in this file.
/// </summary>
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";
/// <summary>The fixtures deliberately share one instanceId, so a gap — not a restart — is under test.</summary>
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 <Position>, not <POSITION>; 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()
{
// <Normal dataItemId="dev1_system_cond" .../> — 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(
$"""<Condition><{element} dataItemId="c" timestamp="2026-07-24T12:00:00Z" sequence="1" type="SYSTEM"/></Condition>"""));
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(
"""<Condition><Fault dataItemId="c" timestamp="2026-07-24T12:00:00Z" nativeCode="E17">Spindle overtemperature</Fault></Condition>"""));
result.Observations.ShouldHaveSingleItem().Value.ShouldBe("Fault");
}
[Fact]
public void An_unavailable_condition_element_is_the_unavailable_sentinel()
{
// <Unavailable/> 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(
"""<Events><Program dataItemId="p" timestamp="2026-07-24T14:00:00+02:00">O1</Program></Events>"""));
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 = """
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.3">
<Header instanceId="7" firstSequence="10" lastSequence="9" nextSequence="10"/>
<Streams/>
</MTConnectStreams>
""";
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 <Condition> 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 = """
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:2.0">
<Header instanceId="1" firstSequence="1" lastSequence="3" nextSequence="4"/>
<Streams>
<DeviceStream name="A" uuid="a">
<ComponentStream component="Device" componentId="a">
<Events><Availability dataItemId="a_avail" timestamp="2026-07-24T12:00:00Z">AVAILABLE</Availability></Events>
</ComponentStream>
</DeviceStream>
<DeviceStream name="B" uuid="b">
<ComponentStream component="Path" componentId="b_path">
<Samples><Position dataItemId="b_pos" timestamp="2026-07-24T12:00:01Z">1.0</Position></Samples>
<Condition><Warning dataItemId="b_cond" timestamp="2026-07-24T12:00:02Z" type="SYSTEM"/></Condition>
</ComponentStream>
</DeviceStream>
</Streams>
</MTConnectStreams>
""";
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 = $"""
<MTConnectStreams{xmlns}>
<Header instanceId="1" firstSequence="1" lastSequence="1" nextSequence="2"/>
<Streams><DeviceStream><ComponentStream>
<Events><Program dataItemId="p" timestamp="2026-07-24T12:00:00Z">O1</Program></Events>
</ComponentStream></DeviceStream></Streams>
</MTConnectStreams>
""";
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("<MTConnectStreams><Streams>")]
public void Parse_throws_on_malformed_xml(string xml)
{
Should.Throw<InvalidDataException>(() => MTConnectStreamsParser.Parse(xml));
}
[Fact]
public void Parse_throws_on_a_devices_document_served_where_streams_were_expected()
{
var ex = Should.Throw<InvalidDataException>(
() => MTConnectStreamsParser.Parse("<MTConnectDevices><Devices/></MTConnectDevices>"));
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 = """
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
<Header instanceId="1"/>
<Errors>
<Error errorCode="OUT_OF_RANGE">'from' must be greater than 100</Error>
</Errors>
</MTConnectError>
""";
var ex = Should.Throw<InvalidDataException>(() => MTConnectStreamsParser.Parse(Xml));
ex.Message.ShouldContain("OUT_OF_RANGE");
ex.Message.ShouldContain("'from' must be greater than 100");
}
[Theory]
[InlineData("")] // no <Header> at all
[InlineData("""<Header instanceId="1" firstSequence="1"/>""")] // no nextSequence
[InlineData("""<Header instanceId="1" nextSequence="2"/>""")] // no firstSequence
[InlineData("""<Header firstSequence="1" nextSequence="2"/>""")] // no instanceId
[InlineData("""<Header instanceId="1" firstSequence="one" nextSequence="2"/>""")] // non-numeric
[InlineData("""<Header instanceId="1" firstSequence="1" nextSequence="lots"/>""")] // non-numeric
public void Parse_throws_on_an_unusable_header(string headerXml)
{
var xml = $"<MTConnectStreams>{headerXml}<Streams/></MTConnectStreams>";
Should.Throw<InvalidDataException>(() => MTConnectStreamsParser.Parse(xml));
}
[Theory]
[InlineData("""<Position timestamp="2026-07-24T12:00:00Z">1</Position>""")] // no dataItemId
[InlineData("""<Position dataItemId="p">1</Position>""")] // no timestamp
[InlineData("""<Position dataItemId="p" timestamp="whenever">1</Position>""")] // unparseable timestamp
public void Parse_throws_on_a_malformed_observation(string observationXml)
{
Should.Throw<InvalidDataException>(
() => MTConnectStreamsParser.Parse(StreamsDocument($"<Samples>{observationXml}</Samples>")));
}
[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(
"""<Samples><Position xmlns:x="urn:vendor" x:dataItemId="p" timestamp="2026-07-24T12:00:00Z">1</Position></Samples>""");
Should.Throw<InvalidDataException>(() => 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<TimeoutException>(async () => await client.CurrentAsync(Ct));
ex.ShouldNotBeAssignableTo<OperationCanceledException>();
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(
"""
<MTConnectError><Errors><Error errorCode="NO_DEVICE">nope</Error></Errors></MTConnectError>
""");
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
var ex = await Should.ThrowAsync<InvalidDataException>(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<HttpRequestException>(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<bool>();
await Should.ThrowAsync<MTConnectStreamEndedException>(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 = """
<MTConnectStreams>
<Header instanceId="1655000000" firstSequence="113" lastSequence="112" nextSequence="113"/>
<Streams/>
</MTConnectStreams>
""";
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<TimeoutException>(async () => await chunks.MoveNextAsync());
ex.ShouldNotBeAssignableTo<OperationCanceledException>();
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<TimeoutException>(async () => await DrainAsync(client.SampleAsync(1, Ct)));
ex.ShouldNotBeAssignableTo<OperationCanceledException>();
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(
"""
<MTConnectError><Errors><Error errorCode="OUT_OF_RANGE">bad from</Error></Errors></MTConnectError>
""");
using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
var ex = await Should.ThrowAsync<InvalidDataException>(
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<HttpRequestException>(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<OperationCanceledException>(
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<MTConnectStreamsResult>();
var ex = await Should.ThrowAsync<MTConnectStreamEndedException>(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<MTConnectStreamEndedException>(
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<MTConnectStreamNotSupportedException>(
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<MTConnectStreamsResult>();
await Should.ThrowAsync<MTConnectStreamNotSupportedException>(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<MTConnectStreamNotSupportedException>(
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<MTConnectStreamEndedException>(
async () => await EnumerateAsync(client.SampleAsync(108, Ct)));
ended.ShouldBeAssignableTo<MTConnectStreamException>();
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
/// <summary>
/// Serving the whole body from one buffer completes every frame in a single
/// <c>ReadAsync</c>, 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.
/// </summary>
[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<OperationCanceledException>(async () => await pending);
ex.ShouldNotBeOfType<ObjectDisposedException>();
}
[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<ObjectDisposedException>(async () => await client.CurrentAsync(Ct));
await Should.ThrowAsync<ObjectDisposedException>(
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<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => 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()
{
// <Entry> 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(
"""
<Events><PathFeedrateOverride dataItemId="ds" timestamp="2026-07-24T12:00:00Z" count="2">
<Entry key="V1">1</Entry><Entry key="V2">2</Entry>
</PathFeedrateOverride></Events>
"""));
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(
"""<Events><Message dataItemId="msg" timestamp="2026-07-24T12:00:00Z">Coolant level low</Message></Events>"""));
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(
"""
<Condition><Fault dataItemId="c" timestamp="2026-07-24T12:00:00Z" type="SYSTEM">
<Detail>vendor extension</Detail>
</Fault></Condition>
"""));
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<List<MTConnectStreamsResult>> DrainAsync(IAsyncEnumerable<MTConnectStreamsResult> source)
{
var results = new List<MTConnectStreamsResult>();
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;
}
/// <summary>Enumerates to completion, discarding chunks and letting every exception escape.</summary>
private static async Task EnumerateAsync(IAsyncEnumerable<MTConnectStreamsResult> source)
{
await foreach (var _ in source)
{
// Drained for effect only.
}
}
/// <summary>A streams document big enough to force the frame reader to grow its buffer.</summary>
private static string LargeStreamsDocument(int observationCount)
{
var samples = new StringBuilder();
for (var i = 0; i < observationCount; i++)
{
samples.Append(CultureInfo.InvariantCulture,
$"""<Position dataItemId="d{i}" timestamp="2026-07-24T12:00:00Z" sequence="{i + 1}">{i}.5</Position>""");
}
return $"""
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.3">
<Header instanceId="1655000000" firstSequence="1" lastSequence="{observationCount}" nextSequence="{observationCount + 1}"/>
<Streams><DeviceStream name="D" uuid="d"><ComponentStream component="Path" componentId="p">
<Samples>{samples}</Samples>
</ComponentStream></DeviceStream></Streams>
</MTConnectStreams>
""";
}
private static Dictionary<string, MTConnectObservation> ParseCurrent() => ParseFixture(CurrentFixture);
private static Dictionary<string, MTConnectObservation> ParseFixture(string path) =>
MTConnectStreamsParser.Parse(File.ReadAllText(path)).Observations.ToDictionary(o => o.DataItemId);
/// <summary>Wraps observation-container XML in a minimal but valid streams document.</summary>
private static string StreamsDocument(string componentStreamBody) =>
$"""
<MTConnectStreams xmlns="urn:mtconnect.org:MTConnectStreams:1.3">
<Header instanceId="1" firstSequence="1" lastSequence="1" nextSequence="2"/>
<Streams>
<DeviceStream name="D" uuid="d">
<ComponentStream component="Path" componentId="d_path">{componentStreamBody}</ComponentStream>
</DeviceStream>
</Streams>
</MTConnectStreams>
""";
/// <summary>A canned <see cref="HttpMessageHandler"/> — no socket is ever opened.</summary>
private sealed class StubHandler : HttpMessageHandler
{
private readonly Func<HttpResponseMessage>? _responder;
private readonly bool _hang;
private StubHandler(Func<HttpResponseMessage>? 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);
/// <summary>Serves an already-built multipart body verbatim (e.g. one with no closing delimiter).</summary>
public static StubHandler RespondingWithBody(string boundary, byte[] body) =>
new(() => Multipart(boundary, new ByteArrayContent(body)), hang: false);
/// <summary>
/// Serves a multipart body through a transport that hands back at most
/// <paramref name="bytesPerRead"/> bytes per <c>ReadAsync</c>, so boundaries, part
/// headers and documents are all split across reads.
/// </summary>
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);
}
/// <summary>A response that claims multipart but omits the <c>boundary</c> parameter.</summary>
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);
/// <summary>Builds a <c>multipart/x-mixed-replace</c> body exactly as an Agent writes one.</summary>
public static byte[] MultipartBody(string boundary, bool withContentLength, bool closing, params string[] parts)
{
var body = new List<byte>();
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<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequestUri = request.RequestUri;
RequestCount++;
if (_hang)
{
await Task.Delay(Timeout.Infinite, cancellationToken);
}
return _responder!();
}
}
/// <summary>Hands back at most <c>bytesPerRead</c> bytes per read — the split-transport double.</summary>
private sealed class ChunkedContent(byte[] body, int bytesPerRead) : HttpContent
{
protected override Task<Stream> CreateContentReadStreamAsync() =>
Task.FromResult<Stream>(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<int> ReadAsync(Memory<byte> 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<int> 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();
}
/// <summary>Captures warning-level log lines so the degraded-framing notice can be asserted.</summary>
private sealed class RecordingLogger : ILogger
{
public List<string> Warnings { get; } = [];
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (logLevel == LogLevel.Warning)
{
Warnings.Add(formatter(state, exception));
}
}
}
/// <summary>Serves <paramref name="prefix"/>, then never completes another read.</summary>
private sealed class StallingContent(byte[] prefix) : HttpContent
{
protected override Task<Stream> CreateContentReadStreamAsync() =>
Task.FromResult<Stream>(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<int> ReadAsync(Memory<byte> 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<int> 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();
}
}