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.Health; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Health; /// /// Tests for : it maintains exactly one peer-OPC-UA-probe child /// per OTHER, non-Detached driver node named in the latest /// snapshot, spawning/stopping children as the topology changes. /// public sealed class PeerProbeSupervisorTests : RuntimeActorTestBase { private static readonly NodeId Local = NodeId.Parse("local:4053"); private static readonly NodeId Peer = NodeId.Parse("peer:4053"); private static readonly NodeId Adm = NodeId.Parse("adm:4053"); /// No-op child actor stub so we can count children without real TCP probes. private sealed class NoopActor : ReceiveActor { } private static Props NoopProps() => Akka.Actor.Props.Create(() => new NoopActor()); /// /// No-op child stub that records its own into a shared list on /// start, in spawn order — lets a test grab a specific (e.g. the FIRST, old-generation) child /// ref so it can deliver a synthetic for it. /// private sealed class RecordingNoopActor : ReceiveActor { public RecordingNoopActor(List spawned) => spawned.Add(Self); } private static Props RecordingProps(List spawned) => Akka.Actor.Props.Create(() => new RecordingNoopActor(spawned)); private static NodeRedundancyState State(NodeId id, RedundancyRole role) => new(id, role, IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow); private static RedundancyStateChanged Snapshot(params NodeRedundancyState[] nodes) => new(nodes, CorrelationId.NewId()); /// Verifies one child is spawned per non-self, non-Detached peer — self and Detached /// nodes are excluded. [Fact] public void Spawns_one_child_per_non_self_non_detached_peer() { var sup = ActorOfAsTestActorRef( PeerProbeSupervisor.PropsForTests(Local, _ => NoopProps())); sup.Tell(Snapshot( State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary), State(Adm, RedundancyRole.Detached))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies the child for a departed peer is stopped when the next snapshot omits it. [Fact] public void Stops_child_for_departed_peer() { var sup = ActorOfAsTestActorRef( PeerProbeSupervisor.PropsForTests(Local, _ => NoopProps())); sup.Tell(Snapshot( State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies a single-node snapshot (just the local node) spawns no children. [Fact] public void Single_node_snapshot_spawns_no_children() { var sup = ActorOfAsTestActorRef( PeerProbeSupervisor.PropsForTests(Local, _ => NoopProps())); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), duration: TimeSpan.FromMilliseconds(500)); } /// Verifies a previously-removed peer is respawned when it re-appears, without an /// "actor name not unique" collision on the sanitized child name. [Fact] public void Re_adding_a_previously_removed_peer_respawns_it() { var sup = ActorOfAsTestActorRef( PeerProbeSupervisor.PropsForTests(Local, _ => NoopProps())); sup.Tell(Snapshot( State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), duration: TimeSpan.FromMilliseconds(500)); sup.Tell(Snapshot( State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); } /// Locks in the stale-Terminated guard: when an OLD (already-replaced) child's /// for a peer arrives AFTER a NEW child for the SAME peer was spawned, /// the fresh child must NOT be evicted (removal is keyed by current-child ref-equality, not by /// peer key). Without this guard a late stale Terminated would silently drop a live probe. [Fact] public void Stale_terminated_for_old_child_does_not_evict_fresh_peer_child() { var spawned = new List(); var sup = ActorOfAsTestActorRef( PeerProbeSupervisor.PropsForTests(Local, _ => RecordingProps(spawned))); // First add of the peer -> child #0 (the OLD ref). sup.Tell(Snapshot( State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); AwaitAssert(() => spawned.Count.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); var oldRef = spawned[0]; // Drop the peer -> child #0 stopped, ChildCount back to 0. sup.Tell(Snapshot(State(Local, RedundancyRole.Primary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(0), duration: TimeSpan.FromMilliseconds(500)); // Re-add the SAME peer -> a NEW child #1 (the FRESH ref) is spawned. sup.Tell(Snapshot( State(Local, RedundancyRole.Primary), State(Peer, RedundancyRole.Secondary))); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); AwaitAssert(() => spawned.Count.ShouldBe(2), duration: TimeSpan.FromMilliseconds(500)); // Now deliver a STALE Terminated for the OLD ref. The current child for Peer is the fresh // child #1, so ref-equality finds no match and the supervisor must leave ChildCount at 1. sup.Tell(new Terminated(oldRef, existenceConfirmed: true, addressTerminated: false)); AwaitAssert(() => sup.UnderlyingActor.ChildCount.ShouldBe(1), duration: TimeSpan.FromMilliseconds(500)); } }