using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa; /// /// Covers DeferredServiceLevelPublisher forwarding invariants (Commons-003 companion). /// public class DeferredServiceLevelPublisherTests { [Fact] public void Before_SetInner_Publish_accepts_a_cached_request() { var publisher = new DeferredServiceLevelPublisher(); // Accept the request before an SDK publisher exists. publisher.Publish(200); } [Fact] public void After_SetInner_Publish_is_forwarded() { var spy = new SpyPublisher(); var publisher = new DeferredServiceLevelPublisher(); publisher.SetInner(spy); publisher.Publish(240); spy.LastPublished.ShouldBe((byte)240); } [Fact] public void SetInner_null_detaches_the_previous_publisher() { var spy = new SpyPublisher(); var publisher = new DeferredServiceLevelPublisher(); publisher.SetInner(spy); publisher.SetInner(null); // revert publisher.Publish(100); // The previous publisher must not receive an unbound request. spy.LastPublished.ShouldBe((byte)0, "the previous publisher should not be updated after unbinding"); } [Fact] public void Binding_without_a_request_does_not_invent_a_level() { var received = new List(); new DeferredServiceLevelPublisher().SetInner(new CallbackPublisher(received.Add)); received.ShouldBeEmpty(); } [Theory] [InlineData((byte)100)] [InlineData((byte)0)] public void Binding_replays_the_latest_request_including_zero(byte latest) { var publisher = new DeferredServiceLevelPublisher(); publisher.Publish(240); publisher.Publish(latest); var received = new List(); publisher.SetInner(new CallbackPublisher(received.Add)); received.ShouldBe(new[] { latest }); } [Fact] public void Unbinding_retains_latest_for_rebinding() { var publisher = new DeferredServiceLevelPublisher(); var old = new List(); publisher.SetInner(new CallbackPublisher(old.Add)); publisher.Publish(240); publisher.SetInner(null); publisher.Publish(0); var replacement = new List(); publisher.SetInner(new CallbackPublisher(replacement.Add)); old.ShouldBe(new byte[] { 240 }); replacement.ShouldBe(new byte[] { 0 }); } [Fact] public void Failed_publish_retains_the_requested_value_for_replay() { var publisher = new DeferredServiceLevelPublisher(); publisher.SetInner(new CallbackPublisher(_ => throw new InvalidOperationException())); Should.Throw(() => publisher.Publish(0)); var received = new List(); publisher.SetInner(new CallbackPublisher(received.Add)); received.ShouldBe(new byte[] { 0 }); } [Fact] public void Failed_replay_keeps_candidate_bound_for_later_publish() { var publisher = new DeferredServiceLevelPublisher(); publisher.Publish(100); var fail = true; var received = new List(); var candidate = new CallbackPublisher(value => { if (fail) throw new InvalidOperationException(); received.Add(value); }); Should.Throw(() => publisher.SetInner(candidate)); fail = false; publisher.Publish(0); publisher.SetInner(null); publisher.SetInner(candidate); received.ShouldBe(new byte[] { 0, 0 }); } [Fact] public void Failed_replay_retains_latest_for_a_replacement_publisher() { var publisher = new DeferredServiceLevelPublisher(); publisher.Publish(100); Should.Throw(() => publisher.SetInner( new CallbackPublisher(_ => throw new InvalidOperationException()))); var received = new List(); publisher.SetInner(new CallbackPublisher(received.Add)); received.ShouldBe(new byte[] { 100 }); } [Fact] public async Task Binding_waits_for_inflight_publish_then_replays_latest() { var publisher = new DeferredServiceLevelPublisher(); using var entered = new ManualResetEventSlim(); using var release = new ManualResetEventSlim(); publisher.SetInner(new CallbackPublisher(_ => { entered.Set(); release.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).ShouldBeTrue(); })); var publish = Task.Run(() => publisher.Publish(100), TestContext.Current.CancellationToken); var received = new List(); using var bindingStarted = new ManualResetEventSlim(); Exception? bindingError = null; var binding = new Thread(() => { bindingStarted.Set(); try { publisher.SetInner(new CallbackPublisher(received.Add)); } catch (Exception ex) { bindingError = ex; } }); var bindingWasStarted = false; try { entered.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).ShouldBeTrue(); binding.Start(); bindingWasStarted = true; bindingStarted.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken).ShouldBeTrue(); // Past the marker, SetInner is the thread's only potentially blocking operation. // Observe actual contention rather than assuming Task.Run has entered the adapter. SpinWait.SpinUntil(() => !binding.IsAlive || (binding.ThreadState & System.Threading.ThreadState.WaitSleepJoin) != 0, TimeSpan.FromSeconds(3)).ShouldBeTrue(); (binding.ThreadState & System.Threading.ThreadState.WaitSleepJoin).ShouldNotBe( (System.Threading.ThreadState)0, "binding must wait for the in-flight delivery"); } finally { release.Set(); if (bindingWasStarted) binding.Join(TimeSpan.FromSeconds(5)).ShouldBeTrue(); await publish.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); } bindingError.ShouldBeNull(); publisher.Publish(0); received.ShouldBe(new byte[] { 100, 0 }); } private sealed class CallbackPublisher(Action publish) : IServiceLevelPublisher { public void Publish(byte serviceLevel) => publish(serviceLevel); } // ---- test double ---- private sealed class SpyPublisher : IServiceLevelPublisher { public byte LastPublished { get; private set; } public void Publish(byte serviceLevel) => LastPublished = serviceLevel; } }