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;
///
/// Pins GalaxyDriver's IAlarmSource implementation. The driver bridges the
/// gateway's session-less alarm feed (, production:
/// GatewayGalaxyAlarmFeed) onto IAlarmSource.OnAlarmEvent and forwards
/// Acknowledge through (production:
/// GatewayGalaxyAlarmAcknowledger calling the session-less
/// AcknowledgeAlarm RPC).
///
public sealed class GalaxyDriverAlarmSourceTests
{
/// Verifies that SubscribeAlarmsAsync starts the alarm feed and events fire on transition.
[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();
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);
}
///
/// Verifies that the driver maps each Galaxy transition kind onto the matching
/// on the surfaced ,
/// so a Part 9 consumer can derive active/ack state. The Galaxy kind is passed by
/// name because GalaxyAlarmTransitionKind is internal to the driver and so
/// cannot appear in a public test method signature.
///
/// The GalaxyAlarmTransitionKind member name fed into the alarm feed.
/// The expected on the surfaced event.
[Theory]
[InlineData(nameof(GalaxyAlarmTransitionKind.Raise), AlarmTransitionKind.Raise)]
[InlineData(nameof(GalaxyAlarmTransitionKind.Acknowledge), AlarmTransitionKind.Acknowledge)]
[InlineData(nameof(GalaxyAlarmTransitionKind.Clear), AlarmTransitionKind.Clear)]
[InlineData(nameof(GalaxyAlarmTransitionKind.Retrigger), AlarmTransitionKind.Retrigger)]
[InlineData(nameof(GalaxyAlarmTransitionKind.Unspecified), AlarmTransitionKind.Unspecified)]
public async Task Transition_kind_maps_onto_AlarmEventArgs_Kind(
string galaxyKindName, AlarmTransitionKind expectedKind)
{
var galaxyKind = Enum.Parse(galaxyKindName);
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();
driver.OnAlarmEvent += (_, args) => observed.Add(args);
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01", galaxyKind, AlarmSeverity.High));
observed.ShouldHaveSingleItem();
observed[0].Kind.ShouldBe(expectedKind);
}
/// Verifies that OnAlarmEvent does not fire before any alarm subscription.
[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();
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();
}
/// Verifies that UnsubscribeAlarmsAsync stops event flow.
[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();
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();
}
///
/// Regression for the session-less alarm-handle leak: the gateway's StreamAlarms feed survives an
/// in-place reconnect, so DriverInstanceActor re-subscribes on every Connected re-entry
/// (after dropping its own cached handle). The driver must COLLAPSE to a single live handle on each
/// subscribe rather than accumulate one per reconnect. Observable proof (the handle set is private):
/// after three re-subscribes, releasing only the LATEST handle fully gates the feed — which can only
/// happen if the earlier handles were not retained. Before the fix the set kept h1/h2/h3, so
/// releasing h3 left the gate open and a transition still surfaced.
///
[Fact]
public async Task Re_subscribe_collapses_to_a_single_handle_no_accumulation()
{
var feed = new FakeAlarmFeed();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(feed, ack);
// Simulate the actor re-subscribing on each in-place reconnect.
var h1 = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
var h2 = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
var latest = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
// Each subscribe issues a DISTINCT handle (not a reused singleton) — otherwise the collapse
// semantics would be vacuous.
h1.DiagnosticId.ShouldNotBe(h2.DiagnosticId);
h2.DiagnosticId.ShouldNotBe(latest.DiagnosticId);
var observed = new List();
driver.OnAlarmEvent += (_, args) => observed.Add(args);
// Releasing ONLY the latest handle must fully gate the feed — proving the driver held exactly one
// live handle, not the three issued across the simulated reconnects.
await driver.UnsubscribeAlarmsAsync(latest, CancellationToken.None);
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));
observed.ShouldBeEmpty("re-subscribe must collapse to one handle; releasing it gates the feed");
// And the latest subscribe is the live one: a fresh subscribe re-opens the gate.
var reopened = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
feed.Emit(NewTransition("Tank01.Level.HiHi", "Tank01",
GalaxyAlarmTransitionKind.Raise, AlarmSeverity.High));
observed.ShouldHaveSingleItem();
observed[0].SubscriptionHandle.ShouldBe(reopened);
}
/// Verifies that UnsubscribeAlarmsAsync throws for a foreign handle.
[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(() =>
driver.UnsubscribeAlarmsAsync(foreignHandle, CancellationToken.None));
}
/// Verifies that AcknowledgeAsync routes each request to the acknowledger.
[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[0].Operator.ShouldBe(string.Empty); // null OperatorUser must fall back to empty string
ack.Calls[1].AlarmRef.ShouldBe("Tank02.Level.HiHi");
}
/// Verifies that AcknowledgeAsync falls back to SourceNodeId when ConditionId is empty.
[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");
}
///
/// Verifies that AcknowledgeAsync forwards the request's authenticated principal
/// () to the acknowledger so the
/// gateway records WHO acked, rather than the generic empty-string fallback.
///
[Fact]
public async Task AcknowledgeAsync_forwards_OperatorUser()
{
var feed = new FakeAlarmFeed();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(feed, ack);
await driver.AcknowledgeAsync(
[new AlarmAcknowledgeRequest("src", "Obj.Alarm", "cmt", "alice")],
CancellationToken.None);
ack.Calls.ShouldHaveSingleItem();
ack.Calls[0].Operator.ShouldBe("alice");
}
/// Verifies that AcknowledgeAsync throws NotSupportedException without an acknowledger.
[Fact]
public async Task AcknowledgeAsync_throws_NotSupported_without_acknowledger()
{
var feed = new FakeAlarmFeed();
using var driver = NewDriver(feed, alarmAcknowledger: null);
await Should.ThrowAsync(() =>
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");
/// In-memory the test drives directly.
private sealed class FakeAlarmFeed : IGalaxyAlarmFeed
{
/// Gets a value indicating whether the feed has been started.
public bool Started { get; private set; }
/// Occurs when an alarm transition is emitted.
public event EventHandler? OnAlarmTransition;
/// Marks the feed as started.
public void Start() => Started = true;
/// Emits an alarm transition to all subscribers.
/// The transition to emit.
public void Emit(GalaxyAlarmTransition transition)
=> OnAlarmTransition?.Invoke(this, transition);
/// Disposes the fake feed.
/// A completed task.
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// Test double that records all acknowledge calls.
private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger
{
/// Gets the list of acknowledge calls recorded.
public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];
/// Records an acknowledge call.
/// The alarm full reference.
/// The acknowledgment comment.
/// The operator user.
/// Cancellation token.
/// A completed task.
public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
{
Calls.Add((alarmFullReference, comment, operatorUser));
return Task.CompletedTask;
}
}
/// Test double that represents a foreign alarm subscription handle.
private sealed class ForeignAlarmHandle : IAlarmSubscriptionHandle
{
/// Gets the diagnostic ID for this handle.
public string DiagnosticId => "foreign";
}
}