using System.Runtime.CompilerServices;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.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;
///
/// Pins — the session-less consumer of the
/// gateway's StreamAlarms feed. Synthetic s go
/// in through the stream-factory seam; the feed fires OnAlarmTransition with
/// decoded payloads and mapped severity buckets, drops malformed messages, and
/// re-opens the stream after a transport fault.
///
public sealed class GatewayGalaxyAlarmFeedTests
{
[Fact]
public async Task Decodes_active_alarm_snapshot_then_live_transition()
{
var raise = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
var messages = new[]
{
SnapshotMessage("Tank01.Level.HiHi", AlarmConditionState.Active, severity: 750,
lastTransition: raise),
SnapshotMessage("Tank02.Level.HiHi", AlarmConditionState.ActiveAcked, severity: 500,
lastTransition: raise, operatorUser: "alice", operatorComment: "investigating"),
new AlarmFeedMessage { SnapshotComplete = true },
TransitionMessage("Tank01.Level.HiHi", AlarmTransitionKind.Clear, severity: 750,
transitionTime: raise.AddMinutes(5), originalRaise: raise),
};
var observed = new List();
var got3 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await using var feed = new GatewayGalaxyAlarmFeed(
(_, ct) => OpenStream(messages, ct), clientName: "FeedTest");
feed.OnAlarmTransition += (_, t) =>
{
lock (observed)
{
observed.Add(t);
if (observed.Count == 3) got3.TrySetResult(true);
}
};
feed.Start();
(await Task.WhenAny(got3.Task, Task.Delay(TimeSpan.FromSeconds(2))))
.ShouldBe(got3.Task, "snapshot + transition should dispatch within 2s");
observed.Count.ShouldBe(3);
// Active snapshot entry → Raise.
observed[0].AlarmFullReference.ShouldBe("Tank01.Level.HiHi");
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.Critical);
observed[0].RawMxAccessSeverity.ShouldBe(750);
// Acknowledged snapshot entry → Acknowledge, operator fields preserved.
observed[1].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Acknowledge);
observed[1].OperatorUser.ShouldBe("alice");
observed[1].OperatorComment.ShouldBe("investigating");
// Live transition after snapshot_complete.
observed[2].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Clear);
observed[2].OriginalRaiseTimestampUtc.ShouldBe(raise);
}
[Fact]
public async Task Drops_transition_with_unspecified_kind_and_empty_message()
{
var messages = new[]
{
TransitionMessage("Tank01.Level.HiHi", AlarmTransitionKind.Unspecified, severity: 100,
transitionTime: DateTime.UtcNow),
new AlarmFeedMessage(), // empty oneof — version skew
TransitionMessage("Tank01.Level.HiHi", AlarmTransitionKind.Raise, severity: 600,
transitionTime: DateTime.UtcNow),
};
var observed = new List();
var gotOne = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await using var feed = new GatewayGalaxyAlarmFeed(
(_, ct) => OpenStream(messages, ct), clientName: "FeedTest");
feed.OnAlarmTransition += (_, t) =>
{
lock (observed)
{
observed.Add(t);
gotOne.TrySetResult(true);
}
};
feed.Start();
(await Task.WhenAny(gotOne.Task, Task.Delay(TimeSpan.FromSeconds(2))))
.ShouldBe(gotOne.Task);
// Only the well-formed Raise survives; the Unspecified + empty messages drop.
observed.ShouldHaveSingleItem();
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.High);
}
[Fact]
public async Task Reopens_stream_after_a_transport_fault()
{
var calls = 0;
var liveTransition = new[]
{
TransitionMessage("Tank01.Level.HiHi", AlarmTransitionKind.Raise, severity: 750,
transitionTime: DateTime.UtcNow),
};
var observed = new List();
var gotOne = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await using var feed = new GatewayGalaxyAlarmFeed(
(_, ct) =>
{
// First open faults; the feed must reconnect and succeed on the retry.
if (Interlocked.Increment(ref calls) == 1)
{
throw new InvalidOperationException("synthetic stream fault");
}
return OpenStream(liveTransition, ct);
},
clientName: "ReconnectTest",
reconnectDelay: TimeSpan.FromMilliseconds(20));
feed.OnAlarmTransition += (_, t) =>
{
observed.Add(t);
gotOne.TrySetResult(true);
};
feed.Start();
(await Task.WhenAny(gotOne.Task, Task.Delay(TimeSpan.FromSeconds(3))))
.ShouldBe(gotOne.Task, "the feed should reopen the stream and deliver after a fault");
calls.ShouldBeGreaterThanOrEqualTo(2);
observed.ShouldHaveSingleItem();
observed[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
}
///
/// Yields each message in order, then holds the stream open until the feed is
/// disposed — mirrors a live server-streaming RPC that does not complete on its
/// own.
///
private static async IAsyncEnumerable OpenStream(
IEnumerable messages,
[EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var message in messages)
{
ct.ThrowIfCancellationRequested();
yield return message;
await Task.Yield();
}
await Task.Delay(Timeout.Infinite, ct);
}
private static AlarmFeedMessage SnapshotMessage(
string fullReference,
AlarmConditionState state,
int severity,
DateTime lastTransition,
string operatorUser = "",
string operatorComment = "")
=> new()
{
ActiveAlarm = new ActiveAlarmSnapshot
{
AlarmFullReference = fullReference,
SourceObjectReference = fullReference.Split('.')[0],
AlarmTypeName = "AnalogLimitAlarm.HiHi",
Severity = severity,
CurrentState = state,
Category = "Process",
Description = "Tank high-high level",
LastTransitionTimestamp = Timestamp.FromDateTime(lastTransition),
OperatorUser = operatorUser,
OperatorComment = operatorComment,
},
};
private static AlarmFeedMessage TransitionMessage(
string fullReference,
AlarmTransitionKind kind,
int severity,
DateTime transitionTime,
DateTime? originalRaise = null)
{
var body = new OnAlarmTransitionEvent
{
AlarmFullReference = fullReference,
SourceObjectReference = fullReference.Split('.')[0],
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = kind,
Severity = severity,
TransitionTimestamp = Timestamp.FromDateTime(transitionTime),
Category = "Process",
Description = "Tank high-high level",
};
if (originalRaise is { } orts)
{
body.OriginalRaiseTimestamp = Timestamp.FromDateTime(orts);
}
return new AlarmFeedMessage { Transition = body };
}
}