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; /// /// 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)); } // ── RateOfChange ─────────────────────────────────────────────────────── /// /// Builds a RateOfChange config JSON with the given threshold (units/sec), /// window (seconds), and direction. Used by the rate-of-change tests. /// 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.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(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(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(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(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(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.Instance))); alarm.Tell(new AttributeValueChanged( "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow)); var msg = instanceProbe.ExpectMsg(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.Instance))); // Status=Critical (not "Good") → alarm activates alarm.Tell(new AttributeValueChanged( "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow)); var activate = instanceProbe.ExpectMsg(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(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.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(TimeSpan.FromSeconds(5)); Assert.Equal(AlarmState.Active, msg.State); } // ── HiLo ─────────────────────────────────────────────────────────────── /// Spawns a HiLo alarm with the given JSON config and alarm-level priority fallback. 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.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(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(TimeSpan.FromSeconds(5)); Assert.Equal(AlarmLevel.High, first.Level); alarm.Tell(TempSample(120)); // crosses HiHi var second = instanceProbe.ExpectMsg(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(TimeSpan.FromSeconds(5)); alarm.Tell(TempSample(85)); // back into the Hi band but still alarmed var msg = instanceProbe.ExpectMsg(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(TimeSpan.FromSeconds(5)); alarm.Tell(TempSample(50)); // back to normal var clear = instanceProbe.ExpectMsg(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(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(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(TimeSpan.FromSeconds(5)); Assert.Equal(AlarmLevel.High, hi.Level); Assert.Equal(600, hi.Priority); alarm.Tell(TempSample(120)); var hiHi = instanceProbe.ExpectMsg(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(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(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(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(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(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(TimeSpan.FromSeconds(5)); // 94 < 95 (HiHi - deadband) → drops to High (still above Hi=80) alarm.Tell(TempSample(94)); var msg = instanceProbe.ExpectMsg(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(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(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(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(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(TimeSpan.FromSeconds(5)); Assert.Equal("Coolant warm — check tank", hi.Message); alarm.Tell(TempSample(120)); var hiHi = instanceProbe.ExpectMsg(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(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(TimeSpan.FromSeconds(5)); // Despite the large deadband, escalation uses the activation threshold (100). alarm.Tell(TempSample(101)); var escalated = instanceProbe.ExpectMsg(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.Instance))); alarm.Tell(new AttributeValueChanged( "Pump1", "Anything", "Anything", "anything", "Good", DateTimeOffset.UtcNow)); instanceProbe.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); } }