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:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||
|
||||
@@ -41,9 +42,10 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
new(sourceRef, "T01", "AnalogLimit.Hi", kind, condition,
|
||||
"Process", "hi", "hi", "", "", null, time ?? DateTimeOffset.UtcNow, "92", "90");
|
||||
|
||||
private IActorRef Spawn(IActorRef instanceActor, IActorRef dclManager) =>
|
||||
private IActorRef Spawn(IActorRef instanceActor, IActorRef dclManager, IServiceProvider? serviceProvider = null) =>
|
||||
ActorOf(Props.Create(() => new NativeAlarmActor(
|
||||
Source(), "inst", instanceActor, dclManager, _storage, _options, NullLogger<NativeAlarmActor>.Instance)));
|
||||
Source(), "inst", instanceActor, dclManager, _storage, _options,
|
||||
NullLogger<NativeAlarmActor>.Instance, AlarmKind.NativeOpcUa, serviceProvider)));
|
||||
|
||||
[Fact]
|
||||
public void SubscribeOnStart_SendsRequestForSourceBinding()
|
||||
@@ -121,6 +123,63 @@ public class NativeAlarmActorTests : TestKit, IDisposable
|
||||
instance.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
// ── M1.5: site event log `alarm` category ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Raise_EmitsAlarmSiteEvent()
|
||||
{
|
||||
var siteLog = new FakeSiteEventLogger();
|
||||
var instance = CreateTestProbe();
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
|
||||
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
||||
|
||||
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
||||
"T01.Hi", AlarmTransitionKind.Raise,
|
||||
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
|
||||
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var rows = siteLog.OfType("alarm");
|
||||
Assert.Single(rows);
|
||||
var row = rows[0];
|
||||
Assert.Equal("Error", row.Severity); // severity 800 → Error
|
||||
Assert.Equal("inst", row.InstanceId);
|
||||
Assert.Equal("NativeAlarmActor:Pressure", row.Source);
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_EmitsInfoAlarmSiteEvent()
|
||||
{
|
||||
var siteLog = new FakeSiteEventLogger();
|
||||
var instance = CreateTestProbe();
|
||||
var dcl = CreateTestProbe();
|
||||
var actor = Spawn(instance.Ref, dcl.Ref, new SingleServiceProvider(siteLog));
|
||||
dcl.ExpectMsg<SubscribeAlarmsRequest>();
|
||||
|
||||
var t0 = DateTimeOffset.UtcNow;
|
||||
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
||||
"T01.Hi", AlarmTransitionKind.Raise,
|
||||
new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800), t0)));
|
||||
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Active);
|
||||
|
||||
// Clear (inactive but not yet acked → stays mirrored, return-to-normal emit).
|
||||
actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
|
||||
"T01.Hi", AlarmTransitionKind.Clear,
|
||||
new AlarmConditionState(false, false, null, AlarmShelveState.Unshelved, false, 0), t0.AddSeconds(5))));
|
||||
instance.ExpectMsg<AlarmStateChanged>(m => m.State == AlarmState.Normal);
|
||||
|
||||
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); // return-to-normal → Info
|
||||
}, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
Shutdown();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// M1 Site Event Logging categories: a capturing fake <see cref="ISiteEventLogger"/>
|
||||
/// used by the actor tests to assert that the right operational events are emitted.
|
||||
/// Thread-safe — the actors fire-and-forget <c>LogEventAsync</c> from background
|
||||
/// tasks, so multiple captures can land concurrently.
|
||||
/// </summary>
|
||||
public sealed class FakeSiteEventLogger : ISiteEventLogger
|
||||
{
|
||||
/// <summary>One captured <see cref="ISiteEventLogger.LogEventAsync"/> invocation.</summary>
|
||||
public sealed record Entry(
|
||||
string EventType,
|
||||
string Severity,
|
||||
string? InstanceId,
|
||||
string Source,
|
||||
string Message,
|
||||
string? Details);
|
||||
|
||||
private readonly ConcurrentQueue<Entry> _entries = new();
|
||||
|
||||
/// <summary>All captured events, in arrival order.</summary>
|
||||
public IReadOnlyList<Entry> Entries => _entries.ToArray();
|
||||
|
||||
/// <summary>Captured events filtered to a single category.</summary>
|
||||
public IReadOnlyList<Entry> OfType(string eventType) =>
|
||||
_entries.Where(e => e.EventType == eventType).ToArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task LogEventAsync(
|
||||
string eventType,
|
||||
string severity,
|
||||
string? instanceId,
|
||||
string source,
|
||||
string message,
|
||||
string? details = null)
|
||||
{
|
||||
_entries.Enqueue(new Entry(eventType, severity, instanceId, source, message, details));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public long FailedWriteCount => 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IServiceProvider"/> that resolves a single
|
||||
/// <see cref="ISiteEventLogger"/> — enough for the actors' optional
|
||||
/// <c>_serviceProvider?.GetService<ISiteEventLogger>()</c> resolution
|
||||
/// without pulling a full DI container into the actor tests.
|
||||
/// </summary>
|
||||
public sealed class SingleServiceProvider(ISiteEventLogger logger) : IServiceProvider
|
||||
{
|
||||
private readonly ISiteEventLogger _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? GetService(Type serviceType) =>
|
||||
serviceType == typeof(ISiteEventLogger) ? _logger : null;
|
||||
}
|
||||
Reference in New Issue
Block a user