diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs index f1c1650..ec01dff 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs @@ -5,10 +5,15 @@ using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Core.Resilience; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Server.Alarms; using ZB.MOM.WW.OtOpcUa.Server.History; using ZB.MOM.WW.OtOpcUa.Server.Security; using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest; +// AlarmConditionState exists in both Opc.Ua (the OPC UA stack's node-state class) and +// ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms (the engine's persisted-state record). Alias the +// OPC UA one so ConditionSink / VariableHandle keep their existing unqualified usage. +using OpcAlarmConditionState = Opc.Ua.AlarmConditionState; // Core.Abstractions defines a type-named HistoryReadResult (driver-side samples + continuation // point) that collides with Opc.Ua.HistoryReadResult (service-layer per-node result). We // assign driver-side results to an explicitly-aliased local and construct only the service @@ -22,7 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa; /// into OPC UA nodes. Implements itself so /// GenericDriverNodeManager.BuildAddressSpaceAsync can stream nodes directly into the /// OPC UA server's namespace. PR 15's MarkAsAlarmCondition hook creates a sibling -/// node per alarm-flagged variable; subsequent driver +/// node per alarm-flagged variable; subsequent driver /// OnAlarmEvent pushes land through the returned sink to drive Activate / /// Acknowledge / Deactivate transitions. /// @@ -106,12 +111,27 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder private readonly Dictionary _conditionSinks = new(StringComparer.OrdinalIgnoreCase); private EventHandler? _alarmTransitionHandler; + // Task #24 — Phase 7 Gap 1: route OPC UA Part 9 Acknowledge / Confirm method calls on + // scripted alarm condition nodes to the ScriptedAlarmEngine so the engine state machine + // advances with the authenticated principal. When null, scripted-alarm method calls fall + // through to the stack's built-in handler (which updates the OPC UA node but does not + // reach the engine — the pre-task-24 gap). + // + // _scriptedAlarmIdByConditionNodeId maps the condition node's string identifier + // (e.g. "sal-abc123.Condition") → the ScriptedAlarmId the engine addresses by + // (e.g. "sal-abc123"). Populated in MarkAsAlarmCondition when the parent variable + // carries NodeSourceKind.ScriptedAlarm. + private readonly ScriptedAlarmEngine? _scriptedAlarmEngine; + private readonly Dictionary _scriptedAlarmIdByConditionNodeId + = new(StringComparer.OrdinalIgnoreCase); + public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration, IDriver driver, CapabilityInvoker invoker, ILogger logger, AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null, IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null, IHistoryRouter? historyRouter = null, - AlarmConditionService? alarmService = null) + AlarmConditionService? alarmService = null, + ScriptedAlarmEngine? scriptedAlarmEngine = null) : base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}") { _driver = driver; @@ -125,6 +145,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder _scriptedAlarmReadable = scriptedAlarmReadable; _historyRouter = historyRouter; _alarmService = alarmService; + _scriptedAlarmEngine = scriptedAlarmEngine; _logger = logger; if (_alarmService is not null) @@ -601,9 +622,116 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder IList errors) { GateCallMethodRequests(methodsToCall, errors, context.UserIdentity, _authzGate, _scopeResolver); + + // Task #24 — Phase 7 Gap 1: route Part 9 Acknowledge / Confirm calls that target + // scripted alarm condition nodes directly to the ScriptedAlarmEngine. The engine + // advances its Part 9 state machine with the authenticated principal (audit + // requirement), persists state, and emits the transition event through + // ScriptedAlarmSource so OPC UA alarm subscribers see the correct AckedState / + // ConfirmedState change. base.Call is skipped for handled slots so the stack's + // built-in handler (which updates the OPC UA node but doesn't call the engine) + // does not double-fire. + if (_scriptedAlarmEngine is not null) + { + RouteScriptedAlarmMethodCalls(context.UserIdentity, methodsToCall, results, errors, + _scriptedAlarmEngine, _scriptedAlarmIdByConditionNodeId); + } + base.Call(context, methodsToCall, results, errors); } + /// + /// Intercepts Part 9 Acknowledge / Confirm slots that + /// target scripted alarm condition nodes and routes them to the + /// . Slots that are handled have their + /// entry set to and are + /// not touched by the caller's subsequent base.Call invocation. + /// + /// + /// + /// The OPC UA Part 9 Acknowledge method signature is: + /// InputArguments[0] = EventId (ByteString, ignored — scripted alarms identify by + /// ConditionId, not EventId), InputArguments[1] = Comment (LocalizedText). + /// Confirm has the same shape. Missing or null comment is treated as empty string. + /// + /// + /// User identity is extracted from 's + /// DisplayName; when absent (anonymous session) the fallback is + /// "opcua-client" so every audit entry carries an identity. Authenticated + /// LDAP sessions populate DisplayName during OtOpcUaServer.OnImpersonateUser. + /// + /// + /// Extracted as a pure function for unit-testability — all dependencies are + /// passed explicitly; no closed-over state. The caller passes + /// context.UserIdentity from the override. + /// + /// + internal static void RouteScriptedAlarmMethodCalls( + IUserIdentity? userIdentity, + IList methodsToCall, + IList results, + IList errors, + ScriptedAlarmEngine engine, + IReadOnlyDictionary conditionIdToAlarmId) + { + var user = userIdentity?.DisplayName; + if (string.IsNullOrWhiteSpace(user)) user = "opcua-client"; + + for (var i = 0; i < methodsToCall.Count; i++) + { + // Skip slots already errored (gate denied or pre-populated by the stack). + if (errors[i] is not null && ServiceResult.IsBad(errors[i])) continue; + + var request = methodsToCall[i]; + + // Only handle the two well-known Part 9 method ids. + var isAcknowledge = request.MethodId == MethodIds.AcknowledgeableConditionType_Acknowledge; + var isConfirm = request.MethodId == MethodIds.AcknowledgeableConditionType_Confirm; + if (!isAcknowledge && !isConfirm) continue; + + // ObjectId must be a string identifier so we can look it up in the index. + if (request.ObjectId.Identifier is not string conditionKey) continue; + + if (!conditionIdToAlarmId.TryGetValue(conditionKey, out var alarmId)) continue; + + // Extract the operator comment from InputArguments[1] (LocalizedText). + // InputArguments[0] is EventId (ByteString) — we address by alarmId, not EventId. + // InputArguments elements are Variant-boxed values; unbox via .Value before casting. + string? comment = null; + if (request.InputArguments?.Count >= 2 + && request.InputArguments[1].Value is LocalizedText lt) + comment = lt.Text; + + try + { + if (isAcknowledge) + engine.AcknowledgeAsync(alarmId, user, comment, CancellationToken.None) + .GetAwaiter().GetResult(); + else + engine.ConfirmAsync(alarmId, user, comment, CancellationToken.None) + .GetAwaiter().GetResult(); + + // Mark the slot as handled so base.Call skips it. A pre-populated Good + // result (not null and not Bad) is the signal the base class uses to + // skip per-slot dispatch — set StatusCode to Good explicitly. + results[i] = new CallMethodResult { StatusCode = StatusCodes.Good }; + errors[i] = ServiceResult.Good; + } + catch (ArgumentException ex) + { + // Unknown alarmId or invalid state (e.g. already acknowledged) — surface + // as BadInvalidArgument so the OPC UA client sees a meaningful status. + errors[i] = new ServiceResult(StatusCodes.BadInvalidArgument, + ex.Message, ex.Message); + } + catch (Exception ex) + { + errors[i] = new ServiceResult(StatusCodes.BadInternalError, + ex.Message, ex.Message); + } + } + } + /// /// Pure-function gate for a batch of . Pre-populates /// slots with @@ -774,7 +902,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder { lock (_owner.Lock) { - var alarm = new AlarmConditionState(_variable) + var alarm = new OpcAlarmConditionState(_variable) { SymbolicName = _variable.BrowseName.Name + "_Condition", ReferenceTypeId = ReferenceTypeIds.HasComponent, @@ -822,6 +950,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder var sink = new ConditionSink(_owner, alarm); + // Task #24 — register the condition nodeId → ScriptedAlarmId mapping so the + // Call override can route Acknowledge/Confirm invocations to the engine. + // The condition's string identifier is "{FullReference}.Condition"; the engine + // addresses alarms by ScriptedAlarmId (= FullReference for scripted alarms, + // because EquipmentNodeWalker sets FullName = ScriptedAlarmId on the attr). + if (_owner._scriptedAlarmEngine is not null + && _owner._sourceByFullRef.TryGetValue(FullReference, out var varSource) + && varSource == NodeSourceKind.ScriptedAlarm) + { + var conditionKey = alarm.NodeId.Identifier?.ToString(); + if (!string.IsNullOrEmpty(conditionKey)) + _owner._scriptedAlarmIdByConditionNodeId[conditionKey!] = FullReference; + } + // PR 2.3 — when the server-level alarm-condition service is wired, register // this condition with it so the state machine runs server-side. The sink-map // entry routes future TransitionRaised events back to this OPC UA node. @@ -884,7 +1026,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder } } - private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm) + private sealed class ConditionSink(DriverNodeManager owner, OpcAlarmConditionState alarm) : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs index 7fe3046..e5570c9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OpcUaApplicationHost.cs @@ -5,6 +5,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Resilience; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Server.Alarms; using ZB.MOM.WW.OtOpcUa.Server.History; using ZB.MOM.WW.OtOpcUa.Server.Observability; @@ -41,6 +42,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable // the host has been DI-constructed (task #246). private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _virtualReadable; private ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? _scriptedAlarmReadable; + // Task #24 — Phase 7 Gap 1. The engine is passed to every DriverNodeManager so OPC UA + // Part 9 Acknowledge / Confirm method calls on scripted alarm condition nodes reach the + // engine with the authenticated principal instead of falling through to the stack's + // default Part 9 no-op handler. + private ScriptedAlarmEngine? _scriptedAlarmEngine; // PR 1+2.W — server-level singletons. Threaded through to OtOpcUaServer + every // DriverNodeManager. Default null preserves existing test construction sites that @@ -90,21 +96,31 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable public OtOpcUaServer? Server => _server; /// - /// Late-bind the Phase 7 engine-backed IReadable sources. Must be - /// called BEFORE — once the OPC UA server starts, the - /// ctor captures the field values + per-node - /// s are constructed. Calling this after start has - /// no effect on already-materialized node managers. + /// Late-bind the Phase 7 engine-backed IReadable sources and the + /// . Must be called BEFORE + /// — once the OPC UA server starts, the ctor captures + /// the field values + per-node s are constructed. + /// Calling this after start has no effect on already-materialized node managers. /// + /// Virtual-tag engine read adapter; null when no virtual tags. + /// Scripted-alarm engine read adapter; null when no scripted alarms. + /// + /// The instance (task #24). When non-null, + /// DriverNodeManager routes OPC UA Part 9 Acknowledge/Confirm method calls + /// on scripted alarm condition nodes directly to the engine with the authenticated + /// principal, producing correct audit entries and state-machine transitions. + /// public void SetPhase7Sources( ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? virtualReadable, - ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable) + ZB.MOM.WW.OtOpcUa.Core.Abstractions.IReadable? scriptedAlarmReadable, + ScriptedAlarmEngine? alarmEngine = null) { if (_server is not null) throw new InvalidOperationException( "Phase 7 sources must be set before OpcUaApplicationHost.StartAsync; the OtOpcUaServer + DriverNodeManagers have already captured the previous values."); _virtualReadable = virtualReadable; _scriptedAlarmReadable = scriptedAlarmReadable; + _scriptedAlarmEngine = alarmEngine; } /// @@ -149,7 +165,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable tierLookup: _tierLookup, resilienceConfigLookup: _resilienceConfigLookup, virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable, anonymousRoles: _options.AnonymousRoles, - historyRouter: _historyRouter, alarmConditionService: _alarmConditionService); + historyRouter: _historyRouter, alarmConditionService: _alarmConditionService, + scriptedAlarmEngine: _scriptedAlarmEngine); await _application.Start(_server).ConfigureAwait(false); _logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}", diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs index ed181e0..25e151b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/OtOpcUaServer.cs @@ -6,6 +6,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Resilience; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; using ZB.MOM.WW.OtOpcUa.Server.Alarms; using ZB.MOM.WW.OtOpcUa.Server.History; using ZB.MOM.WW.OtOpcUa.Server.Security; @@ -35,6 +36,9 @@ public sealed class OtOpcUaServer : StandardServer // dispatch — identical to pre-Phase-7 behaviour. private readonly IReadable? _virtualReadable; private readonly IReadable? _scriptedAlarmReadable; + // Task #24 — Gap 1. Passed through to every DriverNodeManager so OPC UA Part 9 + // Acknowledge/Confirm method calls on scripted alarm condition nodes reach the engine. + private readonly ScriptedAlarmEngine? _scriptedAlarmEngine; // PR 1+2.W — server-level singletons shared across every DriverNodeManager. // Null when the deployment hasn't opted into the new server-side history routing / @@ -68,7 +72,8 @@ public sealed class OtOpcUaServer : StandardServer IReadable? scriptedAlarmReadable = null, IReadOnlyList? anonymousRoles = null, IHistoryRouter? historyRouter = null, - AlarmConditionService? alarmConditionService = null) + AlarmConditionService? alarmConditionService = null, + ScriptedAlarmEngine? scriptedAlarmEngine = null) { _driverHost = driverHost; _authenticator = authenticator; @@ -82,6 +87,7 @@ public sealed class OtOpcUaServer : StandardServer _anonymousRoles = anonymousRoles ?? []; _historyRouter = historyRouter; _alarmConditionService = alarmConditionService; + _scriptedAlarmEngine = scriptedAlarmEngine; _loggerFactory = loggerFactory; } @@ -116,7 +122,8 @@ public sealed class OtOpcUaServer : StandardServer var manager = new DriverNodeManager(server, configuration, driver, invoker, logger, authzGate: _authzGate, scopeResolver: _scopeResolver, virtualReadable: _virtualReadable, scriptedAlarmReadable: _scriptedAlarmReadable, - historyRouter: _historyRouter, alarmService: _alarmConditionService); + historyRouter: _historyRouter, alarmService: _alarmConditionService, + scriptedAlarmEngine: _scriptedAlarmEngine); // The router stays empty after PR 1+2.W — DriverNodeManager's internal // LegacyDriverHistoryAdapter handles every driver that still implements diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs index ca7cd66..a4afb64 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs @@ -56,7 +56,8 @@ public sealed class OpcUaServerService( // — late binding after server start is rejected with InvalidOperationException. // No-op when the generation has no virtual tags or scripted alarms. var phase7 = await phase7Composer.PrepareAsync(gen, stoppingToken); - applicationHost.SetPhase7Sources(phase7.VirtualReadable, phase7.ScriptedAlarmReadable); + applicationHost.SetPhase7Sources(phase7.VirtualReadable, phase7.ScriptedAlarmReadable, + phase7.AlarmEngine); // Phase 6.2 Stream C wiring — build the AuthorizationGate + NodeScopeResolver // from the published generation's NodeAcl rows and the populated equipment diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs index b93e2a1..1414331 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Server/Phase7/Phase7EngineComposer.cs @@ -74,6 +74,7 @@ public static class Phase7EngineComposer } IReadable? alarmReadable = null; + ScriptedAlarmEngine? composedAlarmEngine = null; if (scriptedAlarms.Count > 0) { var alarmDefs = ProjectScriptedAlarms(scriptedAlarms, scriptById).ToList(); @@ -89,11 +90,12 @@ public static class Phase7EngineComposer // instead of BadNotFound. ScriptedAlarmSource stays registered as IAlarmSource // for the event stream; the IReadable is a separate adapter over the same engine. alarmReadable = new ScriptedAlarmReadable(alarmEngine); + composedAlarmEngine = alarmEngine; disposables.Add(alarmEngine); disposables.Add(alarmSource); } - return new Phase7ComposedSources(vtSource, alarmReadable, disposables); + return new Phase7ComposedSources(vtSource, alarmReadable, disposables, composedAlarmEngine); } internal static IEnumerable ProjectVirtualTags( @@ -192,10 +194,17 @@ public static class Phase7EngineComposer /// Non-null when virtual tags were composed; pass to OpcUaApplicationHost.virtualReadable. /// Non-null when scripted alarms were composed; pass to OpcUaApplicationHost.scriptedAlarmReadable. /// Engine + source instances the caller owns. Dispose on shutdown. +/// +/// The instance, non-null when scripted alarms were +/// composed. Passed to OpcUaApplicationHost so DriverNodeManager can +/// route OPC UA Part 9 Acknowledge / Confirm method invocations directly to the engine +/// with the authenticated principal (task #24 — Gap 1 of phase-7-status.md). +/// public sealed record Phase7ComposedSources( IReadable? VirtualReadable, IReadable? ScriptedAlarmReadable, - IReadOnlyList Disposables) + IReadOnlyList Disposables, + ScriptedAlarmEngine? AlarmEngine = null) { public static readonly Phase7ComposedSources Empty = new(null, null, Array.Empty()); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmMethodRoutingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmMethodRoutingTests.cs new file mode 100644 index 0000000..ddcf95c --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/ScriptedAlarmMethodRoutingTests.cs @@ -0,0 +1,447 @@ +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, "") { } +}