Ships the Part 9 alarm fidelity layer Phase 7 committed to in plan decision #5. Every scripted alarm gets a full OPC UA AlarmConditionType state machine — EnabledState, ActiveState, AckedState, ConfirmedState, ShelvingState — with persistent operator-supplied state across server restarts per Phase 7 plan decision #14. Runtime shape matches the Galaxy-native + AB CIP ALMD alarm sources: scripted alarms fan out through the existing IAlarmSource surface so Phase 6.1 AlarmTracker composition consumes them without per-source branching. Part9StateMachine is a pure-functions module — no instance state, no I/O, no mutation. Every transition (ApplyPredicate, ApplyAcknowledge, ApplyConfirm, ApplyOneShotShelve, ApplyTimedShelve, ApplyUnshelve, ApplyEnable, ApplyDisable, ApplyAddComment, ApplyShelvingCheck) takes the current AlarmConditionState record plus the event and returns a fresh state + EmissionKind hint. Two structural invariants enforced: disabled alarms never transition ActiveState / AckedState / ConfirmedState; shelved alarms still advance state (so startup recovery reflects reality) but emit a Suppressed hint so subscribers do not see the transition. OneShot shelving expires on clear; Timed shelving expires via ApplyShelvingCheck against the UnshelveAtUtc timestamp. Comments are append-only — every acknowledge, confirm, shelve, unshelve, enable, disable, explicit add-comment, and auto-unshelve appends an AlarmComment record with user identity + timestamp + kind + text for the GxP / 21 CFR Part 11 audit surface. AlarmConditionState is the persistent record the store saves. Fields: AlarmId, Enabled, Active, Acked, Confirmed, Shelving (kind + UnshelveAtUtc), LastTransitionUtc, LastActiveUtc, LastClearedUtc, LastAckUtc + LastAckUser + LastAckComment, LastConfirmUtc + LastConfirmUser + LastConfirmComment, Comments. Fresh factory initializes everything to the no-event position. IAlarmStateStore is the persistence abstraction — LoadAsync, LoadAllAsync, SaveAsync, RemoveAsync. Stream E wires this to a SQL-backed store with IAuditLogger hooks; tests use InMemoryAlarmStateStore. Startup recovery per Phase 7 plan decision #14: LoadAsync runs every configured alarm predicate against current tag values to rederive ActiveState, but EnabledState / AckedState / ConfirmedState / ShelvingState + audit history are loaded verbatim from the store so operators do not re-ack after an outage and shelved alarms stay shelved through maintenance windows. MessageTemplate implements Phase 7 plan decision #13 — static-with-substitution. {TagPath} tokens resolved at event emission time from the engine value cache. Missing paths, non-Good quality, or null values all resolve to {?} so the event still fires but the operator sees where the reference broke. ExtractTokenPaths enumerates tokens at publish time so the engine knows to subscribe to every template-referenced tag in addition to predicate-referenced tags. AlarmPredicateContext is the ScriptContext subclass alarm scripts see. GetTag reads from the engine shared cache; SetVirtualTag is explicitly rejected at runtime with a pointed error message — alarm predicates must be pure so their output does not couple to virtual-tag state in ways that become impossible to reason about. If cross-tag side effects are needed, the operator authors a virtual tag and the alarm predicate reads it. ScriptedAlarmEngine orchestrates. LoadAsync compiles every predicate through Stream A ScriptSandbox + ForbiddenTypeAnalyzer, runs DependencyExtractor to find the read set, adds template token paths to the input set, reports every compile failure as one aggregated InvalidOperationException (not one-at-a-time), subscribes to each unique referenced upstream path, seeds the value cache, loads persisted state for each alarm (falling back to Fresh for first-load), re-evaluates the predicate, and saves the recovered state. ChangeTrigger — when an upstream tag changes, look up every alarm referencing that path in a per-path inverse index, enqueue all of them for re-evaluation via a SemaphoreSlim-gated path. Unlike the virtual-tag engine, scripted alarms are leaves in the evaluation DAG (no alarm drives another alarm), so no topological sort is needed. Operator actions (AcknowledgeAsync, ConfirmAsync, OneShotShelveAsync, TimedShelveAsync, UnshelveAsync, EnableAsync, DisableAsync, AddCommentAsync) route through the state machine, persist, and emit if there is an emission. A 5-second shelving-check timer auto-expires Timed shelving and emits Unshelved events at the right moment. Predicate evaluation errors (script throws, timeout, compile-time reads bad tag) leave the state unchanged — the engine does NOT invent a clear transition on predicate failure. Logged as scripts-*.log Error; companion WARN in main log. ScriptedAlarmSource implements IAlarmSource. SubscribeAlarmsAsync filter is a set of equipment-path prefixes; empty means all. AcknowledgeAsync from the base interface routes to the engine with user identity "opcua-client" — Stream G will replace this with the authenticated principal from the OPC UA dispatch layer. The adapter implements only the base IAlarmSource methods; richer Part 9 methods (Confirm, Shelve, Unshelve, AddComment) remain on the engine and will bind to OPC UA method nodes in Stream G. 47 unit tests across 5 files. Part9StateMachineTests (16) — every transition + noop edge cases: predicate true/false, same-state noop, disabled ignores predicate, acknowledge records user/comment/adds audit, idempotent acknowledge, reject no-user ack, full activate-ack-clear-confirm walk, one-shot shelve suppresses next activation, one-shot expires on clear, timed shelve requires future unshelve time, timed shelve expires via shelving-check, explicit unshelve emits, add-comment appends to audit, comments append-only through multiple operations, full lifecycle walk emits every expected EmissionKind. MessageTemplateTests (11) — no-token passthrough, single+multiple token substitution, bad quality becomes {?}, unknown path becomes {?}, null value becomes {?}, tokens with slashes+dots, empty + null template, ExtractTokenPaths returns every distinct path, whitespace inside tokens trimmed. ScriptedAlarmEngineTests (13) — load compiles+subscribes, compile failures aggregated, upstream change emits Activated, clearing emits Cleared, message template resolves at emission, ack persists to store, startup recovery preserves ack but rederives active, shelved activation state-advances but suppresses emission, runtime exception isolates to owning alarm, disable prevents activation until re-enable, AddComment appends audit without state change, SetVirtualTag from predicate rejected (state unchanged), Dispose releases upstream subscriptions. ScriptedAlarmSourceTests (5) — empty filter matches all, equipment-prefix filter, Unsubscribe stops events, AcknowledgeAsync routes with default user, null arguments rejected. FakeUpstream fixture gives tests an in-memory driver mock with subscription count tracking. Full Phase 7 test count after Stream C: 146 green (63 Scripting + 36 VirtualTags + 47 ScriptedAlarms). Stream D (historian alarm sink with SQLite store-and-forward + Galaxy.Host IPC) consumes ScriptedAlarmEvent + similar Galaxy / AB CIP emissions to produce the unified alarm timeline. Stream G wires the OPC UA method calls and AlarmSource into DriverNodeManager dispatch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
108 lines
3.3 KiB
C#
108 lines
3.3 KiB
C#
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<string, DataValueSnapshot> 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<string, DataValueSnapshot> { ["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<string, DataValueSnapshot>
|
|
{
|
|
["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<string, DataValueSnapshot> { ["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<string, DataValueSnapshot> { ["X"] = Good(null) };
|
|
MessageTemplate.Resolve("{X}", p => Resolver(map, p)).ShouldBe("{?}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Tokens_with_slashes_and_dots_resolved()
|
|
{
|
|
var map = new Dictionary<string, DataValueSnapshot>
|
|
{
|
|
["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<string, DataValueSnapshot> { ["A"] = Good(42) };
|
|
MessageTemplate.Resolve("{ A }", p => Resolver(map, p)).ShouldBe("42");
|
|
}
|
|
}
|