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>
317 lines
12 KiB
C#
317 lines
12 KiB
C#
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");
|
|
}
|
|
}
|