Adds a new HiLo alarm trigger type with four configurable setpoints
(LoLo / Lo / Hi / HiHi). Each setpoint carries an optional priority,
deadband (for hysteresis), and operator message. The site runtime emits
AlarmStateChanged with an AlarmLevel field so consumers can differentiate
warning vs critical bands.
Plumbing:
- new AlarmLevel enum + AlarmStateChanged.Level/Message init properties
- AlarmTriggerEditor (Blazor) gets a HiLo render with severity tinting
- AlarmTriggerConfigCodec extracted from the editor for testability
- sitestream.proto carries level + message over gRPC
- SemanticValidator enforces numeric attribute, setpoint ordering,
non-negative deadband
- on-trigger scripts get an Alarm global (Name/Level/Priority/Message)
so notification routing can branch by severity
- per-instance InstanceAlarmOverride entity + EF migration + flattening
step + CLI commands; HiLo overrides merge setpoint-by-setpoint, binary
types whole-replace
- DebugView shows a Level badge + per-band message tooltip
- App.razor auto-reloads on permanent Blazor circuit failure
- docker/regen-proto.sh automates the proto regen workflow (the linux/arm64
protoc segfault means generated files are checked in for now)
876 lines
36 KiB
C#
876 lines
36 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit;
|
|
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));
|
|
}
|
|
|
|
// ── RateOfChange ───────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Builds a RateOfChange config JSON with the given threshold (units/sec),
|
|
/// window (seconds), and direction. Used by the rate-of-change tests.
|
|
/// </summary>
|
|
private static string RocConfig(double thresholdPerSecond, double windowSeconds, string direction) =>
|
|
$"{{\"attributeName\":\"Pressure\",\"thresholdPerSecond\":{thresholdPerSecond.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"windowSeconds\":{windowSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)},\"direction\":\"{direction}\"}}";
|
|
|
|
private IActorRef SpawnRocAlarm(string config, TestProbe instanceProbe)
|
|
{
|
|
var alarmConfig = new ResolvedAlarm
|
|
{
|
|
CanonicalName = "RocAlarm",
|
|
TriggerType = "RateOfChange",
|
|
TriggerConfiguration = config,
|
|
PriorityLevel = 3
|
|
};
|
|
return ActorOf(Props.Create(() => new AlarmActor(
|
|
"RocAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
|
null, _sharedLibrary, _options,
|
|
NullLogger<AlarmActor>.Instance)));
|
|
}
|
|
|
|
private static AttributeValueChanged PressureSample(double value, DateTimeOffset ts) =>
|
|
new("Pump1", "Pressure", "Pressure",
|
|
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
"Good", ts);
|
|
|
|
[Fact]
|
|
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidRise()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
// 50 units/sec threshold, 2 sec window
|
|
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
|
|
|
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
// First sample establishes the window baseline; needs ≥2 samples to compute a rate.
|
|
alarm.Tell(PressureSample(0, t0));
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
|
|
// 100 over 1 sec = 100 units/sec > 50 threshold → activate
|
|
alarm.Tell(PressureSample(100, t0.AddSeconds(1)));
|
|
|
|
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmState.Active, msg.State);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_RateOfChange_Either_ActivatesOnRapidFall()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
|
|
|
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
alarm.Tell(PressureSample(100, t0));
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
|
|
// -100 over 1 sec → |rate| = 100 > 50 → activate (Either covers both signs)
|
|
alarm.Tell(PressureSample(0, t0.AddSeconds(1)));
|
|
|
|
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmState.Active, msg.State);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_RateOfChange_Either_DoesNotActivateWhenBelowThreshold()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
|
|
|
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
alarm.Tell(PressureSample(0, t0));
|
|
// 10 over 1 sec = 10 units/sec < 50 threshold → no alarm
|
|
alarm.Tell(PressureSample(10, t0.AddSeconds(1)));
|
|
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_RateOfChange_Rising_IgnoresFallingSpikes()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = SpawnRocAlarm(RocConfig(50, 2, "rising"), instanceProbe);
|
|
|
|
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
alarm.Tell(PressureSample(100, t0));
|
|
// -100 over 1 sec → would trigger Either, but Rising only fires on positive rate
|
|
alarm.Tell(PressureSample(0, t0.AddSeconds(1)));
|
|
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_RateOfChange_Falling_IgnoresRisingSpikes()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);
|
|
|
|
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
alarm.Tell(PressureSample(0, t0));
|
|
alarm.Tell(PressureSample(100, t0.AddSeconds(1)));
|
|
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_RateOfChange_Falling_ActivatesOnFallingRate()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = SpawnRocAlarm(RocConfig(50, 2, "falling"), instanceProbe);
|
|
|
|
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
alarm.Tell(PressureSample(100, t0));
|
|
alarm.Tell(PressureSample(0, t0.AddSeconds(1))); // -100/sec, |rate| > threshold, falling
|
|
|
|
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmState.Active, msg.State);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_RateOfChange_SingleSample_DoesNotActivate()
|
|
{
|
|
// The evaluator needs at least two samples in the window to compute a rate.
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = SpawnRocAlarm(RocConfig(50, 2, "either"), instanceProbe);
|
|
|
|
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
alarm.Tell(PressureSample(1000, t0));
|
|
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_RateOfChange_WindowRollsOff_OldSamplesDiscarded()
|
|
{
|
|
// 1-second window. Sample at t=0 with value 0 should fall out before the
|
|
// sample at t=3, so the in-window history is just the two recent samples
|
|
// (t=2.5, v=99) and (t=3, v=100) → rate = 1 unit / 0.5s = 2/sec — below
|
|
// the threshold, so no alarm even though the long-term delta is huge.
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);
|
|
|
|
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
alarm.Tell(PressureSample(0, t0));
|
|
alarm.Tell(PressureSample(99, t0.AddSeconds(2.5)));
|
|
alarm.Tell(PressureSample(100, t0.AddSeconds(3)));
|
|
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_RateOfChange_ClearsWhenRateDropsBack()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = SpawnRocAlarm(RocConfig(50, 1, "either"), instanceProbe);
|
|
|
|
var t0 = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
// Spike: activate
|
|
alarm.Tell(PressureSample(0, t0));
|
|
alarm.Tell(PressureSample(100, t0.AddSeconds(0.5))); // 200/sec > 50
|
|
var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmState.Active, activate.State);
|
|
|
|
// Now sample again well past the 1-second window with only a tiny change
|
|
// → rate falls below threshold → clears.
|
|
alarm.Tell(PressureSample(101, t0.AddSeconds(3)));
|
|
|
|
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmState.Normal, clear.State);
|
|
}
|
|
|
|
// ── Legacy JSON aliases & not-equals prefix ────────────────────────────
|
|
|
|
[Fact]
|
|
public void AlarmActor_ValueMatch_LegacyAttributeAndValueKeys_StillFire()
|
|
{
|
|
// Old configs used "attribute" / "value" before the canonical names landed.
|
|
var alarmConfig = new ResolvedAlarm
|
|
{
|
|
CanonicalName = "Legacy",
|
|
TriggerType = "ValueMatch",
|
|
TriggerConfiguration = "{\"attribute\":\"Status\",\"value\":\"Critical\"}",
|
|
PriorityLevel = 1
|
|
};
|
|
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
|
"Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
|
|
null, _sharedLibrary, _options,
|
|
NullLogger<AlarmActor>.Instance)));
|
|
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
|
|
|
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmState.Active, msg.State);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_ValueMatch_NotEqualsPrefix_FiresWhenValueDiffers()
|
|
{
|
|
// matchValue "!=Good" means: alarm when Status is anything other than Good.
|
|
var alarmConfig = new ResolvedAlarm
|
|
{
|
|
CanonicalName = "Inverted",
|
|
TriggerType = "ValueMatch",
|
|
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"!=Good\"}",
|
|
PriorityLevel = 1
|
|
};
|
|
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
|
"Inverted", "Pump1", instanceProbe.Ref, alarmConfig,
|
|
null, _sharedLibrary, _options,
|
|
NullLogger<AlarmActor>.Instance)));
|
|
|
|
// Status=Critical (not "Good") → alarm activates
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
|
var activate = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmState.Active, activate.State);
|
|
|
|
// Status=Good → alarm clears
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "Status", "Status", "Good", "Critical", DateTimeOffset.UtcNow));
|
|
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmState.Normal, clear.State);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_RangeViolation_LegacyLowHighKeys_StillFire()
|
|
{
|
|
// Older configs used "low" / "high" instead of the current "min" / "max".
|
|
var alarmConfig = new ResolvedAlarm
|
|
{
|
|
CanonicalName = "Legacy",
|
|
TriggerType = "RangeViolation",
|
|
TriggerConfiguration = "{\"attributeName\":\"Temperature\",\"low\":0,\"high\":100}",
|
|
PriorityLevel = 1
|
|
};
|
|
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
|
"Legacy", "Pump1", instanceProbe.Ref, alarmConfig,
|
|
null, _sharedLibrary, _options,
|
|
NullLogger<AlarmActor>.Instance)));
|
|
|
|
// Within range → no alarm
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "Temperature", "Temperature", "50", "Good", DateTimeOffset.UtcNow));
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
|
|
// Outside range → activate
|
|
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);
|
|
}
|
|
|
|
// ── HiLo ───────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Spawns a HiLo alarm with the given JSON config and alarm-level priority fallback.</summary>
|
|
private IActorRef SpawnHiLoAlarm(string config, TestProbe instanceProbe, int priority = 500)
|
|
{
|
|
var alarmConfig = new ResolvedAlarm
|
|
{
|
|
CanonicalName = "TempAlarm",
|
|
TriggerType = "HiLo",
|
|
TriggerConfiguration = config,
|
|
PriorityLevel = priority
|
|
};
|
|
return ActorOf(Props.Create(() => new AlarmActor(
|
|
"TempAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
|
null, _sharedLibrary, _options,
|
|
NullLogger<AlarmActor>.Instance)));
|
|
}
|
|
|
|
private static AttributeValueChanged TempSample(double value) =>
|
|
new("Pump1", "Temperature", "Temperature",
|
|
value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
"Good", DateTimeOffset.UtcNow);
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_EntersHigh_WhenValueCrossesHi()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(50)); // normal band — no emit
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
|
|
alarm.Tell(TempSample(85)); // crosses Hi but not HiHi
|
|
|
|
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmState.Active, msg.State);
|
|
Assert.Equal(AlarmLevel.High, msg.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_EscalatesToHighHigh_WhenValueClimbsPastHiHi()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(85));
|
|
var first = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmLevel.High, first.Level);
|
|
|
|
alarm.Tell(TempSample(120)); // crosses HiHi
|
|
|
|
var second = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmState.Active, second.State);
|
|
Assert.Equal(AlarmLevel.HighHigh, second.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_DescalatesFromHighHighToHigh_WhenValueDrops()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(120));
|
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
|
|
alarm.Tell(TempSample(85)); // back into the Hi band but still alarmed
|
|
|
|
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmState.Active, msg.State);
|
|
Assert.Equal(AlarmLevel.High, msg.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_ClearsToNormal_WhenValueReturnsToNormalBand()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(85));
|
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
|
|
alarm.Tell(TempSample(50)); // back to normal
|
|
|
|
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmState.Normal, clear.State);
|
|
Assert.Equal(AlarmLevel.None, clear.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_EntersLow_WhenValueCrossesLo()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(8));
|
|
|
|
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmLevel.Low, msg.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_EntersLowLow_WhenValueCrossesLoLo()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(-5));
|
|
|
|
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmLevel.LowLow, msg.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_PerSetpointPriority_OverridesAlarmLevelPriority()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
// Alarm-level priority is 500; HiHi explicitly bumps to 900.
|
|
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiPriority"":600,""hiHiPriority"":900}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 500);
|
|
|
|
alarm.Tell(TempSample(85));
|
|
var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmLevel.High, hi.Level);
|
|
Assert.Equal(600, hi.Priority);
|
|
|
|
alarm.Tell(TempSample(120));
|
|
var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmLevel.HighHigh, hiHi.Level);
|
|
Assert.Equal(900, hiHi.Priority);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_MissingPerSetpointPriority_FallsBackToAlarmLevel()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""hi"":80}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe, priority: 432);
|
|
|
|
alarm.Tell(TempSample(85));
|
|
|
|
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(432, msg.Priority);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_PartialConfig_OnlyHiHiSet_NoEffectInLowRange()
|
|
{
|
|
// Only HiHi is configured — values that would have hit a Lo or Hi band
|
|
// (in a fully-configured alarm) are inside the implicit normal band here.
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""hiHi"":100}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(-1000));
|
|
alarm.Tell(TempSample(95));
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
|
|
alarm.Tell(TempSample(110));
|
|
|
|
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_BoundaryValue_AtHiHi_ResolvesToHighHigh()
|
|
{
|
|
// When the value exactly equals the boundary, the most-severe matching
|
|
// band wins. value == HiHi → HighHigh (not High).
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(100));
|
|
|
|
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmLevel.HighHigh, msg.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_StaysAtSameLevel_NoRedundantEmission()
|
|
{
|
|
// Two updates that resolve to the same level should produce exactly one
|
|
// AlarmStateChanged — the second is a no-op.
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(85));
|
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
|
|
alarm.Tell(TempSample(90)); // still in the Hi band
|
|
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_NoSetpointsConfigured_NeverFires()
|
|
{
|
|
// Validation flags this as a warning at design time; runtime behavior
|
|
// is "evaluates to None forever".
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature""}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(99999));
|
|
alarm.Tell(TempSample(-99999));
|
|
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
|
|
}
|
|
|
|
// ── HiLo hysteresis ────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_Hysteresis_StaysAtHighHigh_UntilDropsBelowDeadband()
|
|
{
|
|
// HiHi=100 with 5-unit deadband. Once at HighHigh, the alarm stays there
|
|
// until the value drops below 95 — at 96 it should still be HighHigh.
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(120));
|
|
var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmLevel.HighHigh, entered.Level);
|
|
|
|
// 96 > 95 (HiHi - deadband) → still HighHigh, no state change emitted
|
|
alarm.Tell(TempSample(96));
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_Hysteresis_DropsToHigh_OnlyAfterDeadbandCleared()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":5}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(120));
|
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
|
|
// 94 < 95 (HiHi - deadband) → drops to High (still above Hi=80)
|
|
alarm.Tell(TempSample(94));
|
|
var msg = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmLevel.High, msg.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_Hysteresis_HiDeadband_PreventsFlapping()
|
|
{
|
|
// Hi=80 with 5-unit deadband. After entering Hi, stays Hi until value drops below 75.
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiDeadband"":5}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(85));
|
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
|
|
alarm.Tell(TempSample(78)); // 78 > 75 → still High
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
|
|
alarm.Tell(TempSample(74)); // 74 < 75 → clears to Normal
|
|
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmLevel.None, clear.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_Hysteresis_LowSide_Symmetric()
|
|
{
|
|
// Lo=10 with 3-unit deadband. After entering Lo, stays Lo until value rises above 13.
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""loLo"":0,""lo"":10,""loDeadband"":3}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(8));
|
|
var entered = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmLevel.Low, entered.Level);
|
|
|
|
alarm.Tell(TempSample(12)); // 12 <= 13 → still Low
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
|
|
|
alarm.Tell(TempSample(14)); // 14 > 13 → clears
|
|
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmLevel.None, clear.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_PerBandMessage_FlowsToAlarmStateChanged()
|
|
{
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiMessage"":""Coolant warm — check tank"",""hiHiMessage"":""Coolant critical — shut down""}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(85));
|
|
var hi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("Coolant warm — check tank", hi.Message);
|
|
|
|
alarm.Tell(TempSample(120));
|
|
var hiHi = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("Coolant critical — shut down", hiHi.Message);
|
|
|
|
// Clearing back to normal carries an empty message.
|
|
alarm.Tell(TempSample(50));
|
|
var clear = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(string.Empty, clear.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_HiLo_Hysteresis_DoesNotDelayEscalation()
|
|
{
|
|
// Deadband is only on de-escalation. Escalating up to HighHigh should not be delayed.
|
|
var instanceProbe = CreateTestProbe();
|
|
const string config = @"{""attributeName"":""Temperature"",""hi"":80,""hiHi"":100,""hiHiDeadband"":50}";
|
|
var alarm = SpawnHiLoAlarm(config, instanceProbe);
|
|
|
|
alarm.Tell(TempSample(85));
|
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
|
|
// Despite the large deadband, escalation uses the activation threshold (100).
|
|
alarm.Tell(TempSample(101));
|
|
var escalated = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(AlarmLevel.HighHigh, escalated.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
|
|
{
|
|
// ParseEvalConfig falls back to a safe default on JSON failure; the actor
|
|
// should accept messages without throwing and just never trigger.
|
|
var alarmConfig = new ResolvedAlarm
|
|
{
|
|
CanonicalName = "Bad",
|
|
TriggerType = "ValueMatch",
|
|
TriggerConfiguration = "{not valid json",
|
|
PriorityLevel = 1
|
|
};
|
|
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
|
"Bad", "Pump1", instanceProbe.Ref, alarmConfig,
|
|
null, _sharedLibrary, _options,
|
|
NullLogger<AlarmActor>.Instance)));
|
|
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Pump1", "Anything", "Anything", "anything", "Good", DateTimeOffset.UtcNow));
|
|
|
|
instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
|
}
|
|
}
|