feat(dcl): implement BrowseChildrenAsync on RealOpcUaClient
This commit is contained in:
@@ -327,10 +327,70 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
: configured;
|
||||
|
||||
/// <inheritdoc />
|
||||
// Real implementation lands in Task 8 of the OPC UA tag browser plan.
|
||||
public Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BrowseChildrenAsync(
|
||||
public async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BrowseChildrenAsync(
|
||||
string? parentNodeId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotImplementedException();
|
||||
{
|
||||
// Mirror the SubscribeAsync/ReadAsync wrap idiom: snapshot the session
|
||||
// reference once, fail fast with a typed exception if the link is
|
||||
// down, then call the SDK's async API directly (no Task.Run wrap —
|
||||
// the OPC Foundation SDK already provides true async I/O).
|
||||
var session = _session;
|
||||
if (session is null || !session.Connected)
|
||||
{
|
||||
throw new Commons.Interfaces.Protocol.ConnectionNotConnectedException(
|
||||
"OPC UA session is not connected.");
|
||||
}
|
||||
|
||||
// ObjectsFolder = ns=0;i=85 — the OPC UA standard server root. Empty
|
||||
// / null input means "browse the root"; anything else is parsed as
|
||||
// an absolute NodeId expression.
|
||||
var nodeToBrowse = string.IsNullOrEmpty(parentNodeId)
|
||||
? ObjectIds.ObjectsFolder
|
||||
: NodeId.Parse(parentNodeId);
|
||||
|
||||
// NodeClassMask intentionally excludes ReferenceType, View, Variable-
|
||||
// Type, ObjectType, DataType. UI only needs Objects (navigable),
|
||||
// Variables (selectable), Methods (display-only).
|
||||
var nodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method);
|
||||
|
||||
var (_, continuationPoint, references) = await session.BrowseAsync(
|
||||
null,
|
||||
null,
|
||||
nodeToBrowse,
|
||||
1000u,
|
||||
BrowseDirection.Forward,
|
||||
ReferenceTypeIds.HierarchicalReferences,
|
||||
true,
|
||||
nodeClassMask,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var refs = references ?? new ReferenceDescriptionCollection();
|
||||
var children = new List<Commons.Interfaces.Protocol.BrowseNode>(refs.Count);
|
||||
foreach (var r in refs)
|
||||
{
|
||||
children.Add(new Commons.Interfaces.Protocol.BrowseNode(
|
||||
NodeId: r.NodeId.ToString(),
|
||||
DisplayName: r.DisplayName?.Text ?? r.BrowseName?.Name ?? "(unnamed)",
|
||||
NodeClass: MapNodeClass(r.NodeClass),
|
||||
HasChildren: r.NodeClass == NodeClass.Object));
|
||||
}
|
||||
|
||||
// A non-empty continuation point means the server had more refs than
|
||||
// our requestedMaxReferencesPerNode cap. The UI surfaces a "more
|
||||
// children, type the node id manually" hint rather than auto-paging;
|
||||
// BrowseNext is not invoked here. Discarding the continuation point
|
||||
// is acceptable because the server expires it on session close.
|
||||
var truncated = continuationPoint != null && continuationPoint.Length > 0;
|
||||
return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, truncated);
|
||||
}
|
||||
|
||||
private static Commons.Interfaces.Protocol.BrowseNodeClass MapNodeClass(NodeClass nc) => nc switch
|
||||
{
|
||||
NodeClass.Object => Commons.Interfaces.Protocol.BrowseNodeClass.Object,
|
||||
NodeClass.Variable => Commons.Interfaces.Protocol.BrowseNodeClass.Variable,
|
||||
NodeClass.Method => Commons.Interfaces.Protocol.BrowseNodeClass.Method,
|
||||
_ => Commons.Interfaces.Protocol.BrowseNodeClass.Other
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="RealOpcUaClient.BrowseChildrenAsync"/>.
|
||||
///
|
||||
/// Two shapes here:
|
||||
///
|
||||
/// 1. A live round-trip against the infra OPC UA server
|
||||
/// (<c>opc.tcp://localhost:50000</c> — see <c>infra/docker-compose.yml</c>,
|
||||
/// started via <c>cd infra && docker compose up -d opcua</c>).
|
||||
/// Marked <c>[SkippableFact]</c> so it reports Skipped — not failed — on
|
||||
/// machines that don't have the infra stack running. The live test asserts
|
||||
/// that the server root browse returns the standard "Server" node, which
|
||||
/// proves we targeted ObjectsFolder (<c>ns=0;i=85</c>) and that the
|
||||
/// response mapping survived the round trip.
|
||||
///
|
||||
/// 2. A pure unit test that exercises the not-connected guard — no infra
|
||||
/// needed, runs in every build.
|
||||
/// </summary>
|
||||
[Trait("Category", "RequiresOpcUa")]
|
||||
public class RealOpcUaClientBrowseTests
|
||||
{
|
||||
// The infra/docker-compose.yml opcua container maps the OPC PLC simulator
|
||||
// on host port 50000 (not the OPC UA default 4840). Matches what the
|
||||
// existing docker/ and docker-env2/ topologies dial into.
|
||||
private const string EndpointUrl = "opc.tcp://localhost:50000";
|
||||
|
||||
[SkippableFact]
|
||||
public async Task BrowseChildren_at_root_returns_ObjectsFolder_with_Server_node()
|
||||
{
|
||||
await using var client = new RealOpcUaClient();
|
||||
|
||||
// Probe the endpoint before asserting anything. If the infra OPC UA
|
||||
// server isn't up, ConnectAsync surfaces a socket/timeout error from
|
||||
// deep inside the OPC Foundation SDK — we treat that as "infra not
|
||||
// available" and skip rather than fail, mirroring the SkippableFact
|
||||
// pattern already used in ConfigurationDatabase/AuditLog tests.
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(
|
||||
EndpointUrl,
|
||||
new OpcUaConnectionOptions(AutoAcceptUntrustedCerts: true));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Skip.If(true, $"OPC UA test server not reachable on {EndpointUrl}: {ex.Message}");
|
||||
return; // Skip.If throws; this is unreachable but keeps the compiler happy.
|
||||
}
|
||||
|
||||
var result = await client.BrowseChildrenAsync(parentNodeId: null);
|
||||
|
||||
// Under ObjectsFolder (ns=0;i=85) every OPC UA-compliant server
|
||||
// exposes a 'Server' object at ns=0;i=2253 — its presence confirms
|
||||
// we hit the right root and that DisplayName mapping survives the
|
||||
// round trip.
|
||||
Assert.NotEmpty(result.Children);
|
||||
Assert.Contains(result.Children, n => n.DisplayName == "Server");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowseChildren_throws_ConnectionNotConnected_when_session_is_null()
|
||||
{
|
||||
// No ConnectAsync — _session is still null, so the typed guard at the
|
||||
// top of BrowseChildrenAsync should fire before any SDK call.
|
||||
await using var client = new RealOpcUaClient();
|
||||
|
||||
await Assert.ThrowsAsync<ConnectionNotConnectedException>(
|
||||
() => client.BrowseChildrenAsync(parentNodeId: null));
|
||||
}
|
||||
}
|
||||
+7
@@ -15,6 +15,13 @@
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<!--
|
||||
Xunit.SkippableFact lets the live-OPC-UA browse test report Skipped when
|
||||
the infra OPC UA server (opc.tcp://localhost:4840) is unreachable, so a
|
||||
CI run without the infra stack stays green. The not-connected unit test
|
||||
uses a plain [Fact] — it never needs the server.
|
||||
-->
|
||||
<PackageReference Include="Xunit.SkippableFact" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user