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