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:
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user