using Akka.Actor; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; 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; public sealed class DriverInstanceActorTests : RuntimeActorTestBase { /// Verifies that initialize failure keeps the actor in Reconnecting state. [Fact] public void Initialize_failure_keeps_actor_in_Reconnecting_state() { var driver = new StubDriver { InitializeShouldThrow = true }; var actor = Sys.ActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50))); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); // The actor should keep trying — we expect multiple Initialize calls because the // reconnect timer fires every 50ms. AwaitCondition(() => driver.InitializeCount >= 3, TimeSpan.FromSeconds(2)); } /// Verifies that writing to a non-IWritable driver returns failure. [Fact] public async Task Write_against_non_IWritable_driver_returns_failure() { var driver = new StubDriver(); // IDriver only, no IWritable. var actor = Sys.ActorOf(DriverInstanceActor.Props(driver)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); var reply = await actor.Ask( new DriverInstanceActor.WriteAttribute("tag-1", 42), TimeSpan.FromSeconds(3)); reply.Success.ShouldBeFalse(); reply.Reason!.ShouldContain("IWritable"); } /// Verifies that writing to an IWritable driver returns success when status is Good. [Fact] public async Task Write_against_IWritable_returns_success_when_status_is_Good() { var driver = new WritableStubDriver(); var actor = Sys.ActorOf(DriverInstanceActor.Props(driver)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); var reply = await actor.Ask( new DriverInstanceActor.WriteAttribute("tag-1", 42), TimeSpan.FromSeconds(3)); reply.Success.ShouldBeTrue(); driver.Writes.Single().FullReference.ShouldBe("tag-1"); driver.Writes.Single().Value.ShouldBe(42); } /// Verifies that write propagates status code on Bad result. [Fact] public async Task Write_propagates_status_code_on_Bad_result() { const uint badStatus = 0x80340000; // BadNodeIdUnknown — top severity bits = 10b var driver = new WritableStubDriver { NextStatusCode = badStatus }; var actor = Sys.ActorOf(DriverInstanceActor.Props(driver)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); var reply = await actor.Ask( new DriverInstanceActor.WriteAttribute("tag-1", 42), TimeSpan.FromSeconds(3)); reply.Success.ShouldBeFalse(); reply.Reason!.ShouldContain("80340000"); } /// Verifies that subscribing to an ISubscribable driver forwards OnDataChange to parent. [Fact] public async Task Subscribe_against_ISubscribable_forwards_OnDataChange_to_parent() { var driver = new SubscribableStubDriver(); var parent = CreateTestProbe(); // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); await actor.Ask( new DriverInstanceActor.Subscribe(new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(250)), TimeSpan.FromSeconds(3)); // Driver fires an OnDataChange — actor should forward it to its parent as an // AttributeValueBatchPublished (single-item, since this driver is per-tag-only) carrying the // driver's status code. driver.FireDataChange("tag-a", value: 3.14, statusCode: 0u); var published = parent.ExpectMsg(TimeSpan.FromSeconds(2)); published.Items.Count.ShouldBe(1); published.Items[0].FullReference.ShouldBe("tag-a"); published.Items[0].Value.ShouldBe(3.14); published.Items[0].StatusCode.ShouldBe(OpcUaStatus.Good); } /// /// Gitea #519 — a driver's status code reaches the parent with its sub-code intact. /// This is the first of the two hops that used to destroy it: the actor projected the /// driver's uint onto a 3-state Good/Uncertain/Bad enum, and the node manager then /// re-expanded Bad to a bare 0x80000000. The net effect was that no driver's sub-code ever /// reached an OPC UA client — BadNoCommunication, BadTypeMismatch and /// BadNotSupported were indistinguishable — while every driver maintained its own named /// constants (16 of which #497 had just corrected) as if they mattered end to end. /// The severity buckets are asserted too, since the bucket is still what /// and friends classify on; what changed is that the bucket is no /// longer all that survives. /// [Fact] public async Task Subscribe_forwards_the_drivers_status_sub_code_without_collapsing_it() { var driver = new SubscribableStubDriver(); var parent = CreateTestProbe(); // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); await actor.Ask( new DriverInstanceActor.Subscribe(new[] { "tag-1" }, TimeSpan.FromMilliseconds(100)), TimeSpan.FromSeconds(3)); // Uncertain — severity bits 01 (top 2 bits = 01). driver.FireDataChange("tag-1", value: 1, statusCode: 0x40000000u); parent.ExpectMsg().Items[0].StatusCode .ShouldBe(OpcUaStatus.Uncertain); // Bad — severity bits 10. driver.FireDataChange("tag-1", value: 2, statusCode: 0x80000000u); parent.ExpectMsg().Items[0].StatusCode .ShouldBe(OpcUaStatus.Bad); // The regression itself: a Bad SUB-code must arrive intact, not flattened to OpcUaStatus.Bad. // This is the exact code MTConnect maps the Agent's UNAVAILABLE sentinel to, and the live gate // that opened #519 observed it reaching a client as 0x80000000. driver.FireDataChange("tag-1", value: null, statusCode: OpcUaStatus.BadNoCommunication); var noComms = parent.ExpectMsg().Items[0]; noComms.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication); noComms.StatusCode.ShouldNotBe(OpcUaStatus.Bad, "the sub-code was collapsed to a generic Bad"); OpcUaStatus.IsBad(noComms.StatusCode).ShouldBeTrue("it must still classify as Bad"); // A second, DIFFERENT Bad sub-code — the two were indistinguishable before the fix, so asserting // one alone would not prove the sub-code is carried rather than hard-coded. driver.FireDataChange("tag-1", value: null, statusCode: 0x80740000u /* BadTypeMismatch */); parent.ExpectMsg().Items[0].StatusCode .ShouldBe(0x80740000u); } /// /// Verifies the SubscribeBulk pass: SetDesiredSubscriptions retains the ref set and the actor /// auto-subscribes when it (re)enters Connected — including a re-subscribe after a reconnect, /// closing the F8b/#113 gap that previously left galaxy variables at BadWaitingForInitialData. /// [Fact] public async Task SetDesiredSubscriptions_auto_subscribes_on_connect_and_resubscribes_after_reconnect() { var driver = new SubscribableStubDriver(); var parent = CreateTestProbe(); // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50))); // Desired set arrives BEFORE connect — retained, not yet applied. actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions( new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(100))); // Connecting → Connected triggers the auto-subscribe. actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitCondition(() => driver.SubscribeCount >= 1, TimeSpan.FromSeconds(2)); driver.LastSubscribedRefs.ShouldBe(new[] { "tag-a", "tag-b" }); // The auto-subscription is live — a data change reaches the parent. driver.FireDataChange("tag-a", value: 7, statusCode: 0u); parent.ExpectMsg(TimeSpan.FromSeconds(2)) .Items[0].Value.ShouldBe(7); // Reconnect → the desired set is re-established without any new host message. actor.Tell(new DriverInstanceActor.DisconnectObserved("backend blip")); AwaitCondition(() => driver.SubscribeCount >= 2, TimeSpan.FromSeconds(3)); } /// /// Verifies the re-subscribe path (the second Subscribe finds a live handle and first awaits /// UnsubscribeAsync) still replies SubscriptionEstablished. Regression for the no-ActorContext /// race: reading Sender after `await UnsubscribeAsync().ConfigureAwait(false)` resumed off the /// actor context and threw, so the reply never arrived. This drives the exact deploy-re-apply / /// bootstrap-restore path where `_subscriptionHandle is not null`. /// [Fact] public async Task Subscribe_twice_replies_SubscriptionEstablished_on_resubscribe() { // UnsubscribeYields makes the inner UnsubscribeAsync genuinely suspend, so the second // Subscribe's `await UnsubscribeAsync()` resumes off the actor context if ConfigureAwait(false) // is used — the exact condition that throws NotSupportedException on the subsequent Sender read. var driver = new SubscribableStubDriver { UnsubscribeYields = true }; var parent = CreateTestProbe(); // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); // First subscribe — establishes the handle. await actor.Ask( new DriverInstanceActor.Subscribe(new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(250)), TimeSpan.FromSeconds(3)); // Second subscribe — `_subscriptionHandle is not null`, so the handler awaits // UnsubscribeAsync first, then reads Sender. Must still reply (today it threw → no reply). var reply = await actor.Ask( new DriverInstanceActor.Subscribe(new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(250)), TimeSpan.FromSeconds(3)); reply.ReferenceCount.ShouldBe(2); driver.SubscribeCount.ShouldBe(2); // Old handler must have been detached before the new one was attached — no leak. driver.OnDataChangeSubscriberCount.ShouldBe(1); } /// Verifies that subscribing to a non-ISubscribable driver replies with failure. [Fact] public async Task Subscribe_against_non_ISubscribable_replies_with_failure() { var driver = new StubDriver(); // IDriver only var actor = Sys.ActorOf(DriverInstanceActor.Props(driver)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); var reply = await actor.Ask( new DriverInstanceActor.Subscribe(new[] { "tag-1" }, TimeSpan.FromMilliseconds(100)), TimeSpan.FromSeconds(3)); reply.Reason.ShouldContain("ISubscribable"); } /// Verifies that DisconnectObserved detaches subscription handler so late events are dropped. [Fact] public async Task DisconnectObserved_detaches_subscription_handler_so_late_events_are_dropped() { var driver = new SubscribableStubDriver(); var parent = CreateTestProbe(); // #477: DriverInstanceActor now Tells the parent ConnectivityChanged on each connect/ // disconnect; these tests assert on the data/alarm/discovery forwards, so ignore it. parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged); var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30))); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); AwaitCondition(() => driver.InitializeCount > 0, TimeSpan.FromSeconds(2)); await actor.Ask( new DriverInstanceActor.Subscribe(new[] { "tag-1" }, TimeSpan.FromMilliseconds(100)), TimeSpan.FromSeconds(3)); actor.Tell(new DriverInstanceActor.DisconnectObserved("backend went away")); // Race window — once disconnect is processed, subsequent FireDataChange calls hit a // detached handler and don't push anything to the parent. AwaitCondition(() => driver.OnDataChangeSubscriberCount == 0, TimeSpan.FromSeconds(2)); driver.FireDataChange("tag-1", value: 99, statusCode: 0u); parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } /// /// Archreview 03/S15 (T11) — a child that is stopped announces comms loss on its way /// out, so the host latches its value nodes BadNoCommunication. /// Every other route into Reconnecting announces it (watchdog trip, failed initial /// connect, Force Reconnect all run the Reconnecting entry). Stopping did not: an operator's /// Restart and a supervision respawn tore the child down silently, so its nodes kept serving their /// last value at Good across the gap. The fresh child's re-subscribe + initial push (03/S16) is what /// clears them again. /// [Fact] public void Stopping_a_connected_child_announces_comms_loss_to_its_parent() { var parent = CreateTestProbe(); var driver = new StubDriver(); var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver)); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); parent.ExpectMsg(PresenceBudget).Connected.ShouldBeTrue(); parent.Watch(actor); parent.Send(actor, PoisonPill.Instance); parent.ExpectMsg(PresenceBudget).Connected.ShouldBeFalse(); parent.ExpectTerminated(actor, PresenceBudget); } /// /// Control. A child that never reached Connected announces nothing when stopped — its /// nodes are still BadWaitingForInitialData, which is a more precise statement than /// "communication lost", and overwriting it would claim an outage that never happened. /// [Fact] public void Stopping_a_never_connected_child_announces_nothing() { var parent = CreateTestProbe(); var driver = new StubDriver { InitializeShouldThrow = true }; var actor = parent.ChildActorOf(DriverInstanceActor.Props( driver, reconnectInterval: TimeSpan.FromSeconds(30))); actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); // The failed initial connect announces Connected=false from the Reconnecting entry; consume it, so // what follows is only what STOPPING produced. parent.ExpectMsg(PresenceBudget).Connected.ShouldBeFalse(); parent.Watch(actor); parent.Send(actor, PoisonPill.Instance); parent.ExpectTerminated(actor, PresenceBudget); parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); } }