using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Messages.Streaming; using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Flattening; using ScadaLink.SiteRuntime.Actors; using ScadaLink.SiteRuntime.Scripts; namespace ScadaLink.SiteRuntime.Tests.Actors; /// /// WP-16: Alarm Actor tests — value match, range violation, rate of change. /// WP-21: Alarm on-trigger call direction tests. /// public class AlarmActorTests : TestKit, IDisposable { private readonly SharedScriptLibrary _sharedLibrary; private readonly SiteRuntimeOptions _options; private readonly ScriptCompilationService _compilationService; public AlarmActorTests() { _compilationService = new ScriptCompilationService( NullLogger.Instance); _sharedLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); _options = new SiteRuntimeOptions(); } void IDisposable.Dispose() { Shutdown(); } [Fact] public void AlarmActor_ValueMatch_ActivatesOnMatch() { var alarmConfig = new ResolvedAlarm { CanonicalName = "HighTemp", TriggerType = "ValueMatch", TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}", PriorityLevel = 1 }; var instanceProbe = CreateTestProbe(); var alarm = ActorOf(Props.Create(() => new AlarmActor( "HighTemp", "Pump1", instanceProbe.Ref, alarmConfig, null, _sharedLibrary, _options, NullLogger.Instance))); // Send value that matches alarm.Tell(new AttributeValueChanged( "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow)); // Instance Actor should receive AlarmStateChanged var msg = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(AlarmState.Active, msg.State); Assert.Equal("HighTemp", msg.AlarmName); } [Fact] public void AlarmActor_ValueMatch_ClearsOnNonMatch() { var alarmConfig = new ResolvedAlarm { CanonicalName = "HighTemp", TriggerType = "ValueMatch", TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}", PriorityLevel = 1 }; var instanceProbe = CreateTestProbe(); var alarm = ActorOf(Props.Create(() => new AlarmActor( "HighTemp", "Pump1", instanceProbe.Ref, alarmConfig, null, _sharedLibrary, _options, NullLogger.Instance))); // Activate alarm.Tell(new AttributeValueChanged( "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow)); var activateMsg = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(AlarmState.Active, activateMsg.State); // Clear alarm.Tell(new AttributeValueChanged( "Pump1", "Status", "Status", "Normal", "Good", DateTimeOffset.UtcNow)); var clearMsg = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(AlarmState.Normal, clearMsg.State); } [Fact] public void AlarmActor_RangeViolation_ActivatesOutsideRange() { var alarmConfig = new ResolvedAlarm { CanonicalName = "TempRange", TriggerType = "RangeViolation", TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}", PriorityLevel = 2 }; var instanceProbe = CreateTestProbe(); var alarm = ActorOf(Props.Create(() => new AlarmActor( "TempRange", "Pump1", instanceProbe.Ref, alarmConfig, null, _sharedLibrary, _options, NullLogger.Instance))); // Value within range -- no alarm alarm.Tell(new AttributeValueChanged( "Pump1", "Temperature", "Temperature", "50", "Good", DateTimeOffset.UtcNow)); instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); // Value outside range -- alarm activates alarm.Tell(new AttributeValueChanged( "Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow)); var msg = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(AlarmState.Active, msg.State); } [Fact] public void AlarmActor_RangeViolation_ClearsWhenBackInRange() { var alarmConfig = new ResolvedAlarm { CanonicalName = "TempRange", TriggerType = "RangeViolation", TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"min\":0,\"max\":100}", PriorityLevel = 2 }; var instanceProbe = CreateTestProbe(); var alarm = ActorOf(Props.Create(() => new AlarmActor( "TempRange", "Pump1", instanceProbe.Ref, alarmConfig, null, _sharedLibrary, _options, NullLogger.Instance))); // Activate alarm.Tell(new AttributeValueChanged( "Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow)); instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); // Clear alarm.Tell(new AttributeValueChanged( "Pump1", "Temperature", "Temperature", "75", "Good", DateTimeOffset.UtcNow)); var clearMsg = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(AlarmState.Normal, clearMsg.State); } [Fact] public void AlarmActor_IgnoresUnmonitoredAttributes() { var alarmConfig = new ResolvedAlarm { CanonicalName = "TempAlarm", TriggerType = "ValueMatch", TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"matchValue\":\"100\"}", PriorityLevel = 1 }; var instanceProbe = CreateTestProbe(); var alarm = ActorOf(Props.Create(() => new AlarmActor( "TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig, null, _sharedLibrary, _options, NullLogger.Instance))); // Send change for a different attribute alarm.Tell(new AttributeValueChanged( "Pump1", "Pressure", "Pressure", "100", "Good", DateTimeOffset.UtcNow)); instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } [Fact] public void AlarmActor_DoesNotReTrigger_WhenAlreadyActive() { var alarmConfig = new ResolvedAlarm { CanonicalName = "TempAlarm", TriggerType = "ValueMatch", TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}", PriorityLevel = 1 }; var instanceProbe = CreateTestProbe(); var alarm = ActorOf(Props.Create(() => new AlarmActor( "TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig, null, _sharedLibrary, _options, NullLogger.Instance))); // First trigger alarm.Tell(new AttributeValueChanged( "Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow)); instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); // Second trigger with same value -- should NOT re-trigger alarm.Tell(new AttributeValueChanged( "Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow)); instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } [Fact] public void AlarmActor_StartsNormal_OnRestart() { // Per design: on restart, alarm starts normal, re-evaluates from incoming values var alarmConfig = new ResolvedAlarm { CanonicalName = "RestartAlarm", TriggerType = "ValueMatch", TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}", PriorityLevel = 1 }; var instanceProbe = CreateTestProbe(); var alarm = ActorOf(Props.Create(() => new AlarmActor( "RestartAlarm", "Pump1", instanceProbe.Ref, alarmConfig, null, _sharedLibrary, _options, NullLogger.Instance))); // A "Good" value should not trigger since alarm starts Normal alarm.Tell(new AttributeValueChanged( "Pump1", "Status", "Status", "Good", "Good", DateTimeOffset.UtcNow)); instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } [Fact] public void AlarmActor_NoClearScript_OnDeactivation() { // WP-16: On clear, NO script is executed. Only on activate. var alarmConfig = new ResolvedAlarm { CanonicalName = "ClearTest", TriggerType = "ValueMatch", TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Bad\"}", PriorityLevel = 1 }; var instanceProbe = CreateTestProbe(); var alarm = ActorOf(Props.Create(() => new AlarmActor( "ClearTest", "Pump1", instanceProbe.Ref, alarmConfig, null, // no on-trigger script _sharedLibrary, _options, NullLogger.Instance))); // Activate alarm.Tell(new AttributeValueChanged( "Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow)); instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); // Clear -- should send state change but no script execution alarm.Tell(new AttributeValueChanged( "Pump1", "Status", "Status", "Good", "Good", DateTimeOffset.UtcNow)); var clearMsg = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(AlarmState.Normal, clearMsg.State); // No additional messages (no script execution side effects) instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } }