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