From f51055b509454780a56b5f36d2260955c459d227 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 16:07:01 -0400 Subject: [PATCH] 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 --- .../MTConnectAgentIntegrationTests.cs | 53 +++++++++++++++++ .../OpcUaClientBrowseSessionTests.cs | 27 ++++++++- .../OpcUaClientSmokeTests.cs | 57 +++++++++++++++++++ 3 files changed, 134 insertions(+), 3 deletions(-) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs index 925a4fe3..4db8f1ab 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs @@ -165,6 +165,59 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent) result.Latency.ShouldBeNull(); } + /// + /// 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). + /// + [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(async () => await dead.PingAsync(Ct)); + } + + /// + /// Review 2026-10-21 T-4 / T-5 against the live Agent: once the /sample 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 lastSequence — is on the diagnostics bag beside the effective count. + /// + [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 ---- /// diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcUaClientBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcUaClientBrowseSessionTests.cs index d84794b6..cabce27e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcUaClientBrowseSessionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.IntegrationTests/OpcUaClientBrowseSessionTests.cs @@ -63,10 +63,10 @@ public sealed class OpcUaClientBrowseSessionTests children.ShouldNotBeNull(); } - /// The OPC UA picker treats variables as terminal leaves, so - /// AttributesAsync must always be empty for this driver. + /// 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.) [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(); } + + /// + /// 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. + /// + [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"); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs index 34276308..b1d3cc1b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests/OpcUaClientSmokeTests.cs @@ -92,4 +92,61 @@ public sealed class OpcUaClientSmokeTests(OpcPlcFixture sim) await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken); } + + /// + /// 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. + /// + [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); + } + + /// + /// 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. + /// + [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); + } }