Files
scadalink-design/tests/ScadaLink.SiteRuntime.Tests/Actors/AlarmActorTests.cs
Joseph Doherty 751248feb6 feat(alarms): HiLo trigger type with per-band level, hysteresis, messages, overrides
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)
2026-05-13 03:23:32 -04:00

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