using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Messages.ScriptExecution; using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Scripts; using ScadaLink.SiteRuntime.Actors; using ScadaLink.SiteRuntime.Scripts; namespace ScadaLink.SiteRuntime.Tests.Actors; /// /// Regression coverage for SiteRuntime-016 — the short-lived execution actors /// (, ) were /// previously untested. Covers success, exception, timeout, Ask-reply, and the /// PoisonPill self-stop after completion. /// public class ExecutionActorTests : TestKit, IDisposable { private readonly SharedScriptLibrary _sharedLibrary; private readonly ScriptCompilationService _compilationService; public ExecutionActorTests() { _compilationService = new ScriptCompilationService( NullLogger.Instance); _sharedLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); } void IDisposable.Dispose() => Shutdown(); private static Script CompileScript(string code) { var scriptOptions = ScriptOptions.Default .WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly) .WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks"); var script = CSharpScript.Create(code, scriptOptions, typeof(ScriptGlobals)); script.Compile(); return script; } private static SiteRuntimeOptions Options(int timeoutSeconds = 30) => new() { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = timeoutSeconds }; // ── ScriptExecutionActor ── [Fact] public void ScriptExecutionActor_Success_RepliesWithResultAndStops() { var compiled = CompileScript("return 7 * 6;"); var replyTo = CreateTestProbe(); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( "Answer", "Inst1", compiled, null, 0, instanceActor.Ref, _sharedLibrary, Options(), replyTo.Ref, "corr-1", NullLogger.Instance, ScriptScope.Root, null, null))); Watch(exec); var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); Assert.True(result.Success); Assert.Equal("corr-1", result.CorrelationId); Assert.Equal(42, result.ReturnValue); // The actor must PoisonPill itself once execution completes. ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } [Fact] public void ScriptExecutionActor_ScriptThrows_RepliesFailureAndStops() { var compiled = CompileScript("throw new InvalidOperationException(\"boom\");"); var replyTo = CreateTestProbe(); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( "Bad", "Inst1", compiled, null, 0, instanceActor.Ref, _sharedLibrary, Options(), replyTo.Ref, "corr-2", NullLogger.Instance, ScriptScope.Root, null, null))); Watch(exec); var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); Assert.False(result.Success); Assert.Equal("corr-2", result.CorrelationId); Assert.Contains("boom", result.ErrorMessage); ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } [Fact] public void ScriptExecutionActor_Timeout_RepliesFailureAndStops() { // A long busy loop that observes the cancellation token so the // 1-second timeout fires cooperatively. var compiled = CompileScript( "while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }"); var replyTo = CreateTestProbe(); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( "Slow", "Inst1", compiled, null, 0, instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1), replyTo.Ref, "corr-3", NullLogger.Instance, ScriptScope.Root, null, null))); Watch(exec); var result = replyTo.ExpectMsg(TimeSpan.FromSeconds(10)); Assert.False(result.Success); Assert.Contains("timed out", result.ErrorMessage); ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } [Fact] public void ScriptExecutionActor_NoReplyTo_StillStopsAfterCompletion() { var compiled = CompileScript("return 1;"); var instanceActor = CreateTestProbe(); // ActorRefs.Nobody as replyTo — fire-and-forget execution. var exec = ActorOf(Props.Create(() => new ScriptExecutionActor( "FireForget", "Inst1", compiled, null, 0, instanceActor.Ref, _sharedLibrary, Options(), ActorRefs.Nobody, "corr-4", NullLogger.Instance, ScriptScope.Root, null, null))); Watch(exec); ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } // ── AlarmExecutionActor ── [Fact] public void AlarmExecutionActor_Success_StopsAfterCompletion() { var compiled = CompileScript("return 0;"); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new AlarmExecutionActor( "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature", compiled, instanceActor.Ref, _sharedLibrary, Options(), NullLogger.Instance))); Watch(exec); ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } [Fact] public void AlarmExecutionActor_ScriptThrows_StillStops() { var compiled = CompileScript("throw new System.Exception(\"alarm-boom\");"); var instanceActor = CreateTestProbe(); var exec = ActorOf(Props.Create(() => new AlarmExecutionActor( "HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature", compiled, instanceActor.Ref, _sharedLibrary, Options(), NullLogger.Instance))); Watch(exec); // Even on a throwing on-trigger body, the actor must self-stop. ExpectTerminated(exec, TimeSpan.FromSeconds(5)); } }