diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
index b629a79f..395cdc04 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AlarmCommandRouterTests.cs
@@ -354,6 +354,56 @@ public sealed class AlarmCommandRouterTests : IDisposable
await host.DisposeAsync();
}
+ /// H6a lifecycle — the native flag is NOT sticky across a rebuild + kind flip. Materialise an
+ /// id as native (flag true), (which clears the
+ /// folder + condition + native-flag sets), re-ensure the equipment folder, then re-materialise the
+ /// SAME id as scripted (isNative:false). The flag must read false — the rebuild dropped it and
+ /// the scripted re-materialise did NOT re-add it. Guards against a stale-native leak that would route a
+ /// now-scripted alarm's inbound ack to the driver.
+ [Fact]
+ public async Task Native_flag_clears_on_rebuild_then_kind_flip()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
+ nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true);
+ nm.IsNativeAlarmNode("a1").ShouldBeTrue();
+
+ // RebuildAddressSpace clears the folder set too, so the equipment folder must be re-ensured
+ // before the same id can be re-materialised (ResolveParentFolder needs the parent back).
+ nm.RebuildAddressSpace();
+ nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
+ nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false);
+
+ nm.IsNativeAlarmNode("a1").ShouldBeFalse();
+
+ await host.DisposeAsync();
+ }
+
+ /// H6a lifecycle (converse) — a scripted condition can flip TO native across a rebuild.
+ /// Materialise an id as scripted (flag false), rebuild, re-ensure the folder, re-materialise the SAME
+ /// id as native (isNative:true). The flag must read true — the native re-materialise re-adds it
+ /// cleanly after the rebuild cleared the slate.
+ [Fact]
+ public async Task Scripted_flag_can_flip_to_native_across_rebuild()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
+ nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false);
+ nm.IsNativeAlarmNode("a1").ShouldBeFalse();
+
+ nm.RebuildAddressSpace();
+ nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
+ nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true);
+
+ nm.IsNativeAlarmNode("a1").ShouldBeTrue();
+
+ await host.DisposeAsync();
+ }
+
/// Builds a (an )
/// carrying a with the given name + roles — the exact seam the
/// gate reads via (context as ISessionOperationContext)?.UserIdentity as RoleCarryingUserIdentity.
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
index 074b99d3..42d7a3db 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
@@ -228,8 +228,9 @@ public sealed class Phase7ApplierTests
// The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent,
// matching display/type/severity) and did NOT drive EnsureVariable.
var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp");
+ // A native equipment-tag alarm: the call-site threads isNative: true.
sink.AlarmConditionCalls.ShouldHaveSingleItem()
- .ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700));
+ .ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true));
sink.VariableCalls.ShouldNotContain(v => v.NodeId == alarmNodeId);
}
@@ -257,8 +258,9 @@ public sealed class Phase7ApplierTests
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
// Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable.
var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "Diagnostics", "OverTemp");
+ // A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true.
sink.AlarmConditionCalls.ShouldHaveSingleItem()
- .ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500));
+ .ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true));
sink.VariableCalls.ShouldBeEmpty();
}
@@ -445,8 +447,9 @@ public sealed class Phase7ApplierTests
applier.MaterialiseScriptedAlarms(composition);
// Only the enabled alarm is materialised; the disabled one is skipped entirely.
+ // A SCRIPTED alarm: the call-site threads isNative: false (guards against a native/scripted swap).
sink.AlarmConditionCalls.ShouldHaveSingleItem()
- .ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700));
+ .ShouldBe(("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, false));
}
/// Verifies that added equipment tags in an otherwise-empty plan trigger an
@@ -717,7 +720,7 @@ public sealed class Phase7ApplierTests
/// keyed by NodeId (null ⇒ that call passed not-historized).
public ConcurrentQueue<(string NodeId, string? HistorianTagname)> HistorianQueue { get; } = new();
/// Gets the queue of alarm-condition materialise calls.
- public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionQueue { get; } = new();
+ public ConcurrentQueue<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionQueue { get; } = new();
/// Gets the number of rebuild calls made on this sink.
public int RebuildCalls;
@@ -730,7 +733,7 @@ public sealed class Phase7ApplierTests
/// Gets the list of recorded (NodeId, historian-tagname) pairs captured per EnsureVariable call.
public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
/// Gets the list of recorded alarm-condition materialise calls.
- public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity)> AlarmConditionCalls => AlarmConditionQueue.ToList();
+ public List<(string AlarmNodeId, string EquipmentNodeId, string DisplayName, string AlarmType, int Severity, bool IsNative)> AlarmConditionCalls => AlarmConditionQueue.ToList();
/// Records a value write (no-op in this recording sink).
/// The node ID.
@@ -751,7 +754,7 @@ public sealed class Phase7ApplierTests
/// The domain alarm type.
/// The domain severity.
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
- => AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity));
+ => AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative));
/// Records a folder creation call.
/// The folder node ID.
/// The parent folder node ID, if any.