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:
+43
-21
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user