using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; /// /// Live smoke test for the OPC UA Alarms & Conditions adapter /// ( as IAlarmSubscribableConnection). /// /// Round-trips against an alarm-capable OPC UA endpoint /// (opc.tcp://localhost:50000 — the infra OPC PLC server, see /// infra/docker-compose.yml). Marked [SkippableFact] so it reports /// Skipped — not failed — when the endpoint is unreachable OR does not expose /// Alarms & Conditions (no ConditionRefresh snapshot arrives). The OPC PLC /// fixture exposes two Boiler event sources used by the multi-source regression. /// That regression skips only when the endpoint is unavailable; missing fixture /// nodes or incomplete refresh frames fail once the server is reachable. /// /// The pure field→transition mapping is covered without a server by /// ; this test proves the live /// event-subscription + ConditionRefresh path end to end when infra supports it. /// [Trait("Category", "RequiresOpcUa")] public class OpcUaAlarmLiveSmokeTests { private const string EndpointUrl = "opc.tcp://localhost:50000"; /// Verifies that subscribing to alarms delivers a condition refresh snapshot. /// A task that represents the asynchronous operation. [SkippableFact] public async Task SubscribeAlarms_DeliversConditionRefreshSnapshot() { using var loggerFactory = LoggerFactory.Create(_ => { }); var clientFactory = new RealOpcUaClientFactory(new OpcUaGlobalOptions(), loggerFactory); var adapter = new OpcUaDataConnection(clientFactory, NullLogger.Instance); // Probe the endpoint. An unreachable infra server surfaces a socket/timeout // error from deep in the OPC Foundation SDK — treat as "infra not available". try { await adapter.ConnectAsync(new Dictionary { ["EndpointUrl"] = EndpointUrl }); } catch (Exception ex) { Skip.If(true, $"OPC UA test server not reachable on {EndpointUrl}: {ex.Message}"); return; } try { var snapshotComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); void OnTransition(NativeAlarmTransition t) { if (t.Kind == AlarmTransitionKind.SnapshotComplete) { snapshotComplete.TrySetResult(true); } } // Empty source reference = mirror every condition under the server's // event notifier; ConditionRefresh replays the active set then a // SnapshotComplete sentinel. await adapter.SubscribeAlarmsAsync(sourceReference: string.Empty, conditionFilter: null, OnTransition); var done = await Task.WhenAny(snapshotComplete.Task, Task.Delay(TimeSpan.FromSeconds(10))); // Reachable but no A&C snapshot within the window → the server does not // expose Alarms & Conditions. Skip rather than fail. Skip.IfNot(done == snapshotComplete.Task, $"OPC UA endpoint {EndpointUrl} reachable but delivered no A&C ConditionRefresh snapshot — " + "server likely does not expose Alarms & Conditions."); Assert.True(snapshotComplete.Task.IsCompletedSuccessfully); } finally { await adapter.DisconnectAsync(); } } /// Verifies that two boiler alarm sources keep distinct refresh scopes and epochs. /// A task that represents the asynchronous operation. [SkippableFact] public async Task TwoBoilerSources_KeepDistinctRefreshScopesAndEpochs() { await using var client = new RealOpcUaClient(); using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(20)); try { await client.ConnectAsync(EndpointUrl, new OpcUaConnectionOptions(OperationTimeoutMs: 5000), deadline.Token); } catch (Exception ex) { Skip.If(true, $"OPC UA test server unavailable: {ex.Message}"); return; } // Distinct event notifier objects exposed by the local OPC PLC fixture. // Adding B requests a subscription-wide ConditionRefresh, so A receives // another refresh as well. Each monitored item must keep its own frame. const string sourceA = "nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=5017"; const string sourceB = "nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=15070"; var transitions = new System.Collections.Concurrent.ConcurrentQueue(); var bothComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); void Capture(NativeAlarmTransition transition) { transitions.Enqueue(transition); if (transitions.Any(t => t.Kind == AlarmTransitionKind.SnapshotComplete && t.SourceObjectReference == sourceA) && transitions.Any(t => t.Kind == AlarmTransitionKind.SnapshotComplete && t.SourceObjectReference == sourceB)) bothComplete.TrySetResult(); } await client.CreateAlarmSubscriptionAsync(sourceA, null, Capture, deadline.Token); await client.CreateAlarmSubscriptionAsync(sourceB, null, Capture, deadline.Token); await bothComplete.Task.WaitAsync(deadline.Token); var boundaries = transitions.Where(t => t.Kind is AlarmTransitionKind.SnapshotStart or AlarmTransitionKind.SnapshotComplete).ToArray(); foreach (var source in new[] { sourceA, sourceB }) { var frames = boundaries.Where(t => t.SourceObjectReference == source).ToArray(); Assert.Contains(frames, t => t.Kind == AlarmTransitionKind.SnapshotStart); Assert.Contains(frames, t => t.Kind == AlarmTransitionKind.SnapshotComplete); Assert.All(frames, t => { Assert.Equal(source, t.SnapshotSourceReference); Assert.NotNull(t.SnapshotId); }); foreach (var end in frames.Where(t => t.Kind == AlarmTransitionKind.SnapshotComplete)) Assert.Single(frames, t => t.Kind == AlarmTransitionKind.SnapshotStart && t.SnapshotId == end.SnapshotId); } Assert.Empty(boundaries.Where(t => t.SourceObjectReference == sourceA).Select(t => t.SnapshotId) .Intersect(boundaries.Where(t => t.SourceObjectReference == sourceB).Select(t => t.SnapshotId))); } }