using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Host.Engines;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
///
/// F9b — verifies 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).
///
public sealed class RoslynScriptedAlarmEvaluatorTests
{
/// Captures published records for assertion.
private sealed class FakePublisher : IScriptLogPublisher
{
/// Gets the entries published so far.
public List Published { get; } = [];
///
public void Publish(ScriptLogEntry entry) => Published.Add(entry);
}
/// Builds a no-op for tests that don't assert on logging.
private static ScriptRootLogger NoOpScriptRoot() =>
new(new LoggerConfiguration().CreateLogger());
/// Verifies evaluation of predicate returning true reports Active.
[Fact]
public void Evaluate_predicate_returning_true_reports_Active()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
var result = sut.Evaluate(
alarmId: "alarm-hi",
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
dependencies: new Dictionary { ["temp"] = 150 });
result.Success.ShouldBeTrue(result.Reason);
result.Active.ShouldBeTrue();
}
/// Verifies evaluation of predicate returning false reports Inactive.
[Fact]
public void Evaluate_predicate_returning_false_reports_Inactive()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
var result = sut.Evaluate(
alarmId: "alarm-hi",
predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;",
dependencies: new Dictionary { ["temp"] = 50 });
result.Success.ShouldBeTrue(result.Reason);
result.Active.ShouldBeFalse();
}
/// Verifies compiled predicates are cached across calls.
[Fact]
public void Evaluate_caches_compiled_predicate_across_calls()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;";
var first = sut.Evaluate("alarm-door", predicate, new Dictionary { ["door_open"] = true });
var second = sut.Evaluate("alarm-door", predicate, new Dictionary { ["door_open"] = false });
first.Active.ShouldBeTrue();
second.Active.ShouldBeFalse();
}
/// Verifies compile errors return Failure.
[Fact]
public void Evaluate_compile_error_returns_Failure()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary());
result.Success.ShouldBeFalse();
result.Reason!.ShouldContain("compile");
}
/// Verifies predicate writing virtual tag returns Failure.
[Fact]
public void Evaluate_predicate_writing_virtual_tag_returns_Failure()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
// 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());
result.Success.ShouldBeFalse();
result.Reason!.ShouldContain("threw");
}
/// Verifies empty predicate returns Failure.
[Fact]
public void Evaluate_empty_predicate_returns_Failure()
{
using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
sut.Evaluate("alarm-empty", "", new Dictionary()).Success.ShouldBeFalse();
}
/// Verifies evaluation after dispose returns Failure.
[Fact]
public void Evaluate_after_dispose_returns_Failure()
{
var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance, NoOpScriptRoot());
sut.Dispose();
var result = sut.Evaluate("alarm", "return true;", new Dictionary());
result.Success.ShouldBeFalse();
result.Reason!.ShouldContain("disposed");
}
///
/// A predicate's ctx.Logger.Warning(...) call flows through the injected root script
/// logger and out the , producing one
/// carrying the message, the bound AlarmId, and the
/// Warning level.
///
[Fact]
public void Script_logger_call_publishes_entry_with_bound_alarm_identity()
{
var publisher = new FakePublisher();
var root = new LoggerConfiguration()
.MinimumLevel.Verbose()
.WriteTo.Sink(new ScriptLogTopicSink(publisher, LogEventLevel.Information))
.CreateLogger();
using var sut = new RoslynScriptedAlarmEvaluator(
NullLogger.Instance, new ScriptRootLogger(root));
var result = sut.Evaluate(
alarmId: "alarm-log",
predicate: "ctx.Logger.Warning(\"alarm log\"); return true;",
dependencies: new Dictionary());
result.Success.ShouldBeTrue(result.Reason);
result.Active.ShouldBeTrue();
publisher.Published.Count.ShouldBe(1);
var entry = publisher.Published[0];
entry.Message.ShouldBe("alarm log");
entry.AlarmId.ShouldBe("alarm-log");
entry.ScriptId.ShouldBe("alarm-log");
entry.Level.ShouldBe("Warning");
}
}