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;
}
}