From 6999aedc603f4dbb4bc48a1cf7ef0dbf99531cf2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 11:59:03 -0400 Subject: [PATCH] feat(dcl): implement BrowseChildrenAsync on RealOpcUaClient --- .../Adapters/RealOpcUaClient.cs | 66 ++++++++++++++++- .../Adapters/RealOpcUaClientBrowseTests.cs | 73 +++++++++++++++++++ ...adaBridge.DataConnectionLayer.Tests.csproj | 7 ++ 3 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs index 86b5f077..27d6b1e0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -327,10 +327,70 @@ public class RealOpcUaClient : IOpcUaClient : configured; /// - // Real implementation lands in Task 8 of the OPC UA tag browser plan. - public Task BrowseChildrenAsync( + public async Task 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(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 + }; } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs new file mode 100644 index 00000000..1b6c66eb --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientBrowseTests.cs @@ -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; + +/// +/// Tests for . +/// +/// Two shapes here: +/// +/// 1. A live round-trip against the infra OPC UA server +/// (opc.tcp://localhost:50000 — see infra/docker-compose.yml, +/// started via cd infra && docker compose up -d opcua). +/// Marked [SkippableFact] 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 (ns=0;i=85) 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. +/// +[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( + () => client.BrowseChildrenAsync(parentNodeId: null)); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj index c55f29ed..207b1e85 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.csproj @@ -15,6 +15,13 @@ + +