test(opcuaclient,mtconnect): live-fixture cases for the lane G1 fixes

Against opc-plc (opc.tcp://10.100.0.35:50000): a shutdown with three live
subscriptions is bounded and the same instance re-initialises and reads (O-9 +
lifecycle generation through the real SDK); a server-refused write
(BadTypeMismatch) passes through and keeps the driver Healthy with its stamp
advanced (health contract); the browse side-panel reports a live variable's type,
shape and access (O-5).
Against the MTConnect fixture Agent: the headers-only ping reaches it and fails on
a dead port (T-7); a delivering /sample stream serves reads and reports its lag and
effective count on the diagnostics bag (T-4/T-5).

Refs #743

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 16:07:01 -04:00
parent ac01c88c0e
commit f51055b509
3 changed files with 134 additions and 3 deletions
@@ -165,6 +165,59 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
result.Latency.ShouldBeNull();
}
/// <summary>
/// Review 2026-10-21 T-7 — the connectivity probe's headers-only ping (no device-model download/parse) answers
/// against the live Agent, and fails against a closed port (the falsifiability control).
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Ping_reaches_the_live_agent_and_not_a_dead_port()
{
SkipIfDown();
using (var client = new MTConnectAgentClient(LiveOptions()))
{
await client.PingAsync(Ct);
}
using var dead = new MTConnectAgentClient(
new MTConnectDriverOptions { AgentUri = $"http://127.0.0.1:{ClosedLoopbackPort()}", RequestTimeoutMs = 5_000 });
await Should.ThrowAsync<Exception>(async () => await dead.PingAsync(Ct));
}
/// <summary>
/// Review 2026-10-21 T-4 / T-5 against the live Agent: once the <c>/sample</c> stream is delivering, a read is
/// answered (from the pump's live index) with Good values, and the stream's lag measurement — the real Agent
/// sends <c>lastSequence</c> — is on the diagnostics bag beside the effective count.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task A_delivering_stream_serves_reads_and_reports_its_lag()
{
SkipIfDown();
var ids = await NumericSampleIdsAsync();
if (ids.Count == 0)
{
Assert.Skip($"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value.");
}
await using var live = await LiveDriverAsync(ids.Select(id => Tag(id, DriverDataType.Float64)));
await live.Driver.SubscribeAsync([.. ids], TimeSpan.FromMilliseconds(200), Ct);
var deadline = DateTime.UtcNow + StreamWait;
while (!live.Driver.GetDiagnostics().ContainsKey("sampleLag") && DateTime.UtcNow < deadline)
{
await Task.Delay(100, Ct);
}
var diag = live.Driver.GetDiagnostics();
diag.ShouldContainKey("sampleLag", "the live stream must have delivered a measured chunk");
diag["sampleCount"].ShouldBe("500");
long.Parse(diag["sampleLag"], System.Globalization.CultureInfo.InvariantCulture).ShouldBeGreaterThanOrEqualTo(0);
var results = await live.Driver.ReadAsync([.. ids], Ct);
results.Where(r => r.StatusCode == Good).ShouldNotBeEmpty();
}
// ---- discovery: the /probe device model ----
/// <summary>
@@ -63,10 +63,10 @@ public sealed class OpcUaClientBrowseSessionTests
children.ShouldNotBeNull();
}
/// <summary>The OPC UA picker treats variables as terminal leaves, so
/// AttributesAsync must always be empty for this driver.</summary>
/// <summary>Review 2026-10-21 O-5: an Object (folder) has no value attributes, so AttributesAsync is empty for
/// it. (It used to be empty for EVERYTHING — the side-panel was a stub; see the variable test below.)</summary>
[Fact]
public async Task AttributesAsync_is_empty_for_opcuaclient()
public async Task AttributesAsync_is_empty_for_an_object_node()
{
OpcPlcProbe.SkipUnlessReachable();
var ct = TestContext.Current.CancellationToken;
@@ -75,4 +75,25 @@ public sealed class OpcUaClientBrowseSessionTests
var attrs = await session.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=85", ct);
attrs.ShouldBeEmpty();
}
/// <summary>
/// Review 2026-10-21 O-5 against the live server: a standard variable (Server/ServerStatus/CurrentTime — a
/// read-only scalar UtcTime every server exposes) reports its mapped type, shape and access.
/// </summary>
[Fact]
public async Task AttributesAsync_reports_a_live_variables_type_shape_and_access()
{
OpcPlcProbe.SkipUnlessReachable();
var ct = TestContext.Current.CancellationToken;
var browser = new OpcUaClientDriverBrowser();
await using var session = await browser.OpenAsync(ConfigJson, ct);
var attrs = await session.AttributesAsync("nsu=http://opcfoundation.org/UA/;i=2258", ct);
var a = attrs.ShouldHaveSingleItem();
a.Name.ShouldBe("CurrentTime");
a.DriverDataType.ShouldBe("DateTime");
a.IsArray.ShouldBeFalse();
a.SecurityClass.ShouldBe("ViewOnly");
}
}
@@ -92,4 +92,61 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim)
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
}
/// <summary>
/// Review 2026-10-21 O-9 + lifecycle generation, through the real SDK: a shutdown with several live
/// subscriptions completes inside the one teardown budget, lands Unknown, and a re-initialise of the same
/// instance reconnects and reads.
/// </summary>
[Fact]
public async Task Shutdown_with_live_subscriptions_is_bounded_and_the_instance_reinitialises()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-lifecycle");
await drv.InitializeAsync("{}", ct);
for (var i = 0; i < 3; i++)
await drv.SubscribeAsync([OpcPlcProfile.FastUInt1Ref], TimeSpan.FromMilliseconds(250), ct);
var sw = System.Diagnostics.Stopwatch.StartNew();
await drv.ShutdownAsync(ct);
sw.Stop();
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10), "one 4 s budget bounds the whole teardown");
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
await drv.InitializeAsync("{}", ct);
var read = await drv.ReadAsync([OpcPlcProfile.StepUpRef], ct);
read.Single().StatusCode.ShouldBe(0u, "the re-initialised instance must be fully usable");
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// The health contract against a real server: a write the server REFUSES (a string into the UInt32 StepUp —
/// BadTypeMismatch) is a reply — the refusal status passes through and the driver stays Healthy with its stamp
/// advanced.
/// </summary>
[Fact]
public async Task A_server_refused_write_is_a_reply_and_keeps_the_driver_healthy()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl);
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-smoke-refused-write");
await drv.InitializeAsync("{}", ct);
var before = drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow <= before && DateTime.UtcNow < deadline) await Task.Yield();
var result = await drv.WriteAsync([new WriteRequest(OpcPlcProfile.StepUpRef, "not-a-number")], ct);
(result.Single().StatusCode & 0x80000000u).ShouldNotBe(0u, "opc-plc refuses a string written into a UInt32");
result.Single().StatusCode.ShouldNotBe(0x80050000u, "a server refusal is not a local comms error");
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Healthy);
h.LastSuccessfulRead.ShouldNotBeNull().ShouldBeGreaterThan(before);
}
}