using Akka.Actor; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// The bridge that carries the Primary-gate decision out of the actor and into /// , where the alarm store-and-forward drain can read it. /// /// /// /// The drain runs on a timer owned by the sink, not on a mailbox, so it cannot receive /// itself — but it must be Primary-scoped, because /// the queue it drains replicates to the pair peer and two draining nodes would deliver /// every alarm event twice. /// /// /// These cases mirror 's matrix on purpose: /// the view must reach exactly the same verdict as the inbound-write and native-ack gates, /// because a second, subtly different notion of "am I the Primary" is how a pair ends up /// with one node writing and neither draining. /// /// public sealed class DriverHostActorRoleViewTests : RuntimeActorTestBase { private static readonly NodeId TestNode = NodeId.Parse("driver-roleview-test"); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); /// /// Before any snapshot arrives the view must read open, not closed. /// /// /// A deployment that runs no redundancy never publishes here at all. Defaulting closed /// would silently stop its alarm history forever — the exact failure the drain gate exists /// to avoid causing. /// [Fact] public void An_unpublished_view_reads_as_primary() { new RedundancyRoleView().ShouldServiceAsPrimary.ShouldBeTrue(); } [Fact] public void Secondary_role_closes_the_view() { var view = SpawnAndTellRole(RedundancyRole.Secondary, driverMemberCount: 2); AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeFalse(), duration: Timeout); } [Fact] public void Primary_role_opens_the_view() { var view = SpawnAndTellRole(RedundancyRole.Primary, driverMemberCount: 2); AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeTrue(), duration: Timeout); } /// Unknown role + a real driver peer ⇒ closed, matching the write gate's default-DENY. [Fact] public void Unknown_role_with_a_driver_peer_closes_the_view() { // A snapshot that never names this node is how the role stays unknown in practice — the // documented identity-mismatch shape, not a contrived case. var view = SpawnAndTellForeignSnapshot(driverMemberCount: 2); AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeFalse(), duration: Timeout); } /// Unknown role + no driver peer ⇒ open, preserving the single-node posture. [Fact] public void Unknown_role_without_a_driver_peer_leaves_the_view_open() { var view = SpawnAndTellForeignSnapshot(driverMemberCount: 1); AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeTrue(), duration: Timeout); } /// A demotion must move the view, not just an initial promotion. [Fact] public void A_demotion_closes_a_previously_open_view() { var view = new RedundancyRoleView(); var host = SpawnHost(view, driverMemberCount: 2); host.Tell(Snapshot(TestNode, RedundancyRole.Primary)); AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeTrue(), duration: Timeout); host.Tell(Snapshot(TestNode, RedundancyRole.Secondary)); AwaitAssert(() => view.ShouldServiceAsPrimary.ShouldBeFalse(), duration: Timeout); } // ---------------- helpers ---------------- private RedundancyRoleView SpawnAndTellRole(RedundancyRole role, int driverMemberCount) { var view = new RedundancyRoleView(); SpawnHost(view, driverMemberCount).Tell(Snapshot(TestNode, role)); return view; } private RedundancyRoleView SpawnAndTellForeignSnapshot(int driverMemberCount) { var view = new RedundancyRoleView(); SpawnHost(view, driverMemberCount).Tell(Snapshot(NodeId.Parse("some-other-node"), RedundancyRole.Primary)); return view; } private IActorRef SpawnHost(IRedundancyRoleView view, int driverMemberCount) => Sys.ActorOf(DriverHostActor.Props( NewInMemoryDbFactory(), TestNode, coordinator: null, driverMemberCountProvider: () => driverMemberCount, redundancyRoleView: view)); private static RedundancyStateChanged Snapshot(NodeId node, RedundancyRole role) => new( [ new NodeRedundancyState(node, role, IsClusterLeader: role == RedundancyRole.Primary, IsRoleLeaderForDriver: role == RedundancyRole.Primary, AsOfUtc: DateTime.UtcNow), ], CorrelationId.NewId()); }