diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 2e14b96..0bfe61c 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -5,6 +5,7 @@ + @@ -30,6 +31,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs new file mode 100644 index 0000000..6d466b9 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmConditionState.cs @@ -0,0 +1,84 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +/// +/// Persistent per-alarm state tracked by the Part 9 state machine. Every field +/// carried here either participates in the state machine or contributes to the +/// audit trail required by Phase 7 plan decision #14 (GxP / 21 CFR Part 11). +/// +/// +/// +/// is re-derived from the predicate at startup per Phase 7 +/// decision #14 — the engine runs every alarm's predicate against current tag +/// values at Load, overriding whatever Active state is in the store. +/// Every other state field persists verbatim across server restarts so +/// operators don't re-ack active alarms after an outage + shelved alarms stay +/// shelved + audit history survives. +/// +/// +/// is append-only; comments + ack/confirm user identities +/// are the audit surface regulators consume. The engine never rewrites past +/// entries. +/// +/// +public sealed record AlarmConditionState( + string AlarmId, + AlarmEnabledState Enabled, + AlarmActiveState Active, + AlarmAckedState Acked, + AlarmConfirmedState Confirmed, + ShelvingState Shelving, + DateTime LastTransitionUtc, + DateTime? LastActiveUtc, + DateTime? LastClearedUtc, + DateTime? LastAckUtc, + string? LastAckUser, + string? LastAckComment, + DateTime? LastConfirmUtc, + string? LastConfirmUser, + string? LastConfirmComment, + IReadOnlyList Comments) +{ + /// Initial-load state for a newly registered alarm — everything in the "no-event" position. + public static AlarmConditionState Fresh(string alarmId, DateTime nowUtc) => new( + AlarmId: alarmId, + Enabled: AlarmEnabledState.Enabled, + Active: AlarmActiveState.Inactive, + Acked: AlarmAckedState.Acknowledged, + Confirmed: AlarmConfirmedState.Confirmed, + Shelving: ShelvingState.Unshelved, + LastTransitionUtc: nowUtc, + LastActiveUtc: null, + LastClearedUtc: null, + LastAckUtc: null, + LastAckUser: null, + LastAckComment: null, + LastConfirmUtc: null, + LastConfirmUser: null, + LastConfirmComment: null, + Comments: []); +} + +/// +/// Shelving state — kind plus, for , the UTC +/// timestamp at which the shelving auto-expires. The engine polls the timer on its +/// evaluation cadence; callers should not rely on millisecond-precision expiry. +/// +public sealed record ShelvingState(ShelvingKind Kind, DateTime? UnshelveAtUtc) +{ + public static readonly ShelvingState Unshelved = new(ShelvingKind.Unshelved, null); +} + +/// +/// A single append-only audit record — acknowledgement / confirmation / explicit +/// comment / shelving action. Every entry carries a monotonic UTC timestamp plus the +/// user identity Phase 6.2 authenticated. +/// +/// When the action happened. +/// OS / LDAP identity of the actor. For engine-internal events (shelving expiry, startup recovery) this is "system". +/// Human-readable classification — "Acknowledge", "Confirm", "ShelveOneShot", "ShelveTimed", "Unshelve", "AddComment", "Enable", "Disable", "AutoUnshelve". +/// Operator-supplied comment or engine-generated message. +public sealed record AlarmComment( + DateTime TimestampUtc, + string User, + string Kind, + string Text); diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs new file mode 100644 index 0000000..87b26d8 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmPredicateContext.cs @@ -0,0 +1,55 @@ +using Serilog; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +/// +/// subclass for alarm predicate evaluation. Reads from +/// the engine's shared tag cache (driver + virtual tags), writes are rejected — +/// predicates must be side-effect free so their output doesn't depend on evaluation +/// order or drive cascade behavior. +/// +/// +/// Per Phase 7 plan Shape A decision, alarm scripts are one-script-per-alarm +/// returning bool. They read any tag they want but should not write +/// anything (the owning alarm's state is tracked by the engine, not the script). +/// +public sealed class AlarmPredicateContext : ScriptContext +{ + private readonly IReadOnlyDictionary _readCache; + private readonly Func _clock; + + public AlarmPredicateContext( + IReadOnlyDictionary readCache, + ILogger logger, + Func? clock = null) + { + _readCache = readCache ?? throw new ArgumentNullException(nameof(readCache)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _clock = clock ?? (() => DateTime.UtcNow); + } + + public override DataValueSnapshot GetTag(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return new DataValueSnapshot(null, 0x80340000u, null, _clock()); + return _readCache.TryGetValue(path, out var v) + ? v + : new DataValueSnapshot(null, 0x80340000u, null, _clock()); + } + + public override void SetVirtualTag(string path, object? value) + { + // Predicates must be pure — writing from an alarm script couples alarm state to + // virtual-tag state in a way that's near-impossible to reason about. Rejected + // at runtime with a clear message; operators see it in the scripts-*.log. + throw new InvalidOperationException( + "Alarm predicate scripts cannot write to virtual tags. Move the write logic " + + "into a virtual tag whose value the alarm predicate then reads."); + } + + public override DateTime Now => _clock(); + + public override ILogger Logger { get; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs new file mode 100644 index 0000000..fa545e4 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs @@ -0,0 +1,40 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +/// +/// The concrete OPC UA Part 9 alarm subtype a scripted alarm materializes as. The +/// engine's internal state machine is identical regardless of kind — the +/// AlarmKind only affects how the alarm node appears to OPC UA clients +/// (which ObjectType it maps to) and what diagnostic fields are populated. +/// +public enum AlarmKind +{ + /// Base AlarmConditionType — no numeric or discrete interpretation. + AlarmCondition, + /// LimitAlarmType — the condition reflects a numeric setpoint / threshold breach. + LimitAlarm, + /// DiscreteAlarmType — the condition reflects a specific discrete value match. + DiscreteAlarm, + /// OffNormalAlarmType — the condition reflects deviation from a configured "normal" state. + OffNormalAlarm, +} + +/// OPC UA Part 9 EnabledState — operator-controlled alarm enable/disable. +public enum AlarmEnabledState { Enabled, Disabled } + +/// OPC UA Part 9 ActiveState — reflects the current predicate truth. +public enum AlarmActiveState { Inactive, Active } + +/// OPC UA Part 9 AckedState — operator has acknowledged the active transition. +public enum AlarmAckedState { Unacknowledged, Acknowledged } + +/// OPC UA Part 9 ConfirmedState — operator has confirmed the clear transition. +public enum AlarmConfirmedState { Unconfirmed, Confirmed } + +/// +/// OPC UA Part 9 shelving mode. +/// suppresses the next active transition; once cleared +/// the shelving expires and the alarm returns to normal behavior. +/// suppresses until a configured expiry timestamp passes. +/// is the default state — no suppression. +/// +public enum ShelvingKind { Unshelved, OneShot, Timed } diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs new file mode 100644 index 0000000..bba047c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/IAlarmStateStore.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; + +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +/// +/// Persistence for across server restarts. Phase 7 +/// plan decision #14: operator-supplied state (EnabledState / AckedState / +/// ConfirmedState / ShelvingState + audit trail) persists; ActiveState is +/// recomputed from the live predicate on startup so operators never re-ack. +/// +/// +/// Stream E wires this to a SQL-backed store against the ScriptedAlarmState +/// table with audit logging through IAuditLogger. +/// Tests + local dev use . +/// +public interface IAlarmStateStore +{ + Task LoadAsync(string alarmId, CancellationToken ct); + Task> LoadAllAsync(CancellationToken ct); + Task SaveAsync(AlarmConditionState state, CancellationToken ct); + Task RemoveAsync(string alarmId, CancellationToken ct); +} + +/// In-memory default — used by tests + by dev deployments without a SQL backend. +public sealed class InMemoryAlarmStateStore : IAlarmStateStore +{ + private readonly ConcurrentDictionary _map + = new(StringComparer.Ordinal); + + public Task LoadAsync(string alarmId, CancellationToken ct) + => Task.FromResult(_map.TryGetValue(alarmId, out var v) ? v : null); + + public Task> LoadAllAsync(CancellationToken ct) + => Task.FromResult>(_map.Values.ToArray()); + + public Task SaveAsync(AlarmConditionState state, CancellationToken ct) + { + _map[state.AlarmId] = state; + return Task.CompletedTask; + } + + public Task RemoveAsync(string alarmId, CancellationToken ct) + { + _map.TryRemove(alarmId, out _); + return Task.CompletedTask; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs new file mode 100644 index 0000000..fff97a7 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs @@ -0,0 +1,64 @@ +using System.Text.RegularExpressions; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +/// +/// Per Phase 7 plan decision #13, alarm messages are static-with-substitution +/// templates. The engine resolves {TagPath} tokens at event emission time +/// against current tag values; unresolvable tokens become {?} so the event +/// still fires but the operator sees where the reference broke. +/// +/// +/// +/// Token syntax: {path/with/slashes}. Brace-stripped the contents must +/// match a path the caller's resolver function can look up. No escaping +/// currently — if you need literal braces in the message, reach for a feature +/// request. +/// +/// +/// Pure function. Same inputs always produce the same string. Tests verify the +/// edge cases (no tokens / one token / many / nested / unresolvable / bad +/// quality / null value). +/// +/// +public static class MessageTemplate +{ + private static readonly Regex TokenRegex = new(@"\{([^{}]+)\}", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// + /// Resolve every {path} token in using + /// . Tokens whose returned + /// has a non-Good or a null + /// resolve to {?}. + /// + public static string Resolve(string template, Func resolveTag) + { + if (string.IsNullOrEmpty(template)) return template ?? string.Empty; + if (resolveTag is null) throw new ArgumentNullException(nameof(resolveTag)); + + return TokenRegex.Replace(template, match => + { + var path = match.Groups[1].Value.Trim(); + if (path.Length == 0) return "{?}"; + var snap = resolveTag(path); + if (snap is null) return "{?}"; + if (snap.StatusCode != 0u) return "{?}"; + return snap.Value?.ToString() ?? "{?}"; + }); + } + + /// Enumerate the token paths the template references. Used at publish time to validate references exist. + public static IReadOnlyList ExtractTokenPaths(string? template) + { + if (string.IsNullOrEmpty(template)) return Array.Empty(); + var tokens = new List(); + foreach (Match m in TokenRegex.Matches(template)) + { + var path = m.Groups[1].Value.Trim(); + if (path.Length > 0) tokens.Add(path); + } + return tokens; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs new file mode 100644 index 0000000..b27abcb --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs @@ -0,0 +1,294 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +/// +/// Pure functions for OPC UA Part 9 alarm-condition state transitions. Input = the +/// current + the event; output = the new state + +/// optional emission hint. The engine calls these; persistence happens around them. +/// +/// +/// +/// No instance state, no I/O, no mutation of the input record. Every transition +/// returns a fresh record. Makes the state machine trivially unit-testable — +/// tests assert on (input, event) -> (output) without standing anything else up. +/// +/// +/// Two invariants the machine enforces: +/// (1) Disabled alarms never transition ActiveState / AckedState / ConfirmedState +/// — all predicate evaluations while disabled produce a no-op result and a +/// diagnostic log line. Re-enable restores normal flow with ActiveState +/// re-derived from the next predicate evaluation. +/// (2) Shelved alarms (OneShot / Timed) don't fire active transitions to +/// subscribers, but the state record still advances so that when shelving +/// expires the ActiveState reflects current reality. OneShot expires on the +/// next clear; Timed expires at . +/// +/// +public static class Part9StateMachine +{ + /// + /// Apply a predicate re-evaluation result. Handles activation, clearing, + /// branch-stack increment when a new active arrives while prior active is + /// still un-acked, and shelving suppression. + /// + public static TransitionResult ApplyPredicate( + AlarmConditionState current, + bool predicateTrue, + DateTime nowUtc) + { + if (current.Enabled == AlarmEnabledState.Disabled) + return TransitionResult.NoOp(current, "disabled — predicate result ignored"); + + // Expire timed shelving if the configured clock has passed. + var shelving = MaybeExpireShelving(current.Shelving, nowUtc); + var stateWithShelving = current with { Shelving = shelving }; + + // Shelved alarms still update state but skip event emission. + var shelved = shelving.Kind != ShelvingKind.Unshelved; + + if (predicateTrue && current.Active == AlarmActiveState.Inactive) + { + // Inactive -> Active transition. + // OneShotShelving is consumed on the NEXT clear, not activation — so we + // still suppress this transition's emission. + var next = stateWithShelving with + { + Active = AlarmActiveState.Active, + Acked = AlarmAckedState.Unacknowledged, + Confirmed = AlarmConfirmedState.Unconfirmed, + LastActiveUtc = nowUtc, + LastTransitionUtc = nowUtc, + }; + return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Activated); + } + + if (!predicateTrue && current.Active == AlarmActiveState.Active) + { + // Active -> Inactive transition. + var next = stateWithShelving with + { + Active = AlarmActiveState.Inactive, + LastClearedUtc = nowUtc, + LastTransitionUtc = nowUtc, + // OneShotShelving expires on clear — resetting here so the next + // activation fires normally. + Shelving = shelving.Kind == ShelvingKind.OneShot + ? ShelvingState.Unshelved + : shelving, + }; + return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Cleared); + } + + // Predicate matches current Active — no state change beyond possible shelving + // expiry. + return new TransitionResult(stateWithShelving, EmissionKind.None); + } + + /// Operator acknowledges the currently-active transition. + public static TransitionResult ApplyAcknowledge( + AlarmConditionState current, + string user, + string? comment, + DateTime nowUtc) + { + if (string.IsNullOrWhiteSpace(user)) + throw new ArgumentException("User identity required for audit.", nameof(user)); + + if (current.Acked == AlarmAckedState.Acknowledged) + return TransitionResult.NoOp(current, "already acknowledged"); + + var audit = AppendComment(current.Comments, nowUtc, user, "Acknowledge", comment); + var next = current with + { + Acked = AlarmAckedState.Acknowledged, + LastAckUtc = nowUtc, + LastAckUser = user, + LastAckComment = comment, + LastTransitionUtc = nowUtc, + Comments = audit, + }; + return new TransitionResult(next, EmissionKind.Acknowledged); + } + + /// Operator confirms the cleared transition. Part 9 requires confirm after clear for retain-flag alarms. + public static TransitionResult ApplyConfirm( + AlarmConditionState current, + string user, + string? comment, + DateTime nowUtc) + { + if (string.IsNullOrWhiteSpace(user)) + throw new ArgumentException("User identity required for audit.", nameof(user)); + + if (current.Confirmed == AlarmConfirmedState.Confirmed) + return TransitionResult.NoOp(current, "already confirmed"); + + var audit = AppendComment(current.Comments, nowUtc, user, "Confirm", comment); + var next = current with + { + Confirmed = AlarmConfirmedState.Confirmed, + LastConfirmUtc = nowUtc, + LastConfirmUser = user, + LastConfirmComment = comment, + LastTransitionUtc = nowUtc, + Comments = audit, + }; + return new TransitionResult(next, EmissionKind.Confirmed); + } + + public static TransitionResult ApplyOneShotShelve( + AlarmConditionState current, string user, DateTime nowUtc) + { + if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user)); + if (current.Shelving.Kind == ShelvingKind.OneShot) + return TransitionResult.NoOp(current, "already one-shot shelved"); + + var audit = AppendComment(current.Comments, nowUtc, user, "ShelveOneShot", null); + var next = current with + { + Shelving = new ShelvingState(ShelvingKind.OneShot, null), + LastTransitionUtc = nowUtc, + Comments = audit, + }; + return new TransitionResult(next, EmissionKind.Shelved); + } + + public static TransitionResult ApplyTimedShelve( + AlarmConditionState current, string user, DateTime unshelveAtUtc, DateTime nowUtc) + { + if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user)); + if (unshelveAtUtc <= nowUtc) + throw new ArgumentOutOfRangeException(nameof(unshelveAtUtc), "Unshelve time must be in the future."); + + var audit = AppendComment(current.Comments, nowUtc, user, "ShelveTimed", + $"UnshelveAtUtc={unshelveAtUtc:O}"); + var next = current with + { + Shelving = new ShelvingState(ShelvingKind.Timed, unshelveAtUtc), + LastTransitionUtc = nowUtc, + Comments = audit, + }; + return new TransitionResult(next, EmissionKind.Shelved); + } + + public static TransitionResult ApplyUnshelve(AlarmConditionState current, string user, DateTime nowUtc) + { + if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user)); + if (current.Shelving.Kind == ShelvingKind.Unshelved) + return TransitionResult.NoOp(current, "not shelved"); + + var audit = AppendComment(current.Comments, nowUtc, user, "Unshelve", null); + var next = current with + { + Shelving = ShelvingState.Unshelved, + LastTransitionUtc = nowUtc, + Comments = audit, + }; + return new TransitionResult(next, EmissionKind.Unshelved); + } + + public static TransitionResult ApplyEnable(AlarmConditionState current, string user, DateTime nowUtc) + { + if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user)); + if (current.Enabled == AlarmEnabledState.Enabled) + return TransitionResult.NoOp(current, "already enabled"); + + var audit = AppendComment(current.Comments, nowUtc, user, "Enable", null); + var next = current with + { + Enabled = AlarmEnabledState.Enabled, + LastTransitionUtc = nowUtc, + Comments = audit, + }; + return new TransitionResult(next, EmissionKind.Enabled); + } + + public static TransitionResult ApplyDisable(AlarmConditionState current, string user, DateTime nowUtc) + { + if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user)); + if (current.Enabled == AlarmEnabledState.Disabled) + return TransitionResult.NoOp(current, "already disabled"); + + var audit = AppendComment(current.Comments, nowUtc, user, "Disable", null); + var next = current with + { + Enabled = AlarmEnabledState.Disabled, + LastTransitionUtc = nowUtc, + Comments = audit, + }; + return new TransitionResult(next, EmissionKind.Disabled); + } + + public static TransitionResult ApplyAddComment( + AlarmConditionState current, string user, string text, DateTime nowUtc) + { + if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user)); + if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Comment text required.", nameof(text)); + + var audit = AppendComment(current.Comments, nowUtc, user, "AddComment", text); + var next = current with { Comments = audit }; + return new TransitionResult(next, EmissionKind.CommentAdded); + } + + /// + /// Re-evaluate whether a currently timed-shelved alarm has expired. Returns + /// the (possibly unshelved) state + emission hint so the engine knows to + /// publish an Unshelved event at the right moment. + /// + public static TransitionResult ApplyShelvingCheck(AlarmConditionState current, DateTime nowUtc) + { + if (current.Shelving.Kind != ShelvingKind.Timed) return TransitionResult.None(current); + if (current.Shelving.UnshelveAtUtc is DateTime t && nowUtc >= t) + { + var audit = AppendComment(current.Comments, nowUtc, "system", "AutoUnshelve", + $"Timed shelving expired at {nowUtc:O}"); + var next = current with + { + Shelving = ShelvingState.Unshelved, + LastTransitionUtc = nowUtc, + Comments = audit, + }; + return new TransitionResult(next, EmissionKind.Unshelved); + } + return TransitionResult.None(current); + } + + private static ShelvingState MaybeExpireShelving(ShelvingState s, DateTime nowUtc) + { + if (s.Kind != ShelvingKind.Timed) return s; + return s.UnshelveAtUtc is DateTime t && nowUtc >= t ? ShelvingState.Unshelved : s; + } + + private static IReadOnlyList AppendComment( + IReadOnlyList existing, DateTime ts, string user, string kind, string? text) + { + var list = new List(existing.Count + 1); + list.AddRange(existing); + list.Add(new AlarmComment(ts, user, kind, text ?? string.Empty)); + return list; + } +} + +/// Result of a state-machine operation — new state + what to emit (if anything). +public sealed record TransitionResult(AlarmConditionState State, EmissionKind Emission) +{ + public static TransitionResult None(AlarmConditionState state) => new(state, EmissionKind.None); + public static TransitionResult NoOp(AlarmConditionState state, string reason) => new(state, EmissionKind.None); +} + +/// What kind of event, if any, the engine should emit after a transition. +public enum EmissionKind +{ + /// State did not change meaningfully — no event to emit. + None, + /// Predicate transitioned to true while shelving was suppressing events. + Suppressed, + Activated, + Cleared, + Acknowledged, + Confirmed, + Shelved, + Unshelved, + Enabled, + Disabled, + CommentAdded, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs new file mode 100644 index 0000000..05f10e7 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmDefinition.cs @@ -0,0 +1,50 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +/// +/// Operator-authored scripted-alarm configuration. Phase 7 Stream E (config DB schema) +/// materializes these from the ScriptedAlarm + Script tables on publish. +/// +/// +/// Stable identity for the alarm — used as the OPC UA ConditionId + the key in the +/// state store. Should be globally unique within the cluster; convention is +/// {EquipmentPath}::{AlarmName}. +/// +/// +/// UNS path of the Equipment node the alarm hangs under. Alarm browse lives here; +/// ACL binding inherits this equipment's scope per Phase 6.2. +/// +/// Human-readable alarm name — used in the browse tree + Admin UI. +/// Concrete OPC UA Part 9 subtype the alarm materializes as. +/// Static severity per Phase 7 plan decision #13; not currently computed by the predicate. +/// +/// Message text with {TagPath} tokens resolved at event-emission time per +/// Phase 7 plan decision #13. Unresolvable tokens emit {?} + a structured +/// error so operators can spot stale references. +/// +/// +/// Roslyn C# script returning bool. true = alarm condition currently holds (active); +/// false = condition has cleared. Same sandbox rules as virtual tags per Phase 7 decision #6. +/// +/// +/// When true, every transition emission of this alarm flows to the Historian alarm +/// sink (Stream D). Defaults to true — plant alarm history is usually the +/// operator's primary diagnostic. Galaxy-native alarms default false since Galaxy +/// historises them directly. +/// +/// +/// Part 9 retain flag — when true, the condition node remains visible after the +/// predicate clears as long as it has un-acknowledged or un-confirmed transitions. +/// Default true. +/// +public sealed record ScriptedAlarmDefinition( + string AlarmId, + string EquipmentPath, + string AlarmName, + AlarmKind Kind, + AlarmSeverity Severity, + string MessageTemplate, + string PredicateScriptSource, + bool HistorizeToAveva = true, + bool Retain = true); diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs new file mode 100644 index 0000000..13e0d06 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs @@ -0,0 +1,429 @@ +using System.Collections.Concurrent; +using Serilog; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +/// +/// Phase 7 scripted-alarm orchestrator. Compiles every configured alarm's predicate +/// against the Stream A sandbox, subscribes to the referenced upstream tags, +/// re-evaluates the predicate on every input change + on a shelving-check timer, +/// applies the resulting transition through , +/// persists state via , and emits the resulting events +/// through (which wires into the existing +/// IAlarmSource fan-out). +/// +/// +/// +/// Scripted alarms are leaves in the evaluation DAG — no alarm's state drives +/// another alarm's predicate. The engine maintains only an inverse index from +/// upstream tag path → alarms referencing it; no topological sort needed +/// (unlike the virtual-tag engine). +/// +/// +/// Evaluation errors (script throws, timeout, coercion fail) surface as +/// structured errors in the dedicated scripts-*.log sink plus a WARN companion +/// in the main log. The alarm's ActiveState stays at its prior value — the +/// engine does NOT invent a clear transition just because the predicate broke. +/// Operators investigating a broken predicate shouldn't see a phantom +/// clear-event preceding the failure. +/// +/// +public sealed class ScriptedAlarmEngine : IDisposable +{ + private readonly ITagUpstreamSource _upstream; + private readonly IAlarmStateStore _store; + private readonly ScriptLoggerFactory _loggerFactory; + private readonly ILogger _engineLogger; + private readonly Func _clock; + private readonly TimeSpan _scriptTimeout; + + private readonly Dictionary _alarms = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _valueCache + = new(StringComparer.Ordinal); + private readonly Dictionary> _alarmsReferencing + = new(StringComparer.Ordinal); // tag path -> alarm ids + + private readonly List _upstreamSubscriptions = []; + private readonly SemaphoreSlim _evalGate = new(1, 1); + private Timer? _shelvingTimer; + private bool _loaded; + private bool _disposed; + + public ScriptedAlarmEngine( + ITagUpstreamSource upstream, + IAlarmStateStore store, + ScriptLoggerFactory loggerFactory, + ILogger engineLogger, + Func? clock = null, + TimeSpan? scriptTimeout = null) + { + _upstream = upstream ?? throw new ArgumentNullException(nameof(upstream)); + _store = store ?? throw new ArgumentNullException(nameof(store)); + _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + _engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger)); + _clock = clock ?? (() => DateTime.UtcNow); + _scriptTimeout = scriptTimeout ?? TimedScriptEvaluator.DefaultTimeout; + } + + /// Raised for every emission the Part9StateMachine produces that the engine should publish. + public event EventHandler? OnEvent; + + public IReadOnlyCollection LoadedAlarmIds => _alarms.Keys; + + /// + /// Load a batch of alarm definitions. Compiles every predicate, aggregates any + /// compile failures into one , subscribes + /// to upstream input tags, seeds the value cache, loads persisted state from + /// the store (falling back to Fresh for first-load alarms), and recomputes + /// ActiveState per Phase 7 plan decision #14 (startup recovery). + /// + public async Task LoadAsync(IReadOnlyList definitions, CancellationToken ct) + { + if (_disposed) throw new ObjectDisposedException(nameof(ScriptedAlarmEngine)); + if (definitions is null) throw new ArgumentNullException(nameof(definitions)); + + await _evalGate.WaitAsync(ct).ConfigureAwait(false); + try + { + UnsubscribeFromUpstream(); + _alarms.Clear(); + _alarmsReferencing.Clear(); + + var compileFailures = new List(); + foreach (var def in definitions) + { + try + { + var extraction = DependencyExtractor.Extract(def.PredicateScriptSource); + if (!extraction.IsValid) + { + var joined = string.Join("; ", extraction.Rejections.Select(r => r.Message)); + compileFailures.Add($"{def.AlarmId}: dependency extraction rejected — {joined}"); + continue; + } + + var evaluator = ScriptEvaluator.Compile(def.PredicateScriptSource); + var timed = new TimedScriptEvaluator(evaluator, _scriptTimeout); + var logger = _loggerFactory.Create(def.AlarmId); + + var templateTokens = MessageTemplate.ExtractTokenPaths(def.MessageTemplate); + var allInputs = new HashSet(extraction.Reads, StringComparer.Ordinal); + foreach (var t in templateTokens) allInputs.Add(t); + + _alarms[def.AlarmId] = new AlarmState(def, timed, extraction.Reads, templateTokens, logger, + AlarmConditionState.Fresh(def.AlarmId, _clock())); + + foreach (var path in allInputs) + { + if (!_alarmsReferencing.TryGetValue(path, out var set)) + _alarmsReferencing[path] = set = new HashSet(StringComparer.Ordinal); + set.Add(def.AlarmId); + } + } + catch (Exception ex) + { + compileFailures.Add($"{def.AlarmId}: {ex.Message}"); + } + } + + if (compileFailures.Count > 0) + { + throw new InvalidOperationException( + $"ScriptedAlarmEngine load failed. {compileFailures.Count} alarm(s) did not compile:\n " + + string.Join("\n ", compileFailures)); + } + + // Seed the value cache with current upstream values + subscribe for changes. + foreach (var path in _alarmsReferencing.Keys) + { + _valueCache[path] = _upstream.ReadTag(path); + _upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange)); + } + + // Restore persisted state, falling back to Fresh where nothing was saved, + // then re-derive ActiveState from the current predicate per decision #14. + foreach (var (alarmId, state) in _alarms) + { + var persisted = await _store.LoadAsync(alarmId, ct).ConfigureAwait(false); + var seed = persisted ?? state.Condition; + var afterPredicate = await EvaluatePredicateToStateAsync(state, seed, nowUtc: _clock(), ct) + .ConfigureAwait(false); + _alarms[alarmId] = state with { Condition = afterPredicate }; + await _store.SaveAsync(afterPredicate, ct).ConfigureAwait(false); + } + + _loaded = true; + _engineLogger.Information("ScriptedAlarmEngine loaded {Count} alarm(s)", _alarms.Count); + + // Start the shelving-check timer — ticks every 5s, expires any timed shelves + // that have passed their UnshelveAtUtc. + _shelvingTimer = new Timer(_ => RunShelvingCheck(), + null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); + } + finally + { + _evalGate.Release(); + } + } + + /// + /// Current persisted state for . Returns null for + /// unknown alarm. Mainly used for diagnostics + the Admin UI status page. + /// + public AlarmConditionState? GetState(string alarmId) + => _alarms.TryGetValue(alarmId, out var s) ? s.Condition : null; + + public IReadOnlyCollection GetAllStates() + => _alarms.Values.Select(a => a.Condition).ToArray(); + + public Task AcknowledgeAsync(string alarmId, string user, string? comment, CancellationToken ct) + => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAcknowledge(cur, user, comment, _clock())); + + public Task ConfirmAsync(string alarmId, string user, string? comment, CancellationToken ct) + => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyConfirm(cur, user, comment, _clock())); + + public Task OneShotShelveAsync(string alarmId, string user, CancellationToken ct) + => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyOneShotShelve(cur, user, _clock())); + + public Task TimedShelveAsync(string alarmId, string user, DateTime unshelveAtUtc, CancellationToken ct) + => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyTimedShelve(cur, user, unshelveAtUtc, _clock())); + + public Task UnshelveAsync(string alarmId, string user, CancellationToken ct) + => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyUnshelve(cur, user, _clock())); + + public Task EnableAsync(string alarmId, string user, CancellationToken ct) + => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyEnable(cur, user, _clock())); + + public Task DisableAsync(string alarmId, string user, CancellationToken ct) + => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyDisable(cur, user, _clock())); + + public Task AddCommentAsync(string alarmId, string user, string text, CancellationToken ct) + => ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAddComment(cur, user, text, _clock())); + + private async Task ApplyAsync(string alarmId, CancellationToken ct, Func op) + { + EnsureLoaded(); + if (!_alarms.TryGetValue(alarmId, out var state)) + throw new ArgumentException($"Unknown alarm {alarmId}", nameof(alarmId)); + + await _evalGate.WaitAsync(ct).ConfigureAwait(false); + try + { + var result = op(state.Condition); + _alarms[alarmId] = state with { Condition = result.State }; + await _store.SaveAsync(result.State, ct).ConfigureAwait(false); + if (result.Emission != EmissionKind.None) EmitEvent(state, result.State, result.Emission); + } + finally { _evalGate.Release(); } + } + + /// + /// Upstream-change callback. Updates the value cache + enqueues predicate + /// re-evaluation for every alarm referencing the changed path. Fire-and-forget + /// so driver-side dispatch isn't blocked. + /// + internal void OnUpstreamChange(string path, DataValueSnapshot value) + { + _valueCache[path] = value; + if (_alarmsReferencing.TryGetValue(path, out var alarmIds)) + { + _ = ReevaluateAsync(alarmIds.ToArray(), CancellationToken.None); + } + } + + private async Task ReevaluateAsync(IReadOnlyList alarmIds, CancellationToken ct) + { + try + { + await _evalGate.WaitAsync(ct).ConfigureAwait(false); + try + { + foreach (var id in alarmIds) + { + if (!_alarms.TryGetValue(id, out var state)) continue; + var newState = await EvaluatePredicateToStateAsync( + state, state.Condition, _clock(), ct).ConfigureAwait(false); + if (!ReferenceEquals(newState, state.Condition)) + { + _alarms[id] = state with { Condition = newState }; + await _store.SaveAsync(newState, ct).ConfigureAwait(false); + } + } + } + finally { _evalGate.Release(); } + } + catch (Exception ex) + { + _engineLogger.Error(ex, "ScriptedAlarmEngine reevaluate failed"); + } + } + + /// + /// Evaluate the predicate + apply the resulting state-machine transition. + /// Returns the new condition state. Emits the appropriate event if the + /// transition produces one. + /// + private async Task EvaluatePredicateToStateAsync( + AlarmState state, AlarmConditionState seed, DateTime nowUtc, CancellationToken ct) + { + var inputs = BuildReadCache(state.Inputs); + var context = new AlarmPredicateContext(inputs, state.Logger, _clock); + + bool predicateTrue; + try + { + predicateTrue = await state.Evaluator.RunAsync(context, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (ScriptTimeoutException tex) + { + state.Logger.Warning("Alarm predicate timed out after {Timeout} — state unchanged", tex.Timeout); + return seed; + } + catch (Exception ex) + { + state.Logger.Error(ex, "Alarm predicate threw — state unchanged"); + return seed; + } + + var result = Part9StateMachine.ApplyPredicate(seed, predicateTrue, nowUtc); + if (result.Emission != EmissionKind.None) + EmitEvent(state, result.State, result.Emission); + return result.State; + } + + private IReadOnlyDictionary BuildReadCache(IReadOnlySet inputs) + { + var d = new Dictionary(StringComparer.Ordinal); + foreach (var p in inputs) + d[p] = _valueCache.TryGetValue(p, out var v) ? v : _upstream.ReadTag(p); + return d; + } + + private void EmitEvent(AlarmState state, AlarmConditionState condition, EmissionKind kind) + { + // Suppressed kind means shelving ate the emission — we don't fire for subscribers + // but the state record still advanced so startup recovery reflects reality. + if (kind == EmissionKind.Suppressed || kind == EmissionKind.None) return; + + var message = MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup); + var evt = new ScriptedAlarmEvent( + AlarmId: state.Definition.AlarmId, + EquipmentPath: state.Definition.EquipmentPath, + AlarmName: state.Definition.AlarmName, + Kind: state.Definition.Kind, + Severity: state.Definition.Severity, + Message: message, + Condition: condition, + Emission: kind, + TimestampUtc: _clock()); + try { OnEvent?.Invoke(this, evt); } + catch (Exception ex) + { + _engineLogger.Warning(ex, "ScriptedAlarmEngine OnEvent subscriber threw for {AlarmId}", state.Definition.AlarmId); + } + } + + private DataValueSnapshot? TryLookup(string path) + => _valueCache.TryGetValue(path, out var v) ? v : null; + + private void RunShelvingCheck() + { + if (_disposed) return; + var ids = _alarms.Keys.ToArray(); + _ = ShelvingCheckAsync(ids, CancellationToken.None); + } + + private async Task ShelvingCheckAsync(IReadOnlyList alarmIds, CancellationToken ct) + { + try + { + await _evalGate.WaitAsync(ct).ConfigureAwait(false); + try + { + var now = _clock(); + foreach (var id in alarmIds) + { + if (!_alarms.TryGetValue(id, out var state)) continue; + var result = Part9StateMachine.ApplyShelvingCheck(state.Condition, now); + if (!ReferenceEquals(result.State, state.Condition)) + { + _alarms[id] = state with { Condition = result.State }; + await _store.SaveAsync(result.State, ct).ConfigureAwait(false); + if (result.Emission != EmissionKind.None) + EmitEvent(state, result.State, result.Emission); + } + } + } + finally { _evalGate.Release(); } + } + catch (Exception ex) + { + _engineLogger.Warning(ex, "ScriptedAlarmEngine shelving-check failed"); + } + } + + private void UnsubscribeFromUpstream() + { + foreach (var s in _upstreamSubscriptions) + { + try { s.Dispose(); } catch { } + } + _upstreamSubscriptions.Clear(); + } + + private void EnsureLoaded() + { + if (!_loaded) throw new InvalidOperationException( + "ScriptedAlarmEngine not loaded. Call LoadAsync first."); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _shelvingTimer?.Dispose(); + UnsubscribeFromUpstream(); + _alarms.Clear(); + _alarmsReferencing.Clear(); + } + + private sealed record AlarmState( + ScriptedAlarmDefinition Definition, + TimedScriptEvaluator Evaluator, + IReadOnlySet Inputs, + IReadOnlyList TemplateTokens, + ILogger Logger, + AlarmConditionState Condition); +} + +/// +/// One alarm emission the engine pushed to subscribers. Carries everything +/// downstream consumers (OPC UA alarm-source adapter + historian sink) need to +/// publish the event without re-querying the engine. +/// +public sealed record ScriptedAlarmEvent( + string AlarmId, + string EquipmentPath, + string AlarmName, + AlarmKind Kind, + AlarmSeverity Severity, + string Message, + AlarmConditionState Condition, + EmissionKind Emission, + DateTime TimestampUtc); + +/// +/// Upstream source abstraction — intentionally identical shape to the virtual-tag +/// engine's so Stream G can compose them behind one driver bridge. +/// +public interface ITagUpstreamSource +{ + DataValueSnapshot ReadTag(string path); + IDisposable SubscribeTag(string path, Action observer); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs new file mode 100644 index 0000000..080ca56 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs @@ -0,0 +1,122 @@ +using System.Collections.Concurrent; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +/// +/// Adapter that exposes through the driver-agnostic +/// surface. The existing Phase 6.1 AlarmTracker +/// composition fan-out consumes this alongside Galaxy / AB CIP / FOCAS alarm +/// sources — no per-source branching in the fan-out. +/// +/// +/// +/// Per Phase 7 plan Stream C.6, ack / confirm / shelve / unshelve are OPC UA +/// method calls per-condition. This adapter implements +/// from the base interface; the richer Part 9 methods (Confirm / Shelve / +/// Unshelve / AddComment) live directly on the engine, invoked from OPC UA +/// method handlers wired up in Stream G. +/// +/// +/// SubscribeAlarmsAsync takes a list of source-node-id filters (typically an +/// Equipment path prefix). When the list is empty every alarm matches. The +/// adapter doesn't maintain per-subscription state beyond the filter set — it +/// checks each emission against every live subscription. +/// +/// +public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable +{ + private readonly ScriptedAlarmEngine _engine; + private readonly ConcurrentDictionary _subscriptions + = new(StringComparer.Ordinal); + private bool _disposed; + + public ScriptedAlarmSource(ScriptedAlarmEngine engine) + { + _engine = engine ?? throw new ArgumentNullException(nameof(engine)); + _engine.OnEvent += OnEngineEvent; + } + + public event EventHandler? OnAlarmEvent; + + public Task SubscribeAlarmsAsync( + IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) + { + if (sourceNodeIds is null) throw new ArgumentNullException(nameof(sourceNodeIds)); + var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N")); + _subscriptions[handle.DiagnosticId] = new Subscription(handle, + new HashSet(sourceNodeIds, StringComparer.Ordinal)); + return Task.FromResult(handle); + } + + public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) + { + if (handle is null) throw new ArgumentNullException(nameof(handle)); + _subscriptions.TryRemove(handle.DiagnosticId, out _); + return Task.CompletedTask; + } + + public async Task AcknowledgeAsync( + IReadOnlyList acknowledgements, CancellationToken cancellationToken) + { + if (acknowledgements is null) throw new ArgumentNullException(nameof(acknowledgements)); + foreach (var a in acknowledgements) + { + // The base interface doesn't carry a user identity — Stream G provides the + // authenticated principal at the OPC UA dispatch layer + proxies through + // the engine's richer AcknowledgeAsync. Here we default to "opcua-client" + // so callers using the raw IAlarmSource still produce an audit entry. + await _engine.AcknowledgeAsync(a.ConditionId, "opcua-client", a.Comment, cancellationToken) + .ConfigureAwait(false); + } + } + + private void OnEngineEvent(object? sender, ScriptedAlarmEvent evt) + { + if (_disposed) return; + + foreach (var sub in _subscriptions.Values) + { + if (!Matches(sub, evt)) continue; + var payload = new AlarmEventArgs( + SubscriptionHandle: sub.Handle, + SourceNodeId: evt.EquipmentPath, + ConditionId: evt.AlarmId, + AlarmType: evt.Kind.ToString(), + Message: evt.Message, + Severity: evt.Severity, + SourceTimestampUtc: evt.TimestampUtc); + try { OnAlarmEvent?.Invoke(this, payload); } + catch { /* subscriber exceptions don't crash the adapter */ } + } + } + + private static bool Matches(Subscription sub, ScriptedAlarmEvent evt) + { + if (sub.Filter.Count == 0) return true; + // A subscription matches if any filter is a prefix of the alarm's equipment + // path — typical use is "Enterprise/Site/Area/Line" filtering a whole line. + foreach (var f in sub.Filter) + { + if (evt.EquipmentPath.Equals(f, StringComparison.Ordinal)) return true; + if (evt.EquipmentPath.StartsWith(f + "/", StringComparison.Ordinal)) return true; + } + return false; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _engine.OnEvent -= OnEngineEvent; + _subscriptions.Clear(); + } + + private sealed class SubscriptionHandle : IAlarmSubscriptionHandle + { + public SubscriptionHandle(string id) { DiagnosticId = id; } + public string DiagnosticId { get; } + } + + private sealed record Subscription(SubscriptionHandle Handle, IReadOnlySet Filter); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj new file mode 100644 index 0000000..9196811 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs new file mode 100644 index 0000000..9182cf2 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/FakeUpstream.cs @@ -0,0 +1,61 @@ +using System.Collections.Concurrent; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests; + +public sealed class FakeUpstream : ITagUpstreamSource +{ + private readonly ConcurrentDictionary _values = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary>> _subs + = new(StringComparer.Ordinal); + public int ActiveSubscriptionCount { get; private set; } + + public void Set(string path, object? value, uint statusCode = 0u) + { + var now = DateTime.UtcNow; + _values[path] = new DataValueSnapshot(value, statusCode, now, now); + } + + public void Push(string path, object? value, uint statusCode = 0u) + { + Set(path, value, statusCode); + if (_subs.TryGetValue(path, out var list)) + { + Action[] snap; + lock (list) { snap = list.ToArray(); } + foreach (var obs in snap) obs(path, _values[path]); + } + } + + public DataValueSnapshot ReadTag(string path) + => _values.TryGetValue(path, out var v) ? v + : new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow); + + public IDisposable SubscribeTag(string path, Action observer) + { + var list = _subs.GetOrAdd(path, _ => []); + lock (list) { list.Add(observer); } + ActiveSubscriptionCount++; + return new Unsub(this, path, observer); + } + + private sealed class Unsub : IDisposable + { + private readonly FakeUpstream _up; + private readonly string _path; + private readonly Action _observer; + public Unsub(FakeUpstream up, string path, Action observer) + { _up = up; _path = path; _observer = observer; } + public void Dispose() + { + if (_up._subs.TryGetValue(_path, out var list)) + { + lock (list) + { + if (list.Remove(_observer)) _up.ActiveSubscriptionCount--; + } + } + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/MessageTemplateTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/MessageTemplateTests.cs new file mode 100644 index 0000000..126ef6a --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/MessageTemplateTests.cs @@ -0,0 +1,107 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests; + +[Trait("Category", "Unit")] +public sealed class MessageTemplateTests +{ + private static DataValueSnapshot Good(object? v) => + new(v, 0u, DateTime.UtcNow, DateTime.UtcNow); + private static DataValueSnapshot Bad() => + new(null, 0x80050000u, null, DateTime.UtcNow); + + private static DataValueSnapshot? Resolver(Dictionary map, string path) + => map.TryGetValue(path, out var v) ? v : null; + + [Fact] + public void No_tokens_returns_template_unchanged() + { + MessageTemplate.Resolve("No tokens here", _ => null).ShouldBe("No tokens here"); + } + + [Fact] + public void Single_token_substituted() + { + var map = new Dictionary { ["Tank/Temp"] = Good(75.5) }; + MessageTemplate.Resolve("Temp={Tank/Temp}C", p => Resolver(map, p)).ShouldBe("Temp=75.5C"); + } + + [Fact] + public void Multiple_tokens_substituted() + { + var map = new Dictionary + { + ["A"] = Good(10), + ["B"] = Good("on"), + }; + MessageTemplate.Resolve("{A}/{B}", p => Resolver(map, p)).ShouldBe("10/on"); + } + + [Fact] + public void Bad_quality_token_becomes_question_mark() + { + var map = new Dictionary { ["Bad"] = Bad() }; + MessageTemplate.Resolve("value={Bad}", p => Resolver(map, p)).ShouldBe("value={?}"); + } + + [Fact] + public void Unknown_path_becomes_question_mark() + { + MessageTemplate.Resolve("value={DoesNotExist}", _ => null).ShouldBe("value={?}"); + } + + [Fact] + public void Null_value_with_good_quality_becomes_question_mark() + { + var map = new Dictionary { ["X"] = Good(null) }; + MessageTemplate.Resolve("{X}", p => Resolver(map, p)).ShouldBe("{?}"); + } + + [Fact] + public void Tokens_with_slashes_and_dots_resolved() + { + var map = new Dictionary + { + ["Line1/Pump.Speed"] = Good(1200), + }; + MessageTemplate.Resolve("rpm={Line1/Pump.Speed}", p => Resolver(map, p)) + .ShouldBe("rpm=1200"); + } + + [Fact] + public void Empty_template_returns_empty() + { + MessageTemplate.Resolve("", _ => null).ShouldBe(""); + } + + [Fact] + public void Null_template_returns_empty_without_throwing() + { + MessageTemplate.Resolve(null!, _ => null).ShouldBe(""); + } + + [Fact] + public void ExtractTokenPaths_returns_every_distinct_token() + { + var tokens = MessageTemplate.ExtractTokenPaths("{A}/{B}/{A}/{C}"); + tokens.ShouldBe(new[] { "A", "B", "A", "C" }); + } + + [Fact] + public void ExtractTokenPaths_empty_for_tokenless_template() + { + MessageTemplate.ExtractTokenPaths("No tokens").ShouldBeEmpty(); + MessageTemplate.ExtractTokenPaths("").ShouldBeEmpty(); + MessageTemplate.ExtractTokenPaths(null).ShouldBeEmpty(); + } + + [Fact] + public void Whitespace_inside_token_is_trimmed() + { + var map = new Dictionary { ["A"] = Good(42) }; + MessageTemplate.Resolve("{ A }", p => Resolver(map, p)).ShouldBe("42"); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/Part9StateMachineTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/Part9StateMachineTests.cs new file mode 100644 index 0000000..debe808 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/Part9StateMachineTests.cs @@ -0,0 +1,205 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests; + +/// +/// Pure state-machine tests — no engine, no I/O, no async. Every transition rule +/// from Phase 7 plan Stream C.2 / C.3 has at least one locking test so regressions +/// surface as clear failures rather than subtle alarm-behavior drift. +/// +[Trait("Category", "Unit")] +public sealed class Part9StateMachineTests +{ + private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + private static AlarmConditionState Fresh() => AlarmConditionState.Fresh("alarm-1", T0); + + [Fact] + public void Predicate_true_on_inactive_becomes_active_and_emits_Activated() + { + var r = Part9StateMachine.ApplyPredicate(Fresh(), predicateTrue: true, T0.AddSeconds(1)); + r.State.Active.ShouldBe(AlarmActiveState.Active); + r.State.Acked.ShouldBe(AlarmAckedState.Unacknowledged); + r.State.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed); + r.Emission.ShouldBe(EmissionKind.Activated); + r.State.LastActiveUtc.ShouldNotBeNull(); + } + + [Fact] + public void Predicate_false_on_active_becomes_inactive_and_emits_Cleared() + { + var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State; + var r = Part9StateMachine.ApplyPredicate(active, false, T0.AddSeconds(2)); + r.State.Active.ShouldBe(AlarmActiveState.Inactive); + r.Emission.ShouldBe(EmissionKind.Cleared); + r.State.LastClearedUtc.ShouldNotBeNull(); + } + + [Fact] + public void Predicate_unchanged_state_emits_None() + { + var r = Part9StateMachine.ApplyPredicate(Fresh(), false, T0); + r.Emission.ShouldBe(EmissionKind.None); + } + + [Fact] + public void Disabled_alarm_ignores_predicate() + { + var disabled = Part9StateMachine.ApplyDisable(Fresh(), "op1", T0.AddSeconds(1)).State; + var r = Part9StateMachine.ApplyPredicate(disabled, true, T0.AddSeconds(2)); + r.State.Active.ShouldBe(AlarmActiveState.Inactive); + r.Emission.ShouldBe(EmissionKind.None); + } + + [Fact] + public void Acknowledge_from_unacked_records_user_and_emits() + { + var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State; + var r = Part9StateMachine.ApplyAcknowledge(active, "alice", "looking into it", T0.AddSeconds(2)); + r.State.Acked.ShouldBe(AlarmAckedState.Acknowledged); + r.State.LastAckUser.ShouldBe("alice"); + r.State.LastAckComment.ShouldBe("looking into it"); + r.State.Comments.Count.ShouldBe(1); + r.Emission.ShouldBe(EmissionKind.Acknowledged); + } + + [Fact] + public void Acknowledge_when_already_acked_is_noop() + { + var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State; + var acked = Part9StateMachine.ApplyAcknowledge(active, "alice", null, T0.AddSeconds(2)).State; + var r = Part9StateMachine.ApplyAcknowledge(acked, "alice", null, T0.AddSeconds(3)); + r.Emission.ShouldBe(EmissionKind.None); + } + + [Fact] + public void Acknowledge_without_user_throws() + { + Should.Throw(() => + Part9StateMachine.ApplyAcknowledge(Fresh(), "", null, T0)); + } + + [Fact] + public void Confirm_after_clear_records_user_and_emits() + { + // Walk: activate -> ack -> clear -> confirm + var s = Fresh(); + s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1)).State; + s = Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2)).State; + s = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3)).State; + + var r = Part9StateMachine.ApplyConfirm(s, "bob", "resolved", T0.AddSeconds(4)); + r.State.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed); + r.State.LastConfirmUser.ShouldBe("bob"); + r.Emission.ShouldBe(EmissionKind.Confirmed); + } + + [Fact] + public void OneShotShelve_suppresses_next_activation_emission() + { + var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0.AddSeconds(1)).State; + var r = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2)); + r.State.Active.ShouldBe(AlarmActiveState.Active, "state still advances"); + r.Emission.ShouldBe(EmissionKind.Suppressed, "but subscribers don't see it"); + } + + [Fact] + public void OneShotShelve_expires_on_clear() + { + var s = Fresh(); + s = Part9StateMachine.ApplyOneShotShelve(s, "alice", T0.AddSeconds(1)).State; + s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2)).State; + var r = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3)); + r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved, "OneShot expires on clear"); + } + + [Fact] + public void TimedShelve_requires_future_unshelve_time() + { + Should.Throw(() => + Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", T0, T0.AddSeconds(5))); + } + + [Fact] + public void TimedShelve_expires_via_shelving_check() + { + var until = T0.AddMinutes(5); + var shelved = Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", until, T0).State; + shelved.Shelving.Kind.ShouldBe(ShelvingKind.Timed); + + // Before expiry — still shelved. + var earlier = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(3)); + earlier.State.Shelving.Kind.ShouldBe(ShelvingKind.Timed); + earlier.Emission.ShouldBe(EmissionKind.None); + + // After expiry — auto-unshelved + emission. + var after = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(6)); + after.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved); + after.Emission.ShouldBe(EmissionKind.Unshelved); + after.State.Comments.Any(c => c.Kind == "AutoUnshelve").ShouldBeTrue(); + } + + [Fact] + public void Unshelve_from_unshelved_is_noop() + { + var r = Part9StateMachine.ApplyUnshelve(Fresh(), "alice", T0); + r.Emission.ShouldBe(EmissionKind.None); + } + + [Fact] + public void Explicit_Unshelve_emits_event() + { + var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0).State; + var r = Part9StateMachine.ApplyUnshelve(s, "bob", T0.AddSeconds(30)); + r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved); + r.Emission.ShouldBe(EmissionKind.Unshelved); + } + + [Fact] + public void AddComment_appends_to_audit_trail_with_event() + { + var r = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "investigating", T0.AddSeconds(5)); + r.State.Comments.Count.ShouldBe(1); + r.State.Comments[0].Kind.ShouldBe("AddComment"); + r.State.Comments[0].User.ShouldBe("alice"); + r.State.Comments[0].Text.ShouldBe("investigating"); + r.Emission.ShouldBe(EmissionKind.CommentAdded); + } + + [Fact] + public void Comments_are_append_only_never_rewritten() + { + var s = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "first", T0.AddSeconds(1)).State; + s = Part9StateMachine.ApplyAddComment(s, "bob", "second", T0.AddSeconds(2)).State; + s = Part9StateMachine.ApplyAddComment(s, "carol", "third", T0.AddSeconds(3)).State; + s.Comments.Count.ShouldBe(3); + s.Comments[0].User.ShouldBe("alice"); + s.Comments[1].User.ShouldBe("bob"); + s.Comments[2].User.ShouldBe("carol"); + } + + [Fact] + public void Full_lifecycle_walk_produces_every_expected_emission() + { + // Walk a condition through its whole lifecycle and make sure emissions line up. + var emissions = new List(); + var s = Fresh(); + + s = Capture(Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1))); + s = Capture(Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2))); + s = Capture(Part9StateMachine.ApplyAddComment(s, "alice", "need to investigate", T0.AddSeconds(3))); + s = Capture(Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(4))); + s = Capture(Part9StateMachine.ApplyConfirm(s, "bob", null, T0.AddSeconds(5))); + + emissions.ShouldBe(new[] { + EmissionKind.Activated, + EmissionKind.Acknowledged, + EmissionKind.CommentAdded, + EmissionKind.Cleared, + EmissionKind.Confirmed, + }); + + AlarmConditionState Capture(TransitionResult r) { emissions.Add(r.Emission); return r.State; } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs new file mode 100644 index 0000000..d88ec5d --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmEngineTests.cs @@ -0,0 +1,316 @@ +using Serilog; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests; + +/// +/// End-to-end engine tests: load, predicate evaluation, change-triggered +/// re-evaluation, state persistence, startup recovery, error isolation. +/// +[Trait("Category", "Unit")] +public sealed class ScriptedAlarmEngineTests +{ + private static ScriptedAlarmEngine Build(FakeUpstream up, out IAlarmStateStore store) + { + store = new InMemoryAlarmStateStore(); + var logger = new LoggerConfiguration().CreateLogger(); + return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger); + } + + private static ScriptedAlarmDefinition Alarm(string id, string predicate, + string msg = "condition", AlarmSeverity sev = AlarmSeverity.High) => + new(AlarmId: id, + EquipmentPath: "Plant/Line1/Reactor", + AlarmName: id, + Kind: AlarmKind.AlarmCondition, + Severity: sev, + MessageTemplate: msg, + PredicateScriptSource: predicate); + + [Fact] + public async Task Load_compiles_and_subscribes_to_referenced_upstreams() + { + var up = new FakeUpstream(); + up.Set("Temp", 50); + using var eng = Build(up, out _); + + await eng.LoadAsync([Alarm("a1", """return (int)ctx.GetTag("Temp").Value > 100;""")], + TestContext.Current.CancellationToken); + + eng.LoadedAlarmIds.ShouldContain("a1"); + up.ActiveSubscriptionCount.ShouldBe(1); + } + + [Fact] + public async Task Compile_failures_aggregated_into_one_error() + { + var up = new FakeUpstream(); + using var eng = Build(up, out _); + + var ex = await Should.ThrowAsync(async () => + await eng.LoadAsync([ + Alarm("bad1", "return unknownIdentifier;"), + Alarm("good", "return true;"), + Alarm("bad2", "var x = alsoUnknown; return x;"), + ], TestContext.Current.CancellationToken)); + ex.Message.ShouldContain("2 alarm(s) did not compile"); + } + + [Fact] + public async Task Upstream_change_re_evaluates_predicate_and_emits_Activated() + { + var up = new FakeUpstream(); + up.Set("Temp", 50); + using var eng = Build(up, out _); + await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")], + TestContext.Current.CancellationToken); + + var events = new List(); + eng.OnEvent += (_, e) => events.Add(e); + + up.Push("Temp", 150); + await WaitForAsync(() => events.Count > 0); + + events[0].AlarmId.ShouldBe("HighTemp"); + events[0].Emission.ShouldBe(EmissionKind.Activated); + eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active); + } + + [Fact] + public async Task Clearing_upstream_emits_Cleared_event() + { + var up = new FakeUpstream(); + up.Set("Temp", 150); + using var eng = Build(up, out _); + await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")], + TestContext.Current.CancellationToken); + + // Startup sees 150 → active. + eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active); + + var events = new List(); + eng.OnEvent += (_, e) => events.Add(e); + + up.Push("Temp", 50); + await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Cleared)); + eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive); + } + + [Fact] + public async Task Message_template_resolves_tag_values_at_emission() + { + var up = new FakeUpstream(); + up.Set("Temp", 50); + up.Set("Limit", 100); + using var eng = Build(up, out _); + await eng.LoadAsync([ + new ScriptedAlarmDefinition( + "HighTemp", "Plant/Line1", "HighTemp", + AlarmKind.LimitAlarm, AlarmSeverity.High, + "Temp {Temp}C exceeded limit {Limit}C", + """return (int)ctx.GetTag("Temp").Value > (int)ctx.GetTag("Limit").Value;"""), + ], TestContext.Current.CancellationToken); + + var events = new List(); + eng.OnEvent += (_, e) => events.Add(e); + + up.Push("Temp", 150); + await WaitForAsync(() => events.Any()); + + events[0].Message.ShouldBe("Temp 150C exceeded limit 100C"); + } + + [Fact] + public async Task Ack_records_user_and_persists_to_store() + { + var up = new FakeUpstream(); + up.Set("Temp", 150); + using var eng = Build(up, out var store); + await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")], + TestContext.Current.CancellationToken); + + await eng.AcknowledgeAsync("HighTemp", "alice", "checking", TestContext.Current.CancellationToken); + + var persisted = await store.LoadAsync("HighTemp", TestContext.Current.CancellationToken); + persisted.ShouldNotBeNull(); + persisted!.Acked.ShouldBe(AlarmAckedState.Acknowledged); + persisted.LastAckUser.ShouldBe("alice"); + persisted.LastAckComment.ShouldBe("checking"); + persisted.Comments.Any(c => c.Kind == "Acknowledge" && c.User == "alice").ShouldBeTrue(); + } + + [Fact] + public async Task Startup_recovery_preserves_ack_but_rederives_active_from_predicate() + { + var up = new FakeUpstream(); + up.Set("Temp", 50); // predicate will go false on second load + + // First run — alarm goes active + operator acks. + using (var eng1 = Build(up, out var sharedStore)) + { + up.Set("Temp", 150); + await eng1.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")], + TestContext.Current.CancellationToken); + eng1.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active); + + await eng1.AcknowledgeAsync("HighTemp", "alice", null, TestContext.Current.CancellationToken); + eng1.GetState("HighTemp")!.Acked.ShouldBe(AlarmAckedState.Acknowledged); + } + + // Simulate restart — temp is back to 50 (below threshold). + up.Set("Temp", 50); + var logger = new LoggerConfiguration().CreateLogger(); + var store2 = new InMemoryAlarmStateStore(); + // seed store2 with the acked state from before restart + await store2.SaveAsync(new AlarmConditionState( + "HighTemp", + AlarmEnabledState.Enabled, + AlarmActiveState.Active, // was active pre-restart + AlarmAckedState.Acknowledged, // ack persisted + AlarmConfirmedState.Unconfirmed, + ShelvingState.Unshelved, + DateTime.UtcNow, + DateTime.UtcNow, null, + DateTime.UtcNow, "alice", null, + null, null, null, + [new AlarmComment(DateTime.UtcNow, "alice", "Acknowledge", "")]), + TestContext.Current.CancellationToken); + + using var eng2 = new ScriptedAlarmEngine(up, store2, new ScriptLoggerFactory(logger), logger); + await eng2.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")], + TestContext.Current.CancellationToken); + + var s = eng2.GetState("HighTemp")!; + s.Active.ShouldBe(AlarmActiveState.Inactive, "Active recomputed from current tag value"); + s.Acked.ShouldBe(AlarmAckedState.Acknowledged, "Ack persisted across restart"); + s.LastAckUser.ShouldBe("alice"); + } + + [Fact] + public async Task Shelved_active_transitions_state_but_suppresses_emission() + { + var up = new FakeUpstream(); + up.Set("Temp", 50); + using var eng = Build(up, out _); + await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")], + TestContext.Current.CancellationToken); + + await eng.OneShotShelveAsync("HighTemp", "alice", TestContext.Current.CancellationToken); + + var events = new List(); + eng.OnEvent += (_, e) => events.Add(e); + + up.Push("Temp", 150); + await Task.Delay(200); + + events.Any(e => e.Emission == EmissionKind.Activated).ShouldBeFalse( + "OneShot shelve suppresses activation emission"); + eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active, + "state still advances so startup recovery is consistent"); + } + + [Fact] + public async Task Predicate_runtime_exception_does_not_transition_state() + { + var up = new FakeUpstream(); + up.Set("Temp", 150); + using var eng = Build(up, out _); + await eng.LoadAsync([ + Alarm("BadScript", """throw new InvalidOperationException("boom");"""), + Alarm("GoodScript", """return (int)ctx.GetTag("Temp").Value > 100;"""), + ], TestContext.Current.CancellationToken); + + // Bad script doesn't activate + doesn't disable other alarms. + eng.GetState("BadScript")!.Active.ShouldBe(AlarmActiveState.Inactive); + eng.GetState("GoodScript")!.Active.ShouldBe(AlarmActiveState.Active); + } + + [Fact] + public async Task Disable_prevents_activation_until_re_enabled() + { + var up = new FakeUpstream(); + up.Set("Temp", 50); + using var eng = Build(up, out _); + await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")], + TestContext.Current.CancellationToken); + + await eng.DisableAsync("HighTemp", "alice", TestContext.Current.CancellationToken); + up.Push("Temp", 150); + await Task.Delay(100); + eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive, + "disabled alarm ignores predicate"); + + await eng.EnableAsync("HighTemp", "alice", TestContext.Current.CancellationToken); + up.Push("Temp", 160); + await WaitForAsync(() => eng.GetState("HighTemp")!.Active == AlarmActiveState.Active); + } + + [Fact] + public async Task AddComment_appends_to_audit_without_state_change() + { + var up = new FakeUpstream(); + up.Set("Temp", 50); + using var eng = Build(up, out var store); + await eng.LoadAsync([Alarm("A", """return false;""")], TestContext.Current.CancellationToken); + + await eng.AddCommentAsync("A", "alice", "peeking at this", TestContext.Current.CancellationToken); + + var s = await store.LoadAsync("A", TestContext.Current.CancellationToken); + s.ShouldNotBeNull(); + s!.Comments.Count.ShouldBe(1); + s.Comments[0].User.ShouldBe("alice"); + s.Comments[0].Kind.ShouldBe("AddComment"); + } + + [Fact] + public async Task Predicate_scripts_cannot_SetVirtualTag() + { + var up = new FakeUpstream(); + up.Set("Temp", 100); + using var eng = Build(up, out _); + + // The script compiles fine but throws at runtime when SetVirtualTag is called. + // The engine swallows the exception + leaves state unchanged. + await eng.LoadAsync([ + new ScriptedAlarmDefinition( + "Bad", "Plant/Line1", "Bad", + AlarmKind.AlarmCondition, AlarmSeverity.High, "bad", + """ + ctx.SetVirtualTag("NotAllowed", 1); + return true; + """), + ], TestContext.Current.CancellationToken); + + // Bad alarm's predicate threw — state unchanged. + eng.GetState("Bad")!.Active.ShouldBe(AlarmActiveState.Inactive); + } + + [Fact] + public async Task Dispose_releases_upstream_subscriptions() + { + var up = new FakeUpstream(); + up.Set("Temp", 50); + var eng = Build(up, out _); + await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")], + TestContext.Current.CancellationToken); + up.ActiveSubscriptionCount.ShouldBe(1); + + eng.Dispose(); + up.ActiveSubscriptionCount.ShouldBe(0); + } + + private static async Task WaitForAsync(Func cond, int timeoutMs = 2000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + if (cond()) return; + await Task.Delay(25); + } + throw new TimeoutException("Condition did not become true in time"); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs new file mode 100644 index 0000000..6526de1 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ScriptedAlarmSourceTests.cs @@ -0,0 +1,142 @@ +using Serilog; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; + +namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests; + +[Trait("Category", "Unit")] +public sealed class ScriptedAlarmSourceTests +{ + private static async Task<(ScriptedAlarmEngine e, ScriptedAlarmSource s, FakeUpstream u)> BuildAsync() + { + var up = new FakeUpstream(); + up.Set("Temp", 50); + var logger = new LoggerConfiguration().CreateLogger(); + var engine = new ScriptedAlarmEngine(up, new InMemoryAlarmStateStore(), + new ScriptLoggerFactory(logger), logger); + await engine.LoadAsync([ + new ScriptedAlarmDefinition( + "Plant/Line1::HighTemp", + "Plant/Line1", + "HighTemp", + AlarmKind.LimitAlarm, + AlarmSeverity.High, + "Temp {Temp}C", + """return (int)ctx.GetTag("Temp").Value > 100;"""), + new ScriptedAlarmDefinition( + "Plant/Line2::OtherAlarm", + "Plant/Line2", + "OtherAlarm", + AlarmKind.AlarmCondition, + AlarmSeverity.Low, + "other", + """return false;"""), + ], CancellationToken.None); + + var source = new ScriptedAlarmSource(engine); + return (engine, source, up); + } + + [Fact] + public async Task Subscribe_with_empty_filter_receives_every_alarm_emission() + { + var (engine, source, up) = await BuildAsync(); + using var _e = engine; + using var _s = source; + + var events = new List(); + source.OnAlarmEvent += (_, e) => events.Add(e); + var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken); + + up.Push("Temp", 150); + await Task.Delay(200); + + events.Count.ShouldBe(1); + events[0].ConditionId.ShouldBe("Plant/Line1::HighTemp"); + events[0].SourceNodeId.ShouldBe("Plant/Line1"); + events[0].Severity.ShouldBe(AlarmSeverity.High); + events[0].AlarmType.ShouldBe("LimitAlarm"); + events[0].Message.ShouldBe("Temp 150C"); + + await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Subscribe_with_equipment_prefix_filters_by_that_prefix() + { + var (engine, source, up) = await BuildAsync(); + using var _e = engine; + using var _s = source; + + var events = new List(); + source.OnAlarmEvent += (_, e) => events.Add(e); + + // Subscribe only to Line1 alarms. + var handle = await source.SubscribeAlarmsAsync(["Plant/Line1"], TestContext.Current.CancellationToken); + + up.Push("Temp", 150); + await Task.Delay(200); + + events.Count.ShouldBe(1); + events[0].SourceNodeId.ShouldBe("Plant/Line1"); + + await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Unsubscribe_stops_further_events() + { + var (engine, source, up) = await BuildAsync(); + using var _e = engine; + using var _s = source; + + var events = new List(); + source.OnAlarmEvent += (_, e) => events.Add(e); + var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken); + await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken); + + up.Push("Temp", 150); + await Task.Delay(200); + + events.Count.ShouldBe(0); + } + + [Fact] + public async Task AcknowledgeAsync_routes_to_engine_with_default_user() + { + var (engine, source, up) = await BuildAsync(); + using var _e = engine; + using var _s = source; + + up.Push("Temp", 150); + await Task.Delay(200); + engine.GetState("Plant/Line1::HighTemp")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged); + + await source.AcknowledgeAsync([new AlarmAcknowledgeRequest( + "Plant/Line1", "Plant/Line1::HighTemp", "ack via opcua")], + TestContext.Current.CancellationToken); + + var state = engine.GetState("Plant/Line1::HighTemp")!; + state.Acked.ShouldBe(AlarmAckedState.Acknowledged); + state.LastAckUser.ShouldBe("opcua-client"); + state.LastAckComment.ShouldBe("ack via opcua"); + } + + [Fact] + public async Task Null_arguments_rejected() + { + var (engine, source, _) = await BuildAsync(); + using var _e = engine; + using var _s = source; + + await Should.ThrowAsync(async () => + await source.SubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken)); + await Should.ThrowAsync(async () => + await source.UnsubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken)); + await Should.ThrowAsync(async () => + await source.AcknowledgeAsync(null!, TestContext.Current.CancellationToken)); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests.csproj new file mode 100644 index 0000000..5767971 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + +