From c189cd6fcbe2edb85a37e68d8d402839afb4e4cf Mon Sep 17 00:00:00 2001 From: Klaus Landsdorf Date: Thu, 1 Oct 2026 08:52:04 +0200 Subject: [PATCH] Include subtypes of AliasNameCategoryType in AliasNameClient.EnumerateSubCategoriesAsync (#4611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description `AliasNameClient.EnumerateSubCategoriesAsync` kept a browsed child only when its TypeDefinition was exactly `AliasNameCategoryType`. OPC 10000-17 §6.3.1 declares `` as an OptionalPlaceholder of `AliasNameCategoryType` and does not rule out subtypes, so sub-categories modelled with a subtype were silently dropped. The exact type is still accepted without a round trip; any other type definition is checked against the type hierarchy with `Session.NodeCache.IsTypeOfAsync` (the pattern `RoleManagementClient` uses for `RoleType`). The node cache keeps the type references, so each distinct type is resolved once. The check runs on the first browse page and on every BrowseNext page. `docs/AliasNames.md` notes that subtypes are included. Tests: the mocked session harness gets a node cache that answers `IsTypeOfAsync` from a subtype map; a new test covers the exact type, a direct subtype, a subtype of a subtype, a FolderType child and an untyped child. It fails on master (`Missing: < "Vendor", "Derived" >`) and passes with the fix. `Opc.Ua.Client.Tests` 2733/2733 on net10.0 and net8.0; `Opc.Ua.Features.Tests` AliasNameIntegrationTests (real server) 7/7. Fork CI (full matrix incl. macOS) and CodeQL: green — [CI run 36769850776](https://github.com/biancode/UA-.NETStandard/actions/runs/36769850776) (full matrix incl. macOS), [CodeQL run 36769855329](https://github.com/biancode/UA-.NETStandard/actions/runs/36769855329). ## Related Issues - Fixes #4610 ## Checklist - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. (Affected suites on net10.0 and net8.0 locally on macOS; .NET Framework is covered by the fork CI's Windows net48 legs.) - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. --- docs/AliasNames.md | 3 +- .../AliasNames/AliasNameClient.cs | 31 ++++++++++-- .../AliasNames/AliasNameClientTests.cs | 49 +++++++++++++++++++ .../AliasNames/AliasNameSessionHarness.cs | 29 +++++++++++ 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/docs/AliasNames.md b/docs/AliasNames.md index 370d27db12..03b08706d1 100644 --- a/docs/AliasNames.md +++ b/docs/AliasNames.md @@ -363,7 +363,8 @@ IReadOnlyList result = * `AddAliasesToCategoryAsync(IEnumerable, ct)` * `DeleteAliasesFromCategoryAsync(IEnumerable, ct)` * `EnumerateSubCategoriesAsync(ct)` — `IAsyncEnumerable` of child - `AliasNameSubCategoryInfo`. + `AliasNameSubCategoryInfo`, including children whose type is a + subtype of `AliasNameCategoryType`. * `ReadLastChangeAsync(ct)` — returns the `VersionTime` (or `null` when the category does not expose `LastChange`). diff --git a/src/Opc.Ua.Client/AliasNames/AliasNameClient.cs b/src/Opc.Ua.Client/AliasNames/AliasNameClient.cs index 704665c0fe..2992f8b309 100644 --- a/src/Opc.Ua.Client/AliasNames/AliasNameClient.cs +++ b/src/Opc.Ua.Client/AliasNames/AliasNameClient.cs @@ -408,7 +408,8 @@ namespace Opc.Ua.Client.AliasNames /// /// Asynchronously enumerates the sub-categories of this category /// (Part 17 §6.3.1 SubAliasNameCategories) via a forward - /// browse on . + /// browse on . Children of + /// AliasNameCategoryType or of a subtype are returned. /// /// The browse operation /// returned an error status code. @@ -452,7 +453,7 @@ namespace Opc.Ua.Client.AliasNames { foreach (ReferenceDescription r in SnapshotReferences(br.References)) { - if (!r.TypeDefinition.Equals(ObjectTypeIds.AliasNameCategoryType)) + if (!await IsAliasNameCategoryAsync(r.TypeDefinition, ct).ConfigureAwait(false)) { continue; } @@ -479,7 +480,7 @@ namespace Opc.Ua.Client.AliasNames foreach (ReferenceDescription r in SnapshotReferences(nextReferences)) { - if (!r.TypeDefinition.Equals(ObjectTypeIds.AliasNameCategoryType)) + if (!await IsAliasNameCategoryAsync(r.TypeDefinition, ct).ConfigureAwait(false)) { continue; } @@ -523,6 +524,30 @@ namespace Opc.Ua.Client.AliasNames return snapshot; } + /// + /// Whether a browsed child is a sub-category: an instance of + /// AliasNameCategoryType or of one of its subtypes. Part 17 + /// §6.3.1 declares <SubAliasNameCategories> as an + /// OptionalPlaceholder of that type and does not rule out + /// subtypes, so the type hierarchy is checked through the node cache. + /// + private async ValueTask IsAliasNameCategoryAsync( + ExpandedNodeId typeDefinition, + CancellationToken ct) + { + if (typeDefinition.IsNull) + { + return false; + } + if (typeDefinition.Equals(ObjectTypeIds.AliasNameCategoryType)) + { + return true; + } + return await Session.NodeCache + .IsTypeOfAsync(typeDefinition, ObjectTypeIds.AliasNameCategoryType, ct) + .ConfigureAwait(false); + } + private async Task ResolveChildAsync( string childBrowseName, CancellationToken ct) diff --git a/tests/Opc.Ua.Client.Tests/AliasNames/AliasNameClientTests.cs b/tests/Opc.Ua.Client.Tests/AliasNames/AliasNameClientTests.cs index f95ae72f35..a4e071ff7a 100644 --- a/tests/Opc.Ua.Client.Tests/AliasNames/AliasNameClientTests.cs +++ b/tests/Opc.Ua.Client.Tests/AliasNames/AliasNameClientTests.cs @@ -290,5 +290,54 @@ namespace Opc.Ua.Client.Tests.AliasNames }; } } + + [Test] + public async Task EnumerateSubCategoriesAsyncIncludesSubtypesOfAliasNameCategoryTypeAsync() + { + var harness = AliasNameSessionHarness.Create(); + var vendorCategoryType = new ExpandedNodeId(new NodeId("VendorCategoryType", 2)); + var derivedCategoryType = new ExpandedNodeId(new NodeId("DerivedCategoryType", 2)); + harness.Supertypes[vendorCategoryType] = ObjectTypeIds.AliasNameCategoryType; + harness.Supertypes[derivedCategoryType] = vendorCategoryType; + + harness.BrowseHandler = _ => new BrowseResult + { + StatusCode = StatusCodes.Good, + ContinuationPoint = ByteString.Empty, + References = new[] + { + CreateReference("Standard", ObjectTypeIds.AliasNameCategoryType), + CreateReference("Vendor", vendorCategoryType), + CreateReference("Derived", derivedCategoryType), + CreateReference("Folder", ObjectTypeIds.FolderType), + CreateReference("Untyped", ExpandedNodeId.Null) + }.ToArrayOf() + }; + + var client = AliasNameClient.OpenStandardAliases(harness.Session); + var names = new List(); + + await foreach (AliasNameSubCategoryInfo info in + client.EnumerateSubCategoriesAsync().ConfigureAwait(false)) + { + names.Add(info.BrowseName.Name); + } + + string[] expectedNames = ["Standard", "Vendor", "Derived"]; + Assert.That(names, Is.EqualTo(expectedNames)); + + static ReferenceDescription CreateReference( + string name, + ExpandedNodeId typeDefinition) + { + return new ReferenceDescription + { + NodeId = new ExpandedNodeId(new NodeId(name, 2)), + BrowseName = new QualifiedName(name, 2), + DisplayName = new LocalizedText(name), + TypeDefinition = typeDefinition + }; + } + } } } diff --git a/tests/Opc.Ua.Client.Tests/AliasNames/AliasNameSessionHarness.cs b/tests/Opc.Ua.Client.Tests/AliasNames/AliasNameSessionHarness.cs index a2d7d7ee16..3f3a1cde54 100644 --- a/tests/Opc.Ua.Client.Tests/AliasNames/AliasNameSessionHarness.cs +++ b/tests/Opc.Ua.Client.Tests/AliasNames/AliasNameSessionHarness.cs @@ -56,6 +56,11 @@ namespace Opc.Ua.Client.Tests.AliasNames public Func BrowseHandler { get; set; } public Func BrowseNextHandler { get; set; } + /// + /// Subtype to supertype pairs the node cache answers type checks from. + /// + public Dictionary Supertypes { get; } = []; + public Func BrowsePathHandler { get; set; } private AliasNameSessionHarness( @@ -77,6 +82,30 @@ namespace Opc.Ua.Client.Tests.AliasNames var harness = new AliasNameSessionHarness(sessionMock, messageContext); + var nodeCache = new Mock(MockBehavior.Loose); + nodeCache + .Setup(c => c.IsTypeOfAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + (subType, superType, _) => + { + ExpandedNodeId current = subType; + while (!current.IsNull) + { + if (current == superType) + { + return new ValueTask(true); + } + current = harness.Supertypes.TryGetValue(current, out ExpandedNodeId next) + ? next + : ExpandedNodeId.Null; + } + return new ValueTask(false); + }); + sessionMock.SetupGet(s => s.NodeCache).Returns(nodeCache.Object); + sessionMock .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( It.IsAny(),