From a00e43c4f9f74eee8a8bc4b3d3d863d26041ed06 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 12:23:04 -0400 Subject: [PATCH] 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. --- .../Actors/AlarmActor.cs | 62 +++++++++- .../Actors/InstanceActor.cs | 6 +- .../Actors/NativeAlarmActor.cs | 77 ++++++++++++- .../Actors/AlarmActorTests.cs | 107 ++++++++++++++++++ .../Actors/NativeAlarmActorTests.cs | 63 ++++++++++- .../TestSupport/FakeSiteEventLogger.cs | 62 ++++++++++ 6 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/TestSupport/FakeSiteEventLogger.cs 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; +}