Communication Layer (WP-1–5): - 8 message patterns with correlation IDs, per-pattern timeouts - Central/Site communication actors, transport heartbeat config - Connection failure handling (no central buffering, debug streams killed) Data Connection Layer (WP-6–14, WP-34): - Connection actor with Become/Stash lifecycle (Connecting/Connected/Reconnecting) - OPC UA + LmxProxy adapters behind IDataConnection - Auto-reconnect, bad quality propagation, transparent re-subscribe - Write-back, tag path resolution with retry, health reporting - Protocol extensibility via DataConnectionFactory Site Runtime (WP-15–25, WP-32–33): - ScriptActor/ScriptExecutionActor (triggers, concurrent execution, blocking I/O dispatcher) - AlarmActor/AlarmExecutionActor (ValueMatch/RangeViolation/RateOfChange, in-memory state) - SharedScriptLibrary (inline execution), ScriptRuntimeContext (API) - ScriptCompilationService (Roslyn, forbidden API enforcement, execution timeout) - Recursion limit (default 10), call direction enforcement - SiteStreamManager (per-subscriber bounded buffers, fire-and-forget) - Debug view backend (snapshot + stream), concurrency serialization - Local artifact storage (4 SQLite tables) Health Monitoring (WP-26–28): - SiteHealthCollector (thread-safe counters, connection state) - HealthReportSender (30s interval, monotonic sequence numbers) - CentralHealthAggregator (offline detection 60s, online recovery) Site Event Logging (WP-29–31): - SiteEventLogger (SQLite, 6 event categories, ISO 8601 UTC) - EventLogPurgeService (30-day retention, 1GB cap) - EventLogQueryService (filters, keyword search, keyset pagination) 541 tests pass, zero warnings.
261 lines
9.9 KiB
C#
261 lines
9.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// WP-16: Alarm Actor tests — value match, range violation, rate of change.
|
|
/// WP-21: Alarm on-trigger call direction tests.
|
|
/// </summary>
|
|
public class AlarmActorTests : TestKit, IDisposable
|
|
{
|
|
private readonly SharedScriptLibrary _sharedLibrary;
|
|
private readonly SiteRuntimeOptions _options;
|
|
private readonly ScriptCompilationService _compilationService;
|
|
|
|
public AlarmActorTests()
|
|
{
|
|
_compilationService = new ScriptCompilationService(
|
|
NullLogger<ScriptCompilationService>.Instance);
|
|
_sharedLibrary = new SharedScriptLibrary(
|
|
_compilationService, NullLogger<SharedScriptLibrary>.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<AlarmActor>.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<AlarmStateChanged>(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<AlarmActor>.Instance)));
|
|
|
|
// Activate
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
|
var activateMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(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<AlarmStateChanged>(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<AlarmActor>.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<AlarmStateChanged>(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<AlarmActor>.Instance)));
|
|
|
|
// Activate
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "Temperature", "Temperature", "150", "Good", DateTimeOffset.UtcNow));
|
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
|
|
// Clear
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "Temperature", "Temperature", "75", "Good", DateTimeOffset.UtcNow));
|
|
var clearMsg = instanceProbe.ExpectMsg<AlarmStateChanged>(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<AlarmActor>.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<AlarmActor>.Instance)));
|
|
|
|
// First trigger
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow));
|
|
instanceProbe.ExpectMsg<AlarmStateChanged>(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<AlarmActor>.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<AlarmActor>.Instance)));
|
|
|
|
// Activate
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "Status", "Status", "Bad", "Good", DateTimeOffset.UtcNow));
|
|
instanceProbe.ExpectMsg<AlarmStateChanged>(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<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmState.Normal, clearMsg.State);
|
|
|
|
// No additional messages (no script execution side effects)
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
|
}
|
|
}
|