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
@@ -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&lt;ISiteEventLogger&gt;()</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;
}