Files
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# Description

Nullable reference types are now enabled once, in `common.props`, and
every project in the repository builds warning-clean with them.

**Configuration**
- `<Nullable>enable</Nullable>` lives in `common.props`. All per-project
`<Nullable>` settings are removed: 178 project files (enable,
annotations, and the `disable` in the three Aot test projects).
- 419 per-file `#nullable enable`/`restore` directives and the 4
`#nullable disable` directives are removed.
- Kept on purpose:
- The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type
Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`,
`PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the
project setting in generated code, so the directive is required there.
- `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against
the 1.5.378 packages and does not import the repository props.
- `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now
describe the central setting.

**Migration** (the 43 unset and 13 annotations-only projects, roughly
10k diagnostics)
- **Production code: annotated at the declarations.** Covers
`tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the
LDS sample.
- Members, parameters and returns are nullable where null actually
flows.
- Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`,
`IsMethodTypeDesign`).
- `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take
`params object?[]`. `Template.AddReplacement` takes `string?` (rendering
already skips null replacements).
- `IRegisteredServerStore.RegisterAsync` returns
`Task<RegistrationEntry?>`: it returns null when a registration is
removed.
- Dereferences backed by an invariant (for example, the validated model)
use `!`, so runtime behaviour is unchanged.
- **Behavioural changes in production code** (small, deliberate):
- `NodeSetToModelDesign` throws `InvalidOperationException` when the
file is not a NodeSet. Before, it threw a NullReferenceException on the
next line.
- `ModelDesignValidator` starts its lookup tables empty instead of null.
A redundant `if (m_nodes == null) return;` guard is gone.
- **Test code: minimal annotations.**
  - `?` on values that can be null, `!` where the test asserts non-null.
  - `= null!` for fixture members assigned in SetUp.
- `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the
`IEncodeable` constraint.
- A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`)
folded into the adjacent `IsNull`/`Count` checks.

**Notes for reviewers**
- The diff is large (~1,500 files) but almost entirely mechanical: `?`,
`!` and directive removal. The non-annotation changes are listed above.
- Known follow-up, not done here:
`IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay
non-nullable even though the validator returns null before it loads a
dictionary. Making them nullable would ripple through every generator
call site.

## Related Issues

- Fixes #4439

## Checklist

- [ ] 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.
- [ ] 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.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

