using System.Collections.Concurrent; using Akka.Actor; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// lmxopcua#869 — asks an driver to /// confirm an ack AFTER returned, off its mailbox, and reports /// to the router when the confirmation fails — including one that /// fails long after the ack handler returned, and one abandoned because the actor stopped. /// public sealed class DriverInstanceActorAckConfirmationTests : RuntimeActorTestBase { private static readonly TimeSpan Timeout = PresenceBudget; /// A confirmation that faults asynchronously, after the ack handler returned, is reported failed. [Fact] public void A_confirmation_that_fails_later_is_reported_as_a_failed_ack() { var driver = new ConfirmingDriver(); var actor = SpawnConnected(driver); var gate = new TaskCompletionSource(); driver.Confirmation = () => gate.Task; var router = CreateTestProbe(); actor.Tell(Ack("n1"), router.Ref); AwaitCondition(() => driver.ConfirmCalls == 1, Timeout, TestContext.Current.CancellationToken); gate.SetException(new DriverOperationRefusedException("not confirmed within 10 s (BadTimeout)")); var failed = router.ExpectMsg(Timeout, cancellationToken: TestContext.Current.CancellationToken); failed.ConditionNodeId.ShouldBe("n1"); failed.Reason.ShouldContain("BadTimeout"); driver.ForeignConfirmations.ShouldBe(0, "the confirmation must get the SAME request instance the ack was sent with"); } /// /// A confirmation still pending when the actor stops is abandoned AND reported failed, so the host reverts the /// ack this incarnation can no longer confirm. (A confirmation continuation posted back to the stopped actor /// would dead-letter and never report.) /// [Fact] public void A_confirmation_pending_when_the_actor_stops_is_reported_as_a_failed_ack() { var driver = new ConfirmingDriver(); var actor = SpawnConnected(driver); driver.Confirmation = () => new TaskCompletionSource().Task; // never completes on its own var router = CreateTestProbe(); actor.Tell(Ack("n-stop"), router.Ref); AwaitCondition(() => driver.ConfirmCalls == 1, Timeout, TestContext.Current.CancellationToken); Sys.Stop(actor); var failed = router.ExpectMsg(Timeout, cancellationToken: TestContext.Current.CancellationToken); failed.ConditionNodeId.ShouldBe("n-stop"); failed.Reason.ShouldContain("stopped"); } /// /// A confirmation that succeeds reports nothing. Positive control: a later ack whose confirmation fails is /// reported, and it is the FIRST report the router receives — so none came for the confirmed one. /// [Fact] public void A_confirmed_ack_reports_nothing() { var driver = new ConfirmingDriver(); var actor = SpawnConnected(driver); var router = CreateTestProbe(); // Already complete: the actor's confirmation task finishes (and would report) right after the call that // bumps ConfirmCalls, long before the second ack below makes its actor round trip. driver.Confirmation = () => Task.CompletedTask; actor.Tell(Ack("n-ok"), router.Ref); AwaitCondition(() => driver.ConfirmCalls == 1, Timeout, TestContext.Current.CancellationToken); driver.Confirmation = () => Task.FromException(new DriverOperationRefusedException("unconfirmed")); actor.Tell(Ack("n-bad"), router.Ref); router.ExpectMsg(Timeout, cancellationToken: TestContext.Current.CancellationToken).ConditionNodeId.ShouldBe("n-bad"); } /// /// lmxopcua#869 review — the failure echoes the routed ack's , /// which is what lets the host revert only the acknowledge that failed (keyed in /// NativeAlarmProjector.TryRevertAcknowledge(nodeId, ackSequence, …)). /// [Fact] public void A_failed_confirmation_echoes_the_ack_sequence() { var driver = new ConfirmingDriver(); var actor = SpawnConnected(driver); driver.Confirmation = () => Task.FromException(new DriverOperationRefusedException("unconfirmed")); var router = CreateTestProbe(); actor.Tell(Ack("n-seq") with { AckSequence = 42 }, router.Ref); router.ExpectMsg(Timeout, cancellationToken: TestContext.Current.CancellationToken) .AckSequence.ShouldBe(42L); } private static DriverInstanceActor.RouteAlarmAck Ack(string conditionNodeId) => new("Tank.Hi", "c", "alice", ConditionNodeId: conditionNodeId); /// Spawns the actor as a child of the test actor and waits for its Connected announcement. private IActorRef SpawnConnected(ConfirmingDriver driver) { var actor = ChildActorOf(DriverInstanceActor.Props(driver)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); FishForMessage(c => c.Connected, Timeout); return actor; } private sealed class ConfirmingDriver : IDriver, IAlarmSource, IAlarmAcknowledgeConfirmation { private int _ackCalls; private int _confirmCalls; private readonly ConcurrentQueue> _ackedLists = new(); private int _foreignConfirmations; /// Confirmations asked for a request list that was NOT the instance passed to AcknowledgeAsync — /// the contract a driver matching by instance (Galaxy) relies on. public int ForeignConfirmations => Volatile.Read(ref _foreignConfirmations); public Func? Confirmation { get; set; } public int AckCalls => Volatile.Read(ref _ackCalls); public int ConfirmCalls => Volatile.Read(ref _confirmCalls); public string DriverInstanceId => "drv-confirm"; public string DriverType => "Test"; public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask; public DriverHealth GetHealth() => new(DriverState.Healthy, null, null); public Task SubscribeAlarmsAsync( IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) => Task.FromResult(new Handle()); public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask; public Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) { _ackedLists.Enqueue(acknowledgements); Interlocked.Increment(ref _ackCalls); return Task.CompletedTask; } public Task ConfirmAcknowledgedAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) { if (!_ackedLists.Any(l => ReferenceEquals(l, acknowledgements))) Interlocked.Increment(ref _foreignConfirmations); Interlocked.Increment(ref _confirmCalls); return (Confirmation?.Invoke() ?? Task.CompletedTask).WaitAsync(cancellationToken); } public event EventHandler? OnAlarmEvent { add { } remove { } } private sealed class Handle : IAlarmSubscriptionHandle { public string DiagnosticId => "h"; } } }