chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)

Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View File

@@ -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<string, DataValueSnapshot> _values = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _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<string, DataValueSnapshot>[] 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<string, DataValueSnapshot> 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<string, DataValueSnapshot> _observer;
public Unsub(FakeUpstream up, string path, Action<string, DataValueSnapshot> 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--;
}
}
}
}
}

View File

@@ -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<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");
}
}

View File

@@ -0,0 +1,205 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
/// <summary>
/// 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.
/// </summary>
[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<ArgumentException>(() =>
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<ArgumentOutOfRangeException>(() =>
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<EmissionKind>();
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; }
}
}

View File

@@ -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;
/// <summary>
/// End-to-end engine tests: load, predicate evaluation, change-triggered
/// re-evaluation, state persistence, startup recovery, error isolation.
/// </summary>
[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<InvalidOperationException>(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<ScriptedAlarmEvent>();
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<ScriptedAlarmEvent>();
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<ScriptedAlarmEvent>();
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<ScriptedAlarmEvent>();
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<bool> 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");
}
}

View File

@@ -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<AlarmEventArgs>();
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<AlarmEventArgs>();
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<AlarmEventArgs>();
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<ArgumentNullException>(async () =>
await source.SubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
await Should.ThrowAsync<ArgumentNullException>(async () =>
await source.UnsubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
await Should.ThrowAsync<ArgumentNullException>(async () =>
await source.AcknowledgeAsync(null!, TestContext.Current.CancellationToken));
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>