From 04ed025ede30f1a48f165a776e6504d089d89189 Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 30 Jul 2026 19:31:13 +0200 Subject: [PATCH 1/2] Allow xRegistry clients to use explicit registry roots Let the xRegistry client base accept an optional registry root NodeId and keep the well-known Object as the default for existing callers. This lets generic or derived clients bind to registries whose root Object was discovered separately without changing the proxy-based lifecycle helpers. Add a GenericXRegistryClient constructor for the explicit-root path and cover both the supplied-root and default-root behavior in the xRegistry client tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- .../GenericXRegistryClient.cs | 23 ++++++++ .../XRegistryClient.cs | 49 ++++++++++++++--- .../XRegistryClientTests.cs | 55 ++++++++++++++++++- 3 files changed, 118 insertions(+), 9 deletions(-) diff --git a/src/Opc.Ua.XRegistry.Client/GenericXRegistryClient.cs b/src/Opc.Ua.XRegistry.Client/GenericXRegistryClient.cs index 6553c1bff9..7d8c981fd8 100644 --- a/src/Opc.Ua.XRegistry.Client/GenericXRegistryClient.cs +++ b/src/Opc.Ua.XRegistry.Client/GenericXRegistryClient.cs @@ -56,6 +56,29 @@ public GenericXRegistryClient( { } + /// + /// Initializes a generic registry client rooted at an explicit registry Object. + /// + /// A domain registry declares its own root rather than reusing the provisional well-known + /// identifier, and a caller typically discovers it by Browse. This overload drives such a + /// registry through the base-model proxies without deriving a domain client. + /// + /// + /// The connected session whose server hosts the registry. + /// The registry companion namespace URI. + /// + /// The registry root Object. Pass a null NodeId to use the well-known root. + /// + /// Telemetry context used by the generated proxies. + public GenericXRegistryClient( + ISession session, + string registryNamespaceUri, + NodeId registryNodeId, + ITelemetryContext telemetry) + : base(session, registryNamespaceUri, registryNodeId, telemetry) + { + } + /// /// Initializes a generic client bound to the abstract xRegistry base namespace. /// diff --git a/src/Opc.Ua.XRegistry.Client/XRegistryClient.cs b/src/Opc.Ua.XRegistry.Client/XRegistryClient.cs index 5f8d8ec3f6..b4a2a111a7 100644 --- a/src/Opc.Ua.XRegistry.Client/XRegistryClient.cs +++ b/src/Opc.Ua.XRegistry.Client/XRegistryClient.cs @@ -41,8 +41,8 @@ namespace Opc.Ua.XRegistry.Client /// /// /// - /// This type is the sanctioned extension point. A domain registry — the PubSub Schema Registry, - /// a WoT registry — derives from it and adds domain-specific naming and defaults; + /// This type is the sanctioned extension point. A domain registry derives from it and adds + /// domain-specific naming and defaults; /// is the plain, sealed implementation for callers that /// only need the base model. /// @@ -58,7 +58,7 @@ public abstract class XRegistryClient { /// /// Initializes a registry client bound to a connected and the - /// registry's companion namespace. + /// registry's companion namespace, using the well-known registry root Object. /// /// The connected session whose server hosts the registry. /// The registry companion namespace URI. @@ -71,6 +71,37 @@ protected XRegistryClient( ISession session, string registryNamespaceUri, ITelemetryContext telemetry) + : this(session, registryNamespaceUri, default, telemetry) + { + } + + /// + /// Initializes a registry client bound to a connected , the + /// registry's companion namespace, and an explicit registry root Object. + /// + /// A domain registry does not necessarily publish its root at + /// — that identifier is provisional, and a + /// domain model can declare its own root, which a client typically discovers by Browse. + /// Passing the resolved NodeId here makes the root a construction-time input that cannot + /// subsequently drift. + /// + /// + /// The connected session whose server hosts the registry. + /// The registry companion namespace URI. + /// + /// The registry root Object. Pass a null NodeId to use the well-known root + /// in the resolved registry namespace. + /// + /// Telemetry context used by the generated proxies. + /// or + /// is null. + /// is null/empty. + /// The server does not expose the registry namespace. + protected XRegistryClient( + ISession session, + string registryNamespaceUri, + NodeId registryNodeId, + ITelemetryContext telemetry) { Session = session ?? throw new ArgumentNullException(nameof(session)); Telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry)); @@ -90,6 +121,9 @@ protected XRegistryClient( RegistryNamespaceUri = registryNamespaceUri; NamespaceIndex = (ushort)index; + RegistryNodeId = registryNodeId.IsNull + ? new NodeId(XRegistryWellKnown.RegistryObject, NamespaceIndex) + : registryNodeId; } /// @@ -108,11 +142,12 @@ protected XRegistryClient( public ushort NamespaceIndex { get; } /// - /// Gets the NodeId of the registry root Object, which a server publishes at a well-known - /// identifier in its registry namespace. This is the starting point for the group lifecycle, - /// so a caller does not have to Browse for it. + /// Gets the NodeId of the registry root Object. By default this is the well-known + /// identifier a server publishes in its registry namespace, so a caller does not have to + /// Browse for it; a domain registry whose root lives elsewhere supplies it at construction. + /// This is the starting point for the group lifecycle. /// - public NodeId RegistryNodeId => new(XRegistryWellKnown.RegistryObject, NamespaceIndex); + public NodeId RegistryNodeId { get; } /// /// Gets the telemetry context handed to the generated proxies. diff --git a/tests/Opc.Ua.XRegistry.Tests/XRegistryClientTests.cs b/tests/Opc.Ua.XRegistry.Tests/XRegistryClientTests.cs index cca66fc527..8b25afcea2 100644 --- a/tests/Opc.Ua.XRegistry.Tests/XRegistryClientTests.cs +++ b/tests/Opc.Ua.XRegistry.Tests/XRegistryClientTests.cs @@ -115,6 +115,57 @@ public void RegistryNodeIdAddressesTheWellKnownRoot() }); } + [Test] + public void RegistryNodeIdUsesAnExplicitRootWhenSupplied() + { + var domainRoot = new NodeId(64100u, 1); + + var client = new GenericXRegistryClient( + CreateSession().Object, + TestNamespaceUri, + domainRoot, + CreateTelemetry()); + + Assert.That(client.RegistryNodeId, Is.EqualTo(domainRoot), + "A domain registry declares its own root, so an explicitly supplied NodeId must " + + "win over the provisional well-known identifier."); + } + + [Test] + public void RegistryNodeIdFallsBackToTheWellKnownRootForANullExplicitRoot() + { + var client = new GenericXRegistryClient( + CreateSession().Object, + TestNamespaceUri, + default, + CreateTelemetry()); + + Assert.That(client.RegistryNodeId, + Is.EqualTo(new NodeId(XRegistryWellKnown.RegistryObject, 1)), + "A null NodeId selects the well-known root, so the explicit-root overload stays " + + "equivalent to the namespace-only constructor."); + } + + [Test] + public async Task AnExplicitRootDrivesTheGroupLifecycleAsync() + { + Mock session = CreateSession(); + var calls = new List(); + SetupCall(session, calls); + var domainRoot = new NodeId(64100u, 1); + + var client = new GenericXRegistryClient( + session.Object, + TestNamespaceUri, + domainRoot, + CreateTelemetry()); + await client.CreateGroupAsync(client.RegistryNodeId, "things") + .ConfigureAwait(false); + + Assert.That(calls[0].ObjectId, Is.EqualTo(domainRoot), + "The lifecycle Methods must be invoked on the explicit root, not the well-known one."); + } + [Test] public async Task RegistryNodeIdDrivesTheGroupLifecycleAsync() { @@ -799,8 +850,8 @@ private static int CountCalls(List calls, ExpandedNodeId meth } /// - /// Stands in for a domain registry client (Schema Registry, WoT registry): it extends the - /// abstract base and adds domain naming without re-implementing the lifecycle. + /// Stands in for a domain registry client: it extends the abstract base and adds domain + /// naming without re-implementing the lifecycle. /// private sealed class TestDomainRegistryClient : XRegistryClient { From 5760a839e28caad016cefb8c8eb1a5bf8e440c1b Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 31 Jul 2026 08:06:01 +0200 Subject: [PATCH 2/2] Assert call capture before indexing in the registry client tests Add an explicit captured-call count assertion before the test indexes the first call. This keeps regressions failing with a meaningful NUnit assertion instead of an ArgumentOutOfRangeException. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9e6a5abf-3299-4cd1-9855-010fedbf0ad8 --- tests/Opc.Ua.XRegistry.Tests/XRegistryClientTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Opc.Ua.XRegistry.Tests/XRegistryClientTests.cs b/tests/Opc.Ua.XRegistry.Tests/XRegistryClientTests.cs index 8b25afcea2..b3d0f9bad6 100644 --- a/tests/Opc.Ua.XRegistry.Tests/XRegistryClientTests.cs +++ b/tests/Opc.Ua.XRegistry.Tests/XRegistryClientTests.cs @@ -162,6 +162,7 @@ public async Task AnExplicitRootDrivesTheGroupLifecycleAsync() await client.CreateGroupAsync(client.RegistryNodeId, "things") .ConfigureAwait(false); + Assert.That(calls, Has.Count.EqualTo(1)); Assert.That(calls[0].ObjectId, Is.EqualTo(domainRoot), "The lifecycle Methods must be invoked on the explicit root, not the well-known one."); }