Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs
Joseph Doherty 05a0596fb1
Some checks failed
v2-ci / build (push) Failing after 39s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (push) Has been skipped
feat(host): F9b RoslynScriptedAlarmEvaluator + #107 close engine DI
RoslynScriptedAlarmEvaluator mirrors F8b's pattern for alarm predicates:
caches a compiled ScriptEvaluator<AlarmPredicateContext, bool> per unique
predicate, runs against the dependency dictionary with a 2s timeout, and
turns every failure (compile error, sandbox violation, runtime throw,
ctx.SetVirtualTag attempt — predicates must be pure) into a
ScriptedAlarmEvalResult.Failure. ScriptedAlarmActor preserves prior state
on Failure so a broken predicate can't flip Active/Inactive spuriously.

Program.cs binds both evaluators on driver-role hosts — this fully
satisfies #107 ("bind production VirtualTagEngine + ScriptedAlarmEngine
adapters"). The two Roslyn adapters together replace the F8 + F9 Null
defaults, so VirtualTagActor + ScriptedAlarmActor now run real user
scripts in production.

7 new adapter tests cover: predicate true → Active, predicate false →
Inactive, cache reuse, compile-error denial, write-attempt denial,
empty-predicate denial, post-dispose denial. Host.IntegrationTests now
17/17 green.

Closes #80 + #107. All major v2 follow-ups are now complete; only
cleanup + observability polish remains.
2026-05-26 10:58:04 -04:00

103 lines
3.8 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Engines;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// F9b — verifies <see cref="RoslynScriptedAlarmEvaluator"/> compiles alarm predicates,
/// returns the bool result on success, surfaces compile/runtime errors as Failure (so the
/// actor preserves prior state), and rejects predicates that try to ctx.SetVirtualTag (the
/// AlarmPredicateContext throws on writes — predicates must stay pure).
/// </summary>
public sealed class RoslynScriptedAlarmEvaluatorTests
{
[Fact]
public void Evaluate_predicate_returning_true_reports_Active()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
var result = sut.Evaluate(
alarmId: "alarm-hi",
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
dependencies: new Dictionary<string, object?> { ["temp"] = 150 });
result.Success.ShouldBeTrue(result.Reason);
result.Active.ShouldBeTrue();
}
[Fact]
public void Evaluate_predicate_returning_false_reports_Inactive()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
var result = sut.Evaluate(
alarmId: "alarm-hi",
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
dependencies: new Dictionary<string, object?> { ["temp"] = 50 });
result.Success.ShouldBeTrue(result.Reason);
result.Active.ShouldBeFalse();
}
[Fact]
public void Evaluate_caches_compiled_predicate_across_calls()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;";
var first = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = true });
var second = sut.Evaluate("alarm-door", predicate, new Dictionary<string, object?> { ["door_open"] = false });
first.Active.ShouldBeTrue();
second.Active.ShouldBeFalse();
}
[Fact]
public void Evaluate_compile_error_returns_Failure()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary<string, object?>());
result.Success.ShouldBeFalse();
result.Reason!.ShouldContain("compile");
}
[Fact]
public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
// AlarmPredicateContext.SetVirtualTag throws — wrapper catches + reports as Failure.
var result = sut.Evaluate(
alarmId: "alarm-bad-write",
predicate: "ctx.SetVirtualTag(\"x\", 1); return true;",
dependencies: new Dictionary<string, object?>());
result.Success.ShouldBeFalse();
result.Reason!.ShouldContain("threw");
}
[Fact]
public void Evaluate_empty_predicate_returns_Failure()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
sut.Evaluate("alarm-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
}
[Fact]
public void Evaluate_after_dispose_returns_Failure()
{
var sut = new RoslynScriptedAlarmEvaluator(NullLogger<RoslynScriptedAlarmEvaluator>.Instance);
sut.Dispose();
var result = sut.Evaluate("alarm", "return true;", new Dictionary<string, object?>());
result.Success.ShouldBeFalse();
result.Reason!.ShouldContain("disposed");
}
}