Include subtypes of AliasNameCategoryType in AliasNameClient.EnumerateSubCategoriesAsync (#4611)

# Description

`AliasNameClient.EnumerateSubCategoriesAsync` kept a browsed child only
when its TypeDefinition was exactly `AliasNameCategoryType`. OPC
10000-17 §6.3.1 declares `<SubAliasNameCategories>` 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.
This commit is contained in:
Klaus Landsdorf
2026-10-01 06:52:04 +00:00
committed by GitHub
parent ce88955f70
commit c189cd6fcb
4 changed files with 108 additions and 4 deletions
+2 -1
View File
@@ -363,7 +363,8 @@ IReadOnlyList<AliasNameDataType> result =
* `AddAliasesToCategoryAsync(IEnumerable<AliasNameAddRequest>, ct)`
* `DeleteAliasesFromCategoryAsync(IEnumerable<AliasNameDeleteRequest>, 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`).
@@ -408,7 +408,8 @@ namespace Opc.Ua.Client.AliasNames
/// <summary>
/// Asynchronously enumerates the sub-categories of this category
/// (Part 17 §6.3.1 <c>SubAliasNameCategories</c>) via a forward
/// browse on <see cref="ReferenceTypeIds.Organizes"/>.
/// browse on <see cref="ReferenceTypeIds.Organizes"/>. Children of
/// <c>AliasNameCategoryType</c> or of a subtype are returned.
/// </summary>
/// <exception cref="ServiceResultException">The browse operation
/// returned an error status code.</exception>
@@ -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;
}
/// <summary>
/// Whether a browsed child is a sub-category: an instance of
/// <c>AliasNameCategoryType</c> or of one of its subtypes. Part 17
/// §6.3.1 declares <c>&lt;SubAliasNameCategories&gt;</c> as an
/// <c>OptionalPlaceholder</c> of that type and does not rule out
/// subtypes, so the type hierarchy is checked through the node cache.
/// </summary>
private async ValueTask<bool> 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<NodeId> ResolveChildAsync(
string childBrowseName,
CancellationToken ct)
@@ -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<string>();
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
};
}
}
}
}
@@ -56,6 +56,11 @@ namespace Opc.Ua.Client.Tests.AliasNames
public Func<BrowseDescription, BrowseResult> BrowseHandler { get; set; }
public Func<ByteString, BrowseResult> BrowseNextHandler { get; set; }
/// <summary>
/// Subtype to supertype pairs the node cache answers type checks from.
/// </summary>
public Dictionary<ExpandedNodeId, ExpandedNodeId> Supertypes { get; } = [];
public Func<BrowsePath, BrowsePathResult> 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<INodeCache>(MockBehavior.Loose);
nodeCache
.Setup(c => c.IsTypeOfAsync(
It.IsAny<ExpandedNodeId>(),
It.IsAny<ExpandedNodeId>(),
It.IsAny<CancellationToken>()))
.Returns<ExpandedNodeId, ExpandedNodeId, CancellationToken>(
(subType, superType, _) =>
{
ExpandedNodeId current = subType;
while (!current.IsNull)
{
if (current == superType)
{
return new ValueTask<bool>(true);
}
current = harness.Supertypes.TryGetValue(current, out ExpandedNodeId next)
? next
: ExpandedNodeId.Null;
}
return new ValueTask<bool>(false);
});
sessionMock.SetupGet(s => s.NodeCache).Returns(nodeCache.Object);
sessionMock
.Setup(s => s.TranslateBrowsePathsToNodeIdsAsync(
It.IsAny<RequestHeader>(),