185 lines
6.7 KiB
C#
185 lines
6.7 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa;
|
|
|
|
/// <summary>
|
|
/// Covers DeferredServiceLevelPublisher forwarding invariants (Commons-003 companion).
|
|
/// </summary>
|
|
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<byte>();
|
|
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<byte>();
|
|
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<byte>();
|
|
publisher.SetInner(new CallbackPublisher(old.Add));
|
|
publisher.Publish(240);
|
|
publisher.SetInner(null);
|
|
publisher.Publish(0);
|
|
var replacement = new List<byte>();
|
|
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<InvalidOperationException>(() => publisher.Publish(0));
|
|
var received = new List<byte>();
|
|
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<byte>();
|
|
var candidate = new CallbackPublisher(value =>
|
|
{
|
|
if (fail) throw new InvalidOperationException();
|
|
received.Add(value);
|
|
});
|
|
Should.Throw<InvalidOperationException>(() => 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<InvalidOperationException>(() => publisher.SetInner(
|
|
new CallbackPublisher(_ => throw new InvalidOperationException())));
|
|
var received = new List<byte>();
|
|
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<byte>();
|
|
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<byte> 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;
|
|
}
|
|
}
|