using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Serilog; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; 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; using CoreAlarmConditionState = ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.AlarmConditionState; namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7; /// /// Task #24 — Gap 1 of phase-7-status.md. Covers /// which intercepts /// OPC UA Part 9 Acknowledge / Confirm method invocations on scripted alarm condition /// nodes and routes them to , and the /// property added to expose the /// engine through the composition chain. /// [Trait("Category", "Unit")] public sealed class ScriptedAlarmMethodRoutingTests { // ----------------------------------------------------------------------- // Phase7ComposedSources — AlarmEngine property // ----------------------------------------------------------------------- [Fact] public void Compose_ScriptedAlarm_rows_exposes_AlarmEngine() { var scripts = new[] { ScriptRow("scr-1", "return false;") }; var alarms = new[] { AlarmRow("al-1", "scr-1") }; var result = Phase7EngineComposer.Compose( scripts, [], alarms, upstream: new CachedTagUpstreamSource(), alarmStateStore: new InMemoryAlarmStateStore(), historianSink: NullAlarmHistorianSink.Instance, rootScriptLogger: new LoggerConfiguration().CreateLogger(), loggerFactory: NullLoggerFactory.Instance); result.AlarmEngine.ShouldNotBeNull("engine is exposed so the server can route method calls"); result.ScriptedAlarmReadable.ShouldNotBeNull(); result.Disposables.Count.ShouldBeGreaterThan(0); } [Fact] public void Compose_empty_rows_AlarmEngine_is_null() { var result = Phase7EngineComposer.Compose( scripts: [], virtualTags: [], scriptedAlarms: [], upstream: new CachedTagUpstreamSource(), alarmStateStore: new InMemoryAlarmStateStore(), historianSink: NullAlarmHistorianSink.Instance, rootScriptLogger: new LoggerConfiguration().CreateLogger(), loggerFactory: NullLoggerFactory.Instance); result.ShouldBeSameAs(Phase7ComposedSources.Empty); result.AlarmEngine.ShouldBeNull("empty composition returns the Empty sentinel with all-null engines"); } [Fact] public void Compose_VirtualTag_only_AlarmEngine_is_null() { var scripts = new[] { ScriptRow("scr-1", "return 1;") }; var vtags = new[] { VtRow("vt-1", "scr-1") }; var result = Phase7EngineComposer.Compose( scripts, vtags, [], upstream: new CachedTagUpstreamSource(), alarmStateStore: new InMemoryAlarmStateStore(), historianSink: NullAlarmHistorianSink.Instance, rootScriptLogger: new LoggerConfiguration().CreateLogger(), loggerFactory: NullLoggerFactory.Instance); result.AlarmEngine.ShouldBeNull("no scripted alarms → alarm engine is null"); result.VirtualReadable.ShouldNotBeNull(); } // ----------------------------------------------------------------------- // RouteScriptedAlarmMethodCalls — pure-function dispatch kernel // ----------------------------------------------------------------------- /// /// Builds a loaded ScriptedAlarmEngine with the given alarm IDs. /// All predicates return false so the alarm starts Inactive. /// private static ScriptedAlarmEngine BuildEngine(params string[] alarmIds) { 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 = alarmIds.Select(id => new ScriptedAlarmDefinition( AlarmId: id, EquipmentPath: "/eq", AlarmName: id, Kind: AlarmKind.LimitAlarm, Severity: AlarmSeverity.Medium, MessageTemplate: "msg", PredicateScriptSource: "return false;")).ToList(); engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult(); return engine; } /// /// Builds a loaded ScriptedAlarmEngine where the named alarm starts Active /// (predicate = return true) so subsequent Acknowledge tests have an /// Unacknowledged state to advance. /// 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 IUserIdentity? MakeIdentity(string? displayName) => displayName is null ? null : new NamedUserIdentity(displayName); private static CallMethodRequest AcknowledgeRequest(string conditionNodeId, string? comment = null) => new() { ObjectId = new NodeId(conditionNodeId, 2), MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge, InputArguments = new VariantCollection { new Variant(new byte[] { 1, 2, 3 }), // EventId (ByteString) — ignored new Variant(new LocalizedText(comment ?? string.Empty)), }, }; private static CallMethodRequest ConfirmRequest(string conditionNodeId, string? comment = null) => new() { ObjectId = new NodeId(conditionNodeId, 2), MethodId = MethodIds.AcknowledgeableConditionType_Confirm, InputArguments = new VariantCollection { new Variant(new byte[] { 1, 2, 3 }), // EventId (ByteString) — ignored new Variant(new LocalizedText(comment ?? string.Empty)), }, }; private static CallMethodRequest GenericRequest(string objectNodeId) => new() { ObjectId = new NodeId(objectNodeId, 2), MethodId = new NodeId("driver-method", 2), }; private static Dictionary Index(params (string condId, string alarmId)[] entries) => entries.ToDictionary(e => e.condId, e => e.alarmId, StringComparer.OrdinalIgnoreCase); // ---- no-op paths ------------------------------------------------------- [Fact] public void No_index_entries_leaves_all_slots_untouched() { 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! }; DriverNodeManager.RouteScriptedAlarmMethodCalls( MakeIdentity("alice"), calls, results, errors, engine, conditionIdToAlarmId: new Dictionary()); errors[0].ShouldBeNull("no matching entry → slot left for base.Call"); } [Fact] public void Non_alarm_method_id_is_ignored() { using var engine = BuildEngine("al-1"); var calls = new List { GenericRequest("al-1.Condition"), }; var results = new List { new CallMethodResult() }; var errors = new List { null! }; var index = Index(("al-1.Condition", "al-1")); DriverNodeManager.RouteScriptedAlarmMethodCalls( MakeIdentity("alice"), calls, results, errors, engine, index); errors[0].ShouldBeNull("non-Acknowledge/Confirm methods pass through untouched"); } [Fact] public void Already_errored_slot_is_skipped() { using var engine = BuildActiveEngine("al-1"); var calls = new List { AcknowledgeRequest("al-1.Condition"), }; var results = new List { new CallMethodResult() }; var priorError = new ServiceResult(StatusCodes.BadUserAccessDenied); var errors = new List { priorError }; var index = Index(("al-1.Condition", "al-1")); DriverNodeManager.RouteScriptedAlarmMethodCalls( MakeIdentity("alice"), calls, results, errors, engine, index); // Pre-populated bad error must not be overwritten. errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied); } // ---- Acknowledge ------------------------------------------------------- [Fact] public void Acknowledge_on_active_alarm_advances_engine_state() { using var engine = BuildActiveEngine("al-1"); // Sanity: alarm must start unacknowledged after activation. engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged); var calls = new List { AcknowledgeRequest("al-1.Condition", "looks ok") }; var results = new List { new CallMethodResult() }; var errors = new List { null! }; var index = Index(("al-1.Condition", "al-1")); DriverNodeManager.RouteScriptedAlarmMethodCalls( MakeIdentity("ops-user"), calls, results, errors, engine, index); errors[0].ShouldNotBeNull(); ServiceResult.IsBad(errors[0]).ShouldBeFalse("Acknowledge succeeded"); engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Acknowledged); engine.GetState("al-1")!.LastAckUser.ShouldBe("ops-user"); engine.GetState("al-1")!.LastAckComment.ShouldBe("looks ok"); } [Fact] public void Acknowledge_uses_opcua_client_as_fallback_when_identity_is_null() { 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 = Index(("al-1.Condition", "al-1")); // Pass null identity (anonymous session). DriverNodeManager.RouteScriptedAlarmMethodCalls( userIdentity: null, calls, results, errors, engine, index); engine.GetState("al-1")!.LastAckUser.ShouldBe("opcua-client"); } [Fact] public void Acknowledge_with_no_input_arguments_uses_null_comment() { using var engine = BuildActiveEngine("al-1"); // Build a request without InputArguments to simulate a client that omits the comment. var requestNoArgs = new CallMethodRequest { ObjectId = new NodeId("al-1.Condition", 2), MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge, }; var calls = new List { requestNoArgs }; var results = new List { new CallMethodResult() }; var errors = new List { null! }; var index = Index(("al-1.Condition", "al-1")); // Should not throw — comment defaults to null. DriverNodeManager.RouteScriptedAlarmMethodCalls( MakeIdentity("alice"), calls, results, errors, engine, index); ServiceResult.IsBad(errors[0]).ShouldBeFalse("Acknowledge without comment succeeds"); } [Fact] public void Acknowledge_marks_slot_result_as_Good_and_error_as_Good() { 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 = Index(("al-1.Condition", "al-1")); DriverNodeManager.RouteScriptedAlarmMethodCalls( MakeIdentity("alice"), calls, results, errors, engine, index); results[0].StatusCode.ShouldBe((StatusCode)StatusCodes.Good); errors[0].ShouldBe(ServiceResult.Good); } // ---- Confirm ----------------------------------------------------------- [Fact] public void Confirm_on_alarm_with_unconfirmed_state_advances_state() { // Build an alarm pre-seeded as Inactive + Acknowledged + Unconfirmed so // ApplyConfirm has a valid transition to execute. var store = new InMemoryAlarmStateStore(); var upstream = new CachedTagUpstreamSource(); var logger = new LoggerConfiguration().CreateLogger(); var factory = new ScriptLoggerFactory(logger); var engine = new ScriptedAlarmEngine(upstream, store, factory, logger); var seedState = CoreAlarmConditionState.Fresh("confirm-alarm", DateTime.UtcNow) with { Active = AlarmActiveState.Inactive, Acked = AlarmAckedState.Acknowledged, Confirmed = AlarmConfirmedState.Unconfirmed, }; store.SaveAsync(seedState, CancellationToken.None).GetAwaiter().GetResult(); var defs = new List { new("confirm-alarm", "/eq", "confirm-alarm", AlarmKind.LimitAlarm, AlarmSeverity.Low, "msg", "return false;"), }; engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult(); engine.GetState("confirm-alarm")!.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed); var calls = new List { ConfirmRequest("confirm-alarm.Condition", "all clear"), }; var results = new List { new CallMethodResult() }; var errors = new List { null! }; var index = Index(("confirm-alarm.Condition", "confirm-alarm")); DriverNodeManager.RouteScriptedAlarmMethodCalls( MakeIdentity("ops-user"), calls, results, errors, engine, index); ServiceResult.IsBad(errors[0]).ShouldBeFalse("Confirm succeeded"); engine.GetState("confirm-alarm")!.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed); engine.GetState("confirm-alarm")!.LastConfirmUser.ShouldBe("ops-user"); } // ---- Mixed batches ----------------------------------------------------- [Fact] public void Mixed_batch_handles_each_slot_independently() { using var engine = BuildActiveEngine("al-1"); var calls = new List { AcknowledgeRequest("al-1.Condition"), // scripted alarm → handled GenericRequest("some-driver-method"), // non-alarm → pass through AcknowledgeRequest("unknown-alarm.Condition"), // not in index → pass through }; var results = Enumerable.Range(0, 3).Select(_ => new CallMethodResult()).ToList(); var errors = new List { null!, null!, null! }; var index = Index(("al-1.Condition", "al-1")); // only one entry DriverNodeManager.RouteScriptedAlarmMethodCalls( MakeIdentity("alice"), calls, results, errors, engine, index); // Slot 0: Acknowledge on known scripted alarm → handled with Good result. ServiceResult.IsBad(errors[0]).ShouldBeFalse("scripted alarm Acknowledge handled"); engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Acknowledged); // Slot 1: Generic method → left null for base.Call. errors[1].ShouldBeNull("generic method left for base.Call"); // Slot 2: Unknown alarm id → left null for base.Call. errors[2].ShouldBeNull("unknown condition id left for base.Call"); } [Fact] public void Unknown_alarm_id_in_engine_returns_BadInvalidArgument() { using var engine = BuildEngine("al-1"); // The index says al-999 maps to "al-999-engine" but the engine has no such alarm. var calls = new List { AcknowledgeRequest("al-999.Condition"), }; var results = new List { new CallMethodResult() }; var errors = new List { null! }; // Put a deliberately wrong alarmId in the index (engine will throw ArgumentException). var index = Index(("al-999.Condition", "al-999-not-in-engine")); DriverNodeManager.RouteScriptedAlarmMethodCalls( MakeIdentity("alice"), calls, results, errors, engine, index); ServiceResult.IsBad(errors[0]).ShouldBeTrue("unknown alarm in engine → error result"); errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument); } // ---- Phase7ComposedSources helpers ------------------------------------- private static Script ScriptRow(string id, string source) => new() { ScriptRowId = Guid.NewGuid(), GenerationId = 1, ScriptId = id, Name = id, SourceCode = source, SourceHash = "h", }; private static VirtualTag VtRow(string id, string scriptId) => new() { VirtualTagRowId = Guid.NewGuid(), GenerationId = 1, VirtualTagId = id, EquipmentId = "eq-1", Name = id, DataType = "Float32", ScriptId = scriptId, }; private static ScriptedAlarm AlarmRow(string id, string scriptId) => new() { ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1, ScriptedAlarmId = id, EquipmentId = "eq-1", Name = id, AlarmType = "LimitAlarm", Severity = 500, MessageTemplate = "x", PredicateScriptId = scriptId, }; // ---- Fake user identity ------------------------------------------------ /// /// Simple with a display name for unit testing. /// Uses the UserIdentity(username, password) constructor so the base-class /// property returns the supplied name when /// accessed through the interface. /// The real production identity is OtOpcUaServer.RoleBasedIdentity which /// populates DisplayName from the LDAP authentication result. /// private sealed class NamedUserIdentity(string displayName) : UserIdentity(displayName, "") { } }