Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/EventPumpAlarmTests.cs
Joseph Doherty 7853e94f4b driver-galaxy: EventPump dispatches OnAlarmTransition family (PR B.1)
Second PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md). Depends on PR A.1 in mxaccessgw
(merged) which added the OnAlarmTransitionEvent body + family. No
runtime impact yet — the gateway doesn't emit the new family until
A.3 ships; this PR just stops dropping it on the floor.

EventPump.Dispatch becomes a switch on MxEventFamily. The new
DispatchAlarmTransition decodes the proto event, runs the raw severity
through MxAccessSeverityMapper (the same four-bucket ladder v1 used —
250/500/750/1000 boundaries per docs/v1/AlarmTracking.md), and fires
an internal OnAlarmTransition event with a GalaxyAlarmTransition
record carrying the full payload.

Body absent or transition-kind unspecified → counted via
galaxy.alarm_transitions.decoding_failures and dropped. Gateway
version skew or worker malformed event therefore degrades to "fall
back to the sub-attribute path" rather than crashing the pump.

GalaxyDriver consumes the internal event in PR B.2 (next), wrapping
it onto IAlarmSource.OnAlarmEvent. The richer fields (operator user
+ comment, original raise time, category) become visible on the OPC
UA Part 9 condition once AlarmEventArgs gets extended in E.7.

Tests:
- MxAccessSeverityMapperTests — full bucket ladder + clamp behaviour
  for negative + out-of-range inputs.
- EventPumpAlarmTests — raise/ack/clear sequence dispatches in order
  with operator metadata + original-raise preserved; unspecified
  kind drops; missing body drops; mixed data-change + alarm streams
  dispatch independently; OnWriteComplete / OperationComplete
  filtered out.

