/* ======================================================================== * 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(TaskCreationOptions.RunContinuationsAsynchronously); var context = new Mock(); context.Setup(value => value.FetchNodeAsync( null, nodeId, NodeClass.Unspecified, false, It.IsAny())) .Returns((RequestHeader _, NodeId _, NodeClass _, bool _, CancellationToken ct) => new ValueTask(fetch.Task.WaitAsync(ct))); context.Setup(value => value.FetchReferencesAsync(null, nodeId, It.IsAny())) .ReturnsAsync([]); using var cache = new NodeCache(context.Object, NUnitTelemetryContext.Create()); using var canceled = new CancellationTokenSource(); Task first = cache.GetNodeAsync( nodeId, cancelFirst ? canceled.Token : CancellationToken.None).AsTask(); Task second = cache.GetNodeAsync( nodeId, cancelFirst ? CancellationToken.None : canceled.Token).AsTask(); Task canceledLookup = cancelFirst ? first : second; Task liveLookup = cancelFirst ? second : first; try { canceled.Cancel(); Assert.That( async () => _ = await canceledLookup.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false), Throws.InstanceOf()); 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()), Times.Once); context.Verify(value => value.FetchReferencesAsync( null, nodeId, It.IsAny()), 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(TaskCreationOptions.RunContinuationsAsynchronously); var context = new Mock(); context.Setup(value => value.FetchValueAsync(null, nodeId, It.IsAny())) .Returns((RequestHeader _, NodeId _, CancellationToken ct) => new ValueTask(fetch.Task.WaitAsync(ct))); using var cache = new NodeCache(context.Object, NUnitTelemetryContext.Create()); using var canceled = new CancellationTokenSource(); Task first = cache.GetValueAsync(nodeId, canceled.Token).AsTask(); Task second = cache.GetValueAsync(nodeId, default).AsTask(); try { canceled.Cancel(); Assert.That( async () => _ = await first.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false), Throws.InstanceOf()); 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()), 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>( TaskCreationOptions.RunContinuationsAsynchronously); var context = new Mock(); context.SetupGet(value => value.NamespaceUris).Returns(new NamespaceTable()); context.Setup(value => value.FetchReferencesAsync(null, nodeId, It.IsAny())) .Returns((RequestHeader _, NodeId _, CancellationToken ct) => new ValueTask>(fetch.Task.WaitAsync(ct))); context.Setup(value => value.FetchNodesAsync( null, It.Is>(ids => ids.Count == 1 && ids[0] == childId), false, It.IsAny())) .ReturnsAsync(new ResultSet { Results = [expected], Errors = [ServiceResult.Good] }); using var cache = new NodeCache(context.Object, NUnitTelemetryContext.Create()); using var canceled = new CancellationTokenSource(); Task> first = cache.GetReferencesAsync( nodeId, ReferenceTypeIds.HasComponent, false, false, canceled.Token).AsTask(); Task> 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()); 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 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 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()), Times.Once); context.Verify(value => value.FetchNodesAsync( null, It.IsAny>(), false, It.IsAny()), Times.Once); } [Test] public async Task FetchRemainingNodesAsyncShouldHandleErrorsAsync() { ITelemetryContext telemetry = NUnitTelemetryContext.Create(); // Arrange var id = new NodeId("test", 0); var context = new Mock(); context .Setup(c => c.FetchNodesAsync( It.IsAny(), It.IsAny>(), false, It.IsAny())) .ReturnsAsync(new ResultSet { Results = [new()], Errors = [new ServiceResult(StatusCodes.BadUnexpectedError)] }) .Verifiable(Times.Once); var nodeCache = new NodeCache(context.Object, telemetry); // Act ArrayOf 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(); var nodeCache = new NodeCache(context.Object, telemetry); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == datatypeId), It.IsAny())) .ReturnsAsync([]) .Verifiable(Times.Once); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i != datatypeId), It.IsAny())) .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(); 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(); var nodeCache = new NodeCache(context.Object, telemetry); // Act ArrayOf 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(); context .Setup(c => c.FetchNodeAsync( It.IsAny(), It.Is(i => i == id), NodeClass.Unspecified, false, It.IsAny())) .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(); var nsTable = new NamespaceTable(); context.Setup(c => c.NamespaceUris).Returns(nsTable); context .Setup(c => c.FetchNodeAsync( It.IsAny(), It.Is(i => i == id), NodeClass.Unspecified, false, It.IsAny())) .Returns((_, nodeId, _, _, ct) => new ValueTask(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(); context .Setup(c => c.FetchNodeAsync( It.IsAny(), It.Is(i => i == id), NodeClass.Unspecified, false, It.IsAny())) .Returns((_, nodeId, _, _, ct) => new ValueTask(Task.FromException(new ServiceResultException()))) .Verifiable(Times.Exactly(3)); var nodeCache = new NodeCache(context.Object, telemetry); _ = Assert.ThrowsAsync(async () => await nodeCache.GetNodeAsync(id, default).ConfigureAwait(false)); _ = Assert.ThrowsAsync(async () => await nodeCache.GetNodeAsync(id, default).ConfigureAwait(false)); _ = Assert.ThrowsAsync(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 browsePath = [QualifiedName.From("invalid")]; var context = new Mock(); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == ReferenceTypeIds.HierarchicalReferences), It.IsAny())) .ReturnsAsync([]) .Verifiable(Times.Once); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == id), It.IsAny())) .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 browsePath = [QualifiedName.From("child")]; var references = new List { new() { NodeId = new ExpandedNodeId(id), BrowseName = QualifiedName.From("child"), ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IsForward = true } }; var context = new Mock(); var nsTable = new NamespaceTable(); context.Setup(c => c.NamespaceUris).Returns(nsTable); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == ReferenceTypeIds.HierarchicalReferences), It.IsAny())) .ReturnsAsync([]) .Verifiable(Times.Once); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == id), It.IsAny())) .ReturnsAsync([.. references]) .Verifiable(Times.Once); context .Setup(c => c.FetchNodesAsync( It.IsAny(), It.Is>(n => n.Count == 1 && n[0] == id), false, It.IsAny())) .ReturnsAsync(new ResultSet { 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 browsePath = [ new QualifiedName("child"), new QualifiedName("grandChild") ]; var rootReferences = new List { 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 { 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(); var nsTable = new NamespaceTable(); context.Setup(c => c.NamespaceUris).Returns(nsTable); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == ReferenceTypeIds.HierarchicalReferences), It.IsAny())) .ReturnsAsync([]) .Verifiable(Times.Once); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == rootId), It.IsAny())) .ReturnsAsync([.. rootReferences]) .Verifiable(Times.Once); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == childId), It.IsAny())) .ReturnsAsync([.. childReferences]) .Verifiable(Times.Once); context .Setup(c => c.FetchNodesAsync( It.IsAny(), It.Is>(n => n.Count == 1 && n[0] == childId), false, It.IsAny())) .ReturnsAsync(new ResultSet { Results = [childNode], Errors = [ServiceResult.Good] }) .Verifiable(Times.Once); context .Setup(c => c.FetchNodesAsync( It.IsAny(), It.Is>(n => n.Count == 1 && n[0] == grandChildId), false, It.IsAny())) .ReturnsAsync(new ResultSet { 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(); var nodeCache = new NodeCache(context.Object, telemetry); // Act ArrayOf 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 { new() { NodeId = targetExpandedNodeId, ReferenceTypeId = referenceTypeId, IsForward = false } }; var id = new NodeId("test", 0); var context = new Mock(); var nsTable = new NamespaceTable(); context.Setup(c => c.NamespaceUris).Returns(nsTable); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == id), It.IsAny())) .ReturnsAsync([.. expected]) .Verifiable(Times.Once); context .Setup(c => c.FetchNodesAsync( It.IsAny(), It.Is>(n => n.Count == 1 && n[0] == targetNodeId), false, It.IsAny())) .ReturnsAsync(new ResultSet { Results = [ new VariableNode { NodeId = targetNodeId, NodeClass = NodeClass.Variable } ], Errors = [ServiceResult.Good] }) .Verifiable(Times.Once); var nodeCache = new NodeCache(context.Object, telemetry); // Act ArrayOf result1 = await nodeCache .GetReferencesAsync(id, referenceTypeId, true, false, default) .ConfigureAwait(false); ArrayOf 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 { new() { NodeId = targetExpandedNodeId, ReferenceTypeId = referenceSubTypeId, IsForward = false } }; var id = new NodeId("test", 0); var context = new Mock(); var nsTable = new NamespaceTable(); context.Setup(c => c.NamespaceUris).Returns(nsTable); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == referenceTypeId), It.IsAny())) .ReturnsAsync( [ new() { ReferenceTypeId = ReferenceTypeIds.HasSubtype, BrowseName = QualifiedName.From("HasSubtype"), NodeId = new ExpandedNodeId(referenceSubTypeId) } ]) .Verifiable(Times.Once); context .Setup(c => c.FetchNodesAsync( It.IsAny(), It.Is>(n => n.Count == 1 && n[0] == referenceSubTypeId), false, It.IsAny())) .ReturnsAsync(new ResultSet { Results = [ new ReferenceTypeNode { NodeId = referenceSubTypeId, NodeClass = NodeClass.ReferenceType } ], Errors = [ServiceResult.Good] }) .Verifiable(Times.Once); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == referenceSubTypeId), It.IsAny())) .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(), It.Is(i => i == id), It.IsAny())) .ReturnsAsync([.. expected]) .Verifiable(Times.Once); context .Setup(c => c.FetchNodesAsync( It.IsAny(), It.Is>(n => n.Count == 1 && n[0] == targetNodeId), false, It.IsAny())) .ReturnsAsync(new ResultSet { Results = [ new VariableNode { NodeId = targetNodeId, NodeClass = NodeClass.Variable } ], Errors = [ServiceResult.Good] }) .Verifiable(Times.Once); var nodeCache = new NodeCache(context.Object, telemetry); // Act ArrayOf result1 = await nodeCache .GetReferencesAsync(id, referenceTypeId, true, true, default) .ConfigureAwait(false); ArrayOf 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 { new() { NodeId = targetExpandedNodeId, ReferenceTypeId = referenceTypeId, IsForward = false } }; var id = new NodeId("test", 0); var context = new Mock(); var nsTable = new NamespaceTable(); context.Setup(c => c.NamespaceUris).Returns(nsTable); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == referenceTypeId), It.IsAny())) .ReturnsAsync([]) .Verifiable(Times.Once); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == id), It.IsAny())) .ReturnsAsync([.. expected]) .Verifiable(Times.Once); context .Setup(c => c.FetchNodesAsync( It.IsAny(), It.Is>(n => n.Count == 1 && n[0] == targetNodeId), false, It.IsAny())) .ReturnsAsync(new ResultSet { Results = [ new VariableNode { NodeId = targetNodeId, NodeClass = NodeClass.Variable } ], Errors = [ServiceResult.Good] }) .Verifiable(Times.Once); var nodeCache = new NodeCache(context.Object, telemetry); // Act ArrayOf result1 = await nodeCache .GetReferencesAsync(id, referenceTypeId, true, true, default) .ConfigureAwait(false); ArrayOf 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(); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == typeId), It.IsAny())) .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 { new() { NodeId = new ExpandedNodeId(superTypeId), ReferenceTypeId = ReferenceTypeIds.HasSubtype, IsForward = false } }; var context = new Mock(); var nsTable = new NamespaceTable(); context.Setup(c => c.NamespaceUris).Returns(nsTable); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == subTypeId), It.IsAny())) .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(); var nsTable = new NamespaceTable(); context.Setup(c => c.NamespaceUris).Returns(nsTable); context .Setup(c => c.FetchNodeAsync( It.IsAny(), It.Is(i => i == rootId), NodeClass.Unspecified, false, It.IsAny())) .ReturnsAsync(new ReferenceTypeNode { NodeId = rootId, BrowseName = QualifiedName.From("References"), NodeClass = NodeClass.ReferenceType }) .Verifiable(Times.Once); context .Setup(c => c.FetchNodesAsync( It.IsAny(), It.Is>(ids => ids.Count == 1 && ids[0] == childId), It.IsAny(), It.IsAny())) .ReturnsAsync(new ResultSet { 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(), It.Is(i => i == rootId), It.IsAny())) .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(); context.Setup(c => c.NamespaceUris).Returns(new NamespaceTable()); context .Setup(c => c.FetchNodeAsync( It.IsAny(), It.IsAny(), NodeClass.Unspecified, false, It.IsAny())) .Returns((RequestHeader _, NodeId nodeId, NodeClass _, bool _, CancellationToken _) => new ValueTask(new ReferenceTypeNode { NodeId = nodeId, BrowseName = QualifiedName.From(nodeId == rootId ? "References" : "Cycle"), NodeClass = NodeClass.ReferenceType })); context .Setup(c => c.FetchNodesAsync( It.IsAny(), It.Is>(ids => ids.Count == 1 && ids[0] == childId), It.IsAny(), It.IsAny())) .ReturnsAsync(new ResultSet { Results = [ new ReferenceTypeNode { NodeId = childId, BrowseName = QualifiedName.From("Cycle"), NodeClass = NodeClass.ReferenceType } ], Errors = [ServiceResult.Good] }); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.IsAny(), It.IsAny())) .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(); var nsTable = new NamespaceTable(); context.Setup(c => c.NamespaceUris).Returns(nsTable); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == encodingId), It.IsAny())) .ReturnsAsync( [ new ReferenceDescription { NodeId = new ExpandedNodeId(dataTypeId), ReferenceTypeId = ReferenceTypeIds.HasEncoding, IsForward = false } ]) .Verifiable(Times.Once); context .Setup(c => c.FetchNodesAsync( It.IsAny(), It.Is>(ids => ids.Count == 1 && ids[0] == dataTypeId), It.IsAny(), It.IsAny())) .ReturnsAsync(new ResultSet { 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(); var nsTable = new NamespaceTable(); context.Setup(c => c.NamespaceUris).Returns(nsTable); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == subTypeId), It.IsAny())) .ReturnsAsync( [ new ReferenceDescription { NodeId = new ExpandedNodeId(middleTypeId), ReferenceTypeId = ReferenceTypeIds.HasSubtype, IsForward = false } ]) .Verifiable(Times.Once); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == middleTypeId), It.IsAny())) .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(); context .Setup(c => c.FetchValueAsync( It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new ServiceResultException(StatusCodes.BadUnexpectedError)) .Verifiable(Times.Once); var nodeCache = new NodeCache(context.Object, telemetry); // Act && Assert _ = Assert.ThrowsAsync(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(); context .Setup(c => c.FetchValueAsync( It.IsAny(), It.Is(i => i == id), It.IsAny())) .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 ids = [new("test1", 0), new("test2", 0)]; var context = new Mock(); context .Setup(c => c.FetchValuesAsync( It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(new ResultSet { Results = [new(), new()], Errors = [new ServiceResult(StatusCodes.BadUnexpectedError), ServiceResult.Good] }) .Verifiable(Times.Once); var nodeCache = new NodeCache(context.Object, telemetry); // Act ArrayOf 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(); } /// /// L9-8: the lookups swallowed every exception, so a cancelled lookup /// reported "node does not exist" instead of honouring cancellation. /// [Test] public void LookupsPropagateCancellation() { ITelemetryContext telemetry = NUnitTelemetryContext.Create(); var context = new Mock(); 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()); Assert.That( async () => await nodeCache.IsKnownAsync(id, cts.Token).ConfigureAwait(false), Throws.InstanceOf()); Assert.That( async () => await nodeCache.FindReferenceTypeNameAsync(id, cts.Token) .ConfigureAwait(false), Throws.InstanceOf()); }); } /// /// 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. /// [Test] public async Task GetValuesAsyncKeepsEmptyDataValueInItsOwnSlotAsync() { ITelemetryContext telemetry = NUnitTelemetryContext.Create(); ArrayOf ids = [new("empty", 0), new("value", 0)]; var context = new Mock(); context .Setup(c => c.FetchValuesAsync( It.IsAny(), It.IsAny>(), It.IsAny())) .ReturnsAsync(new ResultSet { Results = [default, new(new Variant(456), StatusCodes.Good, DateTime.UtcNow)], Errors = [ServiceResult.Good, ServiceResult.Good] }); var nodeCache = new NodeCache(context.Object, telemetry); ArrayOf 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 expected = [ new(new Variant(123), StatusCodes.Good, DateTime.UtcNow), new(new Variant(456), StatusCodes.Good, DateTime.UtcNow) ]; ArrayOf ids = [new("test1", 0), new("test2", 0)]; var context = new Mock(); context .Setup(c => c.FetchValuesAsync( It.IsAny(), It.Is>(i => i.ToList().ToHashSet().SetEquals(ids.ToList().ToHashSet())), It.IsAny())) .ReturnsAsync(new ResultSet { Results = expected, Errors = [ServiceResult.Good, ServiceResult.Good] }) .Verifiable(Times.Once); var nodeCache = new NodeCache(context.Object, telemetry); // Act ArrayOf 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 expected = [ new(new Variant(123), StatusCodes.Good, DateTime.UtcNow), new(new Variant(456), StatusCodes.Good, DateTime.UtcNow) ]; ArrayOf ids = [new("test1", 0), new("test2", 0)]; var context = new Mock(); context .Setup(c => c.FetchValuesAsync( It.IsAny(), It.Is>(i => i.ToList().ToHashSet().SetEquals(ids.ToList().ToHashSet())), It.IsAny())) .ReturnsAsync(new ResultSet { Results = expected, Errors = [ServiceResult.Good, new ServiceResult(StatusCodes.Bad)] }) .Verifiable(Times.Once); var nodeCache = new NodeCache(context.Object, telemetry); // Act ArrayOf 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(), It.Is>(i => i.Count == 1 && i[0] == ids[1]), It.IsAny())) .ReturnsAsync(new ResultSet { 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(); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == subTypeId), It.IsAny())) .ReturnsAsync([]) .Verifiable(Times.Once); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i != subTypeId), It.IsAny())) .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 { new() { NodeId = new ExpandedNodeId(superTypeId), ReferenceTypeId = ReferenceTypeIds.HasSubtype, IsForward = false } }; var context = new Mock(); var nsTable = new NamespaceTable(); context.Setup(c => c.NamespaceUris).Returns(nsTable); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == subTypeId), It.IsAny())) .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(); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == typeId), It.IsAny())) .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 references = [ new() { NodeId = new ExpandedNodeId(subTypeId), ReferenceTypeId = ReferenceTypeIds.HasSubtype, IsForward = true } ]; var context = new Mock(); var nsTable = new NamespaceTable(); context.Setup(c => c.NamespaceUris).Returns(nsTable); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == typeId), It.IsAny())) .ReturnsAsync(references) .Verifiable(Times.Once); context .Setup(c => c.FetchReferencesAsync( It.IsAny(), It.Is(i => i == subTypeId), It.IsAny())) .ReturnsAsync([]) .Verifiable(Times.Once); context .Setup(c => c.FetchNodesAsync( It.IsAny(), It.Is>(n => n.Count == 1 && n[0] == subTypeId), false, It.IsAny())) .ReturnsAsync(new ResultSet { 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(); var capturedMisses = new Dictionary(); var capturedSize = new Dictionary(); using var listener = new MeterListener { InstrumentPublished = (instrument, l) => { if (instrument.Name.StartsWith("opc.ua.client.nodecache.", StringComparison.Ordinal)) { l.EnableMeasurementEvents(instrument); } } }; listener.SetMeasurementEventCallback((instrument, value, tags, state) => { string cacheTag = string.Empty; foreach (KeyValuePair 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(); context .Setup(c => c.FetchNodeAsync( It.IsAny(), It.Is(i => i == id), NodeClass.Unspecified, It.IsAny(), It.IsAny())) .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)); } } }