Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmActorTests.cs
T
Joseph Doherty dd4f1c2aa3 fix(alarms): Severity runs one direction, higher = more urgent; raw priority kept (#129)
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
2026-11-12 14:02:54 -05:00

1157 lines
51 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}