Full Driver.Galaxy.Tests suite: 196 passed (was 191 — 5 new tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:41:44 -04:00

240 lines
9.6 KiB
C#

using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// PR B.1 — pins the EventPump's OnAlarmTransition decode path. Synthetic MxEvents
/// with the new family go in; the pump fires <c>OnAlarmTransition</c> with the
/// decoded payload + mapped severity bucket; data-change subscribers stay
/// untouched.
/// </summary>
public sealed class EventPumpAlarmTests
{
[Fact]
public async Task Dispatches_raise_acknowledge_clear_in_sequence()
{
var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry();
var transitions = new List<GalaxyAlarmTransition>();
var dispatched = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(subscriber, registry, channelCapacity: 16, clientName: "AlarmTest");
pump.OnAlarmTransition += (_, transition) =>
{
lock (transitions)
{
transitions.Add(transition);
if (transitions.Count == 3) dispatched.TrySetResult(true);
}
};
pump.Start();
var raise = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
var ack = raise.AddSeconds(30);
var clear = ack.AddSeconds(60);
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
AlarmTransitionKind.Raise, severity: 750, transitionTime: raise));
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
AlarmTransitionKind.Acknowledge, severity: 750, transitionTime: ack,
originalRaise: raise, operatorUser: "alice", operatorComment: "investigating"));
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
AlarmTransitionKind.Clear, severity: 750, transitionTime: clear,
originalRaise: raise));
var completed = await Task.WhenAny(dispatched.Task, Task.Delay(TimeSpan.FromSeconds(2)));
completed.ShouldBe(dispatched.Task, "all three alarm transitions should dispatch within 2s");
transitions.Count.ShouldBe(3);
transitions[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
transitions[0].SeverityBucket.ShouldBe(AlarmSeverity.Critical);
transitions[0].OpcUaSeverity.ShouldBe(MxAccessSeverityMapper.OpcUaSeverityCritical);
transitions[0].RawMxAccessSeverity.ShouldBe(750);
transitions[0].TransitionTimestampUtc.ShouldBe(raise);
transitions[1].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Acknowledge);
transitions[1].OperatorUser.ShouldBe("alice");
transitions[1].OperatorComment.ShouldBe("investigating");
transitions[1].OriginalRaiseTimestampUtc.ShouldBe(raise);
transitions[2].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Clear);
transitions[2].OriginalRaiseTimestampUtc.ShouldBe(raise);
}
[Fact]
public async Task Drops_alarm_event_with_unspecified_transition_kind()
{
var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry();
var transitions = new List<GalaxyAlarmTransition>();
await using var pump = new EventPump(subscriber, registry, channelCapacity: 4, clientName: "AlarmTest");
pump.OnAlarmTransition += (_, transition) => transitions.Add(transition);
pump.Start();
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
AlarmTransitionKind.Unspecified, severity: 100,
transitionTime: DateTime.UtcNow));
// Give the pump a beat to drain the channel.
await Task.Delay(150);
transitions.ShouldBeEmpty("alarm transitions with Unspecified kind are decoder failures and must not fire OnAlarmTransition");
}
[Fact]
public async Task Drops_alarm_event_with_missing_body()
{
var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry();
var transitions = new List<GalaxyAlarmTransition>();
await using var pump = new EventPump(subscriber, registry, channelCapacity: 4, clientName: "AlarmTest");
pump.OnAlarmTransition += (_, transition) => transitions.Add(transition);
pump.Start();
// Family marked as alarm-transition but body left empty (worker version skew /
// malformed event). Production should count + drop, not throw.
await subscriber.EmitRawAsync(new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
WorkerSequence = 42,
});
await Task.Delay(150);
transitions.ShouldBeEmpty();
}
[Fact]
public async Task Mixed_data_change_and_alarm_events_dispatch_independently()
{
var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tank01.Level", ItemHandle: 7)]);
var dataChanges = new List<DataChangeEventArgs>();
var alarms = new List<GalaxyAlarmTransition>();
var bothSeen = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(subscriber, registry, channelCapacity: 16, clientName: "MixedTest");
pump.OnDataChange += (_, args) =>
{
lock (dataChanges)
{
dataChanges.Add(args);
if (dataChanges.Count >= 1 && alarms.Count >= 1) bothSeen.TrySetResult(true);
}
};
pump.OnAlarmTransition += (_, transition) =>
{
lock (alarms)
{
alarms.Add(transition);
if (dataChanges.Count >= 1 && alarms.Count >= 1) bothSeen.TrySetResult(true);
}
};
pump.Start();
await subscriber.EmitAsync(itemHandle: 7, value: 41.0);
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
AlarmTransitionKind.Raise, severity: 600, transitionTime: DateTime.UtcNow));
var completed = await Task.WhenAny(bothSeen.Task, Task.Delay(TimeSpan.FromSeconds(2)));
completed.ShouldBe(bothSeen.Task);
dataChanges.Count.ShouldBe(1);
alarms.Count.ShouldBe(1);
alarms[0].SeverityBucket.ShouldBe(AlarmSeverity.High);
}
[Fact]
public async Task Filters_out_unsupported_event_families()
{
var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry();
var transitions = new List<GalaxyAlarmTransition>();
await using var pump = new EventPump(subscriber, registry, channelCapacity: 4, clientName: "FilterTest");
pump.OnAlarmTransition += (_, transition) => transitions.Add(transition);
pump.Start();
// OnWriteComplete and OperationComplete should be silently dropped.
await subscriber.EmitRawAsync(new MxEvent { Family = MxEventFamily.OnWriteComplete });
await subscriber.EmitRawAsync(new MxEvent { Family = MxEventFamily.OperationComplete });
await Task.Delay(150);
transitions.ShouldBeEmpty();
}
private static MxEvent NewAlarm(
string fullReference,
AlarmTransitionKind kind,
int severity,
DateTime transitionTime,
DateTime? originalRaise = null,
string operatorUser = "",
string operatorComment = "")
{
var body = new OnAlarmTransitionEvent
{
AlarmFullReference = fullReference,
SourceObjectReference = fullReference.Split('.')[0],
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = kind,
Severity = severity,
TransitionTimestamp = Timestamp.FromDateTime(transitionTime),
OperatorUser = operatorUser,
OperatorComment = operatorComment,
Category = "Process",
Description = "Tank 01 high-high level",
};
if (originalRaise is { } orts)
{
body.OriginalRaiseTimestamp = Timestamp.FromDateTime(orts);
}
return new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnAlarmTransition = body,
};
}
private sealed class ManualSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream =
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
public ValueTask EmitAsync(int itemHandle, double value) =>
_stream.Writer.WriteAsync(new MxEvent
{
Family = MxEventFamily.OnDataChange,
ItemHandle = itemHandle,
Value = new MxValue { DoubleValue = value },
Quality = 192,
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
});
public ValueTask EmitAlarmAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
public ValueTask EmitRawAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
}
}