204 lines
8.4 KiB
C#
204 lines
8.4 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// MES alarm-status API §5.2: the script-facing <c>Alarms.CurrentAsync()</c> accessor.
|
|
/// Routes a real <see cref="ScriptRuntimeContext"/> against a TestProbe standing in for the
|
|
/// Instance Actor (the same harness shape <c>AttributeAccessorWaitAsyncTests</c> uses), so
|
|
/// the Ask contract and the <see cref="AlarmStateChanged"/> → <c>ScriptAlarm</c> projection
|
|
/// are both exercised for real.
|
|
/// </summary>
|
|
public class AlarmsAccessorTests : TestKit, IDisposable
|
|
{
|
|
private ScriptRuntimeContext MakeContext(IActorRef instanceActor) =>
|
|
new(
|
|
instanceActor,
|
|
sharedScriptLibrary: null!,
|
|
currentCallDepth: 0,
|
|
maxCallDepth: 10,
|
|
askTimeout: TimeSpan.FromSeconds(5),
|
|
instanceName: "CvdReactor01",
|
|
logger: NullLogger<ScriptRuntimeContext>.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);
|
|
|
|
/// <summary>An acknowledged native condition mirrored from a source binding.</summary>
|
|
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",
|
|
};
|
|
|
|
/// <summary>Replies to the accessor's Ask with the supplied alarm rows.</summary>
|
|
private void RespondWith(Akka.TestKit.TestProbe probe, params AlarmStateChanged[] alarms)
|
|
{
|
|
var request = probe.ExpectMsg<GetAlarmSnapshotRequest>(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<GetAlarmSnapshotRequest>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal("CvdReactor01", request.InstanceUniqueName);
|
|
Assert.NotEmpty(request.CorrelationId);
|
|
|
|
probe.Reply(new GetAlarmSnapshotResponse(
|
|
request.CorrelationId, request.InstanceUniqueName,
|
|
Array.Empty<AlarmStateChanged>(), 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);
|
|
}
|
|
}
|