feat(mtconnect): current/sample parse + sequence-gap detection (Task 7)

Adds the /current and /sample legs to MTConnectAgentClient:

- MTConnectStreamsParser: hand-rolled System.Xml.Linq parse of an
  MTConnectStreams document into MTConnectStreamsResult. Observations are
  every element child of <Samples>/<Events>/<Condition>, keyed by
  dataItemId (the element NAME is the data item's type in PascalCase, so
  a fixed name set would drop observations). A CONDITION's value is its
  element name, not its text - <Normal/> is empty - and <Unavailable/>
  normalizes onto the same UNAVAILABLE sentinel a Sample/Event carries as
  text, so Task 8's no-comms mapping sees one token. Timestamps are
  normalized to DateTimeKind.Utc explicitly.
- IsSequenceGap(requestedFrom, chunk) => chunk.FirstSequence >
  requestedFrom, so Task 11's pump can re-baseline after a ring-buffer
  overflow. Equality is NOT a gap.
- SampleAsync: ResponseHeadersRead + a hand-rolled
  multipart/x-mixed-replace frame reader (Content-length fast path,
  boundary-scan fallback), bounded by a heartbeat watchdog derived from
  HeartbeatMs so a silent Agent faults instead of wedging the pump - the
  S7 frozen-peer shape (arch-review R2-01). The long poll gets its own
  HttpClient with an infinite Timeout so the unary /probe + /current
  deadline stays bounded.

Task 0 package decision, option (c): both TrakHound PackageReferences and
their PackageVersions are dropped. MTConnectHttpClientStream takes a URL
and dials its own socket (no handler/stream seam, so it cannot serve a
socket-free unit suite), and it emits already-parsed documents through the
formatter lookup Task 6 proved is missing - framing-only reuse was never
actually on offer. The driver now depends on nothing but the BCL. Recorded
in the plan's Task 0 CORRECTION block.

Also folds in two Task 6 review carry-overs: the stale
IMTConnectAgentClient doc comment claiming a TrakHound-backed
implementation, and a probe test constructing a mixed-namespace vendor
extension (<x:Pump> with default-namespace <DataItems> children) that
backs the parser's ignore-the-namespace claim with a test.

187/187 green.
This commit is contained in:
Joseph Doherty
2026-07-24 14:32:36 -04:00
parent d3aaa4a411
commit cf43f8d0ab
9 changed files with 1740 additions and 59 deletions
@@ -196,6 +196,49 @@ public sealed class MTConnectProbeParseTests
device.Components[0].Components[0].Components[0].Id.ShouldBe("m");
}
[Fact]
public void Probe_parse_keeps_a_vendor_extension_component_in_its_own_namespace()
{
// The shape the parser's ignore-the-namespace rule exists for, and the reason attributes are
// read unqualified: the vendor component carries its OWN namespace prefix, while its
// standard <DataItems>/<DataItem> children inherit the DEFAULT MTConnect namespace. Matching
// components on a fully-qualified XName would silently drop <x:Pump> and every data item
// beneath it, leaving the browse tree short with no error anywhere.
const string Xml = """
<MTConnectDevices xmlns="urn:mtconnect.org:MTConnectDevices:2.0"
xmlns:x="urn:vendor:acme:1.0">
<Devices>
<Device id="d" name="D">
<DataItems><DataItem id="d_avail" category="EVENT" type="AVAILABILITY"/></DataItems>
<Components>
<x:Pump id="pump1" name="pump">
<DataItems>
<DataItem id="pump_pressure" category="SAMPLE" type="PRESSURE" units="PASCAL"/>
</DataItems>
<Components>
<x:Impeller id="imp1">
<DataItems>
<DataItem id="imp_rpm" category="SAMPLE" type="ROTARY_VELOCITY"/>
</DataItems>
</x:Impeller>
</Components>
</x:Pump>
</Components>
</Device>
</Devices>
</MTConnectDevices>
""";
var device = MTConnectProbeParser.Parse(Xml).Devices[0];
var pump = device.Components.ShouldHaveSingleItem();
pump.Id.ShouldBe("pump1");
pump.Name.ShouldBe("pump");
pump.Components.ShouldHaveSingleItem().Id.ShouldBe("imp1");
device.AllDataItems().Select(d => d.Id).ShouldBe(["d_avail", "pump_pressure", "imp_rpm"]);
device.AllDataItems().Single(d => d.Id == "pump_pressure").Units.ShouldBe("PASCAL");
}
[Fact]
public void Probe_parse_reads_every_device_of_a_multi_device_agent()
{
@@ -408,27 +451,6 @@ public sealed class MTConnectProbeParseTests
handler.RequestCount.ShouldBe(0);
}
// ------------------------------------------------------------------- scope boundary: Task 7 legs
[Fact]
public async Task CurrentAsync_is_not_implemented_until_task_7()
{
using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
var ex = await Should.ThrowAsync<NotImplementedException>(
async () => await client.CurrentAsync(TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Task 7");
}
[Fact]
public void SampleAsync_is_not_implemented_until_task_7()
{
using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
var ex = Should.Throw<NotImplementedException>(() => client.SampleAsync(1, CancellationToken.None));
ex.Message.ShouldContain("Task 7");
}
private static Task<string> ReadFixtureAsync() =>
File.ReadAllTextAsync(ProbeFixturePath, TestContext.Current.CancellationToken);