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");
+ }
}