using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Messages.Instance;
using ScadaLink.Commons.Messages.ScriptExecution;
using ScadaLink.Commons.Messages.Streaming;
using ScadaLink.Commons.Types.Flattening;
using ScadaLink.SiteRuntime.Actors;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.SiteRuntime.Tests.Actors;
///
/// WP-15: Script Actor and Script Execution Actor tests.
/// WP-20: Recursion limit tests.
/// WP-22: Tell vs Ask convention tests.
/// WP-32: Script error handling tests.
///
public class ScriptActorTests : TestKit, IDisposable
{
private readonly SharedScriptLibrary _sharedLibrary;
private readonly SiteRuntimeOptions _options;
private readonly ScriptCompilationService _compilationService;
public ScriptActorTests()
{
_compilationService = new ScriptCompilationService(
NullLogger.Instance);
_sharedLibrary = new SharedScriptLibrary(
_compilationService, NullLogger.Instance);
_options = new SiteRuntimeOptions
{
MaxScriptCallDepth = 10,
ScriptExecutionTimeoutSeconds = 30
};
}
void IDisposable.Dispose()
{
Shutdown();
}
private 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;
}
[Fact]
public void ScriptActor_CallScript_ReturnsResult()
{
var compiled = CompileScript("42");
var scriptConfig = new ResolvedScript
{
CanonicalName = "GetAnswer",
Code = "42"
};
var instanceActor = CreateTestProbe();
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
"GetAnswer",
"TestInstance",
instanceActor.Ref,
compiled,
scriptConfig,
_sharedLibrary,
_options,
NullLogger.Instance)));
// Ask pattern (WP-22) for CallScript
scriptActor.Tell(new ScriptCallRequest("GetAnswer", null, 0, "corr-1"));
var result = ExpectMsg(TimeSpan.FromSeconds(10));
Assert.True(result.Success, $"Script call failed: {result.ErrorMessage}");
Assert.Equal(42, result.ReturnValue);
}
[Fact]
public void ScriptActor_CallScript_WithParameters_Works()
{
var compiled = CompileScript("(int)Parameters[\"x\"] + (int)Parameters[\"y\"]");
var scriptConfig = new ResolvedScript
{
CanonicalName = "Add",
Code = "(int)Parameters[\"x\"] + (int)Parameters[\"y\"]"
};
var instanceActor = CreateTestProbe();
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
"Add",
"TestInstance",
instanceActor.Ref,
compiled,
scriptConfig,
_sharedLibrary,
_options,
NullLogger.Instance)));
var parameters = new Dictionary { ["x"] = 3, ["y"] = 4 };
scriptActor.Tell(new ScriptCallRequest("Add", parameters, 0, "corr-2"));
var result = ExpectMsg(TimeSpan.FromSeconds(10));
Assert.True(result.Success);
Assert.Equal(7, result.ReturnValue);
}
[Fact]
public void ScriptActor_NullCompiledScript_ReturnsError()
{
var scriptConfig = new ResolvedScript
{
CanonicalName = "Broken",
Code = ""
};
var instanceActor = CreateTestProbe();
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
"Broken",
"TestInstance",
instanceActor.Ref,
null, // no compiled script
scriptConfig,
_sharedLibrary,
_options,
NullLogger.Instance)));
scriptActor.Tell(new ScriptCallRequest("Broken", null, 0, "corr-3"));
var result = ExpectMsg(TimeSpan.FromSeconds(5));
Assert.False(result.Success);
Assert.Contains("not compiled", result.ErrorMessage);
}
[Fact]
public void ScriptActor_ValueChangeTrigger_SpawnsExecution()
{
var compiled = CompileScript("\"triggered\"");
var scriptConfig = new ResolvedScript
{
CanonicalName = "OnChange",
Code = "\"triggered\"",
TriggerType = "ValueChange",
TriggerConfiguration = "{\"attributeName\":\"Temperature\"}"
};
var instanceActor = CreateTestProbe();
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
"OnChange",
"TestInstance",
instanceActor.Ref,
compiled,
scriptConfig,
_sharedLibrary,
_options,
NullLogger.Instance)));
// Send an attribute change that matches the trigger
scriptActor.Tell(new AttributeValueChanged(
"TestInstance", "Temperature", "Temperature", "100.0", "Good", DateTimeOffset.UtcNow));
// The script should execute (we can't easily verify the output since it's fire-and-forget)
// But we can verify the actor doesn't crash
ExpectNoMsg(TimeSpan.FromSeconds(1));
}
[Fact]
public void ScriptActor_MinTimeBetweenRuns_SkipsIfTooSoon()
{
var compiled = CompileScript("\"ok\"");
var scriptConfig = new ResolvedScript
{
CanonicalName = "Throttled",
Code = "\"ok\"",
TriggerType = "ValueChange",
TriggerConfiguration = "{\"attributeName\":\"Temp\"}",
MinTimeBetweenRuns = TimeSpan.FromMinutes(10) // long minimum
};
var instanceActor = CreateTestProbe();
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
"Throttled",
"TestInstance",
instanceActor.Ref,
compiled,
scriptConfig,
_sharedLibrary,
_options,
NullLogger.Instance)));
// First trigger -- should execute
scriptActor.Tell(new AttributeValueChanged(
"TestInstance", "Temp", "Temp", "1", "Good", DateTimeOffset.UtcNow));
// Second trigger immediately -- should be skipped due to min time
scriptActor.Tell(new AttributeValueChanged(
"TestInstance", "Temp", "Temp", "2", "Good", DateTimeOffset.UtcNow));
// No crash expected
ExpectNoMsg(TimeSpan.FromSeconds(1));
}
[Fact]
public void ScriptActor_WP32_ScriptFailure_DoesNotDisable()
{
// Script that throws an exception
var compiled = CompileScript("throw new System.Exception(\"boom\")");
var scriptConfig = new ResolvedScript
{
CanonicalName = "Failing",
Code = "throw new System.Exception(\"boom\")"
};
var instanceActor = CreateTestProbe();
var scriptActor = ActorOf(Props.Create(() => new ScriptActor(
"Failing",
"TestInstance",
instanceActor.Ref,
compiled,
scriptConfig,
_sharedLibrary,
_options,
NullLogger.Instance)));
// First call -- fails
scriptActor.Tell(new ScriptCallRequest("Failing", null, 0, "corr-fail-1"));
var result1 = ExpectMsg(TimeSpan.FromSeconds(10));
Assert.False(result1.Success);
// Second call -- should still work (script not disabled after failure)
scriptActor.Tell(new ScriptCallRequest("Failing", null, 0, "corr-fail-2"));
var result2 = ExpectMsg(TimeSpan.FromSeconds(10));
Assert.False(result2.Success); // Still fails, but the actor is still alive
}
// ── WhileTrue trigger mode (Conditional + Expression) ──────────────────
//
// A fired script runs `Instance.SetAttribute("Fired", "1")`, which the
// Instance Actor receives as a SetStaticAttributeCommand. The probe stands
// in for the Instance Actor: an auto-pilot replies so each execution
// completes promptly (freeing the script-execution scheduler), while every
// command remains observable via ExpectMsg — one command per script firing.
private const string FiringScriptCode = "await Instance.SetAttribute(\"Fired\", \"1\")";
/// Builds a ScriptActor whose script fires one observable command per run.
private (IActorRef Actor, TestProbe Instance) CreateTriggeredActor(
string name,
string triggerType,
string triggerConfig,
TimeSpan? minTimeBetweenRuns,
Script? triggerExpression = null)
{
var compiled = CompileScript(FiringScriptCode);
var scriptConfig = new ResolvedScript
{
CanonicalName = name,
Code = FiringScriptCode,
TriggerType = triggerType,
TriggerConfiguration = triggerConfig,
MinTimeBetweenRuns = minTimeBetweenRuns
};
var instance = CreateTestProbe();
instance.SetAutoPilot(new DelegateAutoPilot((sender, message) =>
{
if (message is SetStaticAttributeCommand cmd)
{
sender.Tell(new SetStaticAttributeResponse(
cmd.CorrelationId, cmd.InstanceUniqueName, cmd.AttributeName,
true, null, DateTimeOffset.UtcNow));
}
return AutoPilot.KeepRunning;
}));
var actor = ActorOf(Props.Create(() => new ScriptActor(
name,
"TestInstance",
instance.Ref,
compiled,
scriptConfig,
_sharedLibrary,
_options,
NullLogger.Instance,
triggerExpression,
null,
null,
null)));
return (actor, instance);
}
private AttributeValueChanged Change(string attribute, object? value) =>
new("TestInstance", attribute, attribute, value, "Good", DateTimeOffset.UtcNow);
private Script CompileTriggerExpression(string expression) =>
_compilationService.CompileTriggerExpression("trigger-expr", expression).CompiledScript!;
[Fact]
public void ScriptActor_ConditionalWhileTrue_FiresOnEdgeThenReFiresWhileConditionHolds()
{
// WhileTrue re-fire cadence is the script's MinTimeBetweenRuns.
var (actor, instance) = CreateTriggeredActor(
"CondWhile",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
TimeSpan.FromMilliseconds(300));
// Temp 100 > 50 -> false->true edge: fire immediately.
actor.Tell(Change("Temp", "100"));
instance.ExpectMsg(TimeSpan.FromSeconds(2)); // edge fire
// Then the timer re-fires while the condition still holds.
instance.ExpectMsg(TimeSpan.FromSeconds(2)); // tick 1
instance.ExpectMsg(TimeSpan.FromSeconds(2)); // tick 2
}
[Fact]
public void ScriptActor_ConditionalWhileTrue_StopsReFiringWhenConditionGoesFalse()
{
var (actor, instance) = CreateTriggeredActor(
"CondStop",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
TimeSpan.FromMilliseconds(300));
actor.Tell(Change("Temp", "100"));
instance.ExpectMsg(TimeSpan.FromSeconds(2)); // edge fire
instance.ExpectMsg(TimeSpan.FromSeconds(2)); // at least one tick
// Temp 10 -> condition false: the re-fire timer stops.
actor.Tell(Change("Temp", "10"));
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(700)); // re-firing has stopped
}
[Fact]
public void ScriptActor_ConditionalWhileTrue_ReArmsAfterConditionFalseThenTrueAgain()
{
var (actor, instance) = CreateTriggeredActor(
"CondReArm",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
TimeSpan.FromMilliseconds(300));
actor.Tell(Change("Temp", "100")); // true edge -> fire
instance.ExpectMsg(TimeSpan.FromSeconds(2));
actor.Tell(Change("Temp", "10")); // false -> stop
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
actor.Tell(Change("Temp", "100")); // false->true again: re-arm + fire
instance.ExpectMsg(TimeSpan.FromSeconds(2));
}
[Fact]
public void ScriptActor_ConditionalWhileTrue_WithoutMinTimeBetweenRuns_FiresOnceOnly()
{
// No MinTimeBetweenRuns -> no re-fire interval: degrades to a single edge fire.
var (actor, instance) = CreateTriggeredActor(
"CondNoInterval",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"WhileTrue\"}",
minTimeBetweenRuns: null);
actor.Tell(Change("Temp", "100"));
instance.ExpectMsg(TimeSpan.FromSeconds(2)); // edge fire
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(900)); // no repeats
}
[Fact]
public void ScriptActor_ConditionalOnTrue_FiresOnEachChangeWhileTrue_NoTimer()
{
// Regression: OnTrue (the existing behavior) fires per matching change
// and never re-fires on a timer of its own.
var (actor, instance) = CreateTriggeredActor(
"CondOnTrue",
"Conditional",
"{\"attributeName\":\"Temp\",\"operator\":\">\",\"threshold\":50,\"mode\":\"OnTrue\"}",
minTimeBetweenRuns: null);
actor.Tell(Change("Temp", "100"));
instance.ExpectMsg(TimeSpan.FromSeconds(2));
actor.Tell(Change("Temp", "101"));
instance.ExpectMsg(TimeSpan.FromSeconds(2));
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(600)); // no self-driven re-fire
}
[Fact]
public void ScriptActor_ExpressionWhileTrue_ReFiresWhileExpressionHolds()
{
var triggerExpr = CompileTriggerExpression("Attributes[\"Active\"]?.ToString() == \"yes\"");
var (actor, instance) = CreateTriggeredActor(
"ExprWhile",
"Expression",
"{\"expression\":\"Attributes[\\\"Active\\\"]?.ToString() == \\\"yes\\\"\",\"mode\":\"WhileTrue\"}",
TimeSpan.FromMilliseconds(300),
triggerExpr);
actor.Tell(Change("Active", "yes"));
instance.ExpectMsg(TimeSpan.FromSeconds(2)); // edge fire
instance.ExpectMsg(TimeSpan.FromSeconds(2)); // tick 1
actor.Tell(Change("Active", "no"));
instance.ReceiveWhile(o => o, TimeSpan.FromSeconds(1)); // drain any in-flight straggler tick
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void ScriptActor_ExpressionOnTrue_FiresOncePerFalseToTrueEdge()
{
// Regression: OnTrue expression triggers stay edge-triggered.
var triggerExpr = CompileTriggerExpression("Attributes[\"Active\"]?.ToString() == \"yes\"");
var (actor, instance) = CreateTriggeredActor(
"ExprOnTrue",
"Expression",
"{\"expression\":\"Attributes[\\\"Active\\\"]?.ToString() == \\\"yes\\\"\",\"mode\":\"OnTrue\"}",
minTimeBetweenRuns: null,
triggerExpr);
actor.Tell(Change("Active", "yes"));
instance.ExpectMsg(TimeSpan.FromSeconds(2)); // edge fire
actor.Tell(Change("Active", "yes")); // still true, no edge
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
actor.Tell(Change("Active", "no")); // -> false
actor.Tell(Change("Active", "yes")); // false->true edge again
instance.ExpectMsg(TimeSpan.FromSeconds(2));
}
}