138 lines
6.5 KiB
C#
138 lines
6.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Task 28: live smoke test for the OPC UA Alarms & Conditions adapter
|
|
/// (<see cref="OpcUaDataConnection"/> as <c>IAlarmSubscribableConnection</c>).
|
|
///
|
|
/// Round-trips against an alarm-capable OPC UA endpoint
|
|
/// (<c>opc.tcp://localhost:50000</c> — the infra OPC PLC server, see
|
|
/// <c>infra/docker-compose.yml</c>). Marked <c>[SkippableFact]</c> 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
|
|
/// <see cref="Adapters.OpcUaAlarmMapperTests"/>; this test proves the live
|
|
/// event-subscription + ConditionRefresh path end to end when infra supports it.
|
|
/// </summary>
|
|
[Trait("Category", "RequiresOpcUa")]
|
|
public class OpcUaAlarmLiveSmokeTests
|
|
{
|
|
private const string EndpointUrl = "opc.tcp://localhost:50000";
|
|
|
|
[SkippableFact]
|
|
public async Task SubscribeAlarms_DeliversConditionRefreshSnapshot()
|
|
{
|
|
using var loggerFactory = LoggerFactory.Create(_ => { });
|
|
var clientFactory = new RealOpcUaClientFactory(new OpcUaGlobalOptions(), loggerFactory);
|
|
var adapter = new OpcUaDataConnection(clientFactory, NullLogger<OpcUaDataConnection>.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<string, string> { ["EndpointUrl"] = EndpointUrl });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Skip.If(true, $"OPC UA test server not reachable on {EndpointUrl}: {ex.Message}");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var snapshotComplete = new TaskCompletionSource<bool>(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();
|
|
}
|
|
}
|
|
|
|
[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<NativeAlarmTransition>();
|
|
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)));
|
|
}
|
|
|
|
}
|