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