using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Messages.DebugView; 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.Persistence; using ScadaLink.SiteRuntime.Scripts; using System.Text.Json; namespace ScadaLink.SiteRuntime.Tests.Actors; /// /// Integration tests for InstanceActor with child Script/Alarm actors (WP-15, WP-16, WP-24, WP-25). /// public class InstanceActorIntegrationTests : TestKit, IDisposable { private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly SiteRuntimeOptions _options; private readonly string _dbFile; public InstanceActorIntegrationTests() { _dbFile = Path.Combine(Path.GetTempPath(), $"instance-int-test-{Guid.NewGuid():N}.db"); _storage = new SiteStorageService( $"Data Source={_dbFile}", NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService( NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); _options = new SiteRuntimeOptions { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = 30 }; } void IDisposable.Dispose() { Shutdown(); try { File.Delete(_dbFile); } catch { /* cleanup */ } } private IActorRef CreateInstanceWithScripts( string instanceName, IReadOnlyList? scripts = null, IReadOnlyList? alarms = null) { var config = new FlattenedConfiguration { InstanceUniqueName = instanceName, Attributes = [ new ResolvedAttribute { CanonicalName = "Temperature", Value = "98.6", DataType = "Double" }, new ResolvedAttribute { CanonicalName = "Status", Value = "Running", DataType = "String" } ], Scripts = scripts ?? [], Alarms = alarms ?? [] }; return ActorOf(Props.Create(() => new InstanceActor( instanceName, JsonSerializer.Serialize(config), _storage, _compilationService, _sharedScriptLibrary, null, // no stream manager _options, NullLogger.Instance))); } [Fact] public void InstanceActor_CreatesScriptActors_FromConfig() { var scripts = new[] { new ResolvedScript { CanonicalName = "GetValue", Code = "42" } }; var actor = CreateInstanceWithScripts("Pump1", scripts); // Verify script actor is reachable via CallScript actor.Tell(new ScriptCallRequest("GetValue", null, 0, "corr-1")); var result = ExpectMsg(TimeSpan.FromSeconds(10)); Assert.True(result.Success); Assert.Equal(42, result.ReturnValue); } [Fact] public void InstanceActor_ScriptCallRequest_UnknownScript_ReturnsError() { var actor = CreateInstanceWithScripts("Pump1"); actor.Tell(new ScriptCallRequest("NonExistent", null, 0, "corr-2")); var result = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.False(result.Success); Assert.Contains("not found", result.ErrorMessage); } [Fact] public void InstanceActor_WP24_StateMutationsSerializedThroughMailbox() { // WP-24: Instance Actor processes messages sequentially. // Verify that rapid attribute changes don't corrupt state. var actor = CreateInstanceWithScripts("Pump1"); // Send many rapid set commands for (int i = 0; i < 50; i++) { actor.Tell(new SetStaticAttributeCommand( $"corr-{i}", "Pump1", "Temperature", $"{i}", DateTimeOffset.UtcNow)); } // Wait for all to process for (int i = 0; i < 50; i++) { ExpectMsg(TimeSpan.FromSeconds(10)); } // The last value should be the final one actor.Tell(new GetAttributeRequest( "corr-final", "Pump1", "Temperature", DateTimeOffset.UtcNow)); var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.True(response.Found); Assert.Equal("49", response.Value?.ToString()); } [Fact] public void InstanceActor_WP25_DebugViewSubscribe_ReturnsSnapshot() { var actor = CreateInstanceWithScripts("Pump1"); // Wait for initialization Thread.Sleep(500); actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-1")); var snapshot = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("Pump1", snapshot.InstanceUniqueName); Assert.True(snapshot.AttributeValues.Count >= 2); // Temperature + Status Assert.True(snapshot.SnapshotTimestamp > DateTimeOffset.MinValue); } [Fact] public void InstanceActor_WP25_DebugViewSubscriber_ReceivesChanges() { var actor = CreateInstanceWithScripts("Pump1"); // Subscribe to debug view actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-2")); ExpectMsg(TimeSpan.FromSeconds(5)); // Now change an attribute actor.Tell(new AttributeValueChanged( "Pump1", "Temperature", "Temperature", "200", "Good", DateTimeOffset.UtcNow)); // The subscriber should receive the change notification var changed = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("Temperature", changed.AttributeName); Assert.Equal("200", changed.Value?.ToString()); } [Fact] public void InstanceActor_WP25_DebugViewUnsubscribe_StopsNotifications() { var actor = CreateInstanceWithScripts("Pump1"); // Subscribe actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-3")); ExpectMsg(TimeSpan.FromSeconds(5)); // Unsubscribe actor.Tell(new UnsubscribeDebugViewRequest("Pump1", "debug-3")); // Change attribute actor.Tell(new AttributeValueChanged( "Pump1", "Temperature", "Temperature", "300", "Good", DateTimeOffset.UtcNow)); // Should NOT receive change notification ExpectNoMsg(TimeSpan.FromSeconds(1)); } [Fact] public void InstanceActor_CreatesAlarmActors_FromConfig() { var alarms = new[] { new ResolvedAlarm { CanonicalName = "HighTemp", TriggerType = "RangeViolation", TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}", PriorityLevel = 1 } }; var actor = CreateInstanceWithScripts("Pump1", alarms: alarms); // Subscribe to debug view to observe alarm state changes actor.Tell(new SubscribeDebugViewRequest("Pump1", "debug-alarm")); ExpectMsg(TimeSpan.FromSeconds(5)); // Send value outside range to trigger alarm actor.Tell(new AttributeValueChanged( "Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow)); // Should receive the attribute change first (from debug subscription) ExpectMsg(TimeSpan.FromSeconds(5)); // Then the alarm state change (forwarded by Instance Actor) var alarmMsg = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal("HighTemp", alarmMsg.AlarmName); Assert.Equal(Commons.Types.Enums.AlarmState.Active, alarmMsg.State); } }