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, 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); } }