using System.Diagnostics; using System.Text.Json; using Moq; using Opc.Ua; using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Review 2026-10-21 R06:O-2 / O-9 and the lifecycle-generation contract: an initialise that throws after its /// session activated closes that LOCAL session; an initialise the host abandoned that finishes after the next /// shutdown discards its session instead of installing it; a read that outlives a shutdown does not overwrite the /// shutdown's health; and the whole shutdown spends one bounded budget on wire calls, issued concurrently. /// [Trait("Category", "Unit")] public sealed class OpcUaClientLifecycleGenerationTests { private const string SpeedRef = "Plant/dev/Speed"; private static readonly IReadOnlyList Tags = [new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)]; private static OpcUaClientDriverOptions Options() => new() { EndpointUrl = "opc.tcp://127.0.0.1:1", RawTags = Tags }; private static Mock NewSession() { var mock = new Mock(MockBehavior.Loose); mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); return mock; } private static ReadResponse GoodRead(ReadValueIdCollection nodes) => new() { ResponseHeader = new ResponseHeader(), Results = [.. nodes.Select(_ => new DataValue(new Variant(1), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))], DiagnosticInfos = [], }; private static async Task WaitForAsync(Func condition, string because) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); while (DateTime.UtcNow < deadline) { if (condition()) return; await Task.Delay(10); } condition().ShouldBeTrue(because); } /// O-2: a throw after the session opened (here: building the namespace map) closes + disposes it. [Fact] public async Task An_init_that_throws_after_the_session_opened_closes_and_disposes_that_session() { using var drv = new OpcUaClientDriver(Options(), "opcua-o2"); var session = NewSession(); session.SetupGet(s => s.NamespaceUris).Throws(new InvalidOperationException("namespace table unavailable")); drv.OpenSessionForTest = (_, _) => Task.FromResult(session.Object); await Should.ThrowAsync(() => drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); session.Verify(s => s.CloseAsync(It.IsAny(), true, It.IsAny()), Times.Once); session.Verify(s => s.Dispose(), Times.Once, "the activated session must not be orphaned until GC"); drv.Session.ShouldBeNull(); drv.GetHealth().State.ShouldBe(DriverState.Faulted); } /// Positive control for the init seam: an uninterrupted init installs the session and goes Healthy. [Fact] public async Task Positive_control_an_uninterrupted_init_installs_its_session() { using var drv = new OpcUaClientDriver(Options(), "opcua-gen-ok"); var session = NewSession(); drv.OpenSessionForTest = (_, _) => Task.FromResult(session.Object); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); drv.Session.ShouldBeSameAs(session.Object); drv.GetHealth().State.ShouldBe(DriverState.Healthy); session.Verify(s => s.Dispose(), Times.Never); } /// /// An init abandoned by the host whose connect completes AFTER the next ShutdownAsync must not install its /// session (it would be live with nothing to ever close it) nor overwrite the shutdown's health. /// [Fact] public async Task An_init_superseded_by_a_shutdown_discards_its_session_and_leaves_the_shutdown_health() { var drv = new OpcUaClientDriver(Options(), "opcua-gen-superseded"); using var _ = drv; var session = NewSession(); var opened = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); drv.OpenSessionForTest = async (_, _) => { opened.TrySetResult(); await release.Task; return session.Object; }; var init = drv.InitializeAsync("{}", TestContext.Current.CancellationToken); await opened.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); await drv.ShutdownAsync(TestContext.Current.CancellationToken); drv.GetHealth().State.ShouldBe(DriverState.Unknown); release.SetResult(); await Should.ThrowAsync(() => init); drv.Session.ShouldBeNull("a superseded init must not resurrect a session behind the shutdown"); session.Verify(s => s.Dispose(), Times.Once); drv.GetHealth().State.ShouldBe(DriverState.Unknown, "the abandoned init must not overwrite the newer health"); } /// A read whose response arrives after a shutdown does not write Healthy over the shutdown. [Fact] public async Task A_read_that_outlives_a_shutdown_does_not_overwrite_the_shutdown_health() { var (drv, session, respond) = ConnectedDriverWithParkedRead(); using var _ = drv; var read = drv.ReadAsync([SpeedRef], TestContext.Current.CancellationToken); await WaitForAsync(() => respond.Count == 1, "the read must reach the wire"); await drv.ShutdownAsync(TestContext.Current.CancellationToken); respond.Single().SetResult(); await read; drv.GetHealth().State.ShouldBe(DriverState.Unknown); } /// Positive control: the same parked read with no shutdown in between writes Healthy + a new stamp. [Fact] public async Task Positive_control_a_read_with_no_intervening_shutdown_writes_healthy() { var (drv, _, respond) = ConnectedDriverWithParkedRead(); using var __ = drv; drv.SetHealthForTest(new DriverHealth(DriverState.Degraded, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc), "x")); var read = drv.ReadAsync([SpeedRef], TestContext.Current.CancellationToken); await WaitForAsync(() => respond.Count == 1, "the read must reach the wire"); respond.Single().SetResult(); await read; drv.GetHealth().State.ShouldBe(DriverState.Healthy); drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull().Year.ShouldBeGreaterThan(2020); } private static (OpcUaClientDriver Driver, Mock Session, System.Collections.Concurrent.ConcurrentQueue Respond) ConnectedDriverWithParkedRead() { var drv = new OpcUaClientDriver(Options(), "opcua-gen-read"); var session = NewSession(); var respond = new System.Collections.Concurrent.ConcurrentQueue(); session.Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(async (RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) => { var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); respond.Enqueue(gate); await gate.Task; return GoodRead(nodes); }); drv.SetSessionForTest(session.Object); drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags)); drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); return (drv, session, respond); } /// /// O-9: against a half-dead upstream every subscription delete and the session close hang. They are issued /// concurrently under ONE budget, so the shutdown ends near the budget rather than N × 5 s. /// [Fact] public async Task Shutdown_spends_one_budget_on_concurrent_subscription_deletes_and_the_close() { using var drv = new OpcUaClientDriver(Options(), "opcua-o9"); var session = NewSession(); // A close that ignores its token entirely — only the driver's own abandonment can bound it. session.Setup(s => s.CloseAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new TaskCompletionSource().Task); drv.SetSessionForTest(session.Object); drv.TeardownBudget = TimeSpan.FromSeconds(1); var inFlight = 0; var maxInFlight = 0; drv.DeleteSubscriptionAsync = async (_, ct) => { var now = Interlocked.Increment(ref inFlight); InterlockedMax(ref maxInFlight, now); try { await Task.Delay(Timeout.Infinite, ct); } finally { Interlocked.Decrement(ref inFlight); } }; for (var i = 0; i < 4; i++) drv.AddSubscriptionForTest([SpeedRef]); var sw = Stopwatch.StartNew(); await drv.ShutdownAsync(TestContext.Current.CancellationToken); sw.Stop(); maxInFlight.ShouldBe(4, "the four deletes must be in flight together, not one after another"); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(3.5), "one 1 s budget bounds the whole teardown; serial deletes alone would take 4 × the budget"); session.Verify(s => s.Dispose(), Times.Once, "the session is still disposed when the close was abandoned"); drv.GetHealth().State.ShouldBe(DriverState.Unknown); } private static void InterlockedMax(ref int target, int value) { int current; while ((current = Volatile.Read(ref target)) < value) if (Interlocked.CompareExchange(ref target, value, current) == current) return; } }