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"; } }