dd4f1c2aa3
AlarmConditionState.Severity mixed two conventions: OPC UA rows carried Part 9 Severity (higher = more severe) while MxGateway native alarms and computed alarms carried a raw AVEVA-style priority (1 = most urgent). Every consumer ranks higher as worse, so Worst Severity, the severity filter/sort and the site event-log Error threshold picked the LEAST urgent MxGateway/computed alarms. - AlarmPriorityBands: the OtOpcUa GalaxyAlarmPriority table (lmxopcua#887, b61f3e05), value for value: 1-250 -> 1000, 251-500 -> 750, 501-750 -> 500, 751-999 -> 250; <= 0 clamps to Critical, > 999 to Low. - MxGatewayAlarmMapper bands the gateway severity (= AVEVA priority); AlarmConditionStateFactory.ForComputed bands PriorityLevel. OPC UA unchanged. - AlarmConditionState gains trailing Priority (int?): the raw source number, exactly the pre-#129 Severity. AlarmStateChanged.Priority and the wire AlarmStateUpdate.priority keep that meaning (no proto change); central derives Severity by kind (SeverityFromPriority), so an older site decodes correctly. - ScriptAlarm gains trailing Priority. The native MES 900-999 band and the MES API Severity/MinSeverity/MaxSeverity key off Priority; rig seed scripts moved from a.Severity to a.Priority, so MES output is unchanged. - AlarmActor raise logs Error when the banded severity >= 700 (same threshold as NativeAlarmActor): priority 1-500 Error, 501+ Warning (was raw >= 700). - NativeAlarmActor upgrades pre-#129 native_alarm_state rows on rehydrate. - Debug snapshot decode restores Condition.Priority from the row's priority. - Docs: Commons, DCL, SiteRuntime, Communication, CentralUI, InboundAPI, HighLevelReqs, MES alarm-status plan, CLAUDE.md. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
1157 lines
51 KiB
C#
1157 lines
51 KiB
C#
using Akka.Actor;
|
||
using Akka.TestKit;
|
||
using Akka.TestKit.Xunit2;
|
||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||
using Microsoft.CodeAnalysis.Scripting;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
||
|
||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||
|
||
/// <summary>
|
||
/// Alarm Actor tests — value match, range violation, rate of change.
|
||
/// Alarm on-trigger call direction tests.
|
||
/// </summary>
|
||
public class AlarmActorTests : TestKit, IDisposable
|
||
{
|
||
private readonly SharedScriptLibrary _sharedLibrary;
|
||
private readonly SiteRuntimeOptions _options;
|
||
private readonly ScriptCompilationService _compilationService;
|
||
|
||
/// <summary>Initializes the test with a compilation service, shared script library and default runtime options.</summary>
|
||
public AlarmActorTests()
|
||
{
|
||
_compilationService = new ScriptCompilationService(
|
||
NullLogger<ScriptCompilationService>.Instance);
|
||
_sharedLibrary = new SharedScriptLibrary(
|
||
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||
_options = new SiteRuntimeOptions();
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
void IDisposable.Dispose()
|
||
{
|
||
Shutdown();
|
||
}
|
||
|
||
/// <summary>Verifies that a value-match alarm activates when the value matches.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a value-match alarm clears when the value no longer matches.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a computed alarm carries the unified condition state.</summary>
|
||
[Fact]
|
||
public void AlarmActor_ComputedAlarm_CarriesUnifiedConditionState()
|
||
{
|
||
var alarmConfig = new ResolvedAlarm
|
||
{
|
||
CanonicalName = "HighTemp",
|
||
TriggerType = "ValueMatch",
|
||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||
PriorityLevel = 700
|
||
};
|
||
|
||
var instanceProbe = CreateTestProbe();
|
||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||
"HighTemp", "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(AlarmKind.Computed, msg.Kind); // computed origin
|
||
Assert.Equal(700, msg.Priority); // raw PriorityLevel on the record
|
||
Assert.Equal(700, msg.Condition.Priority); // ...and on the condition (#129)
|
||
Assert.Equal(500, msg.Condition.Severity); // banded: 501–750 = Medium → 500
|
||
Assert.True(msg.Condition.Active); // active alarm → condition active
|
||
Assert.True(msg.Condition.Acknowledged); // computed alarms are auto-acked
|
||
Assert.Equal(string.Empty, msg.SourceReference); // no native source reference
|
||
}
|
||
|
||
/// <summary>Verifies that a range-violation alarm activates when the value is outside the range.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a range-violation alarm clears when the value is back in range.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that the alarm actor ignores changes to attributes it does not monitor.</summary>
|
||
[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));
|
||
}
|
||
|
||
/// <summary>Verifies that an alarm that is already active does not trigger again.</summary>
|
||
[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));
|
||
}
|
||
|
||
/// <summary>Verifies that the alarm actor starts in the normal state after a restart.</summary>
|
||
[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));
|
||
}
|
||
|
||
/// <summary>Verifies that no on-clear script runs when the alarm deactivates.</summary>
|
||
[Fact]
|
||
public void AlarmActor_NoClearScript_OnDeactivation()
|
||
{
|
||
// 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);
|
||
|
||
/// <summary>Verifies that an either-direction rate-of-change alarm activates on a rapid rise.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that an either-direction rate-of-change alarm activates on a rapid fall.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that an either-direction rate-of-change alarm does not activate when the rate is below the threshold.</summary>
|
||
[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));
|
||
}
|
||
|
||
/// <summary>Verifies that a rising rate-of-change alarm ignores falling spikes.</summary>
|
||
[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));
|
||
}
|
||
|
||
/// <summary>Verifies that a falling rate-of-change alarm ignores rising spikes.</summary>
|
||
[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));
|
||
}
|
||
|
||
/// <summary>Verifies that a falling rate-of-change alarm activates on a falling rate.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a rate-of-change alarm does not activate on a single sample.</summary>
|
||
[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));
|
||
}
|
||
|
||
/// <summary>Verifies that rate-of-change samples that roll out of the window are discarded.</summary>
|
||
[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));
|
||
}
|
||
|
||
/// <summary>Verifies that a rate-of-change alarm clears when the rate drops back.</summary>
|
||
[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 ────────────────────────────
|
||
|
||
/// <summary>Verifies that a value-match alarm using the legacy attribute and value keys still fires.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a value-match alarm with a not-equals prefix fires when the value differs.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a range-violation alarm using the legacy low and high keys still fires.</summary>
|
||
[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);
|
||
|
||
/// <summary>Verifies that a HiLo alarm enters the High level when the value crosses the Hi setpoint.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a HiLo alarm escalates to HighHigh when the value climbs past the HiHi setpoint.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a HiLo alarm descalates from HighHigh to High when the value drops.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a HiLo alarm clears to Normal when the value returns to the normal band.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a HiLo alarm enters Low when the value crosses the Lo setpoint.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a HiLo alarm enters LowLow when the value crosses the LoLo setpoint.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a per-setpoint priority overrides the alarm-level priority.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a setpoint without its own priority falls back to the alarm-level priority.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that with only HiHi configured a HiLo alarm has no effect in the low range.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a value exactly at the HiHi setpoint resolves to HighHigh.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a HiLo alarm that stays at the same level emits no redundant state change.</summary>
|
||
[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));
|
||
}
|
||
|
||
/// <summary>Verifies that a HiLo alarm with no setpoints configured never fires.</summary>
|
||
[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 ────────────────────────────────────────────────────
|
||
|
||
/// <summary>Verifies that with hysteresis a HiLo alarm stays at HighHigh until the value drops below the deadband.</summary>
|
||
[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));
|
||
}
|
||
|
||
/// <summary>Verifies that with hysteresis a HiLo alarm drops to High only after the deadband is cleared.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that the Hi deadband prevents a HiLo alarm from flapping.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that hysteresis on the low side behaves symmetrically to the high side.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that a per-band message flows through to the alarm state change.</summary>
|
||
[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);
|
||
}
|
||
|
||
/// <summary>Verifies that hysteresis does not delay escalation to a higher level.</summary>
|
||
[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);
|
||
}
|
||
|
||
// ── Site event log `alarm` category ──────────────────────────────
|
||
|
||
/// <summary>Verifies that raising an alarm emits an alarm site event.</summary>
|
||
[Fact]
|
||
public void AlarmActor_Raise_EmitsAlarmSiteEvent()
|
||
{
|
||
var siteLog = new FakeSiteEventLogger();
|
||
var sp = new SingleServiceProvider(siteLog);
|
||
|
||
var alarmConfig = new ResolvedAlarm
|
||
{
|
||
CanonicalName = "HighTemp",
|
||
TriggerType = "ValueMatch",
|
||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||
PriorityLevel = 100
|
||
};
|
||
|
||
var instanceProbe = CreateTestProbe();
|
||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||
"HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
|
||
null, _sharedLibrary, _options,
|
||
NullLogger<AlarmActor>.Instance, null, null, null, sp)));
|
||
|
||
alarm.Tell(new AttributeValueChanged(
|
||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||
|
||
// Background fire-and-forget; allow it to land.
|
||
AwaitAssert(() =>
|
||
{
|
||
var rows = siteLog.OfType("alarm");
|
||
Assert.Single(rows);
|
||
var row = rows[0];
|
||
Assert.Equal("Error", row.Severity); // priority 100 = AVEVA Critical → Error (#129)
|
||
Assert.Equal("Pump1", row.InstanceId);
|
||
Assert.Equal("AlarmActor:HighTemp", row.Source);
|
||
}, TimeSpan.FromSeconds(2));
|
||
}
|
||
|
||
/// <summary>Verifies that raising a low-priority alarm emits a warning-severity alarm site event.</summary>
|
||
[Fact]
|
||
public void AlarmActor_RaiseLowPriority_EmitsWarningAlarmSiteEvent()
|
||
{
|
||
var siteLog = new FakeSiteEventLogger();
|
||
var sp = new SingleServiceProvider(siteLog);
|
||
|
||
var alarmConfig = new ResolvedAlarm
|
||
{
|
||
CanonicalName = "MinorTemp",
|
||
TriggerType = "ValueMatch",
|
||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Warn\"}",
|
||
PriorityLevel = 800
|
||
};
|
||
|
||
var instanceProbe = CreateTestProbe();
|
||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||
"MinorTemp", "Pump1", instanceProbe.Ref, alarmConfig,
|
||
null, _sharedLibrary, _options,
|
||
NullLogger<AlarmActor>.Instance, null, null, null, sp)));
|
||
|
||
alarm.Tell(new AttributeValueChanged(
|
||
"Pump1", "Status", "Status", "Warn", "Good", DateTimeOffset.UtcNow));
|
||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||
|
||
AwaitAssert(() =>
|
||
{
|
||
var rows = siteLog.OfType("alarm");
|
||
Assert.Single(rows);
|
||
Assert.Equal("Warning", rows[0].Severity); // priority 800 = AVEVA Low → Warning (#129)
|
||
}, TimeSpan.FromSeconds(2));
|
||
}
|
||
|
||
/// <summary>Verifies that clearing an alarm emits an info-severity alarm site event.</summary>
|
||
[Fact]
|
||
public void AlarmActor_Clear_EmitsInfoAlarmSiteEvent()
|
||
{
|
||
var siteLog = new FakeSiteEventLogger();
|
||
var sp = new SingleServiceProvider(siteLog);
|
||
|
||
var alarmConfig = new ResolvedAlarm
|
||
{
|
||
CanonicalName = "HighTemp",
|
||
TriggerType = "ValueMatch",
|
||
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||
PriorityLevel = 100
|
||
};
|
||
|
||
var instanceProbe = CreateTestProbe();
|
||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||
"HighTemp", "Pump1", instanceProbe.Ref, alarmConfig,
|
||
null, _sharedLibrary, _options,
|
||
NullLogger<AlarmActor>.Instance, null, null, null, sp)));
|
||
|
||
alarm.Tell(new AttributeValueChanged(
|
||
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||
alarm.Tell(new AttributeValueChanged(
|
||
"Pump1", "Status", "Status", "Normal", "Critical", DateTimeOffset.UtcNow));
|
||
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||
|
||
AwaitAssert(() =>
|
||
{
|
||
var rows = siteLog.OfType("alarm");
|
||
Assert.Equal(2, rows.Count); // raise + clear
|
||
Assert.Equal("Error", rows[0].Severity);
|
||
Assert.Equal("Info", rows[1].Severity); // clear → Info
|
||
}, TimeSpan.FromSeconds(2));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gitea #129: a computed raise's event-log severity follows the priority banded onto the unified
|
||
/// Severity (lower priority = more urgent), with the same 700 threshold NativeAlarmActor uses.
|
||
/// </summary>
|
||
[Theory]
|
||
[InlineData(0, "Error")]
|
||
[InlineData(1, "Error")]
|
||
[InlineData(250, "Error")]
|
||
[InlineData(500, "Error")]
|
||
[InlineData(501, "Warning")]
|
||
[InlineData(700, "Warning")]
|
||
[InlineData(999, "Warning")]
|
||
[InlineData(1000, "Warning")]
|
||
public void RaiseSeverity_FollowsTheBandedPriority(int priority, string expected) =>
|
||
Assert.Equal(expected, AlarmActor.RaiseSeverity(priority));
|
||
|
||
/// <summary>Verifies that a malformed trigger configuration does not crash the alarm actor.</summary>
|
||
[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));
|
||
}
|
||
|
||
// ── P1: Expression-alarm evaluation runs off the dispatcher ──
|
||
|
||
/// <summary>
|
||
/// Compiles a trigger expression WITHOUT the trust validator so a test can use
|
||
/// <c>System.Threading.Thread</c> to observe which thread the evaluation runs on.
|
||
/// </summary>
|
||
private static Script<object?> CompileRawTriggerExpression(string expression)
|
||
{
|
||
var opts = ScriptOptions.Default
|
||
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
|
||
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
|
||
var s = CSharpScript.Create<object?>(expression, opts, typeof(TriggerExpressionGlobals));
|
||
s.Compile();
|
||
return s;
|
||
}
|
||
|
||
/// <summary>Verifies that an expression alarm evaluates off the blocking script pool and activates.</summary>
|
||
[Fact]
|
||
public void ExpressionAlarm_EvaluatesOffTheBlockingScriptPool_AndActivates()
|
||
{
|
||
// Retarget of the former assertion, whose sense is deliberately INVERTED.
|
||
//
|
||
// An earlier change moved the evaluation off the actor's dispatcher and onto the dedicated
|
||
// script-execution scheduler, and this test asserted exactly that. Later analysis
|
||
// proved that destination wrong for alarms in particular: sharing the blocking pool
|
||
// meant an alarm that should raise in milliseconds queued behind blocked script bodies
|
||
// for an unbounded time, and its 2 s evaluation timeout did not even start ticking
|
||
// until it was dequeued. Evaluations now run on the shared .NET thread pool behind
|
||
// TriggerEvalGate.
|
||
//
|
||
// TRUE only when evaluated OFF a script-execution thread.
|
||
var expr = CompileRawTriggerExpression(
|
||
"System.Threading.Thread.CurrentThread.Name == null || " +
|
||
"!System.Threading.Thread.CurrentThread.Name.StartsWith(\"script-execution-\")");
|
||
var alarmConfig = new ResolvedAlarm
|
||
{
|
||
CanonicalName = "ExprAlarm",
|
||
TriggerType = "Expression",
|
||
TriggerConfiguration = "{\"expression\":\"true\"}",
|
||
PriorityLevel = 1
|
||
};
|
||
var instanceProbe = CreateTestProbe();
|
||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||
"ExprAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
||
null, _sharedLibrary, _options,
|
||
NullLogger<AlarmActor>.Instance, expr)));
|
||
|
||
alarm.Tell(new AttributeValueChanged("Pump1", "A", "A", 1, "Good", DateTimeOffset.UtcNow));
|
||
|
||
var change = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10));
|
||
Assert.Equal(AlarmState.Active, change.State);
|
||
}
|
||
|
||
/// <summary>Verifies that a fault in the expression evaluation task does not kill the alarm trigger.</summary>
|
||
[Fact]
|
||
public void ExpressionEvalTaskFault_DoesNotKillTheAlarmTrigger()
|
||
{
|
||
var expr = CompileRawTriggerExpression("true");
|
||
var alarmConfig = new ResolvedAlarm
|
||
{
|
||
CanonicalName = "ExprFaultAlarm",
|
||
TriggerType = "Expression",
|
||
TriggerConfiguration = "{\"expression\":\"true\"}",
|
||
PriorityLevel = 1
|
||
};
|
||
var instanceProbe = CreateTestProbe();
|
||
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
||
"ExprFaultAlarm", "Pump1", instanceProbe.Ref, alarmConfig,
|
||
null, _sharedLibrary, _options,
|
||
NullLogger<AlarmActor>.Instance, expr)));
|
||
|
||
// Simulate the faulted scheduler task: must be handled (logged, in-flight
|
||
// cleared, false applied) — NOT an unhandled Status.Failure.
|
||
alarm.Tell(new AlarmActor.ExpressionEvalFailed(new ObjectDisposedException("scheduler")));
|
||
|
||
// The trigger must still be alive: the next change evaluates and activates.
|
||
alarm.Tell(new AttributeValueChanged("Pump1", "A", "A", 1, "Good", DateTimeOffset.UtcNow));
|
||
var change = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10));
|
||
Assert.Equal(AlarmState.Active, change.State);
|
||
}
|
||
}
|