Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverAlarmEventArgsExtensionTests.cs
Joseph Doherty 1d62709060 abstractions+driver+client.shared: extend AlarmEventArgs with rich payload (PR E.7)
Fourteenth PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md). Depends on PR B.2 (GalaxyDriver
implements IAlarmSource, merged) and B.3 (DriverNodeManager prefers
driver-native ack, merged).

Three new optional fields on Core.Abstractions.AlarmEventArgs:

- OperatorComment — populated by the driver-native gateway path on
  Acknowledge transitions. Null on raise / clear, and null on the
  sub-attribute fallback path where the comment collapses into a
  single string write.
- OriginalRaiseTimestampUtc — preserved across Acknowledge so OPC
  UA Part 9 conditions keep the original raise time.
- AlarmCategory — taxonomy bucket from the upstream alarm system.
  Maps to ConditionClassName downstream when a class mapping is
  configured.

GalaxyDriver.OnPumpAlarmTransition populates the new fields from
GalaxyAlarmTransition (PR B.1). Empty strings collapse to null so
consumers can use is-null rather than is-null-or-empty checks.

Client.Shared mirror DTO (Client.Shared/Models/AlarmEventArgs)
gains the same three properties so the Client.UI / Client.CLI
surfaces can reflect the rich payload — the actual UI/CLI
verbose-output and Show-Details rendering ship as a follow-up
PR; this PR locks in the payload contract.

Tests:
- 2 new tests in Driver.Galaxy.Tests pin the populated-vs-null
  behaviour for full-payload Acknowledge and bare-bones Raise
  transitions respectively.
- Solution build clean.

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

139 lines
5.5 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.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// PR E.7 — pins that the GalaxyDriver populates the extended AlarmEventArgs
/// fields (OperatorComment, OriginalRaiseTimestampUtc, AlarmCategory) when the
/// gateway emits a transition with the rich payload, and leaves them null on
/// events that don't carry them.
/// </summary>
public sealed class GalaxyDriverAlarmEventArgsExtensionTests
{
[Fact]
public async Task Acknowledge_transition_with_full_payload_populates_extended_fields()
{
var subscriber = new ManualSubscriber();
using var driver = NewDriver(subscriber);
await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
var observed = new List<AlarmEventArgs>();
driver.OnAlarmEvent += (_, args) => observed.Add(args);
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
var raise = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
var ack = raise.AddSeconds(45);
await subscriber.EmitAlarmAsync(new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = AlarmTransitionKind.Acknowledge,
Severity = 750,
OriginalRaiseTimestamp = Timestamp.FromDateTime(raise),
TransitionTimestamp = Timestamp.FromDateTime(ack),
OperatorUser = "alice",
OperatorComment = "investigating",
Category = "Process",
Description = "Tank 01 high-high level",
},
});
for (var i = 0; i < 20 && observed.Count == 0; i++)
{
await Task.Delay(50);
}
observed.ShouldHaveSingleItem();
observed[0].OperatorComment.ShouldBe("investigating");
observed[0].OriginalRaiseTimestampUtc.ShouldBe(raise);
observed[0].AlarmCategory.ShouldBe("Process");
}
[Fact]
public async Task Raise_transition_without_optional_fields_leaves_them_null()
{
var subscriber = new ManualSubscriber();
using var driver = NewDriver(subscriber);
await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
var observed = new List<AlarmEventArgs>();
driver.OnAlarmEvent += (_, args) => observed.Add(args);
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
await subscriber.EmitAlarmAsync(new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = AlarmTransitionKind.Raise,
Severity = 750,
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
},
});
for (var i = 0; i < 20 && observed.Count == 0; i++)
{
await Task.Delay(50);
}
observed.ShouldHaveSingleItem();
observed[0].OperatorComment.ShouldBeNull();
observed[0].OriginalRaiseTimestampUtc.ShouldBeNull();
observed[0].AlarmCategory.ShouldBeNull();
}
private static GalaxyDriver NewDriver(ManualSubscriber subscriber)
{
var options = new GalaxyDriverOptions(
new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"),
new GalaxyMxAccessOptions("AlarmExtensionTest"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions());
return new GalaxyDriver(
driverInstanceId: "drv-1",
options: options,
hierarchySource: null,
dataReader: null,
dataWriter: null,
subscriber: subscriber,
alarmAcknowledger: null);
}
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)
{
var results = new List<SubscribeResult>();
var nextHandle = 100;
foreach (var r in fullReferences)
{
results.Add(new SubscribeResult { TagAddress = r, ItemHandle = nextHandle++, WasSuccessful = true });
}
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
public ValueTask EmitAlarmAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
}
}