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