The mxaccessgw updated alarms to a session-less central monitor: AcknowledgeAlarm dropped SessionId and alarm transitions now come from the session-less StreamAlarms feed instead of the per-session worker StreamEvents stream. The GalaxyDriver no longer compiled against the updated client. - GatewayGalaxyAlarmAcknowledger: session-less rewrite — no GalaxyMxSession; outcome read from ProtocolStatus (throw) and Hresult (warn). - New IGalaxyAlarmFeed seam + GatewayGalaxyAlarmFeed: background consumer of StreamAlarms that decodes the active-alarm snapshot plus live transitions into GalaxyAlarmTransition and reopens the stream on transport faults. - EventPump: drop the dead per-session OnAlarmTransition path; the per-session stream no longer carries alarms. - GalaxyDriver: bridge the feed onto IAlarmSource.OnAlarmEvent; the feed starts on SubscribeAlarmsAsync, independent of data subscriptions. - Tests: replace EventPumpAlarmTests with GatewayGalaxyAlarmFeedTests; move the driver alarm-source tests onto the IGalaxyAlarmFeed seam. Browse needed no change — GatewayGalaxyHierarchySource consumes the unchanged DiscoverHierarchy contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
209 lines
7.7 KiB
C#
209 lines
7.7 KiB
C#
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>
|
|
/// Pins GalaxyDriver's <c>IAlarmSource</c> implementation. The driver bridges the
|
|
/// gateway's session-less alarm feed (<see cref="IGalaxyAlarmFeed"/>, production:
|
|
/// <c>GatewayGalaxyAlarmFeed</c>) onto <c>IAlarmSource.OnAlarmEvent</c> and forwards
|
|
/// Acknowledge through <see cref="IGalaxyAlarmAcknowledger"/> (production:
|
|
/// <c>GatewayGalaxyAlarmAcknowledger</c> calling the session-less
|
|
/// <c>AcknowledgeAlarm</c> RPC).
|
|
/// </summary>
|
|
public sealed class GalaxyDriverAlarmSourceTests
|
|
{
|
|
[Fact]
|
|
public async Task SubscribeAlarmsAsync_starts_feed_and_event_fires_on_transition()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
|
handle.ShouldNotBeNull();
|
|
feed.Started.ShouldBeTrue("SubscribeAlarmsAsync must start the alarm feed");
|
|
|
|
var observed = new List<AlarmEventArgs>();
|
|
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
|
|
|
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
|
|
GalaxyAlarmTransitionKind.Raise, AlarmSeverity.Critical));
|
|
|
|
observed.ShouldHaveSingleItem();
|
|
observed[0].ConditionId.ShouldBe("Tank01.Level.HiHi");
|
|
observed[0].SourceNodeId.ShouldBe("Tank01");
|
|
observed[0].AlarmType.ShouldBe("AnalogLimitAlarm.HiHi");
|
|
observed[0].Severity.ShouldBe(AlarmSeverity.Critical);
|
|
observed[0].SubscriptionHandle.ShouldBe(handle);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnAlarmEvent_does_not_fire_before_any_alarm_subscription()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
var observed = new List<AlarmEventArgs>();
|
|
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
|
|
|
// No SubscribeAlarmsAsync → the feed is never wired onto the driver, so a
|
|
// transition surfaces nowhere.
|
|
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
|
|
GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));
|
|
|
|
observed.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnsubscribeAlarmsAsync_stops_event_flow()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
|
|
var observed = new List<AlarmEventArgs>();
|
|
driver.OnAlarmEvent += (_, args) => observed.Add(args);
|
|
|
|
await driver.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
|
|
|
|
// The feed keeps running (it is session-less and shared), but with no active
|
|
// subscription the driver suppresses the bridged event.
|
|
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
|
|
GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));
|
|
|
|
observed.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UnsubscribeAlarmsAsync_throws_for_foreign_handle()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
var foreignHandle = new ForeignAlarmHandle();
|
|
await Should.ThrowAsync<ArgumentException>(() =>
|
|
driver.UnsubscribeAlarmsAsync(foreignHandle, CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcknowledgeAsync_routes_each_request_to_the_acknowledger()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
var requests = new[]
|
|
{
|
|
new AlarmAcknowledgeRequest("Tank01", "Tank01.Level.HiHi", "shift handover"),
|
|
new AlarmAcknowledgeRequest("Tank02", "Tank02.Level.HiHi", "investigating"),
|
|
};
|
|
|
|
await driver.AcknowledgeAsync(requests, CancellationToken.None);
|
|
|
|
ack.Calls.Count.ShouldBe(2);
|
|
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
|
|
ack.Calls[0].Comment.ShouldBe("shift handover");
|
|
ack.Calls[1].AlarmRef.ShouldBe("Tank02.Level.HiHi");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcknowledgeAsync_falls_back_to_SourceNodeId_when_ConditionId_empty()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
var ack = new RecordingAcknowledger();
|
|
using var driver = NewDriver(feed, ack);
|
|
|
|
await driver.AcknowledgeAsync(
|
|
[new AlarmAcknowledgeRequest("Tank01.Level.HiHi", string.Empty, null)],
|
|
CancellationToken.None);
|
|
|
|
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AcknowledgeAsync_throws_NotSupported_without_acknowledger()
|
|
{
|
|
var feed = new FakeAlarmFeed();
|
|
using var driver = NewDriver(feed, alarmAcknowledger: null);
|
|
|
|
await Should.ThrowAsync<NotSupportedException>(() =>
|
|
driver.AcknowledgeAsync(
|
|
[new AlarmAcknowledgeRequest("Tank01", "Tank01.Level.HiHi", null)],
|
|
CancellationToken.None));
|
|
}
|
|
|
|
private static GalaxyDriver NewDriver(
|
|
IGalaxyAlarmFeed alarmFeed, IGalaxyAlarmAcknowledger? alarmAcknowledger)
|
|
{
|
|
var options = new GalaxyDriverOptions(
|
|
new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"),
|
|
new GalaxyMxAccessOptions("AlarmSourceTest"),
|
|
new GalaxyRepositoryOptions(),
|
|
new GalaxyReconnectOptions());
|
|
return new GalaxyDriver(
|
|
driverInstanceId: "drv-1",
|
|
options: options,
|
|
hierarchySource: null,
|
|
alarmAcknowledger: alarmAcknowledger,
|
|
alarmFeed: alarmFeed);
|
|
}
|
|
|
|
private static GalaxyAlarmTransition NewTransition(
|
|
string alarmFullReference,
|
|
string sourceObjectReference,
|
|
GalaxyAlarmTransitionKind kind,
|
|
AlarmSeverity severity)
|
|
=> new(
|
|
AlarmFullReference: alarmFullReference,
|
|
SourceObjectReference: sourceObjectReference,
|
|
AlarmTypeName: "AnalogLimitAlarm.HiHi",
|
|
TransitionKind: kind,
|
|
SeverityBucket: severity,
|
|
OpcUaSeverity: 800,
|
|
RawMxAccessSeverity: 750,
|
|
OriginalRaiseTimestampUtc: null,
|
|
TransitionTimestampUtc: DateTime.UtcNow,
|
|
OperatorUser: string.Empty,
|
|
OperatorComment: string.Empty,
|
|
Category: "Process",
|
|
Description: "Tank 01 high-high level");
|
|
|
|
/// <summary>In-memory <see cref="IGalaxyAlarmFeed"/> the test drives directly.</summary>
|
|
private sealed class FakeAlarmFeed : IGalaxyAlarmFeed
|
|
{
|
|
public bool Started { get; private set; }
|
|
|
|
public event EventHandler<GalaxyAlarmTransition>? OnAlarmTransition;
|
|
|
|
public void Start() => Started = true;
|
|
|
|
public void Emit(GalaxyAlarmTransition transition)
|
|
=> OnAlarmTransition?.Invoke(this, transition);
|
|
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
}
|
|
|
|
private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger
|
|
{
|
|
public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];
|
|
|
|
public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
|
|
{
|
|
Calls.Add((alarmFullReference, comment, operatorUser));
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class ForeignAlarmHandle : IAlarmSubscriptionHandle
|
|
{
|
|
public string DiagnosticId => "foreign";
|
|
}
|
|
}
|