Files
Marc Schier 184f19a2b0 Add complete method invocation results and resolved diagnostics (#4747)
# Description

`MethodInvocationResult` carries the operation result,
declaration-ordered outputs and optional position-preserving input
results. Asynchronous callbacks retain the actual receiver, cancellation
token and resolved diagnostic strings. Fluent predefined, typed,
instance, reference-child, resolved and virtual method builders reject
conflicting handlers in either registration order and never overwrite
materialized handlers. Direct configuration remains available.

Shared Call-result assembly retains input diagnostics, including useful
text on Good input positions, and requested operation diagnostics on
Good results. Diagnostic encoding preserves nested Good-status text,
status information bits and the outer-inclusive depth limit. Request
admission clears client-supplied internal diagnostic permission flags
before early returns or validation failures; admitted sessions grant the
flag only after the existing administrator-role checks.

Includes corresponding unit/integration regressions, complete XML API
documentation, and method-result guidance consolidated with the fluent
APIs in `docs/NodeManagers.md`. No WoT
receiver/projection/remote-execution, event-readiness/native-admission,
or bulk/history implementation is included.

## Related Issues

Part of the user-approved two-wave extraction plan tracked by #4511.

_If this is a relatively large or complex change, a design must have
been discussed in the related tracking issue and signed off (which
becomes the Architectural Decision Record (ADR))._

- Related to #4511.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] 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.
- [x] I have addressed **all** PR feedback received.
2026-10-12 18:26:04 +02:00

1088 lines
44 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 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/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Server.Fluent;
// CA2000: BaseObjectState instances created in MakeObject() are passed to the builder under
// test which owns them for the test fixture lifetime. The collection-expression rewrite from
// IDE0300 makes the analyzer's flow analysis lose the ownership-transfer inference; the
// disposables are still cleaned up correctly when the fixture tears down.
#pragma warning disable CA2000
namespace Opc.Ua.Server.Tests.Fluent
{
[TestFixture]
[Category("Fluent")]
public class NodeManagerBuilderTests
{
private const ushort kNs = 2;
private static SystemContext CreateContext()
{
return new SystemContext(telemetry: null!);
}
private static (NodeManagerBuilder Builder, BaseObjectState Root, BaseDataVariableState Var,
MethodState Method) CreateBuilderWithGraph()
{
SystemContext ctx = CreateContext();
var root = new BaseObjectState(parent: null)
{
NodeId = new NodeId("Root", kNs),
BrowseName = new QualifiedName("Root", kNs),
DisplayName = new LocalizedText("Root")
};
var var1 = new BaseDataVariableState(root)
{
NodeId = new NodeId("Root.Var1", kNs),
BrowseName = new QualifiedName("Var1", kNs),
DisplayName = new LocalizedText("Var1"),
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.Scalar
};
root.AddChild(var1);
var method = new MethodState(root)
{
NodeId = new NodeId("Root.M1", kNs),
BrowseName = new QualifiedName("M1", kNs),
DisplayName = new LocalizedText("M1")
};
root.AddChild(method);
var roots = new Dictionary<QualifiedName, NodeState> { [root.BrowseName] = root };
var byId = new Dictionary<NodeId, NodeState>
{
[root.NodeId] = root,
[var1.NodeId] = var1,
[method.NodeId] = method
};
var builder = new NodeManagerBuilder(
ctx,
nodeManager: FluentTestNodeManager.Create(kNs),
defaultNamespaceIndex: kNs,
rootResolver: q => (roots.TryGetValue(q, out NodeState? n) ? n : null)!,
nodeIdResolver: id => (byId.TryGetValue(id, out NodeState? n) ? n : null)!,
typeIdResolver: _ => []);
return (builder, root, var1, method);
}
[Test]
public void NodeByPathReturnsBuilderForResolvedNode()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
INodeBuilder nb = b.Node("Root/Var1");
Assert.That(nb.Node, Is.SameAs(v));
Assert.That(nb.Builder, Is.SameAs(b));
}
/// <summary>
/// Verifies that complete and ordinary handlers cannot coexist regardless of registration order.
/// </summary>
/// <param name="asynchronous">Whether the ordinary handler is asynchronous.</param>
/// <param name="richFirst">Whether the complete-result handler is registered first.</param>
[TestCase(false, false)]
[TestCase(false, true)]
[TestCase(true, false)]
[TestCase(true, true)]
public void CompleteMethodHandlerRejectsConflictsInEitherRegistrationOrder(bool asynchronous, bool richFirst)
{
(NodeManagerBuilder manager, _, _, MethodState method) = CreateBuilderWithGraph();
INodeBuilder<MethodState> builder = manager.Node<MethodState>(method.NodeId);
MethodCalledWithResultEventHandlerAsync rich = s_completeMethodHandler;
void RegisterOrdinary()
{
if (asynchronous)
{
builder.OnCall((_, _, _, _, _, _) => new ValueTask<ServiceResult>(ServiceResult.Good));
}
else
{
builder.OnCall((_, _, _, _, _) => ServiceResult.Good);
}
}
if (richFirst)
{
Assert.That(builder.OnCallWithResult(rich), Is.SameAs(builder));
ServiceResultException? error = Assert.Throws<ServiceResultException>(RegisterOrdinary);
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError));
Assert.That(method.OnCallMethodWithResultAsync, Is.SameAs(rich));
Assert.That(method.OnCallMethod2, Is.Null);
Assert.That(method.OnCallMethod2Async, Is.Null);
}
else
{
RegisterOrdinary();
ServiceResultException? error = Assert.Throws<ServiceResultException>(
() => builder.OnCallWithResult(rich));
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadConfigurationError));
Assert.That(method.OnCallMethodWithResultAsync, Is.Null);
}
}
/// <summary>
/// Verifies that complete-result registration preserves a generated method's typed builder and sealing.
/// </summary>
[Test]
public async Task CompleteMethodHandlerPreservesTheGeneratedMethodBuilderType()
{
var method = new AddCommentMethodState(null) { NodeId = new NodeId("Comment", kNs) };
var manager = new NodeManagerBuilder(
CreateContext(), Mock.Of<IAsyncNodeManager>(), kNs,
rootResolver: _ => throw new AssertionException("This test resolves its method directly by NodeId."),
nodeIdResolver: id => id == method.NodeId
? method
: throw new AssertionException("The builder requested an unexpected method NodeId."),
typeIdResolver: _ => []);
INodeBuilder<AddCommentMethodState> builder = manager.Node<AddCommentMethodState>(method.NodeId);
MethodCalledWithResultEventHandlerAsync handler = s_completeMethodHandler;
INodeBuilder<AddCommentMethodState> configured = builder.OnCallWithResult(handler);
Assert.That(configured, Is.SameAs(builder));
Assert.That(configured.Node, Is.SameAs(method));
Assert.That(method.OnCallMethodWithResultAsync, Is.SameAs(handler));
await manager.SealAsync().ConfigureAwait(false);
ServiceResultException? error = Assert.Throws<ServiceResultException>(
() => configured.OnCallWithResult(handler));
Assert.That(error!.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
Assert.That(method.OnCallMethodWithResultAsync, Is.SameAs(handler));
}
[Test]
public void NodeByPathTypedReturnsTypedBuilder()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
INodeBuilder<BaseDataVariableState> nb = b.Node<BaseDataVariableState>("Root/Var1");
Assert.That(nb.Node, Is.SameAs(v));
}
[Test]
public void NodeByPathTypedThrowsBadTypeMismatch()
{
(NodeManagerBuilder b, _, _, _) = CreateBuilderWithGraph();
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node<MethodState>("Root/Var1"));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch));
}
[Test]
public void NodeByNodeIdResolves()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
INodeBuilder nb = b.Node(new NodeId("Root.Var1", kNs));
Assert.That(nb.Node, Is.SameAs(v));
}
[Test]
public void NodeByNodeIdTypedThrowsBadTypeMismatch()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node<MethodState>(v.NodeId));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch));
}
[Test]
public void NodeByNullNodeIdThrowsBadNodeIdInvalid()
{
(NodeManagerBuilder b, _, _, _) = CreateBuilderWithGraph();
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node(NodeId.Null));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdInvalid));
}
[Test]
public void NodeByUnknownNodeIdThrowsBadNodeIdUnknown()
{
(NodeManagerBuilder b, _, _, _) = CreateBuilderWithGraph();
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node(new NodeId("Missing", kNs)));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdUnknown));
}
[Test]
public async Task NodeAfterSealThrowsBadInvalidStateAsync()
{
(NodeManagerBuilder b, _, _, _) = CreateBuilderWithGraph();
await b.SealAsync();
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node("Root/Var1"));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadInvalidState));
}
[Test]
public void AsCastSucceedsForCompatibleType()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
INodeBuilder<BaseDataVariableState> typed = b.Node("Root/Var1")
.As<BaseDataVariableState>();
Assert.That(typed.Node, Is.SameAs(v));
}
[Test]
public void AsCastThrowsBadTypeMismatchForIncompatibleType()
{
(NodeManagerBuilder b, _, _, _) = CreateBuilderWithGraph();
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node("Root/Var1").As<MethodState>());
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch));
}
[Test]
public void OnSimpleReadAssignsHandlerToVariable()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
static ServiceResult handler(ISystemContext c, NodeState n, ref Variant val) =>
ServiceResult.Good;
b.Node("Root/Var1").OnRead(handler);
Assert.That(v.OnSimpleReadValue, Is.SameAs((NodeValueSimpleEventHandler)handler));
}
[Test]
public void OnReadAssignsFullHandlerToVariable()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
static ServiceResult handler(
ISystemContext c,
NodeState n,
NumericRange range,
QualifiedName encoding,
ref Variant value,
ref StatusCode statusCode,
ref DateTimeUtc timestamp) =>
ServiceResult.Good;
b.Node("Root/Var1").OnRead(handler);
Assert.That(v.OnReadValue, Is.SameAs((NodeValueEventHandler)handler));
}
[Test]
public void OnWriteAssignsHandlersToVariable()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
static ServiceResult full(
ISystemContext c,
NodeState n,
NumericRange range,
QualifiedName encoding,
ref Variant value,
ref StatusCode statusCode,
ref DateTimeUtc timestamp) =>
ServiceResult.Good;
static ServiceResult simple(ISystemContext c, NodeState n, ref Variant value) =>
ServiceResult.Good;
b.Node("Root/Var1").OnWrite(full);
b.Node("Root/Var1").OnWrite(simple);
Assert.That(v.OnWriteValue, Is.SameAs((NodeValueEventHandler)full));
Assert.That(v.OnSimpleWriteValue, Is.SameAs((NodeValueSimpleEventHandler)simple));
}
[Test]
public void OnAsyncReadAndWriteAssignHandlersToVariable()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
static ValueTask<AttributeReadResult> fullRead(
ISystemContext c,
NodeState n,
NumericRange range,
QualifiedName encoding,
CancellationToken ct) =>
new(new AttributeReadResult(ServiceResult.Good, new Variant(1), StatusCodes.Good, DateTimeUtc.Now));
static ValueTask<AttributeSimpleReadResult> simpleRead(
ISystemContext c,
NodeState n,
CancellationToken ct) =>
new(new AttributeSimpleReadResult(ServiceResult.Good, new Variant(2)));
static ValueTask<AttributeWriteResult> fullWrite(
ISystemContext c,
NodeState n,
NumericRange range,
Variant value,
CancellationToken ct) =>
new(new AttributeWriteResult(ServiceResult.Good));
static ValueTask<AttributeWriteResult> simpleWrite(
ISystemContext c,
NodeState n,
Variant value,
CancellationToken ct) =>
new(new AttributeWriteResult(ServiceResult.Good));
b.Node("Root/Var1").OnRead(fullRead);
b.Node("Root/Var1").OnRead(simpleRead);
b.Node("Root/Var1").OnWrite(fullWrite);
b.Node("Root/Var1").OnWrite(simpleWrite);
Assert.That(v.OnReadValueAsync, Is.SameAs((NodeValueEventHandlerAsync)fullRead));
Assert.That(v.OnSimpleReadValueAsync, Is.SameAs((NodeValueSimpleEventHandlerAsync)simpleRead));
Assert.That(v.OnWriteValueAsync, Is.SameAs((NodeValueWriteEventHandlerAsync)fullWrite));
Assert.That(v.OnSimpleWriteValueAsync, Is.SameAs((NodeValueSimpleWriteEventHandlerAsync)simpleWrite));
}
[Test]
public void OnReadOnNonVariableThrowsBadInvalidArgument()
{
(NodeManagerBuilder b, _, _, MethodState m) = CreateBuilderWithGraph();
static ServiceResult noop(ISystemContext c, NodeState n, ref Variant v) =>
ServiceResult.Good;
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node(m.NodeId).OnRead(noop));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadInvalidArgument));
}
[Test]
public void OnSimpleReadCalledTwiceThrowsBadConfigurationError()
{
(NodeManagerBuilder b, _, _, _) = CreateBuilderWithGraph();
static ServiceResult noop(ISystemContext c, NodeState n, ref Variant v) =>
ServiceResult.Good;
INodeBuilder nb = b.Node("Root/Var1").OnRead(noop);
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => nb.OnRead(noop));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadConfigurationError));
}
[Test]
public void OnCallAssignsHandlerToMethod()
{
(NodeManagerBuilder b, _, _, MethodState m) = CreateBuilderWithGraph();
static ServiceResult handler(ISystemContext c, MethodState mn, NodeId oid, ArrayOf<Variant> args, List<Variant> outs) => ServiceResult.Good;
b.Node(m.NodeId).OnCall(handler);
Assert.That(m.OnCallMethod2, Is.SameAs((GenericMethodCalledEventHandler2)handler));
}
[Test]
public void OnCallAsyncAssignsHandlerToMethod()
{
(NodeManagerBuilder b, _, _, MethodState m) = CreateBuilderWithGraph();
static ValueTask<ServiceResult> handler(
ISystemContext c,
MethodState mn,
NodeId oid,
ArrayOf<Variant> args,
List<Variant> outs,
CancellationToken ct) =>
new(ServiceResult.Good);
b.Node(m.NodeId).OnCall(handler);
Assert.That(m.OnCallMethod2Async, Is.SameAs((GenericMethodCalledEventHandler2Async)handler));
}
[Test]
public void OnCallOnNonMethodThrowsBadInvalidArgument()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node(v.NodeId).OnCall((c, m, oid, a, o) => ServiceResult.Good));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadInvalidArgument));
}
[Test]
public void OnEventThrowsWhenSlotAlreadyAssigned()
{
(NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph();
// Simulate prior wiring (e.g. by CustomNodeManager2 for root notifiers).
root.OnReportEvent = (c, n, e) => { };
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node("Root").OnEvent((c, n, e) => { }));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadConfigurationError));
}
[Test]
public void OnEventAssignsHandlerWhenSlotEmpty()
{
(NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph();
b.Node("Root").OnEvent((c, n, e) => { });
Assert.That(root.OnReportEvent, Is.Not.Null);
}
[Test]
public void OnConditionRefreshWiresToNodeStateSlot()
{
(NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph();
b.Node("Root").OnConditionRefresh((c, n, evts) => { });
Assert.That(root.OnConditionRefresh, Is.Not.Null);
}
[Test]
public void OnConditionRefreshThrowsWhenSlotAlreadyAssigned()
{
(NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph();
root.OnConditionRefresh = (c, n, evts) => { };
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node("Root").OnConditionRefresh((c, n, evts) => { }));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadConfigurationError));
}
[Test]
public void DispatcherTryHandleHistoryReadReturnsFalseWhenNoHandler()
{
(NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph();
bool handled = b.Dispatcher.TryHandleHistoryRead(
CreateContext(),
root,
details: null!,
TimestampsToReturn.Both,
releaseContinuationPoints: false,
nodeToRead: null!,
result: null!,
out ServiceResult status);
Assert.That(handled, Is.False);
Assert.That(StatusCode.IsGood(status.StatusCode), Is.True);
}
[Test]
public void DispatcherTryHandleHistoryReadInvokesRegisteredHandler()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
int calls = 0;
b.Node(v.NodeId).OnHistoryRead((c, n, d, t, r, ntr, res) =>
{
calls++;
return new ServiceResult(StatusCodes.GoodCompletesAsynchronously);
});
bool handled = b.Dispatcher.TryHandleHistoryRead(
CreateContext(),
v,
details: null!,
TimestampsToReturn.Both,
releaseContinuationPoints: false,
nodeToRead: null!,
result: null!,
out ServiceResult status);
Assert.That(handled, Is.True);
Assert.That(calls, Is.EqualTo(1));
Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.GoodCompletesAsynchronously));
}
[Test]
public void OnHistoryReadCalledTwiceThrowsBadConfigurationError()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
b.Node(v.NodeId).OnHistoryRead((c, n, d, t, r, ntr, res) => ServiceResult.Good);
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node(v.NodeId).OnHistoryRead((c, n, d, t, r, ntr, res) => ServiceResult.Good));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadConfigurationError));
}
[Test]
public void DispatcherTryHandleHistoryUpdateReturnsFalseWhenNoHandler()
{
(NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph();
bool handled = b.Dispatcher.TryHandleHistoryUpdate(
CreateContext(),
root,
nodeToUpdate: null!,
result: null!,
out ServiceResult _);
Assert.That(handled, Is.False);
}
[Test]
public void DispatcherTryHandleHistoryUpdateInvokesRegisteredHandler()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
int calls = 0;
b.Node(v.NodeId).OnHistoryUpdate((c, n, ntu, res) =>
{
calls++;
return ServiceResult.Good;
});
bool handled = b.Dispatcher.TryHandleHistoryUpdate(
CreateContext(),
v,
nodeToUpdate: null!,
result: null!,
out ServiceResult _);
Assert.That(handled, Is.True);
Assert.That(calls, Is.EqualTo(1));
}
[Test]
public void DispatcherNotifyNodeAddedInvokesRegisteredHandler()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
int calls = 0;
b.Node(v.NodeId).OnNodeAdded((c, n) => calls++);
b.Dispatcher.NotifyNodeAdded(CreateContext(), v);
Assert.That(calls, Is.EqualTo(1));
}
[Test]
public void DispatcherNotifyNodeAddedNoOpWhenNoHandler()
{
(NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph();
Assert.DoesNotThrow(() => b.Dispatcher.NotifyNodeAdded(CreateContext(), root));
}
[Test]
public void DispatcherNotifyNodeRemovedInvokesRegisteredHandler()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
int calls = 0;
b.Node(v.NodeId).OnNodeRemoved((c, n) => calls++);
b.Dispatcher.NotifyNodeRemoved(CreateContext(), v);
Assert.That(calls, Is.EqualTo(1));
}
[Test]
public void DispatcherNotifyMonitoredItemCreatedInvokesRegisteredHandler()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
int calls = 0;
b.Node(v.NodeId).OnMonitoredItemCreated((c, n, item) => calls++);
b.Dispatcher.NotifyMonitoredItemCreated(
CreateContext(),
v,
Mock.Of<ISampledDataChangeMonitoredItem>());
Assert.That(calls, Is.EqualTo(1));
}
[Test]
public void AllowMultipleEventConsumersWithNonAsyncCustomManagerThrows()
{
(NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph();
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node(root.NodeId).AllowMultipleEventConsumers());
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadConfigurationError));
}
[Test]
public void OnNodeAddedTwiceThrowsBadConfigurationError()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
b.Node(v.NodeId).OnNodeAdded((c, n) => { });
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node(v.NodeId).OnNodeAdded((c, n) => { }));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadConfigurationError));
}
[Test]
public void OnSimpleReadWithNullHandlerThrowsArgumentNullException()
{
(NodeManagerBuilder b, _, _, _) = CreateBuilderWithGraph();
Assert.Throws<ArgumentNullException>(
() => b.Node("Root/Var1").OnRead((NodeValueSimpleEventHandler)null!));
}
[Test]
public void OnNodeAddedWithNullHandlerThrowsArgumentNullException()
{
(NodeManagerBuilder b, _, _, _) = CreateBuilderWithGraph();
Assert.Throws<ArgumentNullException>(
() => b.Node("Root/Var1").OnNodeAdded(null!));
}
private static NodeManagerBuilder CreateBuilderWithTypeIndex(
Dictionary<NodeId, IReadOnlyList<NodeState>> byType)
{
return new NodeManagerBuilder(
CreateContext(),
FluentTestNodeManager.Create(kNs),
kNs,
_ => null!,
_ => null!,
id => byType.TryGetValue(id, out IReadOnlyList<NodeState>? list)
? list
: []);
}
private static BaseObjectState MakeObject(string name, NodeId typeDefId)
{
return new BaseObjectState(parent: null)
{
NodeId = new NodeId(name, kNs),
BrowseName = new QualifiedName(name, kNs),
TypeDefinitionId = typeDefId
};
}
private static NodeManagerBuilder CreateBuilderWithDataTypeIndex(
Dictionary<NodeId, ArrayOf<NodeState>> byDataType)
{
return new NodeManagerBuilder(
CreateContext(),
FluentTestNodeManager.Create(kNs),
kNs,
_ => null!,
_ => null!,
_ => [],
dataTypeId => byDataType.TryGetValue(dataTypeId, out ArrayOf<NodeState> list)
? list
: []);
}
private static BaseDataVariableState MakeVariable(string name, NodeId dataTypeId)
{
return new BaseDataVariableState(parent: null)
{
NodeId = new NodeId(name, kNs),
BrowseName = new QualifiedName(name, kNs),
DataType = dataTypeId,
ValueRank = ValueRanks.Scalar
};
}
[Test]
public void NodeFromTypeIdResolvesSingleton()
{
NodeId typeId = ObjectTypeIds.ServerCapabilitiesType;
BaseObjectState only = MakeObject("Caps", typeId);
NodeManagerBuilder b = CreateBuilderWithTypeIndex(
new Dictionary<NodeId, IReadOnlyList<NodeState>> { [typeId] = [only] });
INodeBuilder nb = b.NodeFromTypeId(typeId);
Assert.That(nb.Node, Is.SameAs(only));
}
[Test]
public void NodeFromTypeIdNullThrowsBadNodeIdInvalid()
{
NodeManagerBuilder b = CreateBuilderWithTypeIndex(
[]);
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.NodeFromTypeId(NodeId.Null));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdInvalid));
}
[Test]
public void NodeFromTypeIdUnknownThrowsBadNodeIdUnknown()
{
NodeManagerBuilder b = CreateBuilderWithTypeIndex(
[]);
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.NodeFromTypeId(ObjectTypeIds.BaseObjectType));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdUnknown));
}
[Test]
public void NodeFromTypeIdAmbiguousThrowsBadBrowseNameDuplicated()
{
NodeId typeId = ObjectTypeIds.BaseObjectType;
BaseObjectState a = MakeObject("Boiler1", typeId);
BaseObjectState bn = MakeObject("Boiler2", typeId);
NodeManagerBuilder b = CreateBuilderWithTypeIndex(
new Dictionary<NodeId, IReadOnlyList<NodeState>> { [typeId] = [a, bn] });
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.NodeFromTypeId(typeId));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadBrowseNameDuplicated));
}
[Test]
public void NodeFromTypeIdWithBrowseNameDisambiguates()
{
NodeId typeId = ObjectTypeIds.BaseObjectType;
BaseObjectState a = MakeObject("Boiler1", typeId);
BaseObjectState bn = MakeObject("Boiler2", typeId);
NodeManagerBuilder b = CreateBuilderWithTypeIndex(
new Dictionary<NodeId, IReadOnlyList<NodeState>> { [typeId] = [a, bn] });
INodeBuilder nb = b.NodeFromTypeId(typeId, new QualifiedName("Boiler2", kNs));
Assert.That(nb.Node, Is.SameAs(bn));
}
[Test]
public void NodeFromTypeIdWithBrowseNameMissThrowsBadNodeIdUnknown()
{
NodeId typeId = ObjectTypeIds.BaseObjectType;
BaseObjectState a = MakeObject("Boiler1", typeId);
NodeManagerBuilder b = CreateBuilderWithTypeIndex(
new Dictionary<NodeId, IReadOnlyList<NodeState>> { [typeId] = [a] });
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.NodeFromTypeId(typeId, new QualifiedName("Nope", kNs)));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdUnknown));
}
[Test]
public void NodeFromTypeIdTypedReturnsTypedBuilder()
{
NodeId typeId = ObjectTypeIds.ServerCapabilitiesType;
BaseObjectState only = MakeObject("Caps", typeId);
NodeManagerBuilder b = CreateBuilderWithTypeIndex(
new Dictionary<NodeId, IReadOnlyList<NodeState>> { [typeId] = [only] });
INodeBuilder<BaseObjectState> nb = b.NodeFromTypeId<BaseObjectState>(typeId);
Assert.That(nb.Node, Is.SameAs(only));
}
[Test]
public void NodeFromTypeIdTypedThrowsBadTypeMismatch()
{
NodeId typeId = ObjectTypeIds.ServerCapabilitiesType;
BaseObjectState only = MakeObject("Caps", typeId);
NodeManagerBuilder b = CreateBuilderWithTypeIndex(
new Dictionary<NodeId, IReadOnlyList<NodeState>> { [typeId] = [only] });
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.NodeFromTypeId<MethodState>(typeId));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch));
}
[Test]
public void NodeFromTypeIdTypedWithBrowseNameThrowsBadTypeMismatch()
{
NodeId typeId = ObjectTypeIds.BaseObjectType;
BaseObjectState only = MakeObject("Caps", typeId);
NodeManagerBuilder b = CreateBuilderWithTypeIndex(
new Dictionary<NodeId, IReadOnlyList<NodeState>> { [typeId] = [only] });
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.NodeFromTypeId<MethodState>(typeId, only.BrowseName));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch));
}
[Test]
public void NodeFromTypeIdWithDuplicateBrowseNameThrowsBadBrowseNameDuplicated()
{
NodeId typeId = ObjectTypeIds.BaseObjectType;
BaseObjectState first = MakeObject("Duplicate", typeId);
BaseObjectState second = MakeObject("Duplicate", typeId);
NodeManagerBuilder b = CreateBuilderWithTypeIndex(
new Dictionary<NodeId, IReadOnlyList<NodeState>> { [typeId] = [first, second] });
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.NodeFromTypeId(typeId, first.BrowseName));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadBrowseNameDuplicated));
}
[Test]
public void ChildResolvesByBrowseName()
{
(NodeManagerBuilder b, BaseObjectState root, BaseDataVariableState v, _) = CreateBuilderWithGraph();
INodeBuilder rb = b.Node(root.NodeId);
INodeBuilder cb = rb.Child(v.BrowseName);
Assert.That(cb.Node, Is.SameAs(v));
Assert.That(cb.Builder, Is.SameAs(b));
}
[Test]
public void ChildTypedReturnsTypedBuilder()
{
(NodeManagerBuilder b, BaseObjectState root, BaseDataVariableState v, _) = CreateBuilderWithGraph();
INodeBuilder<BaseDataVariableState> cb =
b.Node(root.NodeId).Child<BaseDataVariableState>(v.BrowseName);
Assert.That(cb.Node, Is.SameAs(v));
}
[Test]
public void ChildTypedThrowsBadTypeMismatchForWrongType()
{
(NodeManagerBuilder b, BaseObjectState root, BaseDataVariableState v, _) = CreateBuilderWithGraph();
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node(root.NodeId).Child<MethodState>(v.BrowseName));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch));
}
[Test]
public void ChildThrowsBadNodeIdUnknownForMissingBrowseName()
{
(NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph();
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node(root.NodeId).Child(new QualifiedName("Missing", kNs)));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdUnknown));
}
[Test]
public void ChildThrowsBadBrowseNameInvalidForNullBrowseName()
{
(NodeManagerBuilder b, BaseObjectState root, _, _) = CreateBuilderWithGraph();
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node(root.NodeId).Child(QualifiedName.Null));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadBrowseNameInvalid));
}
[Test]
public void VariableByBrowseNameReturnsTypedVariableBuilder()
{
(NodeManagerBuilder b, BaseObjectState root, BaseDataVariableState v, _) = CreateBuilderWithGraph();
IVariableBuilder<int> vb = b.Node(root.NodeId).Variable<int>(v.BrowseName);
Assert.That(vb.Node, Is.SameAs(v));
}
[Test]
public void VariableByPathAndNodeIdReturnTypedVariableBuilder()
{
(NodeManagerBuilder b, _, BaseDataVariableState v, _) = CreateBuilderWithGraph();
IVariableBuilder<int> byPath = b.Variable<int>("Root/Var1");
IVariableBuilder<int> byNodeId = b.Variable<int>(v.NodeId);
Assert.That(byPath.Node, Is.SameAs(v));
Assert.That(byNodeId.Node, Is.SameAs(v));
}
[Test]
public void VariableFromTypeIdResolvesVariableAndBrowseName()
{
NodeId typeId = VariableTypeIds.BaseDataVariableType;
var variable = new BaseDataVariableState(parent: null)
{
NodeId = new NodeId("ByType", kNs),
BrowseName = new QualifiedName("ByType", kNs),
TypeDefinitionId = typeId,
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.Scalar
};
NodeManagerBuilder b = CreateBuilderWithTypeIndex(
new Dictionary<NodeId, IReadOnlyList<NodeState>> { [typeId] = [variable] });
IVariableBuilder<int> byType = b.VariableFromTypeId<int>(typeId);
IVariableBuilder<int> byName = b.VariableFromTypeId<int>(typeId, variable.BrowseName);
Assert.That(byType.Node, Is.SameAs(variable));
Assert.That(byName.Node, Is.SameAs(variable));
}
[Test]
public void VariableFromTypeIdThrowsBadTypeMismatchForObject()
{
NodeId typeId = ObjectTypeIds.BaseObjectType;
BaseObjectState only = MakeObject("Object", typeId);
NodeManagerBuilder b = CreateBuilderWithTypeIndex(
new Dictionary<NodeId, IReadOnlyList<NodeState>> { [typeId] = [only] });
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.VariableFromTypeId<int>(typeId));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch));
}
[Test]
public void VariableByBrowseNameThrowsBadTypeMismatchOnNonVariable()
{
(NodeManagerBuilder b, BaseObjectState root, _, MethodState m) = CreateBuilderWithGraph();
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.Node(root.NodeId).Variable<int>(m.BrowseName));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch));
}
[Test]
public void VariableFromDataTypeIdResolvesUniqueVariable()
{
NodeId dataTypeId = DataTypeIds.Int32;
BaseDataVariableState only = MakeVariable("ByDataType", dataTypeId);
NodeManagerBuilder b = CreateBuilderWithDataTypeIndex(
new Dictionary<NodeId, ArrayOf<NodeState>> { [dataTypeId] = [only] });
IVariableBuilder<int> byDataType = b.VariableFromDataTypeId<int>(dataTypeId);
Assert.That(byDataType.Node, Is.SameAs(only));
}
[Test]
public void VariableFromDataTypeIdNullThrowsBadNodeIdInvalid()
{
NodeManagerBuilder b = CreateBuilderWithDataTypeIndex([]);
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.VariableFromDataTypeId<int>(NodeId.Null));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdInvalid));
}
[Test]
public void VariableFromDataTypeIdMissingThrowsBadNodeIdUnknown()
{
NodeManagerBuilder b = CreateBuilderWithDataTypeIndex([]);
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.VariableFromDataTypeId<int>(DataTypeIds.Int32));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdUnknown));
}
[Test]
public void VariableFromDataTypeIdAmbiguousThrowsBadBrowseNameDuplicated()
{
NodeId dataTypeId = DataTypeIds.Int32;
BaseDataVariableState a = MakeVariable("Temp1", dataTypeId);
BaseDataVariableState bn = MakeVariable("Temp2", dataTypeId);
NodeManagerBuilder b = CreateBuilderWithDataTypeIndex(
new Dictionary<NodeId, ArrayOf<NodeState>> { [dataTypeId] = [a, bn] });
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.VariableFromDataTypeId<int>(dataTypeId));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadBrowseNameDuplicated));
}
[Test]
public void VariableFromDataTypeIdWithBrowseNameDisambiguates()
{
NodeId dataTypeId = DataTypeIds.Int32;
BaseDataVariableState a = MakeVariable("Temp1", dataTypeId);
BaseDataVariableState bn = MakeVariable("Temp2", dataTypeId);
NodeManagerBuilder b = CreateBuilderWithDataTypeIndex(
new Dictionary<NodeId, ArrayOf<NodeState>> { [dataTypeId] = [a, bn] });
IVariableBuilder<int> byName = b.VariableFromDataTypeId<int>(dataTypeId, bn.BrowseName);
Assert.That(byName.Node, Is.SameAs(bn));
}
[Test]
public void VariableFromDataTypeIdWithBrowseNameMissThrowsBadNodeIdUnknown()
{
NodeId dataTypeId = DataTypeIds.Int32;
BaseDataVariableState a = MakeVariable("Temp1", dataTypeId);
NodeManagerBuilder b = CreateBuilderWithDataTypeIndex(
new Dictionary<NodeId, ArrayOf<NodeState>> { [dataTypeId] = [a] });
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.VariableFromDataTypeId<int>(dataTypeId, new QualifiedName("Nope", kNs)));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadNodeIdUnknown));
}
[Test]
public void VariableFromDataTypeIdWithDuplicateBrowseNameThrowsBadBrowseNameDuplicated()
{
NodeId dataTypeId = DataTypeIds.Int32;
BaseDataVariableState first = MakeVariable("Duplicate", dataTypeId);
BaseDataVariableState second = MakeVariable("Duplicate", dataTypeId);
NodeManagerBuilder b = CreateBuilderWithDataTypeIndex(
new Dictionary<NodeId, ArrayOf<NodeState>> { [dataTypeId] = [first, second] });
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.VariableFromDataTypeId<int>(dataTypeId, first.BrowseName));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadBrowseNameDuplicated));
}
/// <summary>
/// Mirrors <see cref="VariableFromTypeIdThrowsBadTypeMismatchForObject"/>:
/// the DataType resolver contract only guarantees the candidates it is
/// handed are <see cref="BaseVariableState"/> instances at the
/// production resolver level; if a custom resolver (as injected here)
/// returns a non-variable node, <see cref="NodeManagerBuilder.VariableFromDataTypeId{TValue}(NodeId)"/>
/// must still surface a clear <see cref="StatusCodes.BadTypeMismatch"/>
/// rather than an unhandled cast failure.
/// </summary>
[Test]
public void VariableFromDataTypeIdThrowsBadTypeMismatchForNonVariable()
{
NodeId dataTypeId = DataTypeIds.Int32;
BaseObjectState only = MakeObject("NotAVariable", ObjectTypeIds.BaseObjectType);
NodeManagerBuilder b = CreateBuilderWithDataTypeIndex(
new Dictionary<NodeId, ArrayOf<NodeState>> { [dataTypeId] = [only] });
ServiceResultException ex = Assert.Throws<ServiceResultException>(
() => b.VariableFromDataTypeId<int>(dataTypeId));
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadTypeMismatch));
}
/// <summary>The complete-result handler used to check exclusive registration.</summary>
private static readonly MethodCalledWithResultEventHandlerAsync s_completeMethodHandler =
static (_, _, _, _, _) =>
new ValueTask<MethodInvocationResult>(new MethodInvocationResult(ServiceResult.Good));
}
}