The "no new warnings" box covers net10.0 and net48: full non-incremental
builds of `UA.slnx`, `tools/SourceGeneration.slnx`,
`tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0
warnings and 0 errors under the repository's warnings-as-errors
settings. net9.0 and net8.0 also had no nullable diagnostics, but they
were not rebuilt after the final fixes. Tests have not been run locally
yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:48:13 +02:00

1769 lines
70 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License (MIT). See License.txt in the repo root for license information.
// CA2000: test code; many disposables are ownership-transferred to test fixtures or short-lived,
// making CA2000 noisy without a real leak risk. Disabled file-level for the suite.
#pragma warning disable CA2000
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Tests;
namespace Opc.Ua.Client.Tests
{
[TestFixture]
[Category("Client")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
public sealed class NodeCacheTests
{
[TestCase(true)]
[TestCase(false)]
public async Task GetNodeCancellationDoesNotPoisonSharedFetchAsync(bool cancelFirst)
{
var nodeId = new NodeId("shared-node", 2);
var expected = new VariableNode { NodeId = nodeId, BrowseName = new QualifiedName("Shared", 2) };
var fetch = new TaskCompletionSource<Node>(TaskCreationOptions.RunContinuationsAsynchronously);
var context = new Mock<INodeCacheContext>();
context.Setup(value => value.FetchNodeAsync(
null, nodeId, NodeClass.Unspecified, false, It.IsAny<CancellationToken>()))
.Returns((RequestHeader _, NodeId _, NodeClass _, bool _, CancellationToken ct) =>
new ValueTask<Node>(fetch.Task.WaitAsync(ct)));
context.Setup(value => value.FetchReferencesAsync(null, nodeId, It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
using var cache = new NodeCache(context.Object, NUnitTelemetryContext.Create());
using var canceled = new CancellationTokenSource();
Task<INode> first = cache.GetNodeAsync(
nodeId, cancelFirst ? canceled.Token : CancellationToken.None).AsTask();
Task<INode> second = cache.GetNodeAsync(
nodeId, cancelFirst ? CancellationToken.None : canceled.Token).AsTask();
Task<INode> canceledLookup = cancelFirst ? first : second;
Task<INode> liveLookup = cancelFirst ? second : first;
try
{
canceled.Cancel();
Assert.That(
async () => _ = await canceledLookup.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>());
Assert.That(liveLookup.IsCompleted, Is.False);
}
finally
{
fetch.TrySetResult(expected);
try
{
await Task.WhenAll(first, second).ConfigureAwait(false);
}
catch (OperationCanceledException) when (canceled.IsCancellationRequested)
{
}
}
INode node = await liveLookup.ConfigureAwait(false);
Assert.That(node.NodeId, Is.EqualTo(nodeId));
Assert.That(node.BrowseName, Is.EqualTo(new QualifiedName("Shared", 2)));
Assert.That(await cache.GetNodeAsync(nodeId, default).ConfigureAwait(false), Is.SameAs(node));
context.Verify(value => value.FetchNodeAsync(
null, nodeId, NodeClass.Unspecified, false, It.IsAny<CancellationToken>()), Times.Once);
context.Verify(value => value.FetchReferencesAsync(
null, nodeId, It.IsAny<CancellationToken>()), Times.Once);
}
[Test]
public async Task GetValueCancellationDoesNotPoisonSharedFetchAsync()
{
var nodeId = new NodeId("shared-value", 2);
var expected = new DataValue(Variant.From(42), StatusCodes.GoodClamped);
var fetch = new TaskCompletionSource<DataValue>(TaskCreationOptions.RunContinuationsAsynchronously);
var context = new Mock<INodeCacheContext>();
context.Setup(value => value.FetchValueAsync(null, nodeId, It.IsAny<CancellationToken>()))
.Returns((RequestHeader _, NodeId _, CancellationToken ct) =>
new ValueTask<DataValue>(fetch.Task.WaitAsync(ct)));
using var cache = new NodeCache(context.Object, NUnitTelemetryContext.Create());
using var canceled = new CancellationTokenSource();
Task<DataValue> first = cache.GetValueAsync(nodeId, canceled.Token).AsTask();
Task<DataValue> second = cache.GetValueAsync(nodeId, default).AsTask();
try
{
canceled.Cancel();
Assert.That(
async () => _ = await first.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>());
Assert.That(second.IsCompleted, Is.False);
}
finally
{
fetch.TrySetResult(expected);
try
{
await Task.WhenAll(first, second).ConfigureAwait(false);
}
catch (OperationCanceledException) when (canceled.IsCancellationRequested)
{
}
}
DataValue result = await second.ConfigureAwait(false);
Assert.That(result.WrappedValue, Is.EqualTo(Variant.From(42)));
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.GoodClamped));
Assert.That(await cache.GetValueAsync(nodeId, default).ConfigureAwait(false), Is.EqualTo(expected));
context.Verify(value => value.FetchValueAsync(
null, nodeId, It.IsAny<CancellationToken>()), Times.Once);
}
[Test]
public async Task GetReferencesCancellationDoesNotPoisonSharedFetchAsync()
{
var nodeId = new NodeId("shared-references", 2);
var childId = new NodeId("child", 2);
var expected = new VariableNode { NodeId = childId, BrowseName = new QualifiedName("Child", 2) };
var fetch = new TaskCompletionSource<ArrayOf<ReferenceDescription>>(
TaskCreationOptions.RunContinuationsAsynchronously);
var context = new Mock<INodeCacheContext>();
context.SetupGet(value => value.NamespaceUris).Returns(new NamespaceTable());
context.Setup(value => value.FetchReferencesAsync(null, nodeId, It.IsAny<CancellationToken>()))
.Returns((RequestHeader _, NodeId _, CancellationToken ct) =>
new ValueTask<ArrayOf<ReferenceDescription>>(fetch.Task.WaitAsync(ct)));
context.Setup(value => value.FetchNodesAsync(
null,
It.Is<ArrayOf<NodeId>>(ids => ids.Count == 1 && ids[0] == childId),
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<Node>
{
Results = [expected],
Errors = [ServiceResult.Good]
});
using var cache = new NodeCache(context.Object, NUnitTelemetryContext.Create());
using var canceled = new CancellationTokenSource();
Task<ArrayOf<INode>> first = cache.GetReferencesAsync(
nodeId, ReferenceTypeIds.HasComponent, false, false, canceled.Token).AsTask();
Task<ArrayOf<INode>> second = cache.GetReferencesAsync(
nodeId, ReferenceTypeIds.HasComponent, false, false, default).AsTask();
try
{
canceled.Cancel();
Assert.That(
async () => _ = await first.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>());
Assert.That(second.IsCompleted, Is.False);
}
finally
{
fetch.TrySetResult(
[
new ReferenceDescription
{
NodeId = childId,
ReferenceTypeId = ReferenceTypeIds.HasComponent,
IsForward = true
}
]);
try
{
await Task.WhenAll(first, second).ConfigureAwait(false);
}
catch (OperationCanceledException) when (canceled.IsCancellationRequested)
{
}
}
ArrayOf<INode> result = await second.ConfigureAwait(false);
Assert.That(result, Has.Count.EqualTo(1));
Assert.That(result[0].NodeId, Is.EqualTo(childId));
Assert.That(result[0].BrowseName, Is.EqualTo(new QualifiedName("Child", 2)));
ArrayOf<INode> cached = await cache.GetReferencesAsync(
nodeId, ReferenceTypeIds.HasComponent, false, false, default).ConfigureAwait(false);
Assert.That(cached, Has.Count.EqualTo(1));
Assert.That(cached[0], Is.SameAs(result[0]));
context.Verify(value => value.FetchReferencesAsync(
null, nodeId, It.IsAny<CancellationToken>()), Times.Once);
context.Verify(value => value.FetchNodesAsync(
null, It.IsAny<ArrayOf<NodeId>>(), false, It.IsAny<CancellationToken>()), Times.Once);
}
[Test]
public async Task FetchRemainingNodesAsyncShouldHandleErrorsAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var id = new NodeId("test", 0);
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchNodesAsync(
It.IsAny<RequestHeader>(),
It.IsAny<ArrayOf<NodeId>>(),
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<Node>
{
Results = [new()],
Errors = [new ServiceResult(StatusCodes.BadUnexpectedError)]
})
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
ArrayOf<INode> result = await nodeCache.GetNodesAsync([id], default)
.ConfigureAwait(false);
// Assert
Assert.That(result.Count, Is.EqualTo(1));
context.Verify();
}
[Test]
public async Task GetBuiltInTypeAsyncShouldHandleUnknownTypeAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var datatypeId = new NodeId("unknownType", 0);
var context = new Mock<INodeCacheContext>();
var nodeCache = new NodeCache(context.Object, telemetry);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == datatypeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync([])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i != datatypeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync([])
.Verifiable(Times.Never);
// Act
BuiltInType result = await nodeCache.GetBuiltInTypeAsync(datatypeId, default)
.ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(BuiltInType.Null));
context.Verify();
}
[Test]
public async Task GetBuiltInTypeAsyncShouldReturnBuiltInTypeAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var datatypeId = new NodeId((uint)BuiltInType.Int32, 0);
var context = new Mock<INodeCacheContext>();
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
BuiltInType result = await nodeCache.GetBuiltInTypeAsync(datatypeId, default)
.ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(BuiltInType.Int32));
}
[Test]
public async Task GetNodeAsyncShouldHandleEmptyListAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var context = new Mock<INodeCacheContext>();
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
ArrayOf<INode> result = await nodeCache.GetNodesAsync([], default)
.ConfigureAwait(false);
// Assert
Assert.That(result.IsEmpty, Is.True);
}
[Test]
public async Task GetNodeAsyncShouldReturnNodeFromCacheAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var expected = new Node();
var id = new NodeId("test", 0);
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchNodeAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == id),
NodeClass.Unspecified,
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(expected)
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
INode result = await nodeCache.GetNodeAsync(id, default).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(expected));
result = await nodeCache.GetNodeAsync(id, default).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(expected));
context.Verify();
}
[Test]
public async Task GetNodeTestAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var expected = new Node();
var id = new NodeId("test", 0);
var context = new Mock<INodeCacheContext>();
var nsTable = new NamespaceTable();
context.Setup(c => c.NamespaceUris).Returns(nsTable);
context
.Setup(c => c.FetchNodeAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == id),
NodeClass.Unspecified,
false,
It.IsAny<CancellationToken>()))
.Returns<RequestHeader, NodeId, NodeClass, bool, CancellationToken>((_, nodeId, _, _, ct)
=> new ValueTask<Node>(expected))
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
INode result = await nodeCache.GetNodeAsync(id, default).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(expected));
result = await nodeCache.GetNodeAsync(id, default).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(expected));
result = await nodeCache.GetNodeAsync(id, default).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(expected));
context.Verify();
}
[Test]
public void GetNodeThrowsTest()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var expected = new Node();
var id = new NodeId("test", 0);
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchNodeAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == id),
NodeClass.Unspecified,
false,
It.IsAny<CancellationToken>()))
.Returns<RequestHeader, NodeId, NodeClass, bool, CancellationToken>((_, nodeId, _, _, ct)
=> new ValueTask<Node>(Task.FromException<Node>(new ServiceResultException())))
.Verifiable(Times.Exactly(3));
var nodeCache = new NodeCache(context.Object, telemetry);
_ = Assert.ThrowsAsync<ServiceResultException>(async () =>
await nodeCache.GetNodeAsync(id, default).ConfigureAwait(false));
_ = Assert.ThrowsAsync<ServiceResultException>(async () =>
await nodeCache.GetNodeAsync(id, default).ConfigureAwait(false));
_ = Assert.ThrowsAsync<ServiceResultException>(async () =>
await nodeCache.GetNodeAsync(id, default).ConfigureAwait(false));
context.Verify();
}
[Test]
public async Task GetNodeWithBrowsePathAsyncShouldHandleInvalidBrowsePathAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var id = new NodeId("test", 0);
ArrayOf<QualifiedName> browsePath = [QualifiedName.From("invalid")];
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == ReferenceTypeIds.HierarchicalReferences),
It.IsAny<CancellationToken>()))
.ReturnsAsync([])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == id),
It.IsAny<CancellationToken>()))
.ReturnsAsync([])
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
INode? result = await nodeCache.GetNodeWithBrowsePathAsync(id, browsePath, default)
.ConfigureAwait(false);
// Assert
Assert.That(result, Is.Null);
context.Verify();
}
[Test]
public async Task GetNodeWithBrowsePathAsyncShouldReturnNodeAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var id = new NodeId("test", 0);
var expected = new VariableNode
{
BrowseName = QualifiedName.From("child"),
NodeId = id,
NodeClass = NodeClass.Variable
};
ArrayOf<QualifiedName> browsePath = [QualifiedName.From("child")];
var references = new List<ReferenceDescription>
{
new()
{
NodeId = new ExpandedNodeId(id),
BrowseName = QualifiedName.From("child"),
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IsForward = true
}
};
var context = new Mock<INodeCacheContext>();
var nsTable = new NamespaceTable();
context.Setup(c => c.NamespaceUris).Returns(nsTable);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == ReferenceTypeIds.HierarchicalReferences),
It.IsAny<CancellationToken>()))
.ReturnsAsync([])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == id),
It.IsAny<CancellationToken>()))
.ReturnsAsync([.. references])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchNodesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(n => n.Count == 1 && n[0] == id),
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<Node>
{
Results = [expected],
Errors = [ServiceResult.Good]
})
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
INode? result = await nodeCache.GetNodeWithBrowsePathAsync(id, browsePath, default)
.ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(expected));
// Act
result = await nodeCache.GetNodeWithBrowsePathAsync(id, browsePath, default)
.ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(expected));
context.Verify();
}
[Test]
public async Task GetNodeWithBrowsePathAsyncShouldReturnNodeWithMultipleElementsAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var rootId = new NodeId("root", 0);
var childId = new NodeId("child", 0);
var grandChildId = new NodeId("grandChild", 0);
ArrayOf<QualifiedName> browsePath =
[
new QualifiedName("child"),
new QualifiedName("grandChild")
];
var rootReferences = new List<ReferenceDescription>
{
new()
{
NodeId = new ExpandedNodeId(childId),
BrowseName = QualifiedName.From("child"),
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IsForward = true
}
};
var childNode = new VariableNode
{
BrowseName = QualifiedName.From("child"),
NodeId = childId,
NodeClass = NodeClass.Variable
};
var childReferences = new List<ReferenceDescription>
{
new()
{
NodeId = new ExpandedNodeId(grandChildId),
BrowseName = QualifiedName.From("grandChild"),
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IsForward = true
}
};
var expected = new VariableNode
{
BrowseName = QualifiedName.From("grandChild"),
NodeId = grandChildId,
NodeClass = NodeClass.Variable
};
var context = new Mock<INodeCacheContext>();
var nsTable = new NamespaceTable();
context.Setup(c => c.NamespaceUris).Returns(nsTable);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == ReferenceTypeIds.HierarchicalReferences),
It.IsAny<CancellationToken>()))
.ReturnsAsync([])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == rootId),
It.IsAny<CancellationToken>()))
.ReturnsAsync([.. rootReferences])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == childId),
It.IsAny<CancellationToken>()))
.ReturnsAsync([.. childReferences])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchNodesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(n => n.Count == 1 && n[0] == childId),
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<Node>
{
Results = [childNode],
Errors = [ServiceResult.Good]
})
.Verifiable(Times.Once);
context
.Setup(c => c.FetchNodesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(n => n.Count == 1 && n[0] == grandChildId),
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<Node>
{
Results = [expected],
Errors = [ServiceResult.Good]
})
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
INode? result = await nodeCache
.GetNodeWithBrowsePathAsync(rootId, browsePath, default)
.ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(expected));
// Act
result = await nodeCache.GetNodeWithBrowsePathAsync(rootId, browsePath, default)
.ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(expected));
context.Verify();
}
[Test]
public async Task GetReferencesAsyncShouldHandleEmptyListOfNodeIdsAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var context = new Mock<INodeCacheContext>();
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
ArrayOf<INode> result = await nodeCache
.GetReferencesAsync([], [], false, false, default)
.ConfigureAwait(false);
// Assert
Assert.That(result.IsEmpty, Is.True);
}
[Test]
public async Task GetReferencesAsyncShouldReturnReferencesFromCacheAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var referenceTypeId = new NodeId("referenceType", 0);
var targetExpandedNodeId = new ExpandedNodeId("target", 0);
var targetNodeId = new NodeId("target", 0);
var expected = new List<ReferenceDescription>
{
new()
{
NodeId = targetExpandedNodeId,
ReferenceTypeId = referenceTypeId,
IsForward = false
}
};
var id = new NodeId("test", 0);
var context = new Mock<INodeCacheContext>();
var nsTable = new NamespaceTable();
context.Setup(c => c.NamespaceUris).Returns(nsTable);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == id),
It.IsAny<CancellationToken>()))
.ReturnsAsync([.. expected])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchNodesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(n => n.Count == 1 && n[0] == targetNodeId),
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<Node>
{
Results =
[
new VariableNode
{
NodeId = targetNodeId,
NodeClass = NodeClass.Variable
}
],
Errors = [ServiceResult.Good]
})
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
ArrayOf<INode> result1 = await nodeCache
.GetReferencesAsync(id, referenceTypeId, true, false, default)
.ConfigureAwait(false);
ArrayOf<INode> result2 = await nodeCache
.GetReferencesAsync(id, referenceTypeId, false, false, default)
.ConfigureAwait(false);
// Assert
Assert.That(result1.Count, Is.EqualTo(1));
Assert.That(result2.IsEmpty, Is.True);
// Act
result1 = await nodeCache
.GetReferencesAsync(id, referenceTypeId, true, false, default)
.ConfigureAwait(false);
result2 = await nodeCache
.GetReferencesAsync(id, referenceTypeId, false, false, default)
.ConfigureAwait(false);
// Assert
Assert.That(result1.Count, Is.EqualTo(1));
Assert.That(result2.IsEmpty, Is.True);
context.Verify();
}
[Test]
public async Task GetReferencesAsyncWithMoreThanOneSubtypeShouldReturnReferencesFromCacheAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var referenceTypeId = new NodeId("referenceType", 0);
var referenceSubTypeId = new NodeId("referenceSubType", 0);
var targetExpandedNodeId = new ExpandedNodeId("target", 0);
var targetNodeId = new NodeId("target", 0);
var expected = new List<ReferenceDescription>
{
new()
{
NodeId = targetExpandedNodeId,
ReferenceTypeId = referenceSubTypeId,
IsForward = false
}
};
var id = new NodeId("test", 0);
var context = new Mock<INodeCacheContext>();
var nsTable = new NamespaceTable();
context.Setup(c => c.NamespaceUris).Returns(nsTable);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == referenceTypeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(
[
new()
{
ReferenceTypeId = ReferenceTypeIds.HasSubtype,
BrowseName = QualifiedName.From("HasSubtype"),
NodeId = new ExpandedNodeId(referenceSubTypeId)
}
])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchNodesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(n => n.Count == 1 && n[0] == referenceSubTypeId),
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<Node>
{
Results =
[
new ReferenceTypeNode
{
NodeId = referenceSubTypeId,
NodeClass = NodeClass.ReferenceType
}
],
Errors = [ServiceResult.Good]
})
.Verifiable(Times.Once);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == referenceSubTypeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(
[
new()
{
ReferenceTypeId = ReferenceTypeIds.HasSubtype,
IsForward = false,
BrowseName = QualifiedName.From("HasSuperType"),
NodeId = new ExpandedNodeId(referenceTypeId)
}
])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == id),
It.IsAny<CancellationToken>()))
.ReturnsAsync([.. expected])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchNodesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(n => n.Count == 1 && n[0] == targetNodeId),
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<Node>
{
Results =
[
new VariableNode
{
NodeId = targetNodeId,
NodeClass = NodeClass.Variable
}
],
Errors = [ServiceResult.Good]
})
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
ArrayOf<INode> result1 = await nodeCache
.GetReferencesAsync(id, referenceTypeId, true, true, default)
.ConfigureAwait(false);
ArrayOf<INode> result2 = await nodeCache
.GetReferencesAsync(id, referenceTypeId, false, true, default)
.ConfigureAwait(false);
// Assert
Assert.That(result1.Count, Is.EqualTo(1));
Assert.That(result2.IsEmpty, Is.True);
// Act
result1 = await nodeCache
.GetReferencesAsync(id, referenceTypeId, true, true, default)
.ConfigureAwait(false);
result2 = await nodeCache
.GetReferencesAsync(id, referenceTypeId, false, true, default)
.ConfigureAwait(false);
// Assert
Assert.That(result1.Count, Is.EqualTo(1));
Assert.That(result2.IsEmpty, Is.True);
context.Verify();
}
[Test]
public async Task GetReferencesAsyncWithSubtypesShouldReturnReferencesFromCacheAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var referenceTypeId = new NodeId("referenceType", 0);
var targetExpandedNodeId = new ExpandedNodeId("target", 0);
var targetNodeId = new NodeId("target", 0);
var expected = new List<ReferenceDescription>
{
new()
{
NodeId = targetExpandedNodeId,
ReferenceTypeId = referenceTypeId,
IsForward = false
}
};
var id = new NodeId("test", 0);
var context = new Mock<INodeCacheContext>();
var nsTable = new NamespaceTable();
context.Setup(c => c.NamespaceUris).Returns(nsTable);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == referenceTypeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync([])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == id),
It.IsAny<CancellationToken>()))
.ReturnsAsync([.. expected])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchNodesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(n => n.Count == 1 && n[0] == targetNodeId),
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<Node>
{
Results =
[
new VariableNode
{
NodeId = targetNodeId,
NodeClass = NodeClass.Variable
}
],
Errors = [ServiceResult.Good]
})
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
ArrayOf<INode> result1 = await nodeCache
.GetReferencesAsync(id, referenceTypeId, true, true, default)
.ConfigureAwait(false);
ArrayOf<INode> result2 = await nodeCache
.GetReferencesAsync(id, referenceTypeId, false, true, default)
.ConfigureAwait(false);
// Assert
Assert.That(result1.Count, Is.EqualTo(1));
Assert.That(result2.IsEmpty, Is.True);
// Act
result1 = await nodeCache
.GetReferencesAsync(id, referenceTypeId, true, true, default)
.ConfigureAwait(false);
result2 = await nodeCache
.GetReferencesAsync(id, referenceTypeId, false, true, default)
.ConfigureAwait(false);
// Assert
Assert.That(result1.Count, Is.EqualTo(1));
Assert.That(result2.IsEmpty, Is.True);
context.Verify();
}
[Test]
public async Task FindSuperTypeAsyncShouldHandleNoSupertypeAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var typeId = new NodeId("type", 0);
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == typeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync([])
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
NodeId result = await nodeCache.FindSuperTypeAsync(typeId, default)
.ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(NodeId.Null));
context.Verify();
}
[Test]
public async Task FindSuperTypeAsyncShouldReturnSuperTypeAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var superTypeId = new NodeId("superType", 0);
var subTypeId = new NodeId("subType", 0);
var references = new List<ReferenceDescription>
{
new()
{
NodeId = new ExpandedNodeId(superTypeId),
ReferenceTypeId = ReferenceTypeIds.HasSubtype,
IsForward = false
}
};
var context = new Mock<INodeCacheContext>();
var nsTable = new NamespaceTable();
context.Setup(c => c.NamespaceUris).Returns(nsTable);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == subTypeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync([.. references])
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
NodeId result = await nodeCache.FindSuperTypeAsync(subTypeId, default)
.ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(superTypeId));
// Act
result = await nodeCache.FindSuperTypeAsync(subTypeId, default).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(superTypeId));
context.Verify();
}
[Test]
public async Task FindReferenceTypeAsyncReturnsNodeIdFromHierarchyAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
NodeId rootId = ReferenceTypeIds.References;
var childId = new NodeId("CustomReference", 0);
var context = new Mock<INodeCacheContext>();
var nsTable = new NamespaceTable();
context.Setup(c => c.NamespaceUris).Returns(nsTable);
context
.Setup(c => c.FetchNodeAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == rootId),
NodeClass.Unspecified,
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReferenceTypeNode
{
NodeId = rootId,
BrowseName = QualifiedName.From("References"),
NodeClass = NodeClass.ReferenceType
})
.Verifiable(Times.Once);
context
.Setup(c => c.FetchNodesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(ids => ids.Count == 1 && ids[0] == childId),
It.IsAny<bool>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<Node>
{
Results =
[
new ReferenceTypeNode
{
NodeId = childId,
BrowseName = QualifiedName.From("CustomReference"),
NodeClass = NodeClass.ReferenceType
}
],
Errors = [ServiceResult.Good]
})
.Verifiable(Times.Once);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == rootId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(
[
new ReferenceDescription
{
NodeId = new ExpandedNodeId(childId),
ReferenceTypeId = ReferenceTypeIds.HasSubtype,
IsForward = true
}
])
.Verifiable(Times.Exactly(2));
var nodeCache = new NodeCache(context.Object, telemetry);
NodeId result = await nodeCache.FindReferenceTypeAsync(
QualifiedName.From("CustomReference"),
default).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(childId));
Assert.That(
await nodeCache.FindReferenceTypeAsync(QualifiedName.Null, default).ConfigureAwait(false),
Is.EqualTo(NodeId.Null));
context.Verify();
}
[Test]
public async Task FindReferenceTypeAsyncStopsAtHierarchyCycleAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
NodeId rootId = ReferenceTypeIds.References;
var childId = new NodeId("Cycle", 0);
var context = new Mock<INodeCacheContext>();
context.Setup(c => c.NamespaceUris).Returns(new NamespaceTable());
context
.Setup(c => c.FetchNodeAsync(
It.IsAny<RequestHeader>(),
It.IsAny<NodeId>(),
NodeClass.Unspecified,
false,
It.IsAny<CancellationToken>()))
.Returns((RequestHeader _, NodeId nodeId, NodeClass _, bool _, CancellationToken _) =>
new ValueTask<Node>(new ReferenceTypeNode
{
NodeId = nodeId,
BrowseName = QualifiedName.From(nodeId == rootId ? "References" : "Cycle"),
NodeClass = NodeClass.ReferenceType
}));
context
.Setup(c => c.FetchNodesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(ids => ids.Count == 1 && ids[0] == childId),
It.IsAny<bool>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<Node>
{
Results =
[
new ReferenceTypeNode
{
NodeId = childId,
BrowseName = QualifiedName.From("Cycle"),
NodeClass = NodeClass.ReferenceType
}
],
Errors = [ServiceResult.Good]
});
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.IsAny<NodeId>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((RequestHeader _, NodeId nodeId, CancellationToken _) =>
[
new ReferenceDescription
{
NodeId = new ExpandedNodeId(nodeId == rootId ? childId : rootId),
ReferenceTypeId = ReferenceTypeIds.HasSubtype,
IsForward = true
}
]);
var nodeCache = new NodeCache(context.Object, telemetry);
NodeId result = await nodeCache.FindReferenceTypeAsync(
QualifiedName.From("Missing"),
default).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(NodeId.Null));
}
[Test]
public async Task FindDataTypeIdAsyncAndIsEncodingOfAsyncReturnMatchesAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var dataTypeId = new NodeId("DataType", 0);
var encodingId = new NodeId("DataTypeEncoding", 0);
var context = new Mock<INodeCacheContext>();
var nsTable = new NamespaceTable();
context.Setup(c => c.NamespaceUris).Returns(nsTable);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == encodingId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(
[
new ReferenceDescription
{
NodeId = new ExpandedNodeId(dataTypeId),
ReferenceTypeId = ReferenceTypeIds.HasEncoding,
IsForward = false
}
])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchNodesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(ids => ids.Count == 1 && ids[0] == dataTypeId),
It.IsAny<bool>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<Node>
{
Results =
[
new Node
{
NodeId = dataTypeId,
BrowseName = QualifiedName.From("DataType"),
NodeClass = NodeClass.DataType
}
],
Errors = [ServiceResult.Good]
})
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
NodeId result = await nodeCache.FindDataTypeIdAsync(
new ExpandedNodeId(encodingId),
default).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(dataTypeId));
Assert.That(
await nodeCache.IsEncodingOfAsync(
new ExpandedNodeId(encodingId),
new ExpandedNodeId(dataTypeId),
default).ConfigureAwait(false),
Is.True);
context.Verify();
}
[Test]
public async Task IsTypeOfAsyncReturnsTrueForRecursiveSubtypeChainAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var superTypeId = new NodeId("SuperType", 0);
var middleTypeId = new NodeId("MiddleType", 0);
var subTypeId = new NodeId("SubType", 0);
var context = new Mock<INodeCacheContext>();
var nsTable = new NamespaceTable();
context.Setup(c => c.NamespaceUris).Returns(nsTable);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == subTypeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(
[
new ReferenceDescription
{
NodeId = new ExpandedNodeId(middleTypeId),
ReferenceTypeId = ReferenceTypeIds.HasSubtype,
IsForward = false
}
])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == middleTypeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(
[
new ReferenceDescription
{
NodeId = new ExpandedNodeId(superTypeId),
ReferenceTypeId = ReferenceTypeIds.HasSubtype,
IsForward = false
}
])
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
Assert.That(await nodeCache.IsTypeOfAsync(subTypeId, superTypeId, default).ConfigureAwait(false), Is.True);
Assert.That(await nodeCache.IsTypeOfAsync(superTypeId, superTypeId, default).ConfigureAwait(false), Is.True);
context.Verify();
}
[Test]
public void GetValueAsyncShouldHandleErrors()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var id = new NodeId("test", 0);
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchValueAsync(
It.IsAny<RequestHeader>(),
It.IsAny<NodeId>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadUnexpectedError))
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act && Assert
_ = Assert.ThrowsAsync<ServiceResultException>(async () =>
await nodeCache.GetValueAsync(id, default).ConfigureAwait(false));
context.Verify();
}
[Test]
public async Task GetValueAsyncShouldReturnValueFromCacheAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var expected = new DataValue(new Variant(123), StatusCodes.Good, DateTime.UtcNow);
var id = new NodeId("test", 0);
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchValueAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == id),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expected)
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
DataValue result = await nodeCache.GetValueAsync(id, default).ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(expected));
result = await nodeCache.GetValueAsync(id, default).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(expected));
context.Verify();
}
[Test]
public async Task GetValuesAsyncShouldHandleErrorsAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
ArrayOf<NodeId> ids = [new("test1", 0), new("test2", 0)];
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchValuesAsync(
It.IsAny<RequestHeader>(),
It.IsAny<ArrayOf<NodeId>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<DataValue>
{
Results = [new(), new()],
Errors = [new ServiceResult(StatusCodes.BadUnexpectedError), ServiceResult.Good]
})
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
ArrayOf<DataValue> result = await nodeCache.GetValuesAsync(ids, default)
.ConfigureAwait(false);
// Assert
Assert.That(result.Count, Is.EqualTo(2));
Assert.That((uint)result[0].StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
Assert.That((uint)result[1].StatusCode, Is.EqualTo(StatusCodes.Good));
context.Verify();
}
/// <summary>
/// L9-8: the lookups swallowed every exception, so a cancelled lookup
/// reported "node does not exist" instead of honouring cancellation.
/// </summary>
[Test]
public void LookupsPropagateCancellation()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var context = new Mock<INodeCacheContext>();
context.Setup(c => c.NamespaceUris).Returns(new NamespaceTable());
var nodeCache = new NodeCache(context.Object, telemetry);
var id = new NodeId("uncached", 0);
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.Multiple(() =>
{
Assert.That(
async () => await nodeCache.FindAsync(id, cts.Token).ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>());
Assert.That(
async () => await nodeCache.IsKnownAsync(id, cts.Token).ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>());
Assert.That(
async () => await nodeCache.FindReferenceTypeNameAsync(id, cts.Token)
.ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>());
});
}
/// <summary>
/// L9-6: an empty DataValue (legal on the wire) collided with the
/// "missing slot" sentinel, so the next node's value landed in its
/// slot and the next slot stayed empty.
/// </summary>
[Test]
public async Task GetValuesAsyncKeepsEmptyDataValueInItsOwnSlotAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
ArrayOf<NodeId> ids = [new("empty", 0), new("value", 0)];
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchValuesAsync(
It.IsAny<RequestHeader>(),
It.IsAny<ArrayOf<NodeId>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<DataValue>
{
Results = [default, new(new Variant(456), StatusCodes.Good, DateTime.UtcNow)],
Errors = [ServiceResult.Good, ServiceResult.Good]
});
var nodeCache = new NodeCache(context.Object, telemetry);
ArrayOf<DataValue> result = await nodeCache.GetValuesAsync(ids, default)
.ConfigureAwait(false);
Assert.That(result.Count, Is.EqualTo(2));
Assert.That(result[0].IsNull, Is.True, "the empty value belongs to the first node");
Assert.That(result[1].WrappedValue, Is.EqualTo(new Variant(456)));
}
[Test]
public async Task GetValuesAsyncShouldReturnValuesFromCacheAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
ArrayOf<DataValue> expected =
[
new(new Variant(123), StatusCodes.Good, DateTime.UtcNow),
new(new Variant(456), StatusCodes.Good, DateTime.UtcNow)
];
ArrayOf<NodeId> ids = [new("test1", 0), new("test2", 0)];
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchValuesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(i => i.ToList().ToHashSet().SetEquals(ids.ToList().ToHashSet())),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<DataValue>
{
Results = expected,
Errors = [ServiceResult.Good, ServiceResult.Good]
})
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
ArrayOf<DataValue> result = await nodeCache.GetValuesAsync(ids, default)
.ConfigureAwait(false);
// Assert
Assert.That(result, Is.EqualTo(expected));
result = await nodeCache.GetValuesAsync(ids, default).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(expected));
context.Verify();
}
[Test]
public async Task GetValuesAsyncShouldReturnValuesFromCacheButHonorStatusOfReadAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
ArrayOf<DataValue> expected =
[
new(new Variant(123), StatusCodes.Good, DateTime.UtcNow),
new(new Variant(456), StatusCodes.Good, DateTime.UtcNow)
];
ArrayOf<NodeId> ids = [new("test1", 0), new("test2", 0)];
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchValuesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(i => i.ToList().ToHashSet().SetEquals(ids.ToList().ToHashSet())),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<DataValue>
{
Results = expected,
Errors = [ServiceResult.Good, new ServiceResult(StatusCodes.Bad)]
})
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
ArrayOf<DataValue> result = await nodeCache.GetValuesAsync(ids, default)
.ConfigureAwait(false);
// Assert
Assert.That(result[0], Is.EqualTo(expected[0]));
Assert.That((uint)result[1].StatusCode, Is.EqualTo(StatusCodes.Bad));
Assert.That(result[1].WrappedValue, Is.EqualTo(expected[1].WrappedValue));
context
.Setup(c => c.FetchValuesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(i => i.Count == 1 && i[0] == ids[1]),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<DataValue>
{
Results = [expected[1]],
Errors = [ServiceResult.Good]
})
.Verifiable(Times.Once);
result = await nodeCache.GetValuesAsync(ids, default).ConfigureAwait(false);
context.Verify();
}
[Test]
public async Task IsTypeOfShouldHandleNoReferencesAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var superTypeId = new NodeId("superType", 0);
var subTypeId = new NodeId("subType", 0);
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == subTypeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync([])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i != subTypeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync([])
.Verifiable(Times.Never);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
bool result = await nodeCache.IsTypeOfAsync(subTypeId, superTypeId, default)
.ConfigureAwait(false);
// Assert
Assert.That(result, Is.False);
context.Verify();
}
[Test]
public async Task IsTypeOfShouldReturnTrueForSuperTypeAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var superTypeId = new NodeId("superType", 0);
var subTypeId = new NodeId("subType", 0);
var references = new List<ReferenceDescription>
{
new()
{
NodeId = new ExpandedNodeId(superTypeId),
ReferenceTypeId = ReferenceTypeIds.HasSubtype,
IsForward = false
}
};
var context = new Mock<INodeCacheContext>();
var nsTable = new NamespaceTable();
context.Setup(c => c.NamespaceUris).Returns(nsTable);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == subTypeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync([.. references])
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
bool result = await nodeCache.IsTypeOfAsync(subTypeId, superTypeId, default)
.ConfigureAwait(false);
// Assert
Assert.That(result, Is.True);
// Act
result = await nodeCache.IsTypeOfAsync(subTypeId, superTypeId, default)
.ConfigureAwait(false);
// Assert
Assert.That(result, Is.True);
context.Verify();
}
[Test]
public async Task LoadTypeHierarchyAsyncShouldHandleNoSubtypesAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var typeId = new NodeId("type", 0);
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == typeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync([])
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
await nodeCache.LoadTypeHierarchyAsync([typeId], default).ConfigureAwait(false);
// Assert
context.Verify();
}
[Test]
public async Task LoadTypeHierarchyAsyncShouldLoadTypeHierarchyAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange
var typeId = new NodeId("type", 0);
var subTypeId = new NodeId("subType", 0);
ArrayOf<ReferenceDescription> references =
[
new()
{
NodeId = new ExpandedNodeId(subTypeId),
ReferenceTypeId = ReferenceTypeIds.HasSubtype,
IsForward = true
}
];
var context = new Mock<INodeCacheContext>();
var nsTable = new NamespaceTable();
context.Setup(c => c.NamespaceUris).Returns(nsTable);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == typeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(references)
.Verifiable(Times.Once);
context
.Setup(c => c.FetchReferencesAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == subTypeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync([])
.Verifiable(Times.Once);
context
.Setup(c => c.FetchNodesAsync(
It.IsAny<RequestHeader>(),
It.Is<ArrayOf<NodeId>>(n => n.Count == 1 && n[0] == subTypeId),
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResultSet<Node>
{
Results =
[
new DataTypeNode
{
NodeId = subTypeId,
NodeClass = NodeClass.DataType
}
],
Errors = [ServiceResult.Good]
})
.Verifiable(Times.Once);
var nodeCache = new NodeCache(context.Object, telemetry);
// Act
await nodeCache.LoadTypeHierarchyAsync([typeId], default).ConfigureAwait(false);
await nodeCache.LoadTypeHierarchyAsync([typeId], default).ConfigureAwait(false);
await nodeCache.LoadTypeHierarchyAsync([typeId], default).ConfigureAwait(false);
// Assert
context.Verify();
}
[Test]
public async Task MetricsAreEmittedViaTelemetryContextAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
// Arrange — capture instruments published on the OPC UA client meter
var capturedHits = new Dictionary<string, long>();
var capturedMisses = new Dictionary<string, long>();
var capturedSize = new Dictionary<string, long>();
using var listener = new MeterListener
{
InstrumentPublished = (instrument, l) =>
{
if (instrument.Name.StartsWith("opc.ua.client.nodecache.", StringComparison.Ordinal))
{
l.EnableMeasurementEvents(instrument);
}
}
};
listener.SetMeasurementEventCallback<long>((instrument, value, tags, state) =>
{
string cacheTag = string.Empty;
foreach (KeyValuePair<string, object?> kv in tags)
{
if (kv.Key == "cache" && kv.Value is string s)
{
cacheTag = s;
break;
}
}
switch (instrument.Name)
{
case "opc.ua.client.nodecache.hits":
capturedHits[cacheTag] = value;
break;
case "opc.ua.client.nodecache.misses":
capturedMisses[cacheTag] = value;
break;
case "opc.ua.client.nodecache.size":
capturedSize[cacheTag] = value;
break;
}
});
listener.Start();
var id = new NodeId("metricsNode", 0);
var context = new Mock<INodeCacheContext>();
context
.Setup(c => c.FetchNodeAsync(
It.IsAny<RequestHeader>(),
It.Is<NodeId>(i => i == id),
NodeClass.Unspecified,
It.IsAny<bool>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new Node { NodeId = id });
using var nodeCache = new NodeCache(context.Object, telemetry);
// Act — first call is a miss (fetches), second call is a hit
_ = await nodeCache.GetNodeAsync(id, default).ConfigureAwait(false);
_ = await nodeCache.GetNodeAsync(id, default).ConfigureAwait(false);
listener.RecordObservableInstruments();
// Assert — at least one miss and one hit on the nodes cache, size == 1
Assert.That(capturedMisses.ContainsKey("nodes"), Is.True);
Assert.That(capturedHits.ContainsKey("nodes"), Is.True);
Assert.That(capturedSize.ContainsKey("nodes"), Is.True);
Assert.That(capturedMisses["nodes"], Is.GreaterThanOrEqualTo(1));
Assert.That(capturedHits["nodes"], Is.GreaterThanOrEqualTo(1));
Assert.That(capturedSize["nodes"], Is.EqualTo(1));
}
}
}