using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
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.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
///
/// MES alarm-status API §5.2: the script-facing Alarms.CurrentAsync() accessor.
/// Routes a real against a TestProbe standing in for the
/// Instance Actor (the same harness shape AttributeAccessorWaitAsyncTests uses), so
/// the Ask contract and the → ScriptAlarm projection
/// are both exercised for real.
///
public class AlarmsAccessorTests : TestKit, IDisposable
{
private ScriptRuntimeContext MakeContext(IActorRef instanceActor) =>
new(
instanceActor,
instanceActor,
sharedScriptLibrary: null!,
currentCallDepth: 0,
maxCallDepth: 10,
askTimeout: TimeSpan.FromSeconds(5),
instanceName: "CvdReactor01",
logger: NullLogger.Instance);
void IDisposable.Dispose() => Shutdown();
private static readonly DateTimeOffset RaisedAt = new(2026, 8, 1, 10, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset AckedAt = new(2026, 8, 1, 10, 5, 0, TimeSpan.Zero);
private static readonly DateTimeOffset ObservedAt = new(2026, 8, 1, 10, 6, 0, TimeSpan.Zero);
/// An acknowledged native condition mirrored from a source binding.
private static AlarmStateChanged NativeAcked() =>
new("CvdReactor01", "Z28061.HeartbeatTimeoutAlarm", AlarmState.Active, 950, ObservedAt)
{
Kind = AlarmKind.NativeMxAccess,
Condition = new AlarmConditionState(
Active: true, Acknowledged: true, Confirmed: null,
Shelve: AlarmShelveState.TimedShelved, Suppressed: true, Severity: 950),
SourceReference = "Z28061.HeartbeatTimeoutAlarm",
NativeSourceCanonicalName = "ReactorAlarms",
AlarmTypeName = "DiscreteAlarm",
Category = "Process",
Message = "Heartbeat lost",
OperatorUser = "op1",
OperatorComment = "investigating",
OriginalRaiseTime = RaisedAt,
AckTime = AckedAt,
CurrentValue = "FAULT",
LimitValue = "0",
};
/// Replies to the accessor's Ask with the supplied alarm rows.
private void RespondWith(Akka.TestKit.TestProbe probe, params AlarmStateChanged[] alarms)
{
var request = probe.ExpectMsg(TimeSpan.FromSeconds(5));
probe.Reply(new GetAlarmSnapshotResponse(
request.CorrelationId, request.InstanceUniqueName, alarms, DateTimeOffset.UtcNow));
}
[Fact]
public async Task CurrentAsync_AsksTheInstanceActor_ForItsOwnInstance()
{
// The script runs inside its own instance's context, so the read is a LOCAL Ask
// stamped with that instance's name — never a cross-instance or cross-cluster hop.
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var pending = ctx.Alarms.CurrentAsync();
var request = probe.ExpectMsg(TimeSpan.FromSeconds(5));
Assert.Equal("CvdReactor01", request.InstanceUniqueName);
Assert.NotEmpty(request.CorrelationId);
probe.Reply(new GetAlarmSnapshotResponse(
request.CorrelationId, request.InstanceUniqueName,
Array.Empty(), DateTimeOffset.UtcNow));
Assert.Empty(await pending);
}
[Fact]
public async Task CurrentAsync_ProjectsEveryNativeField_IncludingAckTime()
{
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var pending = ctx.Alarms.CurrentAsync();
RespondWith(probe, NativeAcked());
var alarm = Assert.Single(await pending);
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", alarm.Name);
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", alarm.SourceReference);
Assert.Equal("ReactorAlarms", alarm.NativeSourceCanonicalName);
Assert.True(alarm.Active);
Assert.True(alarm.Acknowledged);
Assert.Null(alarm.Confirmed);
Assert.True(alarm.Shelved); // TimedShelved collapses to a single boolean
Assert.True(alarm.Suppressed);
Assert.Equal(950, alarm.Severity);
Assert.Equal("NativeMxAccess", alarm.Kind);
Assert.Equal("Heartbeat lost", alarm.Message);
Assert.Equal("DiscreteAlarm", alarm.AlarmTypeName);
Assert.Equal("Process", alarm.Category);
Assert.Equal("op1", alarm.OperatorUser);
Assert.Equal("investigating", alarm.OperatorComment);
Assert.Equal(RaisedAt, alarm.OriginalRaiseTime);
Assert.Equal(ObservedAt, alarm.Timestamp);
Assert.Equal(AckedAt, alarm.AckTime); // §6.4 — the whole point of the enrichment
Assert.Equal("FAULT", alarm.CurrentValue);
Assert.Equal("0", alarm.LimitValue);
Assert.False(alarm.IsConfiguredPlaceholder);
}
[Fact]
public async Task CurrentAsync_UnackedNativeAlarm_ReportsNoAckTime()
{
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var pending = ctx.Alarms.CurrentAsync();
RespondWith(probe, NativeAcked() with
{
Condition = new AlarmConditionState(
true, Acknowledged: false, null, AlarmShelveState.Unshelved, false, 950),
AckTime = null,
});
var alarm = Assert.Single(await pending);
Assert.False(alarm.Acknowledged);
Assert.Null(alarm.AckTime);
Assert.False(alarm.Shelved);
}
[Fact]
public async Task CurrentAsync_ComputedAlarm_ReadsFromTheDerivedCondition()
{
// A computed alarm sets no explicit Condition — it is derived from State + Priority.
// Scripts must still see a coherent Active/Acknowledged/Severity triple so one
// filter expression works across computed and native alarms alike.
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var pending = ctx.Alarms.CurrentAsync();
RespondWith(probe,
new AlarmStateChanged("CvdReactor01", "HighTemp", AlarmState.Active, 700, ObservedAt));
var alarm = Assert.Single(await pending);
Assert.Equal("HighTemp", alarm.Name);
Assert.Equal("Computed", alarm.Kind);
Assert.True(alarm.Active);
Assert.True(alarm.Acknowledged); // computed alarms are auto-acked…
Assert.Null(alarm.AckTime); // …but carry no ack instant
Assert.Equal(700, alarm.Severity);
Assert.Equal(string.Empty, alarm.NativeSourceCanonicalName);
}
[Fact]
public async Task CurrentAsync_SurfacesPlaceholderRows_SoQuietBindingsAreDistinguishable()
{
// The accessor deliberately does NOT pre-filter placeholders: a caller needs to tell
// "this binding is configured and quiet" from "this binding does not exist".
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var pending = ctx.Alarms.CurrentAsync();
RespondWith(probe,
NativeAcked(),
new AlarmStateChanged("CvdReactor01", "LeftSideAlarms", AlarmState.Normal, 0, ObservedAt)
{
Kind = AlarmKind.NativeMxAccess,
NativeSourceCanonicalName = "LeftSideAlarms",
IsConfiguredPlaceholder = true,
});
var alarms = await pending;
Assert.Equal(2, alarms.Count);
// …and the documented caller-side filter yields only the real alarm.
var real = Assert.Single(alarms, a => a.Active && !a.IsConfiguredPlaceholder);
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", real.Name);
}
[Fact]
public async Task CurrentAsync_IsNotScopePrefixed_EveryScopeSeesTheSameList()
{
// Unlike Attributes, alarm identity is not a scope-relative name. A composed script
// must not silently receive a truncated alarm set.
var probe = CreateTestProbe();
var ctx = MakeContext(probe.Ref);
var pending = new AlarmsAccessor(ctx).CurrentAsync();
RespondWith(probe, NativeAcked());
Assert.Single(await pending);
}
}