feat(siteeventlog): emit alarm-category events on alarm transitions (M1.5)

AlarmActor (computed) and NativeAlarmActor (native mirror) now fire-and-forget
an 'alarm' site operational event on every state transition:
- raise/activate: Error (priority/severity >= 700) or Warning
- clear/return-to-normal, ack, inter-band transition: Info

Both actors take a new optional IServiceProvider? ctor param (default null so
existing direct-construction tests still compile); InstanceActor passes its
_serviceProvider at the two Props.Create sites. Resolution is optional and the
LogEventAsync call is fire-and-forget, so a logging failure never affects alarm
evaluation. Rehydration replays are not re-logged.

Adds a capturing FakeSiteEventLogger test helper + SingleServiceProvider.
This commit is contained in:
Joseph Doherty
2026-06-15 12:23:04 -04:00
parent f49ac51771
commit a00e43c4f9
6 changed files with 368 additions and 9 deletions
@@ -7,6 +7,7 @@ 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;
@@ -877,6 +878,112 @@ public class AlarmActorTests : TestKit, IDisposable
Assert.Equal(AlarmLevel.HighHigh, escalated.Level);
}
// ── M1.5: site event log `alarm` category ──────────────────────────────
[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 = 800
};
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 800 → Error
Assert.Equal("Pump1", row.InstanceId);
Assert.Equal("AlarmActor:HighTemp", row.Source);
}, TimeSpan.FromSeconds(2));
}
[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 = 100
};
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 100 → Warning
}, TimeSpan.FromSeconds(2));
}
[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 = 800
};
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));
}
[Fact]
public void AlarmActor_MalformedTriggerConfig_DoesNotCrash()
{