using Opc.Ua; using Serilog; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Core.Scripting; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; using ZB.MOM.WW.OtOpcUa.Server.Phase7; namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7; /// /// Regression for Server-008 — RouteScriptedAlarmMethodCalls must mark a handled /// slot as Processed = true so the stack's /// CustomNodeManager2.Call skips it. The pre-fix code relied on the slot's /// errors[i] being ServiceResult.Good, but the SDK's actual skip predicate is /// ; without setting it, the stack's built-in /// Part 9 Acknowledge / Confirm handler would also fire, producing a double transition. /// [Trait("Category", "Unit")] public sealed class ScriptedAlarmMethodRoutingProcessedFlagTests { private static ScriptedAlarmEngine BuildActiveEngine(string alarmId) { var upstream = new CachedTagUpstreamSource(); var logger = new LoggerConfiguration().CreateLogger(); var factory = new ScriptLoggerFactory(logger); var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger); var defs = new List { new(AlarmId: alarmId, EquipmentPath: "/eq", AlarmName: alarmId, Kind: AlarmKind.LimitAlarm, Severity: AlarmSeverity.Medium, MessageTemplate: "msg", PredicateScriptSource: "return true;"), }; engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult(); return engine; } private static ScriptedAlarmEngine BuildEngine(string alarmId) { var upstream = new CachedTagUpstreamSource(); var logger = new LoggerConfiguration().CreateLogger(); var factory = new ScriptLoggerFactory(logger); var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger); var defs = new List { new(AlarmId: alarmId, EquipmentPath: "/eq", AlarmName: alarmId, Kind: AlarmKind.LimitAlarm, Severity: AlarmSeverity.Medium, MessageTemplate: "msg", PredicateScriptSource: "return false;"), }; engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult(); return engine; } private static CallMethodRequest AcknowledgeRequest(string conditionNodeId) => new() { ObjectId = new NodeId(conditionNodeId, 2), MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge, InputArguments = { new Variant(new byte[] { 1, 2, 3 }), new Variant(new LocalizedText("ack-comment")), }, }; private static CallMethodRequest AddCommentRequest(string conditionNodeId) => new() { ObjectId = new NodeId(conditionNodeId, 2), MethodId = MethodIds.ConditionType_AddComment, InputArguments = { new Variant(new byte[] { 1, 2, 3 }), new Variant(new LocalizedText("comment-text")), }, }; [Fact] public void Handled_Acknowledge_marks_Processed_true_so_baseCall_skips_the_slot() { using var engine = BuildActiveEngine("al-1"); var calls = new List { AcknowledgeRequest("al-1.Condition") }; var results = new List { new CallMethodResult() }; var errors = new List { null! }; var index = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["al-1.Condition"] = "al-1", }; DriverNodeManager.RouteScriptedAlarmMethodCalls( new NamedIdentity("ops-user"), calls, results, errors, engine, index); calls[0].Processed.ShouldBeTrue( "CustomNodeManager2.Call/CallInternalAsync skips slots with Processed=true. " + "Without this flag, base.Call would re-dispatch the Acknowledge to the stack's " + "built-in Part 9 handler and the engine would observe a double transition."); } [Fact] public void Handled_AddComment_marks_Processed_true() { using var engine = BuildEngine("al-1"); var calls = new List { AddCommentRequest("al-1.Condition") }; var results = new List { new CallMethodResult() }; var errors = new List { null! }; var index = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["al-1.Condition"] = "al-1", }; DriverNodeManager.RouteScriptedAlarmMethodCalls( new NamedIdentity("ops-user"), calls, results, errors, engine, index); calls[0].Processed.ShouldBeTrue("AddComment handled by the engine must not re-dispatch via base.Call"); } [Fact] public void Engine_error_path_also_marks_Processed_so_baseCall_does_not_re_run_the_method() { using var engine = BuildEngine("al-1"); var calls = new List { // Index maps to an alarm id the engine doesn't know — engine throws // ArgumentException, helper sets errors[i] = BadInvalidArgument. AcknowledgeRequest("al-1.Condition"), }; var results = new List { new CallMethodResult() }; var errors = new List { null! }; var index = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["al-1.Condition"] = "al-NOT-IN-ENGINE", }; DriverNodeManager.RouteScriptedAlarmMethodCalls( new NamedIdentity("ops-user"), calls, results, errors, engine, index); ServiceResult.IsBad(errors[0]).ShouldBeTrue("engine error path"); calls[0].Processed.ShouldBeTrue( "even when the engine returns Bad, the slot was handled — base.Call must not " + "re-dispatch the method against the OPC UA built-in handler."); } [Fact] public void Unhandled_slot_leaves_Processed_false_so_baseCall_drives_it() { using var engine = BuildActiveEngine("al-1"); var genericMethod = new CallMethodRequest { ObjectId = new NodeId("some-driver-method", 2), MethodId = new NodeId("driver-method", 2), }; var calls = new List { genericMethod }; var results = new List { new CallMethodResult() }; var errors = new List { null! }; DriverNodeManager.RouteScriptedAlarmMethodCalls( new NamedIdentity("ops-user"), calls, results, errors, engine, conditionIdToAlarmId: new Dictionary()); calls[0].Processed.ShouldBeFalse("non-alarm methods must fall through to base.Call"); errors[0].ShouldBeNull("unhandled slot's error must stay null for the base implementation"); } private sealed class NamedIdentity(string displayName) : UserIdentity(displayName, "") { } }