diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs index 0c6b89c9..64c373ce 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs @@ -89,19 +89,24 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase } /// Tell the host a snapshot marking - /// with so the gate observes the local role. - private static void TellRedundancyRole(IActorRef host, RedundancyRole role) => + /// (defaults to ) with + /// so the gate observes the local role. Pass a different to produce a + /// snapshot that does NOT contain the host's own node. + private static void TellRedundancyRole(IActorRef host, RedundancyRole role, NodeId? nodeId = null) + { + var id = nodeId ?? LocalNode; host.Tell(new RedundancyStateChanged( new[] { new NodeRedundancyState( - NodeId: LocalNode, + NodeId: id, Role: role, IsClusterLeader: role == RedundancyRole.Primary, IsRoleLeaderForDriver: role == RedundancyRole.Primary, AsOfUtc: DateTime.UtcNow), }, CorrelationId.NewId())); + } /// Subscribe to the alerts DPS topic and wait for the ack. /// The Subscribe is sent FROM the probe so the SubscribeAck returns to it. @@ -508,4 +513,63 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase acked.AlarmNodeId.ShouldBe("alm-1"); acked.State.Acknowledged.ShouldBeTrue(); } + + /// Detached suppression (A1): when the cached local role is Detached the host MUST NOT + /// publish the cluster-wide alerts transition (identical gating as Secondary) — but it MUST still + /// write the local OPC UA condition node so the detached node's address space stays warm. + [Fact] + public void Detached_node_suppresses_alerts_publish_but_still_writes_opcua() + { + var publish = CreateTestProbe(); + var mux = CreateTestProbe(); + var alerts = CreateTestProbe(); + SubscribeToAlerts(alerts); + + var (host, _) = Spawn(publish, mux, LocalNode); + host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(severity: 800) })); + mux.ExpectMsg(Timeout); // load completed + + // Mark this node Detached, then activate. + TellRedundancyRole(host, RedundancyRole.Detached); + host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow)); + + // The local OPC UA node write is UNGATED — it must still arrive. + var state = publish.FishForMessage(m => m.State.Active, Timeout); + state.AlarmNodeId.ShouldBe("alm-1"); + + // The cluster-wide alerts publish is gated off on a detached node. + alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + } + + /// Absent-node default-emit (A1): a snapshot that + /// contains ONLY other nodes (the host's own is absent) must leave the + /// cached local role unchanged (null/unknown) — the host therefore defaults to emit, publishing + /// the alerts transition AND writing the OPC UA node, exactly as in the boot-window case. + [Fact] + public void Redundancy_snapshot_without_local_node_leaves_role_unknown_and_emits() + { + var publish = CreateTestProbe(); + var mux = CreateTestProbe(); + var alerts = CreateTestProbe(); + SubscribeToAlerts(alerts); + + var (host, _) = Spawn(publish, mux, LocalNode); + host.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(new[] { Plan(severity: 800) })); + mux.ExpectMsg(Timeout); // load completed + + // Send a snapshot that mentions only a DIFFERENT node — LocalNode is absent. + // The host cannot determine its own role from this snapshot, so the cached role + // stays null (unknown) ⇒ treated as Primary ⇒ default-emit path. + TellRedundancyRole(host, RedundancyRole.Primary, nodeId: new NodeId("some-other-node")); + + host.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow)); + + // Both the OPC UA write AND the alerts publish must arrive (default-emit, role unknown). + var state = publish.FishForMessage(m => m.State.Active, Timeout); + state.AlarmNodeId.ShouldBe("alm-1"); + + var evt = alerts.ExpectMsg(Timeout); + evt.AlarmId.ShouldBe("alm-1"); + evt.TransitionKind.ShouldBe("Activated"); + } }