From 226587d817c711f9aede0c332a0f99f1a588a23e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 14:20:20 -0400 Subject: [PATCH] test(alarms): cover isNative rebuild/kind-flip lifecycle + Phase7Applier call-site (code-review) --- .../AlarmCommandRouterTests.cs | 50 +++++++++++++++++++ .../Phase7ApplierTests.cs | 15 +++--- 2 files changed, 59 insertions(+), 6 deletions(-) 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.