using Opc.Ua.Client; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Client.Shared.Adapters; namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.Adapters; /// /// A deleted subscription must also leave the SDK session's Subscriptions list, or every alarm re-target in the UI /// leaves a dead behind until the session is disposed. /// public class DefaultSubscriptionAdapterTests { private sealed class CountingSubscription() : Subscription(ClientTelemetry.Context) { public int DisposeCount; protected override void Dispose(bool disposing) { if (disposing) Interlocked.Increment(ref DisposeCount); base.Dispose(disposing); } } [Fact] public async Task DeleteAndDispose_ReleaseSdkResourcesExactlyOnce() { var subscription = new CountingSubscription(); var detached = 0; var adapter = new DefaultSubscriptionAdapter(subscription, (_, _) => { Interlocked.Increment(ref detached); return Task.CompletedTask; }); await adapter.DeleteAsync(TestContext.Current.CancellationToken); adapter.Dispose(); adapter.Dispose(); detached.ShouldBe(1); subscription.DisposeCount.ShouldBe(1); } [Fact] public async Task ConcurrentTeardown_HasOneSdkResourceOwner() { var subscription = new CountingSubscription(); var detached = 0; var adapter = new DefaultSubscriptionAdapter(subscription, (_, _) => { Interlocked.Increment(ref detached); return Task.CompletedTask; }); await Task.WhenAll(Task.Run(adapter.Dispose, TestContext.Current.CancellationToken), adapter.DeleteAsync(TestContext.Current.CancellationToken)); detached.ShouldBe(1); subscription.DisposeCount.ShouldBe(1); } /// DeleteAsync detaches the wrapped subscription exactly once, even when Dispose follows. [Fact] public async Task DeleteAsync_detaches_the_subscription_from_its_session_once() { var subscription = new Subscription(ClientTelemetry.Context); var detached = new List(); var adapter = new DefaultSubscriptionAdapter(subscription, (s, _) => { lock (detached) detached.Add(s); return Task.CompletedTask; }); await adapter.DeleteAsync(TestContext.Current.CancellationToken); adapter.Dispose(); detached.ShouldHaveSingleItem().ShouldBeSameAs(subscription); } /// Dispose alone (no prior DeleteAsync) detaches too. [Fact] public void Dispose_detaches_the_subscription_from_its_session() { var detached = 0; var adapter = new DefaultSubscriptionAdapter(new Subscription(ClientTelemetry.Context), (_, _) => { Interlocked.Increment(ref detached); return Task.CompletedTask; }); adapter.Dispose(); detached.ShouldBe(1); } /// A failing detach is logged, not thrown: teardown stays best effort. [Fact] public async Task A_failing_detach_does_not_throw_from_DeleteAsync() { var adapter = new DefaultSubscriptionAdapter(new Subscription(ClientTelemetry.Context), (_, _) => throw new InvalidOperationException("boom")); await Should.NotThrowAsync(() => adapter.DeleteAsync(TestContext.Current.CancellationToken)); } }