Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/Runtime/GatewayGalaxyAlarmFeedTests.cs
T
Joseph Doherty 560b327ee1
v2-ci / build (push) Failing after 33s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
refactor(galaxy): migrate to ZB.MOM.WW.MxGateway.* nupkg packages
Imports the freshly-rebuilt ZB.MOM.WW.MxGateway.Client + ZB.MOM.WW.MxGateway.Contracts
nupkgs (0.1.0) from /tmp/mxgw-dist. Replaces the vendored libs/ DLLs and the
pre-restructure MxGateway.* namespaces across the runtime Galaxy driver,
Galaxy.Browser, and their tests.

Key changes:
- nuget-packages/ added as a local feed via NuGet.config; .gitignore exempts it
  from the *.nupkg rule so the packages are tracked
- Directory.Packages.props pins both packages at 0.1.0
- 4 csprojs swap <Reference HintPath="libs/...dll"/> for <PackageReference/>
- 36 .cs files renamed `using MxGateway.*` -> `using ZB.MOM.WW.MxGateway.*`
- libs/ removed (vendored DLLs + README.md)

GalaxyBrowseSession rewritten around the new lazy API:
- RootAsync calls GalaxyRepositoryClient.BrowseAsync (returns LazyBrowseNodes)
  and caches them by TagName instead of bulk-fetching the whole hierarchy
- ExpandAsync looks up the cached LazyBrowseNode and calls its ExpandAsync,
  giving true one-wire-call-per-click instead of in-memory parent/child scan
- _byGobjectId + _hasChildrenSet dropped (LazyBrowseNode carries HasChildrenHint)
- AttributesAsync unchanged (already uses DiscoverHierarchyAsync MaxDepth=0)

Tests: Galaxy.Tests 245/245, Galaxy.Browser.Tests 10/10, AdminUI.Tests 66/66.
Pre-existing 12 solution errors unchanged (test sinks + Cli XML comments).
2026-05-29 07:14:18 -04:00

217 lines
8.5 KiB
C#

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;
/// <summary>
/// Pins <see cref="GatewayGalaxyAlarmFeed"/> — the session-less consumer of the
/// gateway's <c>StreamAlarms</c> feed. Synthetic <see cref="AlarmFeedMessage"/>s go
/// in through the stream-factory seam; the feed fires <c>OnAlarmTransition</c> with
/// decoded payloads and mapped severity buckets, drops malformed messages, and
/// re-opens the stream after a transport fault.
/// </summary>
public sealed class GatewayGalaxyAlarmFeedTests
{
/// <summary>Verifies that the feed decodes active alarm snapshots and live transitions.</summary>
[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<GalaxyAlarmTransition>();
var got3 = new TaskCompletionSource<bool>(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);
}
/// <summary>Verifies that the feed drops transitions with unspecified kind and empty messages.</summary>
[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<GalaxyAlarmTransition>();
var gotOne = new TaskCompletionSource<bool>(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);
}
/// <summary>Verifies that the feed reopens the stream after a transport fault.</summary>
[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<GalaxyAlarmTransition>();
var gotOne = new TaskCompletionSource<bool>(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);
}
/// <summary>
/// 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.
/// </summary>
private static async IAsyncEnumerable<AlarmFeedMessage> OpenStream(
IEnumerable<AlarmFeedMessage> 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 };
}
}