diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs
index bafd20d9..e3020b23 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs
@@ -1,10 +1,12 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
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.HealthMonitoring;
+using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using System.Globalization;
using System.Text.Json;
@@ -37,6 +39,14 @@ public class AlarmActor : ReceiveActor
private readonly SiteRuntimeOptions _options;
private readonly ILogger _logger;
private readonly ISiteHealthCollector? _healthCollector;
+ private readonly IServiceProvider? _serviceProvider;
+
+ ///
+ /// M1.5: priority at or above which a computed-alarm raise is logged as
+ /// Error to the site event log; below it, raises log as Warning .
+ /// Mirrors the 0–1000 alarm-severity scale.
+ ///
+ private const int ErrorPriorityThreshold = 700;
private AlarmState _currentState = AlarmState.Normal;
///
@@ -83,6 +93,9 @@ public class AlarmActor : ReceiveActor
/// Pre-compiled trigger expression, or null for non-expression triggers.
/// Seed attribute snapshot so static attributes evaluate correctly at startup.
/// Optional health collector for surfacing alarm execution metrics.
+ /// Optional DI service provider used to resolve the optional
+ /// for M1.5 alarm operational events. Fire-and-forget;
+ /// a logging failure never affects alarm evaluation.
public AlarmActor(
string alarmName,
string instanceName,
@@ -94,7 +107,8 @@ public class AlarmActor : ReceiveActor
ILogger logger,
Script? compiledTriggerExpression = null,
IReadOnlyDictionary? initialAttributes = null,
- ISiteHealthCollector? healthCollector = null)
+ ISiteHealthCollector? healthCollector = null,
+ IServiceProvider? serviceProvider = null)
{
_alarmName = alarmName;
_instanceName = instanceName;
@@ -103,6 +117,7 @@ public class AlarmActor : ReceiveActor
_options = options;
_logger = logger;
_healthCollector = healthCollector;
+ _serviceProvider = serviceProvider;
_priority = alarmConfig.PriorityLevel;
_onTriggerScriptName = alarmConfig.OnTriggerScriptCanonicalName;
_onTriggerCompiledScript = onTriggerCompiledScript;
@@ -208,6 +223,9 @@ public class AlarmActor : ReceiveActor
_instanceName, _alarmName, AlarmState.Active, _priority, DateTimeOffset.UtcNow);
_instanceActor.Tell(alarmChanged);
+ // M1.5: operational `alarm` event — raise. Severity by priority.
+ LogAlarmEvent(RaiseSeverity(_priority), $"Alarm {_alarmName} activated (priority {_priority})");
+
// Spawn AlarmExecutionActor if on-trigger script defined
if (_onTriggerCompiledScript != null)
{
@@ -225,6 +243,9 @@ public class AlarmActor : ReceiveActor
var alarmChanged = new AlarmStateChanged(
_instanceName, _alarmName, AlarmState.Normal, _priority, DateTimeOffset.UtcNow);
_instanceActor.Tell(alarmChanged);
+
+ // M1.5: operational `alarm` event — return to normal.
+ LogAlarmEvent("Info", $"Alarm {_alarmName} cleared");
}
}
catch (Exception ex)
@@ -265,6 +286,24 @@ public class AlarmActor : ReceiveActor
};
_instanceActor.Tell(alarmChanged);
+ // M1.5: operational `alarm` event. Entering a band from Normal is a raise
+ // (severity by the band's priority); returning to None is a clear; a
+ // level-to-level escalation/de-escalation is an informational transition.
+ if (newLevel == AlarmLevel.None)
+ {
+ LogAlarmEvent("Info", $"Alarm {_alarmName} cleared ({previousLevel} → Normal)");
+ }
+ else if (previousLevel == AlarmLevel.None)
+ {
+ LogAlarmEvent(RaiseSeverity(priority),
+ $"Alarm {_alarmName} activated at {newLevel} (priority {priority})");
+ }
+ else
+ {
+ LogAlarmEvent("Info",
+ $"Alarm {_alarmName} transitioned {previousLevel} → {newLevel} (priority {priority})");
+ }
+
if (previousLevel == AlarmLevel.None
&& newLevel != AlarmLevel.None
&& _onTriggerCompiledScript != null)
@@ -273,6 +312,27 @@ public class AlarmActor : ReceiveActor
}
}
+ ///
+ /// M1.5: maps an alarm priority (0–1000) to a site-event severity for a
+ /// raise transition — Error at or above
+ /// , otherwise Warning . Clears and
+ /// inter-band transitions always log as Info .
+ ///
+ private static string RaiseSeverity(int priority) =>
+ priority >= ErrorPriorityThreshold ? "Error" : "Warning";
+
+ ///
+ /// M1.5: fire-and-forget an alarm operational event to the optional
+ /// . Resolved optionally and never awaited so a
+ /// logging failure cannot affect alarm evaluation (matching the established
+ /// ScriptActor/ScriptExecutionActor pattern).
+ ///
+ private void LogAlarmEvent(string severity, string message)
+ {
+ _ = _serviceProvider?.GetService()?.LogEventAsync(
+ "alarm", severity, _instanceName, $"AlarmActor:{_alarmName}", message);
+ }
+
///
/// Returns the per-setpoint priority for the given level. Falls back to
/// the alarm-level when the HiLo config did not
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
index 75909a8e..310eb9d8 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
@@ -763,7 +763,8 @@ public class InstanceActor : ReceiveActor
_logger,
triggerExpression,
attributeSnapshot,
- _healthCollector));
+ _healthCollector,
+ _serviceProvider));
var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
_alarmActors[alarm.CanonicalName] = actorRef;
@@ -793,7 +794,8 @@ public class InstanceActor : ReceiveActor
_storage,
_options,
_logger,
- nativeKind));
+ nativeKind,
+ _serviceProvider));
var actorRef = Context.ActorOf(props, $"native-alarm-{nativeSource.CanonicalName}");
_nativeAlarmActors[nativeSource.CanonicalName] = actorRef;
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs
index 28747cea..bca89d4d 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/NativeAlarmActor.cs
@@ -1,11 +1,13 @@
using System.Text.Json;
using Akka.Actor;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
+using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
@@ -35,6 +37,14 @@ public class NativeAlarmActor : ReceiveActor
private readonly SiteRuntimeOptions _options;
private readonly ILogger _logger;
private readonly AlarmKind _nativeKind;
+ private readonly IServiceProvider? _serviceProvider;
+
+ ///
+ /// M1.5: severity at or above which a native-alarm raise is logged as
+ /// Error to the site event log; below it, raises log as Warning .
+ /// Mirrors the 0–1000 condition-severity scale.
+ ///
+ private const int ErrorSeverityThreshold = 700;
/// Current mirrored conditions, keyed by source reference.
private readonly Dictionary _alarms = new();
@@ -54,6 +64,9 @@ public class NativeAlarmActor : ReceiveActor
/// Logger for diagnostics.
/// Alarm kind to stamp on emitted events (OPC UA vs MxAccess); set by the
/// Instance Actor from the connection protocol. Defaults to .
+ /// Optional DI service provider used to resolve the optional
+ /// for M1.5 alarm operational events. Fire-and-forget;
+ /// a logging failure never affects the mirror.
public NativeAlarmActor(
ResolvedNativeAlarmSource source,
string instanceName,
@@ -62,7 +75,8 @@ public class NativeAlarmActor : ReceiveActor
SiteStorageService storage,
SiteRuntimeOptions options,
ILogger logger,
- AlarmKind nativeKind = AlarmKind.NativeOpcUa)
+ AlarmKind nativeKind = AlarmKind.NativeOpcUa,
+ IServiceProvider? serviceProvider = null)
{
_source = source;
_instanceName = instanceName;
@@ -72,6 +86,7 @@ public class NativeAlarmActor : ReceiveActor
_options = options;
_logger = logger;
_nativeKind = nativeKind;
+ _serviceProvider = serviceProvider;
Receive(HandleRehydration);
Receive(HandleTransition);
@@ -150,7 +165,10 @@ public class NativeAlarmActor : ReceiveActor
condition, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty,
null, row.LastTransitionAt, string.Empty, string.Empty);
_alarms[row.SourceReference] = t;
- Emit(t, t.Condition);
+ // M1.5: rehydration replays last-known state on (re)start — surface it
+ // upward for the DebugView but do NOT re-log it as a fresh operational
+ // event (it is not a live transition).
+ Emit(t, t.Condition, logSiteEvent: false);
}
}
@@ -277,8 +295,16 @@ public class NativeAlarmActor : ReceiveActor
}
}
- /// Builds and tells the parent an enriched for a condition.
- private void Emit(NativeAlarmTransition t, AlarmConditionState condition)
+ ///
+ /// Builds and tells the parent an enriched for a condition.
+ ///
+ /// The mirrored transition.
+ /// The condition state to surface (may differ from 's
+ /// own condition, e.g. a synthesised return-to-normal on snapshot swap).
+ /// M1.5: when true (live + snapshot transitions), emit an
+ /// alarm operational event. Suppressed for SQLite rehydration so a node restart does not
+ /// re-log every last-known condition.
+ private void Emit(NativeAlarmTransition t, AlarmConditionState condition, bool logSiteEvent = true)
{
var change = new AlarmStateChanged(
_instanceName,
@@ -301,6 +327,49 @@ public class NativeAlarmActor : ReceiveActor
};
_instanceActor.Tell(change);
+
+ if (logSiteEvent)
+ {
+ LogAlarmEvent(t, condition);
+ }
+ }
+
+ ///
+ /// M1.5: fire-and-forget an alarm operational event mirroring a native
+ /// condition transition. An active condition is a raise (severity by the
+ /// condition's severity); an inactive condition is a return-to-normal; an
+ /// acknowledge transition is informational. Resolved optionally and never
+ /// awaited so a logging failure cannot affect the mirror (matching the
+ /// established ScriptActor/ScriptExecutionActor pattern).
+ ///
+ private void LogAlarmEvent(NativeAlarmTransition t, AlarmConditionState condition)
+ {
+ var logger = _serviceProvider?.GetService();
+ if (logger == null)
+ {
+ return;
+ }
+
+ string severity;
+ string message;
+ if (t.Kind == AlarmTransitionKind.Acknowledge)
+ {
+ severity = "Info";
+ message = $"Native alarm {t.SourceReference} acknowledged";
+ }
+ else if (condition.Active)
+ {
+ severity = condition.Severity >= ErrorSeverityThreshold ? "Error" : "Warning";
+ message = $"Native alarm {t.SourceReference} active (severity {condition.Severity})";
+ }
+ else
+ {
+ severity = "Info";
+ message = $"Native alarm {t.SourceReference} returned to normal";
+ }
+
+ _ = logger.LogEventAsync(
+ "alarm", severity, _instanceName, $"NativeAlarmActor:{_source.CanonicalName}", message);
}
private void PersistUpsert(NativeAlarmTransition t)
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmActorTests.cs
index 150122a3..d7ee865d 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/AlarmActorTests.cs
@@ -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.Instance, null, null, null, sp)));
+
+ alarm.Tell(new AttributeValueChanged(
+ "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
+ instanceProbe.ExpectMsg(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.Instance, null, null, null, sp)));
+
+ alarm.Tell(new AttributeValueChanged(
+ "Pump1", "Status", "Status", "Warn", "Good", DateTimeOffset.UtcNow));
+ instanceProbe.ExpectMsg(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.Instance, null, null, null, sp)));
+
+ alarm.Tell(new AttributeValueChanged(
+ "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
+ instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5));
+ alarm.Tell(new AttributeValueChanged(
+ "Pump1", "Status", "Status", "Normal", "Critical", DateTimeOffset.UtcNow));
+ instanceProbe.ExpectMsg(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()
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs
index ac9614b9..4cc5f594 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/NativeAlarmActorTests.cs
@@ -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.Instance)));
+ Source(), "inst", instanceActor, dclManager, _storage, _options,
+ NullLogger.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();
+
+ actor.Tell(new NativeAlarmTransitionUpdate("Opc", Transition(
+ "T01.Hi", AlarmTransitionKind.Raise,
+ new AlarmConditionState(true, false, null, AlarmShelveState.Unshelved, false, 800))));
+ instance.ExpectMsg(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();
+
+ 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(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(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();
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/FakeSiteEventLogger.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/FakeSiteEventLogger.cs
new file mode 100644
index 00000000..49239928
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/FakeSiteEventLogger.cs
@@ -0,0 +1,62 @@
+using System.Collections.Concurrent;
+using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
+
+namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
+
+///
+/// M1 Site Event Logging categories: a capturing fake
+/// used by the actor tests to assert that the right operational events are emitted.
+/// Thread-safe — the actors fire-and-forget LogEventAsync from background
+/// tasks, so multiple captures can land concurrently.
+///
+public sealed class FakeSiteEventLogger : ISiteEventLogger
+{
+ /// One captured invocation.
+ public sealed record Entry(
+ string EventType,
+ string Severity,
+ string? InstanceId,
+ string Source,
+ string Message,
+ string? Details);
+
+ private readonly ConcurrentQueue _entries = new();
+
+ /// All captured events, in arrival order.
+ public IReadOnlyList Entries => _entries.ToArray();
+
+ /// Captured events filtered to a single category.
+ public IReadOnlyList OfType(string eventType) =>
+ _entries.Where(e => e.EventType == eventType).ToArray();
+
+ ///
+ 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;
+ }
+
+ ///
+ public long FailedWriteCount => 0;
+}
+
+///
+/// Minimal that resolves a single
+/// — enough for the actors' optional
+/// _serviceProvider?.GetService<ISiteEventLogger>() resolution
+/// without pulling a full DI container into the actor tests.
+///
+public sealed class SingleServiceProvider(ISiteEventLogger logger) : IServiceProvider
+{
+ private readonly ISiteEventLogger _logger = logger;
+
+ ///
+ public object? GetService(Type serviceType) =>
+ serviceType == typeof(ISiteEventLogger) ? _logger : null;
+}